From 07b13c846899ed56bb0245a81e1c2b15f3edc9fe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:44:34 -0700 Subject: [PATCH 01/10] refactor git repository host boundaries (#16177) --- config/max-lines-baseline.txt | 2 - src/main/git/repo-api-parity.test.ts | 37 + src/main/git/repo-base-ref-search.ts | 240 +++++ src/main/git/repo-branch-conflict.ts | 59 ++ src/main/git/repo-default-base-ref.ts | 133 +++ src/main/git/repo-default-remote.test.ts | 79 ++ src/main/git/repo-detection.ts | 141 +++ src/main/git/repo-git-marker-scan.ts | 251 +++++ src/main/git/repo.ts | 965 +----------------- .../ssh-git-noninteractive-provider.ts | 166 +++ .../providers/ssh-git-provider-api.test.ts | 71 ++ .../providers/ssh-git-provider-exec.test.ts | 34 + src/main/providers/ssh-git-provider.ts | 837 +-------------- src/main/providers/ssh-git-read-provider.ts | 187 ++++ src/main/providers/ssh-git-relay-errors.ts | 8 + .../providers/ssh-git-remote-sync-provider.ts | 84 ++ .../providers/ssh-git-review-head-provider.ts | 70 ++ .../ssh-git-working-tree-provider.ts | 122 +++ .../providers/ssh-git-worktree-provider.ts | 143 +++ 19 files changed, 1886 insertions(+), 1743 deletions(-) create mode 100644 src/main/git/repo-api-parity.test.ts create mode 100644 src/main/git/repo-base-ref-search.ts create mode 100644 src/main/git/repo-branch-conflict.ts create mode 100644 src/main/git/repo-default-base-ref.ts create mode 100644 src/main/git/repo-default-remote.test.ts create mode 100644 src/main/git/repo-detection.ts create mode 100644 src/main/git/repo-git-marker-scan.ts create mode 100644 src/main/providers/ssh-git-noninteractive-provider.ts create mode 100644 src/main/providers/ssh-git-provider-api.test.ts create mode 100644 src/main/providers/ssh-git-read-provider.ts create mode 100644 src/main/providers/ssh-git-relay-errors.ts create mode 100644 src/main/providers/ssh-git-remote-sync-provider.ts create mode 100644 src/main/providers/ssh-git-review-head-provider.ts create mode 100644 src/main/providers/ssh-git-working-tree-provider.ts create mode 100644 src/main/providers/ssh-git-worktree-provider.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index fb97a469747..79d033f010d 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -24,7 +24,6 @@ inline src/main/daemon/daemon-init.ts inline src/main/daemon/daemon-pty-adapter.ts inline src/main/daemon/daemon-server.ts inline src/main/daemon/pty-subprocess.ts -inline src/main/git/repo.ts inline src/main/git/runner.ts inline src/main/git/status.ts inline src/main/git/worktree.ts @@ -49,7 +48,6 @@ inline src/main/ports/advertised-url-watcher.ts inline src/main/ports/local-workspace-port-scanner.ts inline src/main/project-groups/nested-repo-discovery.ts inline src/main/providers/local-pty-provider.ts -inline src/main/providers/ssh-git-provider.ts inline src/main/rate-limits/claude-fetcher.ts inline src/main/rate-limits/claude-pty.ts inline src/main/rate-limits/codex-fetcher.ts diff --git a/src/main/git/repo-api-parity.test.ts b/src/main/git/repo-api-parity.test.ts new file mode 100644 index 00000000000..9143b7bf65f --- /dev/null +++ b/src/main/git/repo-api-parity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import * as repo from './repo' + +describe('repo public API parity', () => { + it('keeps the historical runtime export surface', () => { + expect(Object.keys(repo).sort()).toEqual( + [ + 'DEFAULT_BASE_REF_PROBES', + 'buildSearchBaseRefsArgv', + 'getBaseRefDefault', + 'getBranchConflictKind', + 'getDefaultBaseRef', + 'getDefaultRemote', + 'getGitRepoRoot', + 'getLinkedWorktreeMainRepoRoot', + 'getRecentDriftSubjects', + 'getRemoteCommitUrl', + 'getRemoteCount', + 'getRemoteDrift', + 'getRemoteFileUrl', + 'getRemoteUrl', + 'getRepoName', + 'isForEachRefExcludeUnsupportedError', + 'isGitRepo', + 'mergeBaseRefSearchResultGroups', + 'normalizeGitRepoRootForInputPath', + 'normalizeRefSearchQuery', + 'parseAndFilterSearchRefDetails', + 'parseRemoteCount', + 'resolveDefaultBaseRefViaExec', + 'resolveDefaultBaseRefWithLocalGit', + 'searchBaseRefDetails', + 'searchBaseRefs' + ].sort() + ) + }) +}) diff --git a/src/main/git/repo-base-ref-search.ts b/src/main/git/repo-base-ref-search.ts new file mode 100644 index 00000000000..5ea01a7ec1c --- /dev/null +++ b/src/main/git/repo-base-ref-search.ts @@ -0,0 +1,240 @@ +import type { BaseRefSearchResult } from '../../shared/repo-types' +import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' +import { getLocalGitCapabilityCache } from './git-capability-state' +import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' +import { gitExecFileAsync } from './runner' + +const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 +const REF_SEARCH_LEGACY_HEADROOM = 100 + +type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' + +function getRefSearchTokens(normalizedQuery: string): string[] { + return normalizedQuery.split('/').filter((token) => token.length > 0) +} + +function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER + return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM +} + +/** Build the bounded `for-each-ref` argv shared by local and remote searches. */ +export function buildSearchBaseRefsArgv( + normalizedQuery: string, + limit: number, + options: { + excludeRemoteHead?: boolean + remoteNames?: readonly string[] + patternGroup?: RefSearchPatternGroup + } = {} +): string[] { + const excludeRemoteHead = options.excludeRemoteHead ?? true + const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) + const base = [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)', + '--sort=-committerdate', + ...(excludeRemoteHead ? ['--exclude=refs/remotes/**/HEAD'] : []), + `--count=${candidateCount}` + ] + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length <= 1) { + const query = tokens[0] ?? '' + return [ + ...base, + `refs/heads/**/*${query}*`, + `refs/heads/**/*${query}*/**`, + `refs/remotes/**/*${query}*`, + `refs/remotes/**/*${query}*/**` + ] + } + + const segmented = tokens.map((token) => `*${token}*`).join('/') + const substringQuery = tokens.join('/') + const remoteBranchRootPatterns = + options.remoteNames && options.remoteNames.length > 0 + ? options.remoteNames.flatMap((remote) => [ + `refs/remotes/${remote}/${substringQuery}*`, + `refs/remotes/${remote}/${substringQuery}*/**` + ]) + : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] + const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] + const branchRootPatterns = [ + `refs/heads/${substringQuery}*`, + `refs/heads/${substringQuery}*/**`, + ...remoteBranchRootPatterns + ] + const patterns = + options.patternGroup === 'segmented' + ? segmentedPatterns + : options.patternGroup === 'branchRoot' + ? branchRootPatterns + : [...segmentedPatterns, ...branchRootPatterns] + return [...base, ...patterns] +} + +async function runSearchBaseRefsGit( + path: string, + normalizedQuery: string, + limit: number, + options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } +): Promise<{ stdout: string }> { + return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( + 'for-each-ref-exclude', + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + isForEachRefExcludeUnsupportedError + ) +} + +export function mergeBaseRefSearchResultGroups( + groups: readonly BaseRefSearchResult[][], + limit: number +): BaseRefSearchResult[] { + const seen = new Set() + const merged: BaseRefSearchResult[] = [] + const maxLength = Math.max(0, ...groups.map((group) => group.length)) + for (let index = 0; index < maxLength && merged.length < limit; index += 1) { + for (const group of groups) { + const entry = group[index] + if (!entry || seen.has(entry.refName)) { + continue + } + seen.add(entry.refName) + merged.push(entry) + if (merged.length >= limit) { + break + } + } + } + return merged +} + +export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { + return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) +} + +export async function searchBaseRefDetails( + path: string, + query: string, + limit = 25 +): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + return [] + } + const normalizedQuery = normalizeRefSearchQuery(query) + + try { + const remotes = await listRemoteNames(path) + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length > 1) { + const results = await Promise.all([ + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'segmented' + }), + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'branchRoot' + }) + ]) + return mergeBaseRefSearchResultGroups( + results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), + limit + ) + } + + const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes + }) + return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) + } catch (err) { + console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) + return [] + } +} + +export async function listRemoteNames( + path: string, + options: LocalGitExecOptions = {} +): Promise { + try { + const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +export function parseAndFilterSearchRefDetails( + stdout: string, + limit: number, + remotes: string[] = [] +): BaseRefSearchResult[] { + const seen = new Set() + const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const nul = line.indexOf('\0') + return nul === -1 ? null : { full: line.slice(0, nul), short: line.slice(nul + 1) } + }) + .filter((entry): entry is { full: string; short: string } => entry !== null) + .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) + .filter(({ short }) => { + if (seen.has(short)) { + return false + } + seen.add(short) + return true + }) + .map(({ full, short }) => ({ + refName: short, + localBranchName: resolveLocalBranchName(full, short, sortedRemotes) + })) + .slice(0, Math.max(0, limit)) +} + +export function resolveLocalBranchName( + fullRef: string, + shortRef: string, + remotes: string[] +): string { + const remoteRefPrefix = 'refs/remotes/' + if (!fullRef.startsWith(remoteRefPrefix)) { + return shortRef + } + const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) + const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) + if (remote) { + return remoteAndBranch.slice(remote.length + 1) + } + return remoteAndBranch.split('/').slice(1).join('/') || shortRef +} + +export function normalizeRefSearchQuery(query: string): string { + return query.trim().replace(/[*?[\]\\]/g, '') +} diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts new file mode 100644 index 00000000000..dbadf254d11 --- /dev/null +++ b/src/main/git/repo-branch-conflict.ts @@ -0,0 +1,59 @@ +import { listRemoteNames, resolveLocalBranchName } from './repo-base-ref-search' +import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' +import { gitExecFileAsync } from './runner' + +export type BranchConflictKind = 'local' | 'remote' + +async function hasGitRefAsync( + path: string, + ref: string, + options: LocalGitExecOptions = {} +): Promise { + try { + await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) + return true + } catch { + return false + } +} + +export async function getBranchConflictKind( + path: string, + branchName: string, + allowedBaseRef?: string, + options: LocalGitExecOptions = {} +): Promise { + if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { + return 'local' + } + + try { + const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) + const { stdout } = await gitExecFileAsync( + ['for-each-ref', '--format=%(refname)', 'refs/remotes'], + gitExecOptions(path, options) + ) + const hasRemoteConflict = stdout.split('\n').some((ref) => { + const trimmed = ref.trim() + if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { + return false + } + const shortRef = trimmed.replace(/^refs\/remotes\//, '') + return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName + }) + + return hasRemoteConflict ? 'remote' : null + } catch { + return null + } +} + +function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { + if (!allowedBaseRef) { + return false + } + const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') + ? allowedBaseRef + : `refs/remotes/${allowedBaseRef}` + return refName === normalizedAllowedRef +} diff --git a/src/main/git/repo-default-base-ref.ts b/src/main/git/repo-default-base-ref.ts new file mode 100644 index 00000000000..59adaeed512 --- /dev/null +++ b/src/main/git/repo-default-base-ref.ts @@ -0,0 +1,133 @@ +import { gitExecFileAsync, gitExecFileSync } from './runner' + +export type LocalGitExecOptions = { + wslDistro?: string +} + +export type LocalDefaultBaseRefGitOptions = { + cwd: string + wslDistro?: string +} + +export const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 + +export function gitExecOptions( + cwd: string, + options: LocalGitExecOptions = {} +): { cwd: string; wslDistro?: string } { + return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +} + +export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ + { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, + { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, + { ref: 'refs/heads/main', returnAs: 'main' }, + { ref: 'refs/heads/master', returnAs: 'master' } +] + +async function resolveDefaultBaseRefFromProbes( + hasRef: (ref: string) => Promise +): Promise { + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (await hasRef(ref)) { + return returnAs + } + } + return null +} + +function hasGitRef(path: string, ref: string): boolean { + try { + gitExecFileSync(['rev-parse', '--verify', ref], { cwd: path }) + return true + } catch { + return false + } +} + +function gitRefToDefaultBaseRef(ref: string): string { + return ref.replace(/^refs\/remotes\//, '') +} + +function getVerifiedOriginHeadBaseRef(path: string): string | null { + try { + const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: path + }).trim() + return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null + } catch { + return null + } +} + +/** Resolve the default base ref without inventing a fallback branch. */ +export function getDefaultBaseRef(path: string): string | null { + const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) + if (originHeadBaseRef) { + return originHeadBaseRef + } + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (hasGitRef(path, ref)) { + return returnAs + } + } + return null +} + +export async function getBaseRefDefault( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return getDefaultBaseRefAsync(path, options) +} + +export type GitExec = (argv: string[]) => Promise<{ stdout: string }> + +async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { + try { + await exec(['rev-parse', '--verify', '--quiet', ref]) + return true + } catch { + return false + } +} + +async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { + try { + const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) + const ref = stdout.trim() + if (!ref || !(await hasGitRefViaExec(exec, ref))) { + return null + } + return gitRefToDefaultBaseRef(ref) + } catch { + return null + } +} + +/** Resolve the same default-base ordering through a host-owned Git executor. */ +export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { + const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) + if (originHeadBaseRef) { + return originHeadBaseRef + } + return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) +} + +export function resolveDefaultBaseRefWithLocalGit( + options: LocalDefaultBaseRefGitOptions +): Promise { + return resolveDefaultBaseRefViaExec((argv) => + gitExecFileAsync(argv, { + ...options, + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + }) + ) +} + +export function getDefaultBaseRefAsync( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) +} diff --git a/src/main/git/repo-default-remote.test.ts b/src/main/git/repo-default-remote.test.ts new file mode 100644 index 00000000000..a0764598fb8 --- /dev/null +++ b/src/main/git/repo-default-remote.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn() +})) + +import { getDefaultRemote } from './repo' + +describe('getDefaultRemote', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('prefers the configured remote for the resolved default branch', async () => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n' } + } + if (argv[0] === 'rev-parse') { + return { stdout: 'abc123\n' } + } + if (argv[0] === 'config') { + return { stdout: 'upstream\n' } + } + throw new Error('unexpected command') + }) + + await expect(getDefaultRemote('/repo')).resolves.toBe('upstream') + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['config', '--get', 'branch.main.remote'], + { cwd: '/repo' } + ) + }) + + it.each([ + ['origin\nupstream\n', 'origin'], + ['company\n', 'company'] + ])('falls back from unresolved defaults for remotes %j', async (stdout, expected) => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'remote') { + return { stdout } + } + throw new Error('missing ref') + }) + + await expect(getDefaultRemote('/repo')).resolves.toBe(expected) + }) + + it.each([ + ['', 'Repo has no configured git remotes.'], + [ + 'upstream\nfork\n', + 'Repo has multiple remotes (upstream, fork) and no default is configured. Set branch..remote.' + ] + ])('preserves the exact ambiguous-remote error for %j', async (stdout, message) => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'remote') { + return { stdout } + } + throw new Error('missing ref') + }) + + await expect(getDefaultRemote('/repo')).rejects.toThrow(message) + }) + + it('normalizes a non-Error remote rejection', async () => { + for (let probe = 0; probe < 5; probe += 1) { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('missing ref')) + } + gitExecFileAsyncMock.mockRejectedValueOnce('transport failed') + + await expect(getDefaultRemote('/repo')).rejects.toThrow( + 'Failed to resolve default remote for repo.' + ) + }) +}) diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts new file mode 100644 index 00000000000..2f67005a4e7 --- /dev/null +++ b/src/main/git/repo-detection.ts @@ -0,0 +1,141 @@ +import { existsSync, statSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { scanGitMarkerSync, resolveRealPathSync } from './repo-git-marker-scan' +import { gitExecFileSync } from './runner' + +type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' + +let warnedMarkerFallbackThisSession = false + +/** Check if a path is a valid git repository (regular or bare). */ +export function isGitRepo(path: string): boolean { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return false + } + } catch { + return false + } + + const gitProbeResult = probeGitRepo(path) + if (gitProbeResult === 'repo') { + return true + } + if (gitProbeResult === 'not-repo') { + return false + } + + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { + warnedMarkerFallbackThisSession = true + console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { + path + }) + } + return markerScan.status === 'valid' +} + +/** Only a clean pair of negative Git answers is a definitive non-repo. */ +function probeGitRepo(path: string): GitRepoProbeResult { + let sawFailure = false + + try { + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path + }).trim() + if (insideWorkTree === 'true') { + return 'repo' + } + if (insideWorkTree !== 'false') { + return 'indeterminate' + } + } catch { + sawFailure = true + } + + try { + const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { + cwd: path + }).trim() + if (bareRepo === 'true') { + return 'repo' + } + if (bareRepo !== 'false') { + return 'indeterminate' + } + } catch { + sawFailure = true + } + + return sawFailure ? 'indeterminate' : 'not-repo' +} + +export function getGitRepoRoot(path: string): string { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return path + } + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path + }).trim() + if (insideWorkTree === 'true') { + const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { + cwd: path + }).trim() + return normalizeGitRepoRootForInputPath(path, root) + } + } catch { + // Fall through to preserving the original path. + } + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid') { + return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) + } + return path +} + +function canonicalizeGitDirPath(path: string): string { + return resolveRealPathSync(path) ?? path +} + +/** Return the main-checkout path only when `path` is a linked worktree. */ +export function getLinkedWorktreeMainRepoRoot(path: string): string | null { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return null + } + if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { + return null + } + const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { + cwd: path + }) + .split('\n') + .map((line) => line.trim()) + if (!gitDir || !commonDir) { + return null + } + const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) + if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { + return null + } + if (basename(absoluteCommonDir) !== '.git') { + return null + } + return getGitRepoRoot(dirname(absoluteCommonDir)) + } catch { + return null + } +} + +export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { + const inputWsl = parseWslUncPath(inputPath) + if (inputWsl && rootPath.startsWith('/')) { + // Why: persist the UNC root so later Git calls keep routing through the WSL runner. + return toWindowsWslPath(rootPath, inputWsl.distro) + } + return normalizeRuntimePathSeparators(rootPath) +} diff --git a/src/main/git/repo-git-marker-scan.ts b/src/main/git/repo-git-marker-scan.ts new file mode 100644 index 00000000000..5442d74d391 --- /dev/null +++ b/src/main/git/repo-git-marker-scan.ts @@ -0,0 +1,251 @@ +import { readFileSync, realpathSync, statSync } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' + +export type GitMarkerScanResult = + | { status: 'valid'; rootPath: string } + | { status: 'absent' | 'invalid' } + +/** Filesystem fallback for genuine Git metadata when git cannot answer cleanly. */ +export function scanGitMarkerSync(path: string): GitMarkerScanResult { + const realPath = resolveRealPathSync(path) + if (realPath && realPath !== path) { + const lexicalScan = scanGitMarkerAncestorsSync(path) + const realPathScan = scanGitMarkerAncestorsSync(realPath) + if ( + lexicalScan.status === 'valid' && + realPathScan.status === 'valid' && + pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) + ) { + // Why: preserve lexical spellings, but let a cross-repo symlink bind to its real target. + return lexicalScan + } + return realPathScan + } + return scanGitMarkerAncestorsSync(path) +} + +export function resolveRealPathSync(path: string): string | null { + try { + return realpathSync.native(path) + } catch { + try { + return realpathSync(path) + } catch { + return null + } + } +} + +function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { + for (const candidate of ancestorDirectories(path)) { + if (!isInsideDotGitMarker(candidate, path)) { + const worktreeMarker = scanWorktreeMarkerSync(candidate) + if (worktreeMarker.status !== 'absent') { + return worktreeMarker + } + } + if (hasValidBareRepoMarkerSync(candidate)) { + return { status: 'valid', rootPath: candidate } + } + } + return { status: 'absent' } +} + +function ancestorDirectories(path: string): string[] { + const directories: string[] = [] + let current = path + while (true) { + directories.push(current) + const parent = dirname(current) + if (parent === current) { + return directories + } + current = parent + } +} + +function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { + const relativePath = relative(rootPath, targetPath) + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + return false + } + const firstSegment = relativePath.split(/[\\/]+/)[0] + if (firstSegment === '.git') { + return true + } + if (firstSegment.toLowerCase() !== '.git') { + return false + } + return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) +} + +function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { + try { + const leftStat = statSync(leftPath) + const rightStat = statSync(rightPath) + if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { + return true + } + const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) + const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) + return process.platform === 'win32' + ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() + : leftRealPath === rightRealPath + } catch { + return false + } +} + +function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { + const dotGit = join(worktreePath, '.git') + let marker: ReturnType + try { + marker = statSync(dotGit) + } catch { + return { status: 'absent' } + } + + if (marker.isDirectory()) { + return hasValidGitDirectorySync(dotGit) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + if (marker.isFile()) { + let gitDir: string | null + try { + gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) + } catch { + return { status: 'invalid' } + } + return gitDir !== null && hasValidGitDirectorySync(gitDir) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + return { status: 'invalid' } +} + +function parseGitdirFile(basePath: string, content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) + if (!match) { + return null + } + return resolveGitMetadataPath(basePath, match[1]) +} + +function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { + const value = rawPath.trim() + if (!value) { + return null + } + const baseWsl = parseWslUncPath(basePath) + if (baseWsl && value.startsWith('/')) { + return toWindowsWslPath(value, baseWsl.distro) + } + return isAbsolute(value) ? value : resolve(basePath, value) +} + +function hasValidGitDirectorySync(gitDir: string): boolean { + return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) +} + +function hasValidCommonGitDirectorySync(gitDir: string): boolean { + try { + return ( + statSync(join(gitDir, 'HEAD')).isFile() && + statSync(join(gitDir, 'objects')).isDirectory() && + statSync(join(gitDir, 'refs')).isDirectory() + ) + } catch { + return false + } +} + +function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { + try { + if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { + return false + } + const commonDir = resolveGitMetadataPath( + gitDir, + readFileSync(join(gitDir, 'commondir'), 'utf8') + ) + return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) + } catch { + return false + } +} + +function hasValidBareRepoMarkerSync(path: string): boolean { + return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) +} + +function gitConfigDeclaresNonBare(gitDir: string): boolean { + try { + const config = readFileSync(join(gitDir, 'config'), 'utf8') + let inCoreSection = false + for (const line of config.split(/\r?\n/)) { + const section = line.match(/^\s*\[([^\]]+)\]/) + if (section) { + inCoreSection = section[1].trim().toLowerCase() === 'core' + continue + } + const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) + if (inCoreSection && bare) { + return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) + } + } + return false + } catch { + return false + } +} + +function normalizeGitConfigValue(value: string): string { + const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') + if ( + unescaped.length >= 2 && + ((unescaped.startsWith('"') && unescaped.endsWith('"')) || + (unescaped.startsWith("'") && unescaped.endsWith("'"))) + ) { + return unescaped.slice(1, -1) + } + return unescaped +} + +function stripGitConfigInlineComment(value: string): string { + let quote: '"' | "'" | null = null + let escaped = false + for (let i = 0; i < value.length; i++) { + const char = value[i] + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '#' || char === ';') { + return value.slice(0, i) + } + } + return value +} + +function isGitBooleanFalse(value: string): boolean { + return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) +} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 50d8a278be2..fcf5167f907 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,542 +1,57 @@ -/* oxlint-disable max-lines */ -import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { gitExecFileSync, gitExecFileAsync } from './runner' -import type { BaseRefSearchResult } from '../../shared/repo-types' +import { basename } from 'node:path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' -import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' -import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' -import { getLocalGitCapabilityCache } from './git-capability-state' +import { + DEFAULT_BASE_REF_PROBE_TIMEOUT_MS, + getDefaultBaseRef, + getDefaultBaseRefAsync, + gitExecOptions, + type LocalGitExecOptions +} from './repo-default-base-ref' +import { gitExecFileAsync, gitExecFileSync } from './runner' -type LocalGitExecOptions = { - wslDistro?: string -} - -type LocalDefaultBaseRefGitOptions = { - cwd: string - wslDistro?: string -} - -const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 - -type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' -type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } - -function gitExecOptions( - cwd: string, - options: LocalGitExecOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } -} - -/** - * Ordered probe list for a repo's default base ref when no origin/HEAD symbolic-ref is set. - * `returnAs` is the short-name format the UI expects (as `for-each-ref --format=%(refname:short)` renders it). - * Shared local/SSH so both resolve identical defaults. - */ -export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ - { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, - { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, - { ref: 'refs/heads/main', returnAs: 'main' }, - { ref: 'refs/heads/master', returnAs: 'master' } -] - -/** - * Walk DEFAULT_BASE_REF_PROBES in order, returning the first ref `hasRef` confirms, or null. - * Abstracts the existence test so local and SSH paths share one authoritative probe ordering. - */ -async function resolveDefaultBaseRefFromProbes( - hasRef: (ref: string) => Promise -): Promise { - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (await hasRef(ref)) { - return returnAs - } - } - return null -} - -/** Check if a path is a valid git repository (regular or bare). */ -export function isGitRepo(path: string): boolean { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return false - } - } catch { - return false - } - - // Ask git directly first — authoritative for work trees, linked worktrees, submodules, and bare repos. - const gitProbeResult = probeGitRepo(path) - if (gitProbeResult === 'repo') { - return true - } - if (gitProbeResult === 'not-repo') { - return false - } - - // Why: rev-parse can fail for reasons unrelated to repo-ness (spawn hiccup, config error); fall back to a - // validated `.git` marker instead of downgrading a real repo to a plain folder (the spurious "Open as Folder" bug). - const markerScan = scanGitMarkerSync(path) - if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { - // Why: warn once per session; the folder scanner calls isGitRepo for many paths and would otherwise flood logs. - warnedMarkerFallbackThisSession = true - console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { - path - }) - } - return markerScan.status === 'valid' -} - -let warnedMarkerFallbackThisSession = false - -/** - * Tri-state git probe: only a clean pair of negative answers is a definitive - * non-repo. Spawn/config failures stay indeterminate so marker fallback can run. - */ -function probeGitRepo(path: string): GitRepoProbeResult { - let sawFailure = false - - try { - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - return 'repo' - } - if (insideWorkTree !== 'false') { - return 'indeterminate' - } - } catch { - sawFailure = true - } - - try { - const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { - cwd: path - }).trim() - if (bareRepo === 'true') { - return 'repo' - } - if (bareRepo !== 'false') { - return 'indeterminate' - } - } catch { - sawFailure = true - } - - return sawFailure ? 'indeterminate' : 'not-repo' -} - -export function getGitRepoRoot(path: string): string { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return path - } - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { - cwd: path - }).trim() - return normalizeGitRepoRootForInputPath(path, root) - } - } catch { - // Fall through to preserving the original path. - } - const markerScan = scanGitMarkerSync(path) - if (markerScan.status === 'valid') { - return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) - } - return path -} - -function canonicalizeGitDirPath(path: string): string { - return resolveRealPathSync(path) ?? path -} - -/** - * Main-checkout path when `path` is a *linked* worktree, else null (main worktree, bare repo, - * non-repo, or any git failure). A linked worktree's `--git-dir` is `/worktrees/` - * while the main worktree's equals `--git-common-dir`; comparing the two from one invocation is - * git's own canonical test and avoids symlink-canonicalization mismatches. Baseline-safe: both - * flags long predate Git 2.25, and a relative answer resolves against `path` as old Git reports it. - */ -export function getLinkedWorktreeMainRepoRoot(path: string): string | null { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return null - } - if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { - return null - } - const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { - cwd: path - }) - .split('\n') - .map((line) => line.trim()) - if (!gitDir || !commonDir) { - return null - } - // Why realpath both: git answers one flag absolutely (already symlink-resolved) and the other - // relative to cwd, so a repo under a symlinked root (macOS /var -> /private/var) compares - // unequal on raw strings and a main checkout gets misread as a linked worktree. - const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) - if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { - return null - } - // A bare/separate git dir has no adjacent working checkout to point at. - if (basename(absoluteCommonDir) !== '.git') { - return null - } - // Re-resolve through getGitRepoRoot so the returned path matches the canonical form - // add-project stores for the main checkout (symlinks resolved the way git reports them). - return getGitRepoRoot(dirname(absoluteCommonDir)) - } catch { - return null - } -} - -export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { - const inputWsl = parseWslUncPath(inputPath) - if (inputWsl && rootPath.startsWith('/')) { - // Why: WSL git reports Linux-native roots; persist the UNC path so later git calls keep routing through the WSL runner. - return toWindowsWslPath(rootPath, inputWsl.distro) - } - return normalizeRuntimePathSeparators(rootPath) -} - -/** - * Filesystem-only fallback check for genuine Git metadata when git can't answer cleanly. Strict enough to - * reject a garbage `.git` file (validation from 18ed7b27d): accepts a `.git` dir/file with real gitdir shape - * or a bare-repo root (HEAD + objects/ + refs/, not a worktree admin dir). - */ -function scanGitMarkerSync(path: string): GitMarkerScanResult { - const realPath = resolveRealPathSync(path) - if (realPath && realPath !== path) { - const lexicalScan = scanGitMarkerAncestorsSync(path) - const realPathScan = scanGitMarkerAncestorsSync(realPath) - if ( - lexicalScan.status === 'valid' && - realPathScan.status === 'valid' && - pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) - ) { - // Why: preserve lexical spellings (/var vs /private/var), but let a cross-repo symlink bind to the real target like git. - return lexicalScan - } - return realPathScan - } - return scanGitMarkerAncestorsSync(path) -} - -function resolveRealPathSync(path: string): string | null { - try { - return realpathSync.native(path) - } catch { - try { - return realpathSync(path) - } catch { - return null - } - } -} - -function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { - for (const candidate of ancestorDirectories(path)) { - if (!isInsideDotGitMarker(candidate, path)) { - const worktreeMarker = scanWorktreeMarkerSync(candidate) - if (worktreeMarker.status !== 'absent') { - return worktreeMarker - } - } - if (hasValidBareRepoMarkerSync(candidate)) { - return { status: 'valid', rootPath: candidate } - } - } - return { status: 'absent' } -} - -function ancestorDirectories(path: string): string[] { - const directories: string[] = [] - let current = path - while (true) { - directories.push(current) - const parent = dirname(current) - if (parent === current) { - return directories - } - current = parent - } -} - -function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { - const relativePath = relative(rootPath, targetPath) - if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { - return false - } - const firstSegment = relativePath.split(/[\\/]+/)[0] - if (firstSegment === '.git') { - return true - } - if (firstSegment.toLowerCase() !== '.git') { - return false - } - return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) -} - -function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { - try { - const leftStat = statSync(leftPath) - const rightStat = statSync(rightPath) - if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { - return true - } - const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) - const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) - return process.platform === 'win32' - ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() - : leftRealPath === rightRealPath - } catch { - return false - } -} - -function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { - const dotGit = join(worktreePath, '.git') - let marker: ReturnType - try { - marker = statSync(dotGit) - } catch { - return { status: 'absent' } - } - - if (marker.isDirectory()) { - return hasValidGitDirectorySync(dotGit) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - if (marker.isFile()) { - let gitDir: string | null - try { - gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) - } catch { - return { status: 'invalid' } - } - return gitDir !== null && hasValidGitDirectorySync(gitDir) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - return { status: 'invalid' } -} - -function parseGitdirFile(basePath: string, content: string): string | null { - const firstLine = content.split(/\r?\n/, 1)[0] ?? '' - const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) - if (!match) { - return null - } - return resolveGitMetadataPath(basePath, match[1]) -} - -function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { - const value = rawPath.trim() - if (!value) { - return null - } - const baseWsl = parseWslUncPath(basePath) - if (baseWsl && value.startsWith('/')) { - return toWindowsWslPath(value, baseWsl.distro) - } - return isAbsolute(value) ? value : resolve(basePath, value) -} - -function hasValidGitDirectorySync(gitDir: string): boolean { - return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) -} - -function hasValidCommonGitDirectorySync(gitDir: string): boolean { - try { - return ( - statSync(join(gitDir, 'HEAD')).isFile() && - statSync(join(gitDir, 'objects')).isDirectory() && - statSync(join(gitDir, 'refs')).isDirectory() - ) - } catch { - return false - } -} - -function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { - try { - if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { - return false - } - const commonDir = resolveGitMetadataPath( - gitDir, - readFileSync(join(gitDir, 'commondir'), 'utf8') - ) - return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) - } catch { - return false - } -} - -function hasValidBareRepoMarkerSync(path: string): boolean { - return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) -} - -function gitConfigDeclaresNonBare(gitDir: string): boolean { - try { - const config = readFileSync(join(gitDir, 'config'), 'utf8') - let inCoreSection = false - for (const line of config.split(/\r?\n/)) { - const section = line.match(/^\s*\[([^\]]+)\]/) - if (section) { - inCoreSection = section[1].trim().toLowerCase() === 'core' - continue - } - const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) - if (inCoreSection && bare) { - return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) - } - } - return false - } catch { - return false - } -} - -function normalizeGitConfigValue(value: string): string { - const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') - if ( - unescaped.length >= 2 && - ((unescaped.startsWith('"') && unescaped.endsWith('"')) || - (unescaped.startsWith("'") && unescaped.endsWith("'"))) - ) { - return unescaped.slice(1, -1) - } - return unescaped -} - -function stripGitConfigInlineComment(value: string): string { - let quote: '"' | "'" | null = null - let escaped = false - for (let i = 0; i < value.length; i++) { - const char = value[i] - if (escaped) { - escaped = false - continue - } - if (char === '\\') { - escaped = true - continue - } - if (quote) { - if (char === quote) { - quote = null - } - continue - } - if (char === '"' || char === "'") { - quote = char - continue - } - if (char === '#' || char === ';') { - return value.slice(0, i) - } - } - return value -} - -function isGitBooleanFalse(value: string): boolean { - return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) -} +export { + isGitRepo, + getGitRepoRoot, + getLinkedWorktreeMainRepoRoot, + normalizeGitRepoRootForInputPath +} from './repo-detection' +export { + DEFAULT_BASE_REF_PROBES, + getDefaultBaseRef, + getBaseRefDefault, + resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit +} from './repo-default-base-ref' +export type { GitExec } from './repo-default-base-ref' +export { + buildSearchBaseRefsArgv, + mergeBaseRefSearchResultGroups, + searchBaseRefs, + searchBaseRefDetails, + parseAndFilterSearchRefDetails, + normalizeRefSearchQuery +} from './repo-base-ref-search' +export { getBranchConflictKind } from './repo-branch-conflict' +export type { BranchConflictKind } from './repo-branch-conflict' +export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' /** Get a human-readable name for the repo from its path. */ export function getRepoName(path: string): string { const name = basename(path) - // Strip .git suffix from bare repos return name.endsWith('.git') ? name.slice(0, -4) : name } /** Get the remote origin URL, or null if not set. */ export function getRemoteUrl(path: string): string | null { try { - return getRemoteUrlByName(path, 'origin') + return gitExecFileSync(['remote', 'get-url', 'origin'], { cwd: path }).trim() } catch { return null } } -function getRemoteUrlByName(path: string, remote: string): string { - return gitExecFileSync(['remote', 'get-url', remote], { - cwd: path - }).trim() -} - -function hasGitRef(path: string, ref: string): boolean { - try { - gitExecFileSync(['rev-parse', '--verify', ref], { - cwd: path - }) - return true - } catch { - return false - } -} - -function gitRefToDefaultBaseRef(ref: string): string { - return ref.replace(/^refs\/remotes\//, '') -} - -function getVerifiedOriginHeadBaseRef(path: string): string | null { - try { - const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: path - }).trim() - - // Why: origin/HEAD may survive a default-branch rename pointing at a deleted ref; verify before trusting it. - return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null - } catch { - return null - } -} - -/** - * Resolve the default base ref for new worktrees, preferring the remote primary over a stale local branch. - * Returns null when nothing resolves (rather than a hardcoded `origin/main`) so callers fail loudly or degrade. - */ -export function getDefaultBaseRef(path: string): string | null { - const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) - if (originHeadBaseRef) { - return originHeadBaseRef - } - - // Why: walk the shared DEFAULT_BASE_REF_PROBES so sync and async/SSH paths can't drift on ref order. - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (hasGitRef(path, ref)) { - return returnAs - } - } - return null -} - -export async function getBaseRefDefault( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return getDefaultBaseRefAsync(path, options) -} - -/** - * Return { ahead, behind } (merge-base-symmetric delta) for localRef vs remoteRef, or null on failure. - * ahead = commits on localRef not in remoteRef; behind = the reverse. Used by the stale-base dispatch guard (§3.1). - * Async because this runs before every orchestration dispatch on Electron main. - */ +/** Return the symmetric commit delta, or null when Git cannot prove it. */ export async function getRemoteDrift( repoPath: string, localRef: string, @@ -561,10 +76,7 @@ export async function getRemoteDrift( } } -/** - * Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. - * Powers the preamble drift section (§3.2) so a worker sees whether stale-base drift touches its area. - */ +/** Return recent subjects on the remote side of the local/remote delta. */ export async function getRecentDriftSubjects( repoPath: string, localRef: string, @@ -580,238 +92,34 @@ export async function getRecentDriftSubjects( timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS } ) - return stdout.split('\n').filter((s) => s.trim().length > 0) + return stdout.split('\n').filter((subject) => subject.trim().length > 0) } catch { return [] } } -/** Parse `git remote` stdout into a remote count. Shared local/SSH so count semantics can't drift. */ +/** Parse `git remote` stdout into a remote count. */ export function parseRemoteCount(stdout: string): number { return stdout.split('\n').filter((line) => line.trim().length > 0).length } -/** Count configured remotes via `git remote`; returns 0 on error (callers read 0 as "unknown / no hint"). */ +/** Count configured remotes; zero means either none or unavailable. */ export async function getRemoteCount(path: string): Promise { try { const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) return parseRemoteCount(stdout) } catch (err) { - // Why: log so a missing multi-remote hint is debuggable; callers still treat 0 as "unknown". console.warn('[getRemoteCount] git remote failed', { path, err }) return 0 } } -/** Callback shape for a git exec function that yields stdout. */ -export type GitExec = (argv: string[]) => Promise<{ stdout: string }> - -async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { - try { - await exec(['rev-parse', '--verify', '--quiet', ref]) - return true - } catch { - return false - } -} - -async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { - try { - const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - const ref = stdout.trim() - if (!ref || !(await hasGitRefViaExec(exec, ref))) { - return null - } - return gitRefToDefaultBaseRef(ref) - } catch { - return null - } -} - -/** - * Resolve the default base ref via a git exec callback: prefer origin/HEAD's symbolic-ref target, - * else fall back to DEFAULT_BASE_REF_PROBES. Shared local/SSH so both transports agree. - * - * Why swallow symbolic-ref's error: a non-zero exit is the expected "origin/HEAD unset" signal, not a failure. - */ -export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { - const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) - if (originHeadBaseRef) { - return originHeadBaseRef - } - return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) -} - -export function resolveDefaultBaseRefWithLocalGit( - options: LocalDefaultBaseRefGitOptions -): Promise { - return resolveDefaultBaseRefViaExec((argv) => - gitExecFileAsync(argv, { - ...options, - // Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound. - timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS - }) - ) -} - -async function getDefaultBaseRefAsync( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) -} - -/** - * Build the argv for `git for-each-ref` used by ref search, given an already-normalized query. - * - * Why: glob every remote (`refs/remotes/*\/*`), not just origin, so fork workflows can find branches like - * `upstream/main` — see docs/upstream-base-ref-design.md. Shared with the SSH relay path so argv can't diverge. - */ -const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 -const REF_SEARCH_LEGACY_HEADROOM = 100 - -type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' - -function getRefSearchTokens(normalizedQuery: string): string[] { - return normalizedQuery.split('/').filter((t) => t.length > 0) -} - -function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { - if (!Number.isInteger(limit) || limit <= 0) { - throw new Error('invalid_limit') - } - const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER - return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM -} - -export function buildSearchBaseRefsArgv( - normalizedQuery: string, - limit: number, - options: { - excludeRemoteHead?: boolean - remoteNames?: readonly string[] - patternGroup?: RefSearchPatternGroup - } = {} -): string[] { - const excludeRemoteHead = options.excludeRemoteHead ?? true - const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) - const base = [ - 'for-each-ref', - '--format=%(refname)%00%(refname:short)', - '--sort=-committerdate', - ...(excludeRemoteHead - ? [ - // Why: exclude remote HEAD pseudo-refs before --count so the candidate window holds displayable refs. - '--exclude=refs/remotes/**/HEAD' - ] - : []), - // Why: cap git output so broad globs don't overflow execFile/SSH buffers in very large repos. - `--count=${candidateCount}` - ] - // Why: split on `/` so display-format queries route each token to one ref segment; filter empties from stray slashes. - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length <= 1) { - const q = tokens[0] ?? '' - // Why `**` not `*`: fnmatch `*` can't cross `/`, so match slash-named branches at both leaf and ancestor segments. - return [ - ...base, - `refs/heads/**/*${q}*`, - `refs/heads/**/*${q}*/**`, - `refs/remotes/**/*${q}*`, - `refs/remotes/**/*${q}*/**` - ] - } - // Why: one `*token*` per ref segment because fnmatch `*` can't cross `/`; lets a retyped `/` result match. - const segmented = tokens.map((token) => `*${token}*`).join('/') - const substringQuery = tokens.join('/') - const remoteBranchRootPatterns = - options.remoteNames && options.remoteNames.length > 0 - ? options.remoteNames.flatMap((remote) => [ - `refs/remotes/${remote}/${substringQuery}*`, - `refs/remotes/${remote}/${substringQuery}*/**` - ]) - : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] - const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] - const branchRootPatterns = [ - // Why: branch names often contain slashes (plan/docs); these root patterns also match a local branch beneath any remote. - `refs/heads/${substringQuery}*`, - `refs/heads/${substringQuery}*/**`, - ...remoteBranchRootPatterns - ] - const patterns = - options.patternGroup === 'segmented' - ? segmentedPatterns - : options.patternGroup === 'branchRoot' - ? branchRootPatterns - : [...segmentedPatterns, ...branchRootPatterns] - return [...base, ...patterns] -} - -async function runSearchBaseRefsGit( - path: string, - normalizedQuery: string, - limit: number, - options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } -): Promise<{ stdout: string }> { - return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( - 'for-each-ref-exclude', - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - isForEachRefExcludeUnsupportedError - ) -} - -export function mergeBaseRefSearchResultGroups( - groups: readonly BaseRefSearchResult[][], - limit: number -): BaseRefSearchResult[] { - const seen = new Set() - const merged: BaseRefSearchResult[] = [] - const maxLength = Math.max(0, ...groups.map((group) => group.length)) - for (let index = 0; index < maxLength && merged.length < limit; index += 1) { - for (const group of groups) { - const entry = group[index] - if (!entry || seen.has(entry.refName)) { - continue - } - seen.add(entry.refName) - merged.push(entry) - if (merged.length >= limit) { - break - } - } - } - return merged -} - -export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' - -/** - * Resolve the default push remote for a repo. - * Order: remote configured on the current default branch → origin → the single - * remote when the repo has exactly one → error. - */ +/** Resolve the configured push remote without assuming a provider. */ export async function getDefaultRemote( path: string, options: LocalGitExecOptions = {} ): Promise { const defaultRef = await getDefaultBaseRefAsync(path, options) - // Why: getDefaultBaseRefAsync returns null when no default branch exists; guard so .includes() can't crash. const defaultBranch = defaultRef ? defaultRef.includes('/') ? defaultRef.split('/').slice(1).join('/') @@ -859,179 +167,7 @@ export async function getDefaultRemote( } } -export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { - return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) -} - -export async function searchBaseRefDetails( - path: string, - query: string, - limit = 25 -): Promise { - if (!Number.isInteger(limit) || limit <= 0) { - return [] - } - const normalizedQuery = normalizeRefSearchQuery(query) - - try { - // Why: argv lives in buildSearchBaseRefsArgv so the SSH sibling cannot drift. - const remotes = await listRemoteNames(path) - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length > 1) { - // Why: slash queries need both display-format and local-branch matches; merge before the limit so neither starves. - const results = await Promise.all([ - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'segmented' - }), - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'branchRoot' - }) - ]) - return mergeBaseRefSearchResultGroups( - results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), - limit - ) - } - - const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes - }) - return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) - } catch (err) { - // Why: log so a missing result set is debuggable; callers still treat [] as "no matches". - console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) - return [] - } -} - -async function listRemoteNames(path: string, options: LocalGitExecOptions = {}): Promise { - try { - const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) - return stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - } catch { - return [] - } -} - -export function parseAndFilterSearchRefDetails( - stdout: string, - limit: number, - remotes: string[] = [] -): BaseRefSearchResult[] { - const seen = new Set() - const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) - return ( - stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const nul = line.indexOf('\0') - if (nul === -1) { - // Why: no NUL means an unexpected %(refname) format; drop it rather than hand callers an unusable "short" ref. - return null - } - return { full: line.slice(0, nul), short: line.slice(nul + 1) } - }) - .filter((entry): entry is { full: string; short: string } => entry !== null) - // Why: drop `/HEAD` pseudo-refs; `.+` (not `[^/]+`) since git allows slashes in remote names. - .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) - .filter(({ short }) => { - if (seen.has(short)) { - return false - } - seen.add(short) - return true - }) - .map(({ full, short }) => ({ - refName: short, - localBranchName: resolveLocalBranchName(full, short, sortedRemotes) - })) - // Why: Math.max(0, limit) so pathological limit <= 0 yields zero results, not one. - .slice(0, Math.max(0, limit)) - ) -} - -function resolveLocalBranchName(fullRef: string, shortRef: string, remotes: string[]): string { - const remoteRefPrefix = 'refs/remotes/' - if (!fullRef.startsWith(remoteRefPrefix)) { - return shortRef - } - const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) - const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) - if (remote) { - return remoteAndBranch.slice(remote.length + 1) - } - return remoteAndBranch.split('/').slice(1).join('/') || shortRef -} - -export function normalizeRefSearchQuery(query: string): string { - return query.trim().replace(/[*?[\]\\]/g, '') -} - -async function hasGitRefAsync( - path: string, - ref: string, - options: LocalGitExecOptions = {} -): Promise { - try { - await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) - return true - } catch { - return false - } -} - -export type BranchConflictKind = 'local' | 'remote' - -export async function getBranchConflictKind( - path: string, - branchName: string, - allowedBaseRef?: string, - options: LocalGitExecOptions = {} -): Promise { - if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { - return 'local' - } - - try { - const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) - const { stdout } = await gitExecFileAsync( - ['for-each-ref', '--format=%(refname)', 'refs/remotes'], - gitExecOptions(path, options) - ) - const hasRemoteConflict = stdout.split('\n').some((ref) => { - const trimmed = ref.trim() - if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { - return false - } - const shortRef = trimmed.replace(/^refs\/remotes\//, '') - // Why: git allows slashes in remote names; use the configured list so foo/bar/feature resolves to branch "feature". - return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName - }) - - return hasRemoteConflict ? 'remote' : null - } catch { - return null - } -} - -function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { - if (!allowedBaseRef) { - return false - } - const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') - ? allowedBaseRef - : `refs/remotes/${allowedBaseRef}` - return refName === normalizedAllowedRef -} - -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a file+line; null when the remote isn't a recognized host. */ +/** Build a hosted file URL when the origin belongs to a supported provider. */ export function getRemoteFileUrl( repoPath: string, relativePath: string, @@ -1041,21 +177,20 @@ export function getRemoteFileUrl( if (!remoteUrl) { return null } - const defaultBaseRef = getDefaultBaseRef(repoPath) if (!defaultBaseRef) { return null } - const defaultBranch = defaultBaseRef.replace(/^origin\//, '') - - return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) + return buildHostedRemoteFileUrl( + remoteUrl, + relativePath, + defaultBaseRef.replace(/^origin\//, ''), + line + ) } -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a commit; null when origin isn't a recognized host. */ +/** Build a hosted commit URL when the origin belongs to a supported provider. */ export function getRemoteCommitUrl(repoPath: string, sha: string): string | null { const remoteUrl = getRemoteUrl(repoPath) - if (!remoteUrl) { - return null - } - return buildHostedRemoteCommitUrl(remoteUrl, sha) + return remoteUrl ? buildHostedRemoteCommitUrl(remoteUrl, sha) : null } diff --git a/src/main/providers/ssh-git-noninteractive-provider.ts b/src/main/providers/ssh-git-noninteractive-provider.ts new file mode 100644 index 00000000000..ee60b254134 --- /dev/null +++ b/src/main/providers/ssh-git-noninteractive-provider.ts @@ -0,0 +1,166 @@ +import type { CommitMessagePlan } from '../../shared/commit-message-plan' +import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' +import { SshGitReadProvider } from './ssh-git-read-provider' + +type NonInteractiveExecQueueEntry = { + started: boolean + canceled: boolean + done: Promise + release: () => void +} + +const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 + +export class SshGitNoninteractiveProvider extends SshGitReadProvider { + private readonly nonInteractiveExecQueues = new Map() + + async executeCommitMessagePlan( + plan: CommitMessagePlan, + cwd: string, + timeoutMs: number, + operation = 'commit-message' + ): Promise { + return this.runQueuedNonInteractiveExec( + cwd, + { + binary: plan.binary, + args: plan.args, + cwd, + stdin: plan.stdinPayload, + timeoutMs, + operation + }, + undefined, + operation + ) + } + + async execNonInteractive( + binary: string, + args: string[], + cwd: string, + timeoutMs: number, + signal?: AbortSignal, + env?: Record + ): Promise { + return this.runQueuedNonInteractiveExec( + cwd, + { + binary, + args, + cwd, + stdin: null, + timeoutMs, + ...(env ? { env } : {}) + }, + signal + ) + } + + async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { + const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) + const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) + if (queuedEntry) { + queuedEntry.canceled = true + return + } + await this.cancelActiveNonInteractiveExec(cwd, operation) + } + + async cancelGenerateCommitMessage( + worktreePath: string, + operation = 'commit-message' + ): Promise { + await this.cancelNonInteractiveExec(worktreePath, operation) + } + + private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { + try { + await this.mux.request('agent.cancelExec', { + cwd, + ...(operation ? { operation } : {}) + }) + } catch { + // Best-effort: callers are already unwinding after cancellation. + } + } + + private nonInteractiveLaneKey(cwd: string, operation?: string): string { + return JSON.stringify([operation || 'default', cwd]) + } + + private async runQueuedNonInteractiveExec( + cwd: string, + payload: { + binary: string + args: string[] + cwd: string + stdin: string | null + timeoutMs: number + env?: Record + operation?: string + }, + signal?: AbortSignal, + operation?: string + ): Promise { + const laneKey = this.nonInteractiveLaneKey(cwd, operation) + const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] + const previous = queue.at(-1)?.done ?? Promise.resolve() + let releaseEntry!: () => void + const entry: NonInteractiveExecQueueEntry = { + started: false, + canceled: false, + done: previous + .catch(() => {}) + .then( + () => + new Promise((resolve) => { + releaseEntry = resolve + }) + ), + release: () => releaseEntry() + } + queue.push(entry) + this.nonInteractiveExecQueues.set(laneKey, queue) + const abortEntry = (): void => { + if (!entry.started) { + entry.canceled = true + return + } + void this.cancelActiveNonInteractiveExec(cwd, operation) + } + if (signal?.aborted) { + entry.canceled = true + } else { + signal?.addEventListener('abort', abortEntry, { once: true }) + } + + await previous.catch(() => {}) + try { + if (entry.canceled) { + return { + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + canceled: true + } + } + entry.started = true + return (await this.mux.request('agent.execNonInteractive', payload, { + timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS + })) as RemoteCommitMessageExecResult + } finally { + signal?.removeEventListener('abort', abortEntry) + entry.release() + const currentQueue = this.nonInteractiveExecQueues.get(laneKey) + const entryIndex = currentQueue?.indexOf(entry) ?? -1 + if (entryIndex >= 0) { + currentQueue?.splice(entryIndex, 1) + } + if (currentQueue?.length === 0) { + this.nonInteractiveExecQueues.delete(laneKey) + } + } + } +} diff --git a/src/main/providers/ssh-git-provider-api.test.ts b/src/main/providers/ssh-git-provider-api.test.ts new file mode 100644 index 00000000000..dd0915dafe5 --- /dev/null +++ b/src/main/providers/ssh-git-provider-api.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { IGitProvider } from './types' +import { SshGitProvider } from './ssh-git-provider' +import { createMockMux } from './ssh-git-provider-test-harness' + +describe('SshGitProvider public API parity', () => { + it('retains every historical provider operation', () => { + const provider: IGitProvider & SshGitProvider = new SshGitProvider( + 'conn-1', + createMockMux() as never + ) + const methods = [ + 'getConnectionId', + 'getHostPlatform', + 'getStatus', + 'getSubmoduleStatus', + 'checkIgnoredPaths', + 'getHistory', + 'commit', + 'getStagedCommitContext', + 'executeCommitMessagePlan', + 'execNonInteractive', + 'cancelNonInteractiveExec', + 'cancelGenerateCommitMessage', + 'getDiff', + 'stageFile', + 'unstageFile', + 'bulkStageFiles', + 'bulkUnstageFiles', + 'discardChanges', + 'bulkDiscardChanges', + 'detectConflictOperation', + 'abortMerge', + 'abortRebase', + 'checkoutBranch', + 'listLocalBranches', + 'getBranchCompare', + 'getCommitCompare', + 'getUpstreamStatus', + 'pushBranch', + 'pullBranch', + 'fastForwardBranch', + 'rebaseFromBase', + 'fetchRemote', + 'syncForkDefaultBranch', + 'fetchRemoteTrackingRef', + 'fetchGitLabMergeRequestHead', + 'fetchGitHubPullRequestHead', + 'getBranchDiff', + 'getCommitDiff', + 'listWorktrees', + 'addWorktree', + 'removeWorktree', + 'worktreeIsClean', + 'refreshLocalBaseRefForWorktreeCreate', + 'renameCurrentBranch', + 'forceDeletePreservedBranch', + 'exec', + 'clone', + 'isGitRepoAsync', + 'isGitRepo', + 'getRemoteFileUrl', + 'getRemoteCommitUrl' + ] as const + + expect(methods).toHaveLength(51) + for (const method of methods) { + expect(provider[method], method).toBeTypeOf('function') + } + }) +}) diff --git a/src/main/providers/ssh-git-provider-exec.test.ts b/src/main/providers/ssh-git-provider-exec.test.ts index 9fefe4abfda..ef0c3198a8d 100644 --- a/src/main/providers/ssh-git-provider-exec.test.ts +++ b/src/main/providers/ssh-git-provider-exec.test.ts @@ -209,6 +209,40 @@ describe('SshGitProvider', () => { await second }) + it('isolates non-interactive lanes between provider instances', async () => { + const completeRequests: (() => void)[] = [] + mux.request.mockImplementation( + () => + new Promise((resolve) => { + completeRequests.push(() => + resolve({ stdout: '', stderr: '', exitCode: 0, timedOut: false }) + ) + }) + ) + const otherProvider = new SshGitProvider('conn-2', mux as never) + + const first = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) + const second = otherProvider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) + + await waitForRequestCount(mux.request, 2) + expect(mux.request).toHaveBeenCalledTimes(2) + completeRequests.forEach((complete) => complete()) + await Promise.all([first, second]) + }) + + it('forwards leading global Git options without reordering argv', async () => { + mux.request.mockResolvedValue({ stdout: '', stderr: '' }) + const args = ['-c', 'maintenance.auto=false', 'fetch', 'origin', 'main'] + + await provider.exec(args, '/home/user/repo') + + expect(mux.request).toHaveBeenCalledWith('git.exec', { + args, + cwd: '/home/user/repo', + __streamResponse: true + }) + }) + it('cancels a queued non-interactive exec without canceling the active relay child', async () => { const completeRequests: (() => void)[] = [] mux.request.mockImplementation( diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 8086548ed5d..5fd475a0698 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -1,229 +1,16 @@ -/* eslint-disable max-lines -- Why: this provider mirrors IGitProvider one - method per RPC call (~16 methods). Splitting it would only add - indirection — every method is a 1:1 forwarder to a relay RPC plus a - small amount of param plumbing. */ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { GitProviderStatusOptions, IGitProvider } from './types' -import type { - GitBranchCompareResult, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { - GitConflictOperation, - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' -import type { CommitMessagePlan } from '../../shared/commit-message-plan' -import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' +import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from '../git/max-buffer-overflow' -import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' -import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' -import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' -import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' - -type NonInteractiveExecQueueEntry = { - started: boolean - canceled: boolean - done: Promise - release: () => void -} - -const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 -const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const - -function isJsonRpcMethodNotFoundError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound -} - -// Why: the relay returns the durable ref it wrote; re-deriving it on the client -// can disagree (URL normalization) and leave resolve looking at the wrong path. -function readDurableReviewHeadLocalRef( - result: unknown, - kind: 'pull request' | 'merge request' -): string { - if (result && typeof result === 'object' && 'localRef' in result) { - const localRef = (result as { localRef: unknown }).localRef - if (typeof localRef === 'string') { - const trimmed = localRef.trim() - if (trimmed.startsWith('refs/orca/')) { - return trimmed - } - } - } - throw new Error( - `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` - ) -} - -function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { - if (entries.length === 0) { - return undefined - } - return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') -} - -function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { - const trackedLines = (stdout ?? '') - .split(/\r?\n/) - .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) - return trackedLines.length > 0 ? trackedLines.join('\n') : undefined -} - -export class SshGitProvider implements IGitProvider { - private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() - private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() - private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() - - private connectionId: string - private mux: SshChannelMultiplexer - private nonInteractiveExecQueues = new Map() - - private async runWithGitReadInvalidation(run: () => Promise): Promise { - this.invalidateGitReads() - try { - return await run() - } finally { - this.invalidateGitReads() - } - } - - private invalidateGitReads(): void { - this.gitDiffReadDedupe.clear() - this.statusReadLeaseOwner.invalidate() - this.upstreamStatusReadOwner.invalidate() - } - - private loggedWorktreeIsCleanFallback = false - - constructor( - connectionId: string, - mux: SshChannelMultiplexer, - private readonly hostPlatform: RemoteHostPlatform | null = null - ) { - this.connectionId = connectionId - this.mux = mux - } - - getConnectionId(): string { - return this.connectionId - } - - getHostPlatform(): RemoteHostPlatform | null { - return this.hostPlatform - } - - async getStatus( - worktreePath: string, - options?: GitProviderStatusOptions - ): Promise { - this.gitDiffReadDedupe.clear() - const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} - const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache - ? { bypassEffectiveUpstreamNegativeCache: true } - : {} - const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} - const branchLineTotalArgs = - options?.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: options.branchLineTotalMergeBase } - const request = { - worktreePath, - ...includeIgnoredArgs, - ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs, - ...branchLineTotalArgs - } - const key = stableInFlightKey([ - worktreePath, - options?.includeIgnored === true, - options?.bypassEffectiveUpstreamNegativeCache === true, - options?.reuseLineStats === true, - // Why: the result carries a total only for callers who asked, and only for - // this fork point, so a shared lease must never serve one to the other. - options?.branchLineTotalMergeBase ?? '' - ]) - return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { - return (await this.mux.request('git.status', request, { - signal: sharedSignal - })) as GitStatusResult - }) - } - - async getSubmoduleStatus( - worktreePath: string, - submodulePath: string, - area: GitStagingArea = 'unstaged' - ): Promise { - // Why: mirror getStatus() — refreshing submodule state must invalidate - // in-flight diff reads so a later diff can't reuse a stale pending RPC. - this.gitDiffReadDedupe.clear() - try { - return (await this.mux.request('git.submoduleStatus', { - worktreePath, - submodulePath, - area - })) as GitStatusResult - } catch (error) { - // Why: a newer desktop client may talk to an older relay that predates - // git.submoduleStatus; surface an actionable reconnect hint instead of a - // raw JSON-RPC method-not-found error in Source Control. - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw error - } - } - - async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { - return (await this.mux.request('git.checkIgnored', { - worktreePath, - paths: relativePaths - })) as string[] - } - - async getHistory( - worktreePath: string, - options: GitHistoryOptions = {} - ): Promise { - return (await this.mux.request('git.history', { - worktreePath, - ...options - })) as GitHistoryResult - } - - async commit( - worktreePath: string, - message: string - ): Promise<{ success: boolean; error?: string }> { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.commit', { - worktreePath, - message - })) as { success: boolean; error?: string } - ) - } +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' +import type { IGitProvider } from './types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitWorktreeProvider } from './ssh-git-worktree-provider' +export class SshGitProvider extends SshGitWorktreeProvider implements IGitProvider { async getStagedCommitContext(worktreePath: string): Promise { const branchPromise = this.exec(['branch', '--show-current'], worktreePath).catch(() => ({ stdout: '' @@ -247,9 +34,6 @@ export class SshGitProvider implements IGitProvider { if (!isMaxBufferOverflowError(error)) { throw error } - // Why: a very large staged diff can overflow the remote exec buffer. The - // patch is optional context (truncated later anyway), so degrade to the - // file-name summary instead of failing commit-message generation. console.warn( '[ssh-git] Staged patch too large to read; using file summary only:', describeMaxBufferOverflowError(error) @@ -262,594 +46,6 @@ export class SshGitProvider implements IGitProvider { } } - async executeCommitMessagePlan( - plan: CommitMessagePlan, - cwd: string, - timeoutMs: number, - operation = 'commit-message' - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary: plan.binary, - args: plan.args, - cwd, - stdin: plan.stdinPayload, - timeoutMs, - operation - }, - undefined, - operation - ) - } - - async execNonInteractive( - binary: string, - args: string[], - cwd: string, - timeoutMs: number, - signal?: AbortSignal, - env?: Record - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary, - args, - cwd, - stdin: null, - timeoutMs, - ...(env ? { env } : {}) - }, - signal - ) - } - - async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { - const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) - const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) - if (queuedEntry) { - queuedEntry.canceled = true - return - } - await this.cancelActiveNonInteractiveExec(cwd, operation) - } - - private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { - try { - await this.mux.request('agent.cancelExec', { - cwd, - ...(operation ? { operation } : {}) - }) - } catch { - // Best-effort: callers are already unwinding after cancellation. - } - } - - async cancelGenerateCommitMessage( - worktreePath: string, - operation = 'commit-message' - ): Promise { - // Why: best-effort — the relay returns `{canceled: false}` when there is - // nothing in flight. Callers should not block UI updates on this. - await this.cancelNonInteractiveExec(worktreePath, operation) - } - - private nonInteractiveLaneKey(cwd: string, operation?: string): string { - return JSON.stringify([operation || 'default', cwd]) - } - - private async runQueuedNonInteractiveExec( - cwd: string, - payload: { - binary: string - args: string[] - cwd: string - stdin: string | null - timeoutMs: number - env?: Record - operation?: string - }, - signal?: AbortSignal, - operation?: string - ): Promise { - const laneKey = this.nonInteractiveLaneKey(cwd, operation) - const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] - const previous = queue.at(-1)?.done ?? Promise.resolve() - let releaseEntry!: () => void - const entry: NonInteractiveExecQueueEntry = { - started: false, - canceled: false, - done: previous - .catch(() => {}) - .then( - () => - new Promise((resolve) => { - releaseEntry = resolve - }) - ), - release: () => releaseEntry() - } - queue.push(entry) - this.nonInteractiveExecQueues.set(laneKey, queue) - const abortEntry = (): void => { - if (!entry.started) { - entry.canceled = true - return - } - void this.cancelActiveNonInteractiveExec(cwd, operation) - } - if (signal?.aborted) { - entry.canceled = true - } else { - signal?.addEventListener('abort', abortEntry, { once: true }) - } - - // Why: the SSH relay tracks children per operation; serialize only matching - // lanes so commit-message and PR-field generation can coexist. - await previous.catch(() => {}) - try { - if (entry.canceled) { - return { - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - canceled: true - } - } - entry.started = true - return (await this.mux.request('agent.execNonInteractive', payload, { - timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS - })) as RemoteCommitMessageExecResult - } finally { - signal?.removeEventListener('abort', abortEntry) - entry.release() - const currentQueue = this.nonInteractiveExecQueues.get(laneKey) - const entryIndex = currentQueue?.indexOf(entry) ?? -1 - if (entryIndex >= 0) { - currentQueue?.splice(entryIndex, 1) - } - if (currentQueue?.length === 0) { - this.nonInteractiveExecQueues.delete(laneKey) - } - } - } - - async getDiff( - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead?: boolean - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), - async () => - (await requestGitStreamable(this.mux, 'git.diff', { - worktreePath, - filePath, - staged, - compareAgainstHead - })) as GitDiffResult - ) as Promise - } - - async stageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.stage', { worktreePath, filePath }) - }) - } - - async unstageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.unstage', { worktreePath, filePath }) - }) - } - - async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkStage', { worktreePath, filePaths }) - }) - } - - async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) - }) - } - - async discardChanges(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.discard', { worktreePath, filePath }) - }) - } - - async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) - }) - } - - async detectConflictOperation(worktreePath: string): Promise { - return (await this.mux.request('git.conflictOperation', { - worktreePath - })) as GitConflictOperation - } - - async abortMerge(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortMerge', { worktreePath }) - }) - } - - async abortRebase(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortRebase', { worktreePath }) - }) - } - - async checkoutBranch(worktreePath: string, branch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.checkout', { worktreePath, branch }) - }) - } - - async listLocalBranches( - worktreePath: string - ): Promise<{ current: string | null; branches: string[] }> { - return (await this.mux.request('git.localBranches', { worktreePath })) as { - current: string | null - branches: string[] - } - } - - async getBranchCompare(worktreePath: string, baseRef: string): Promise { - return (await this.mux.request('git.branchCompare', { - worktreePath, - baseRef - })) as GitBranchCompareResult - } - - async getCommitCompare(worktreePath: string, commitId: string): Promise { - return (await this.mux.request('git.commitCompare', { - worktreePath, - commitId - })) as GitCommitCompareResult - } - - async getUpstreamStatus( - worktreePath: string, - pushTarget?: GitPushTarget - ): Promise { - return this.upstreamStatusReadOwner.read( - { kind: 'ssh-provider' }, - worktreePath, - pushTarget, - async () => - (await this.mux.request('git.upstreamStatus', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - })) as GitUpstreamStatus - ) - } - - async pushBranch( - worktreePath: string, - publish = false, - pushTarget?: GitPushTarget, - options: { forceWithLease?: boolean } = {} - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.push', { - worktreePath, - publish, - pushTarget, - ...(options.forceWithLease === true ? { forceWithLease: true } : {}) - }) - }) - } - - async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fastForward', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - }) - }) - } - - async rebaseFromBase(worktreePath: string, baseRef: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request( - 'git.rebaseFromBase', - { worktreePath, baseRef }, - { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } - ) - }) - } - - async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async syncForkDefaultBranch( - worktreePath: string, - expectedUpstream: GitForkSyncExpectedUpstream - ): Promise { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.forkSync', { - worktreePath, - ...(expectedUpstream ? { expectedUpstream } : {}) - })) as GitForkSyncResult - ) - } - - async fetchRemoteTrackingRef( - worktreePath: string, - remote: string, - branch: string, - ref: string, - options?: { skipAutoMaintenance?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetchRemoteTrackingRef', { - worktreePath, - remote, - branch, - ref, - ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) - }) - }) - } - - async fetchGitLabMergeRequestHead( - worktreePath: string, - remote: string, - mrIid: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - // Why: the durable-ref RPC is a NEW method name. Old relays only implement - // FETCH_HEAD-semantics git.fetchGitLabMergeRequestHead, so calling the ref - // variant makes them return -32601 rather than silently no-op the durable - // ref (which would leave the client resolving a stale/missing MR head). - const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { - worktreePath, - remote, - mrIid - }) - // Why: use the host-written path; a second client-side get-url can disagree. - return readDurableReviewHeadLocalRef(result, 'merge request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate the durable-ref MR fetch; surface a - // reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async fetchGitHubPullRequestHead( - worktreePath: string, - remote: string, - prNumber: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - const result = await this.mux.request('git.fetchGitHubPullRequestHead', { - worktreePath, - remote, - prNumber - }) - // Why: use the host-written path; a second client-side get-url can disagree. - return readDurableReviewHeadLocalRef(result, 'pull request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate git.fetchGitHubPullRequestHead; surface a - // reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async getBranchDiff( - worktreePath: string, - baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } - ): Promise { - const keyOptions = options ?? {} - const { headOid: rawHeadOid, ...relayOptions } = keyOptions - // Why: compare snapshots type headOid as `string | null`, so collapse an - // unpinned null to absent instead of putting a field on the wire that a - // pinned-OID relay must reject. - const headOid = rawHeadOid == null ? undefined : rawHeadOid - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'branchDiff', - worktreePath, - baseRef, - keyOptions.includePatch ?? null, - keyOptions.filePath ?? null, - keyOptions.oldPath ?? null, - headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid - ]), - async () => - (await requestGitStreamable(this.mux, 'git.branchDiff', { - worktreePath, - baseRef, - ...relayOptions, - ...(headOid === undefined ? {} : { headOid }) - })) as GitDiffResult[] - ) as Promise - } - - async getCommitDiff( - worktreePath: string, - args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'commitDiff', - worktreePath, - args.commitOid, - args.parentOid ?? null, - args.filePath, - args.oldPath ?? null - ]), - async () => - (await requestGitStreamable(this.mux, 'git.commitDiff', { - worktreePath, - ...args - })) as GitDiffResult - ) as Promise - } - - async listWorktrees( - repoPath: string, - options?: { signal?: AbortSignal } - ): Promise { - return (await this.mux.request( - 'git.listWorktrees', - { - repoPath - }, - { signal: options?.signal } - )) as GitWorktreeInfo[] - } - - async addWorktree( - repoPath: string, - branchName: string, - targetDir: string, - options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.addWorktree', { - repoPath, - branchName, - targetDir, - ...options - }) - }) - } - - async removeWorktree( - worktreePath: string, - force?: boolean, - options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } - ): Promise { - return this.runWithGitReadInvalidation( - async () => - ((await this.mux.request('git.removeWorktree', { - worktreePath, - force, - ...options - })) ?? {}) as RemoveWorktreeResult - ) - } - - async worktreeIsClean( - worktreePath: string, - options: { includeUntracked?: boolean } = {} - ): Promise<{ clean: boolean; stdout?: string }> { - try { - const result = (await this.mux.request('git.worktreeIsClean', { - worktreePath, - ...(options.includeUntracked === false ? { includeUntracked: false } : {}) - })) as { - clean: boolean - stdout?: string - } - if (options.includeUntracked === false) { - if (!result.clean && result.stdout === undefined) { - return result - } - const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) - return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } - } - return result - } catch (error) { - if (!isJsonRpcMethodNotFoundError(error)) { - throw error - } - if (!this.loggedWorktreeIsCleanFallback) { - this.loggedWorktreeIsCleanFallback = true - console.warn( - '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' - ) - } - // Why: existing SSH relays may predate git.worktreeIsClean, but git.status - // is a narrow relay RPC and avoids the generic git.exec allowlist. - const status = await this.getStatus(worktreePath) - const entries = - options.includeUntracked === false - ? status.entries.filter((entry) => entry.area !== 'untracked') - : status.entries - const clean = entries.length === 0 - return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } - } - } - - async refreshLocalBaseRefForWorktreeCreate(args: { - repoPath: string - fullRef: string - remoteTrackingRef: string - ownerWorktreePath?: string - checkOnly?: boolean - }): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) - }) - } - - async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) - }) - } - - async forceDeletePreservedBranch( - repoPath: string, - branchName: string, - expectedHead: string - ): Promise { - try { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.forceDeletePreservedBranch', { - repoPath, - branchName, - expectedHead - }) - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate git.forceDeletePreservedBranch; surface - // a reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - async exec( args: string[], cwd: string, @@ -862,10 +58,7 @@ export class SshGitProvider implements IGitProvider { const result = gitExecMutatesRepository(args) ? await this.runWithGitReadInvalidation(run) : await run() - return result as { - stdout: string - stderr: string - } + return result as { stdout: string; stderr: string } } async clone( @@ -897,10 +90,7 @@ export class SshGitProvider implements IGitProvider { { args, cwd, progressId }, { signal: options?.signal, timeoutMs: options?.timeoutMs } ) - return result as { - stdout: string - stderr: string - } + return result as { stdout: string; stderr: string } } catch (error) { if (isJsonRpcMethodNotFoundError(error)) { throw new Error( @@ -921,16 +111,11 @@ export class SshGitProvider implements IGitProvider { } } - // Why: isGitRepo requires synchronous return in the interface, but remote - // operations are async. We always return true for remote paths since the - // relay validates git repos on its side. The renderer already guards git - // operations behind worktree registration which validates the path. + // Remote paths are validated asynchronously by the relay before registration. isGitRepo(_path: string): boolean { return true } - // Why: SSH worktrees need the remote URL from the relay-side .git/config - // before local code can map it to a hosted source link. private async readOriginRemoteUrl(worktreePath: string): Promise { try { const result = await this.exec(['remote', 'get-url', 'origin'], worktreePath) @@ -961,7 +146,7 @@ export class SshGitProvider implements IGitProvider { defaultBranch = ref.replace(/^refs\/remotes\/origin\//, '') } } catch { - // Fall back to 'main' + // Fall back to 'main'. } return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) diff --git a/src/main/providers/ssh-git-read-provider.ts b/src/main/providers/ssh-git-read-provider.ts new file mode 100644 index 00000000000..54720d8d9fb --- /dev/null +++ b/src/main/providers/ssh-git-read-provider.ts @@ -0,0 +1,187 @@ +import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' +import type { GitDiffResult } from '../../shared/git-diff-compare-types' +import type { + GitStagingArea, + GitStatusResult, + GitUpstreamStatus +} from '../../shared/git-status-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' +import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { GitProviderStatusOptions } from './types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' + +const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const + +export class SshGitReadProvider { + private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() + private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() + private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() + + constructor( + private readonly connectionId: string, + protected readonly mux: SshChannelMultiplexer, + private readonly hostPlatform: RemoteHostPlatform | null = null + ) {} + + getConnectionId(): string { + return this.connectionId + } + + getHostPlatform(): RemoteHostPlatform | null { + return this.hostPlatform + } + + protected async runWithGitReadInvalidation(run: () => Promise): Promise { + this.invalidateGitReads() + try { + return await run() + } finally { + this.invalidateGitReads() + } + } + + private invalidateGitReads(): void { + this.gitDiffReadDedupe.clear() + this.statusReadLeaseOwner.invalidate() + this.upstreamStatusReadOwner.invalidate() + } + + async getStatus( + worktreePath: string, + options?: GitProviderStatusOptions + ): Promise { + this.gitDiffReadDedupe.clear() + const request = { + worktreePath, + ...(options?.includeIgnored ? { includeIgnored: true } : {}), + ...(options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(options?.reuseLineStats ? { reuseLineStats: true } : {}), + ...(options?.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: options.branchLineTotalMergeBase }) + } + const key = stableInFlightKey([ + worktreePath, + options?.includeIgnored === true, + options?.bypassEffectiveUpstreamNegativeCache === true, + options?.reuseLineStats === true, + options?.branchLineTotalMergeBase ?? '' + ]) + return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { + return (await this.mux.request('git.status', request, { + signal: sharedSignal + })) as GitStatusResult + }) + } + + async getSubmoduleStatus( + worktreePath: string, + submodulePath: string, + area: GitStagingArea = 'unstaged' + ): Promise { + this.gitDiffReadDedupe.clear() + try { + return (await this.mux.request('git.submoduleStatus', { + worktreePath, + submodulePath, + area + })) as GitStatusResult + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + } + throw error + } + } + + async getDiff( + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead?: boolean + ): Promise { + return this.gitDiffReadDedupe.run( + stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), + async () => + (await requestGitStreamable(this.mux, 'git.diff', { + worktreePath, + filePath, + staged, + compareAgainstHead + })) as GitDiffResult + ) as Promise + } + + async getBranchDiff( + worktreePath: string, + baseRef: string, + options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } + ): Promise { + const keyOptions = options ?? {} + const { headOid: rawHeadOid, ...relayOptions } = keyOptions + const headOid = rawHeadOid == null ? undefined : rawHeadOid + return this.gitDiffReadDedupe.run( + stableInFlightKey([ + 'branchDiff', + worktreePath, + baseRef, + keyOptions.includePatch ?? null, + keyOptions.filePath ?? null, + keyOptions.oldPath ?? null, + headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid + ]), + async () => + (await requestGitStreamable(this.mux, 'git.branchDiff', { + worktreePath, + baseRef, + ...relayOptions, + ...(headOid === undefined ? {} : { headOid }) + })) as GitDiffResult[] + ) as Promise + } + + async getCommitDiff( + worktreePath: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise { + return this.gitDiffReadDedupe.run( + stableInFlightKey([ + 'commitDiff', + worktreePath, + args.commitOid, + args.parentOid ?? null, + args.filePath, + args.oldPath ?? null + ]), + async () => + (await requestGitStreamable(this.mux, 'git.commitDiff', { + worktreePath, + ...args + })) as GitDiffResult + ) as Promise + } + + async getUpstreamStatus( + worktreePath: string, + pushTarget?: GitPushTarget + ): Promise { + return this.upstreamStatusReadOwner.read( + { kind: 'ssh-provider' }, + worktreePath, + pushTarget, + async () => + (await this.mux.request('git.upstreamStatus', { + worktreePath, + ...(pushTarget ? { pushTarget } : {}) + })) as GitUpstreamStatus + ) + } +} diff --git a/src/main/providers/ssh-git-relay-errors.ts b/src/main/providers/ssh-git-relay-errors.ts new file mode 100644 index 00000000000..d8bd8fe9016 --- /dev/null +++ b/src/main/providers/ssh-git-relay-errors.ts @@ -0,0 +1,8 @@ +import { JsonRpcErrorCode } from '../ssh/relay-protocol' + +export function isJsonRpcMethodNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound +} diff --git a/src/main/providers/ssh-git-remote-sync-provider.ts b/src/main/providers/ssh-git-remote-sync-provider.ts new file mode 100644 index 00000000000..bead9414f3b --- /dev/null +++ b/src/main/providers/ssh-git-remote-sync-provider.ts @@ -0,0 +1,84 @@ +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitPushTarget } from '../../shared/worktree/types' +import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' +import { SshGitWorkingTreeProvider } from './ssh-git-working-tree-provider' + +export class SshGitRemoteSyncProvider extends SshGitWorkingTreeProvider { + async pushBranch( + worktreePath: string, + publish = false, + pushTarget?: GitPushTarget, + options: { forceWithLease?: boolean } = {} + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.push', { + worktreePath, + publish, + pushTarget, + ...(options.forceWithLease === true ? { forceWithLease: true } : {}) + }) + }) + } + + async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) + }) + } + + async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fastForward', { + worktreePath, + ...(pushTarget ? { pushTarget } : {}) + }) + }) + } + + async rebaseFromBase(worktreePath: string, baseRef: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request( + 'git.rebaseFromBase', + { worktreePath, baseRef }, + { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } + ) + }) + } + + async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) + }) + } + + async syncForkDefaultBranch( + worktreePath: string, + expectedUpstream: GitForkSyncExpectedUpstream + ): Promise { + return this.runWithGitReadInvalidation( + async () => + (await this.mux.request('git.forkSync', { + worktreePath, + ...(expectedUpstream ? { expectedUpstream } : {}) + })) as GitForkSyncResult + ) + } + + async fetchRemoteTrackingRef( + worktreePath: string, + remote: string, + branch: string, + ref: string, + options?: { skipAutoMaintenance?: boolean } + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fetchRemoteTrackingRef', { + worktreePath, + remote, + branch, + ref, + ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) + }) + }) + } +} diff --git a/src/main/providers/ssh-git-review-head-provider.ts b/src/main/providers/ssh-git-review-head-provider.ts new file mode 100644 index 00000000000..5ff5cc69048 --- /dev/null +++ b/src/main/providers/ssh-git-review-head-provider.ts @@ -0,0 +1,70 @@ +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitRemoteSyncProvider } from './ssh-git-remote-sync-provider' + +function readDurableReviewHeadLocalRef( + result: unknown, + kind: 'pull request' | 'merge request' +): string { + if (result && typeof result === 'object' && 'localRef' in result) { + const localRef = (result as { localRef: unknown }).localRef + if (typeof localRef === 'string') { + const trimmed = localRef.trim() + if (trimmed.startsWith('refs/orca/')) { + return trimmed + } + } + } + throw new Error( + `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` + ) +} + +export class SshGitReviewHeadProvider extends SshGitRemoteSyncProvider { + async fetchGitLabMergeRequestHead( + worktreePath: string, + remote: string, + mrIid: number + ): Promise { + try { + return await this.runWithGitReadInvalidation(async () => { + const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { + worktreePath, + remote, + mrIid + }) + return readDurableReviewHeadLocalRef(result, 'merge request') + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } + + async fetchGitHubPullRequestHead( + worktreePath: string, + remote: string, + prNumber: number + ): Promise { + try { + return await this.runWithGitReadInvalidation(async () => { + const result = await this.mux.request('git.fetchGitHubPullRequestHead', { + worktreePath, + remote, + prNumber + }) + return readDurableReviewHeadLocalRef(result, 'pull request') + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } +} diff --git a/src/main/providers/ssh-git-working-tree-provider.ts b/src/main/providers/ssh-git-working-tree-provider.ts new file mode 100644 index 00000000000..6942b91ca1b --- /dev/null +++ b/src/main/providers/ssh-git-working-tree-provider.ts @@ -0,0 +1,122 @@ +import type { + GitBranchCompareResult, + GitCommitCompareResult +} from '../../shared/git-diff-compare-types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { GitConflictOperation } from '../../shared/git-status-types' +import { SshGitNoninteractiveProvider } from './ssh-git-noninteractive-provider' + +export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { + async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { + return (await this.mux.request('git.checkIgnored', { + worktreePath, + paths: relativePaths + })) as string[] + } + + async getHistory( + worktreePath: string, + options: GitHistoryOptions = {} + ): Promise { + return (await this.mux.request('git.history', { + worktreePath, + ...options + })) as GitHistoryResult + } + + async commit( + worktreePath: string, + message: string + ): Promise<{ success: boolean; error?: string }> { + return this.runWithGitReadInvalidation( + async () => + (await this.mux.request('git.commit', { + worktreePath, + message + })) as { success: boolean; error?: string } + ) + } + + async stageFile(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.stage', { worktreePath, filePath }) + }) + } + + async unstageFile(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.unstage', { worktreePath, filePath }) + }) + } + + async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkStage', { worktreePath, filePaths }) + }) + } + + async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) + }) + } + + async discardChanges(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.discard', { worktreePath, filePath }) + }) + } + + async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) + }) + } + + async detectConflictOperation(worktreePath: string): Promise { + return (await this.mux.request('git.conflictOperation', { + worktreePath + })) as GitConflictOperation + } + + async abortMerge(worktreePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.abortMerge', { worktreePath }) + }) + } + + async abortRebase(worktreePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.abortRebase', { worktreePath }) + }) + } + + async checkoutBranch(worktreePath: string, branch: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.checkout', { worktreePath, branch }) + }) + } + + async listLocalBranches( + worktreePath: string + ): Promise<{ current: string | null; branches: string[] }> { + return (await this.mux.request('git.localBranches', { worktreePath })) as { + current: string | null + branches: string[] + } + } + + async getBranchCompare(worktreePath: string, baseRef: string): Promise { + return (await this.mux.request('git.branchCompare', { + worktreePath, + baseRef + })) as GitBranchCompareResult + } + + async getCommitCompare(worktreePath: string, commitId: string): Promise { + return (await this.mux.request('git.commitCompare', { + worktreePath, + commitId + })) as GitCommitCompareResult + } +} diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts new file mode 100644 index 00000000000..ce509ccf847 --- /dev/null +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -0,0 +1,143 @@ +import type { GitStatusResult } from '../../shared/git-status-types' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' + +function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { + if (entries.length === 0) { + return undefined + } + return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') +} + +function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { + const trackedLines = (stdout ?? '') + .split(/\r?\n/) + .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) + return trackedLines.length > 0 ? trackedLines.join('\n') : undefined +} + +export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { + private loggedWorktreeIsCleanFallback = false + + async listWorktrees( + repoPath: string, + options?: { signal?: AbortSignal } + ): Promise { + return (await this.mux.request( + 'git.listWorktrees', + { repoPath }, + { signal: options?.signal } + )) as GitWorktreeInfo[] + } + + async addWorktree( + repoPath: string, + branchName: string, + targetDir: string, + options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.addWorktree', { + repoPath, + branchName, + targetDir, + ...options + }) + }) + } + + async removeWorktree( + worktreePath: string, + force?: boolean, + options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } + ): Promise { + return this.runWithGitReadInvalidation( + async () => + ((await this.mux.request('git.removeWorktree', { + worktreePath, + force, + ...options + })) ?? {}) as RemoveWorktreeResult + ) + } + + async worktreeIsClean( + worktreePath: string, + options: { includeUntracked?: boolean } = {} + ): Promise<{ clean: boolean; stdout?: string }> { + try { + const result = (await this.mux.request('git.worktreeIsClean', { + worktreePath, + ...(options.includeUntracked === false ? { includeUntracked: false } : {}) + })) as { clean: boolean; stdout?: string } + if (options.includeUntracked === false) { + if (!result.clean && result.stdout === undefined) { + return result + } + const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) + return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } + } + return result + } catch (error) { + if (!isJsonRpcMethodNotFoundError(error)) { + throw error + } + if (!this.loggedWorktreeIsCleanFallback) { + this.loggedWorktreeIsCleanFallback = true + console.warn( + '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' + ) + } + const status = await this.getStatus(worktreePath) + const entries = + options.includeUntracked === false + ? status.entries.filter((entry) => entry.area !== 'untracked') + : status.entries + const clean = entries.length === 0 + return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } + } + } + + async refreshLocalBaseRefForWorktreeCreate(args: { + repoPath: string + fullRef: string + remoteTrackingRef: string + ownerWorktreePath?: string + checkOnly?: boolean + }): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) + }) + } + + async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) + }) + } + + async forceDeletePreservedBranch( + repoPath: string, + branchName: string, + expectedHead: string + ): Promise { + try { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.forceDeletePreservedBranch', { + repoPath, + branchName, + expectedHead + }) + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } +} From d3c37b8e40dd0b14a68c492fdbaecb92a1738efa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:44:53 -0700 Subject: [PATCH 02/10] refactor(renderer): split web preload API (#16181) --- config/max-lines-baseline.txt | 1 - .../web/preload-api/web-agent-accounts-api.ts | 49 + .../web/preload-api/web-agent-hooks-api.ts | 42 + .../web/preload-api/web-agent-status-api.ts | 24 + .../src/web/preload-api/web-ai-vault-api.ts | 93 + .../src/web/preload-api/web-app-api.ts | 55 + .../src/web/preload-api/web-browser-api.ts | 101 + .../src/web/preload-api/web-cli-api.ts | 27 + .../src/web/preload-api/web-clipboard-api.ts | 177 + .../web/preload-api/web-diagnostics-api.ts | 41 + .../src/web/preload-api/web-e2e-api.ts | 10 + .../src/web/preload-api/web-e2e-config.ts | 11 + .../src/web/preload-api/web-fallback-api.ts | 63 + .../src/web/preload-api/web-filesystem-api.ts | 200 + .../src/web/preload-api/web-git-api.ts | 291 ++ .../src/web/preload-api/web-github-api.ts | 193 + .../web/preload-api/web-github-cache-api.ts | 19 + .../src/web/preload-api/web-github-routes.ts | 150 + .../src/web/preload-api/web-gitlab-api.ts | 71 + .../src/web/preload-api/web-gitlab-routes.ts | 71 + .../preload-api/web-host-capability-api.ts | 195 + .../web-keybinding-normalization.ts | 174 + .../web/preload-api/web-keybindings-api.ts | 174 + .../src/web/preload-api/web-macos-tcc-api.ts | 13 + .../src/web/preload-api/web-memory-api.ts | 24 + .../src/web/preload-api/web-mobile-api.ts | 26 + .../web/preload-api/web-native-chat-api.ts | 156 + .../web/preload-api/web-notifications-api.ts | 14 + .../src/web/preload-api/web-onboarding-api.ts | 56 + .../web/preload-api/web-orca-profiles-api.ts | 70 + .../src/web/preload-api/web-platform-api.ts | 16 + .../web-preference-normalization.ts | 164 + .../web/preload-api/web-preferences-store.ts | 297 ++ .../web/preload-api/web-rate-limits-api.ts | 36 + .../web/preload-api/web-repositories-api.ts | 130 + .../src/web/preload-api/web-review-api.ts | 44 + .../src/web/preload-api/web-runtime-api.ts | 21 + .../src/web/preload-api/web-runtime-calls.ts | 120 + .../web-runtime-environments-api.ts | 198 + .../web/preload-api/web-runtime-session.ts | 145 + .../web-runtime-worktree-catalog.ts | 183 + .../src/web/preload-api/web-settings-api.ts | 118 + .../src/web/preload-api/web-shell-api.ts | 30 + .../src/web/preload-api/web-star-nag-api.ts | 21 + .../src/web/preload-api/web-storage.ts | 43 + .../src/web/preload-api/web-telemetry-api.ts | 11 + .../src/web/preload-api/web-terminal-api.ts | 180 + .../src/web/preload-api/web-ui-api.ts | 241 + .../src/web/preload-api/web-updater-api.ts | 34 + .../preload-api/web-workspace-ports-api.ts | 23 + .../preload-api/web-workspace-session-api.ts | 71 + .../src/web/preload-api/web-worktrees-api.ts | 188 + .../src/web/web-preload-api-clipboard.test.ts | 17 +- .../web/web-preload-api-composition.test.ts | 119 + .../src/web/web-preload-api-github.test.ts | 3 +- .../src/web/web-preload-api-gitlab.test.ts | 3 +- .../web/web-preload-api-runtime-calls.test.ts | 206 + ...eb-preload-api-runtime-environment.test.ts | 55 + .../web-preload-api-workspace-catalog.test.ts | 34 + src/renderer/src/web/web-preload-api.ts | 4596 +---------------- .../web-runtime-client-timeout-budget.test.ts | 78 + 61 files changed, 5480 insertions(+), 4536 deletions(-) create mode 100644 src/renderer/src/web/preload-api/web-agent-accounts-api.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-hooks-api.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-status-api.ts create mode 100644 src/renderer/src/web/preload-api/web-ai-vault-api.ts create mode 100644 src/renderer/src/web/preload-api/web-app-api.ts create mode 100644 src/renderer/src/web/preload-api/web-browser-api.ts create mode 100644 src/renderer/src/web/preload-api/web-cli-api.ts create mode 100644 src/renderer/src/web/preload-api/web-clipboard-api.ts create mode 100644 src/renderer/src/web/preload-api/web-diagnostics-api.ts create mode 100644 src/renderer/src/web/preload-api/web-e2e-api.ts create mode 100644 src/renderer/src/web/preload-api/web-e2e-config.ts create mode 100644 src/renderer/src/web/preload-api/web-fallback-api.ts create mode 100644 src/renderer/src/web/preload-api/web-filesystem-api.ts create mode 100644 src/renderer/src/web/preload-api/web-git-api.ts create mode 100644 src/renderer/src/web/preload-api/web-github-api.ts create mode 100644 src/renderer/src/web/preload-api/web-github-cache-api.ts create mode 100644 src/renderer/src/web/preload-api/web-github-routes.ts create mode 100644 src/renderer/src/web/preload-api/web-gitlab-api.ts create mode 100644 src/renderer/src/web/preload-api/web-gitlab-routes.ts create mode 100644 src/renderer/src/web/preload-api/web-host-capability-api.ts create mode 100644 src/renderer/src/web/preload-api/web-keybinding-normalization.ts create mode 100644 src/renderer/src/web/preload-api/web-keybindings-api.ts create mode 100644 src/renderer/src/web/preload-api/web-macos-tcc-api.ts create mode 100644 src/renderer/src/web/preload-api/web-memory-api.ts create mode 100644 src/renderer/src/web/preload-api/web-mobile-api.ts create mode 100644 src/renderer/src/web/preload-api/web-native-chat-api.ts create mode 100644 src/renderer/src/web/preload-api/web-notifications-api.ts create mode 100644 src/renderer/src/web/preload-api/web-onboarding-api.ts create mode 100644 src/renderer/src/web/preload-api/web-orca-profiles-api.ts create mode 100644 src/renderer/src/web/preload-api/web-platform-api.ts create mode 100644 src/renderer/src/web/preload-api/web-preference-normalization.ts create mode 100644 src/renderer/src/web/preload-api/web-preferences-store.ts create mode 100644 src/renderer/src/web/preload-api/web-rate-limits-api.ts create mode 100644 src/renderer/src/web/preload-api/web-repositories-api.ts create mode 100644 src/renderer/src/web/preload-api/web-review-api.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-api.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-calls.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-environments-api.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-session.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts create mode 100644 src/renderer/src/web/preload-api/web-settings-api.ts create mode 100644 src/renderer/src/web/preload-api/web-shell-api.ts create mode 100644 src/renderer/src/web/preload-api/web-star-nag-api.ts create mode 100644 src/renderer/src/web/preload-api/web-storage.ts create mode 100644 src/renderer/src/web/preload-api/web-telemetry-api.ts create mode 100644 src/renderer/src/web/preload-api/web-terminal-api.ts create mode 100644 src/renderer/src/web/preload-api/web-ui-api.ts create mode 100644 src/renderer/src/web/preload-api/web-updater-api.ts create mode 100644 src/renderer/src/web/preload-api/web-workspace-ports-api.ts create mode 100644 src/renderer/src/web/preload-api/web-workspace-session-api.ts create mode 100644 src/renderer/src/web/preload-api/web-worktrees-api.ts create mode 100644 src/renderer/src/web/web-preload-api-composition.test.ts create mode 100644 src/renderer/src/web/web-preload-api-runtime-calls.test.ts create mode 100644 src/renderer/src/web/web-runtime-client-timeout-budget.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 79d033f010d..ae05468b04a 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -146,7 +146,6 @@ inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/terminals.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/store/slices/workspace-cleanup.ts -inline src/renderer/src/web/web-preload-api.ts inline src/renderer/src/web/web-runtime-client.ts inline src/shared/agent-hook-listener.ts inline src/shared/automation-schedules.ts diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts new file mode 100644 index 00000000000..921c8ca7a5d --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -0,0 +1,49 @@ +import type { PreloadApi } from '../../../../preload/api-types' + +export function createMiniMaxCredentialsApi(): NonNullable< + Partial['minimaxCredentials'] +> { + const notConfigured = { configured: false } + const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') + return { + getStatus: () => Promise.resolve(notConfigured), + saveCookie: () => Promise.reject(unsupportedError), + clearCookie: () => Promise.resolve(notConfigured) + } +} + +export function createGrokAccountsApi(): NonNullable['grokAccounts']> { + const unsigned = { + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: null + } + return { + getStatus: () => Promise.resolve(unsigned) + } +} + +export function createAccountsApi(): never { + const empty = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + return { + list: () => Promise.resolve(empty), + add: () => Promise.resolve(empty), + cancelPendingLogin: () => Promise.resolve(false), + reauthenticate: () => Promise.resolve(empty), + remove: () => Promise.resolve(empty), + select: () => Promise.resolve(empty), + // Why: launch accounts are recorded on the host that owns the PTY, which the + // web client never is — report no stale panes rather than reject the sweep. + listStalePanes: () => Promise.resolve([]), + // Why empty rather than absent: the same host owns both records, so a web + // client has no recorded lane to offer and every pane falls to derivation. + listRecordedPaneLanes: () => Promise.resolve({}), + forgetStalePanes: () => Promise.resolve() + } as never +} diff --git a/src/renderer/src/web/preload-api/web-agent-hooks-api.ts b/src/renderer/src/web/preload-api/web-agent-hooks-api.ts new file mode 100644 index 00000000000..41ed387d559 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-hooks-api.ts @@ -0,0 +1,42 @@ +import type { PreloadApi } from '../../../../preload/api-types' + +export function createAgentHooksApi(): NonNullable['agentHooks']> { + const status = ( + agent: + | 'claude' + | 'openclaude' + | 'codex' + | 'gemini' + | 'antigravity' + | 'amp' + | 'cursor' + | 'droid' + | 'command-code' + | 'grok' + | 'copilot' + | 'hermes' + | 'devin' + ) => + Promise.resolve({ + agent, + state: 'not_installed', + configPath: '', + managedHooksPresent: false, + detail: 'Agent hook status is only available on the Orca server.' + } as const) + return { + claudeStatus: () => status('claude'), + openClaudeStatus: () => status('openclaude'), + codexStatus: () => status('codex'), + geminiStatus: () => status('gemini'), + antigravityStatus: () => status('antigravity'), + ampStatus: () => status('amp'), + cursorStatus: () => status('cursor'), + droidStatus: () => status('droid'), + commandCodeStatus: () => status('command-code'), + grokStatus: () => status('grok'), + copilotStatus: () => status('copilot'), + hermesStatus: () => status('hermes'), + devinStatus: () => status('devin') + } +} diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts new file mode 100644 index 00000000000..ed4636ab5fb --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-status-api.ts @@ -0,0 +1,24 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { noopUnsubscribe } from './web-storage' + +export function createWebAgentStatusApi(): Partial { + return { + agentStatus: { + onSet: () => noopUnsubscribe, + onClear: () => noopUnsubscribe, + getSnapshot: () => Promise.resolve([]), + inferInterrupt: () => Promise.resolve(false), + inferQuestionAnswered: () => Promise.resolve(false), + onMigrationUnsupported: () => noopUnsubscribe, + onMigrationUnsupportedClear: () => noopUnsubscribe, + onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, + getMigrationUnsupportedSnapshot: () => Promise.resolve([]), + drop: () => {}, + reconcileEndedProcess: () => {}, + dropByTabPrefix: () => {}, + retirePaneAuthority: () => {}, + restorePaneAuthority: () => {}, + transferPaneAuthority: () => {} + } + } +} diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts new file mode 100644 index 00000000000..383a657f7b8 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -0,0 +1,93 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../../../shared/ai-vault-resume-preparation' +import type { AiVaultDeleteSessionArgs } from '../../../../shared/ai-vault-session-deletion' +import type { + AiVaultSessionTitlesArgs, + AiVaultSessionTitlesResult +} from '../../../../shared/ai-vault-session-title' +import type { AiVaultListArgs, AiVaultListResult } from '../../../../shared/ai-vault-types' +import { + normalizeExecutionHostScope, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironment } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' +import { translate } from '@/i18n/i18n' + +export function createWebAiVaultApi(): NonNullable['aiVault']> { + return { + listSessions: (args?: AiVaultListArgs) => { + const environment = requireActiveEnvironment() + const executionHostId = toRuntimeExecutionHostId(environment.id) + const requestedScope = normalizeExecutionHostScope( + args?.executionHostScope ?? executionHostId + ) + if (requestedScope !== 'all' && requestedScope !== executionHostId) { + return Promise.resolve(webAiVaultUnavailableResult(requestedScope)) + } + // Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host. + return callRuntimeResult('aiVault.listSessions', { + limit: args?.limit, + force: args?.force, + scopePaths: args?.scopePaths, + executionHostId + }) + }, + resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => { + const environment = requireActiveEnvironment() + const executionHostId = toRuntimeExecutionHostId(environment.id) + if ( + args.executionHostScope && + normalizeExecutionHostScope(args.executionHostScope) !== executionHostId + ) { + return Promise.resolve({ titles: [] }) + } + return callRuntimeResult('aiVault.resolveSessionTitles', { + requests: args.requests + }).catch(() => ({ titles: [] })) + }, + // Why: the runtime RPC transport has no cancel verb, so the in-flight scan + // settles on its own timeout. The renderer's refreshId guard already drops + // the late result; this only means web pays for a scan nobody reads. + cancelListSessions: () => Promise.resolve(), + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => + callRuntimeResult('aiVault.prepareSessionResume', args), + // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. + listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), + // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. + getFirstUserPrompt: () => Promise.resolve({ prompt: null }), + // Why: session deletion is local-only and has no runtime RPC; a web + // client's sessions are runtime-hosted, so report the same non-local + // rejection the UI already gates on rather than pretend to delete. + deleteSession: (args: AiVaultDeleteSessionArgs) => + Promise.resolve({ + outcome: 'rejected', + agent: args.agent, + reason: 'non-local-host' as const + }), + onWindowFocused: () => noopUnsubscribe + } +} + +export function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId, + agent: 'codex', + path: executionHostId, + message: translate( + 'auto.web.webPreloadApi.aiVaultUnavailableForHost', + 'Agent Session History is not available for this execution host.' + ) + } + ], + scannedAt: new Date().toISOString() + } +} diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts new file mode 100644 index 00000000000..68d9b80eb55 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -0,0 +1,55 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { sanitizeWebRuntimeWorkspaceSession } from '../web-workspace-session' +import { sessionStorageKeyForHost } from './web-workspace-session-api' +import { mergeWebUIState } from './web-preference-normalization' +import { readLocalWebUIState } from './web-preferences-store' +import { UI_STORAGE_KEY, writeJson } from './web-storage' + +export function createWebAppApi(): Partial { + return { + app: { + getIdentity: () => + Promise.resolve({ + name: 'Orca', + isDev: false, + devLabel: null, + devBranch: null, + devWorktreeName: null, + devRepoRoot: null, + dockBadgeLabel: null + }), + getFeatureWallAssetBaseUrl: () => Promise.resolve('/'), + relaunch: () => Promise.resolve(window.location.reload()), + restart: () => Promise.resolve(window.location.reload()), + reload: () => Promise.resolve(window.location.reload()), + stageBeforeUnloadSync: ({ sessions, ui }) => { + // Why: beforeunload cannot await the paired runtime, so the web adapter + // guarantees immediate browser-local durability for the final snapshot. + for (const { state, hostId } of sessions) { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(state)) + } + writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui)) + }, + // Staging already wrote through to browser storage, so there is nothing left to join. + awaitBeforeUnloadCheckpoint: () => Promise.resolve(), + awaitFirstWindowStartupServices: () => Promise.resolve(), + recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), + startupDiagnostic: () => Promise.resolve(), + getKeyboardInputSourceId: () => Promise.resolve(null), + // The web client cannot inspect local Mission Control shortcuts. + getMacCapturedDigitRowChords: () => Promise.resolve([]), + getKeyboardLayoutSnapshot: () => Promise.resolve(null), + onKeyboardLayoutChanged: () => () => undefined, + setUnreadDockBadgeCount: () => Promise.resolve(), + getFloatingTerminalCwd: () => Promise.resolve(''), + getFloatingMarkdownDirectory: () => Promise.resolve(''), + pickFloatingMarkdownDocument: () => Promise.resolve(null), + pickFloatingWorkspaceDirectory: () => Promise.resolve(null), + // Browser fallback has no app-owned userData dir; reject so the sentinel can't claim sensitive evidence was persisted. + writeTerminalRenderDesyncEvidence: () => + Promise.reject( + new Error('Terminal render evidence is unavailable in the browser fallback.') + ) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-browser-api.ts b/src/renderer/src/web/preload-api/web-browser-api.ts new file mode 100644 index 00000000000..259d7c73a60 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-browser-api.ts @@ -0,0 +1,101 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { noopUnsubscribe } from './web-storage' + +export function createBrowserApi(): NonNullable['browser']> { + return { + registerGuest: () => Promise.resolve(false), + isGuestRegistered: () => Promise.resolve(false), + repairGuestRegistration: () => Promise.resolve(false), + unregisterGuest: () => Promise.resolve(), + openDevTools: () => Promise.resolve(false), + setViewportOverride: () => Promise.resolve(false), + setAnnotationViewportBridge: () => Promise.resolve(false), + onGuestLoadFailed: () => noopUnsubscribe, + onCertificateFailureChanged: () => noopUnsubscribe, + proceedCertificate: () => Promise.resolve({ ok: false, reason: 'missing' }), + onPermissionDenied: () => noopUnsubscribe, + onPopup: () => noopUnsubscribe, + onDownloadRequested: () => noopUnsubscribe, + onDownloadProgress: () => noopUnsubscribe, + onDownloadFinished: () => noopUnsubscribe, + onContextMenuRequested: () => noopUnsubscribe, + onContextMenuDismissed: () => noopUnsubscribe, + onNavigationUpdate: () => noopUnsubscribe, + onActivateView: () => noopUnsubscribe, + onPaneFocus: () => noopUnsubscribe, + onOpenLinkInOrcaTab: () => noopUnsubscribe, + cancelDownload: () => Promise.resolve(false), + setGrabMode: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.31bea294d5', + 'Grab mode is unavailable in the web client.' + ) + }), + awaitGrabSelection: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.31bea294d5', + 'Grab mode is unavailable in the web client.' + ) + }), + cancelGrab: () => Promise.resolve(false), + captureSelectionScreenshot: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.8dfcb7a351', + 'Selection screenshots are unavailable in the web client.' + ) + }), + extractHoverPayload: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.275a776357', + 'Hover extraction is unavailable in the web client.' + ) + }), + onGrabModeToggle: () => noopUnsubscribe, + onGrabActionShortcut: () => noopUnsubscribe, + sessionListProfiles: () => Promise.resolve([]), + sessionCreateProfile: () => Promise.resolve(null), + sessionDeleteProfile: () => Promise.resolve(false), + sessionImportCookies: () => + Promise.resolve({ + ok: false, + summary: null, + error: translate( + 'auto.web.web.preload.api.67ec964791', + 'Cookie import is unavailable in the web client.' + ) + }), + sessionResolvePartition: () => Promise.resolve(null), + sessionDetectBrowsers: () => Promise.resolve([]), + sessionImportFromBrowser: () => + Promise.resolve({ + ok: false, + summary: null, + error: translate( + 'auto.web.web.preload.api.67ec964791', + 'Cookie import is unavailable in the web client.' + ) + }), + sessionClearDefaultCookies: () => Promise.resolve(false), + notifyActiveTabChanged: () => Promise.resolve(false) + } as unknown as NonNullable['browser']> +} + +export function createEmulatorApi(): NonNullable['emulator']> { + return { + onPaneFocus: () => noopUnsubscribe, + onAutoAttach: () => noopUnsubscribe, + startFrameStream: () => Promise.reject(new Error('Mobile emulator is unavailable on web.')), + stopFrameStream: () => Promise.resolve(), + onFrameStreamFrame: () => noopUnsubscribe, + onFrameStreamError: () => noopUnsubscribe + } as unknown as NonNullable['emulator']> +} diff --git a/src/renderer/src/web/preload-api/web-cli-api.ts b/src/renderer/src/web/preload-api/web-cli-api.ts new file mode 100644 index 00000000000..c85bba87e14 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-cli-api.ts @@ -0,0 +1,27 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform } from './web-storage' + +export function createCliApi(): NonNullable['cli']> { + const status = { + platform: getBrowserPlatform(), + commandName: getBrowserPlatform() === 'linux' ? 'orca-ide' : 'orca', + commandPath: null, + pathDirectory: null, + pathConfigured: false, + launcherPath: null, + installMethod: null, + supported: false, + state: 'unsupported', + currentTarget: null, + unsupportedReason: 'launch_mode_unavailable', + detail: 'CLI registration is managed on the Orca server, not in the web browser.' + } as const + return { + getInstallStatus: () => Promise.resolve(status), + install: () => Promise.resolve(status), + remove: () => Promise.resolve(status), + getWslInstallStatus: (_args?: { distro?: string | null }) => Promise.resolve(status), + installWsl: (_args?: { distro?: string | null }) => Promise.resolve(status), + removeWsl: (_args?: { distro?: string | null }) => Promise.resolve(status) + } as NonNullable['cli']> +} diff --git a/src/renderer/src/web/preload-api/web-clipboard-api.ts b/src/renderer/src/web/preload-api/web-clipboard-api.ts new file mode 100644 index 00000000000..d86ac8c45b3 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-clipboard-api.ts @@ -0,0 +1,177 @@ +import { + CLIPBOARD_IMAGE_MAX_BASE64_CHARS, + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR, + assertClipboardImageByteLengthWithinLimit, + assertClipboardImageDimensionsWithinLimit +} from '../../../../shared/clipboard-image' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../../../shared/clipboard-text' +import { copyClipboardTextViaExecCommand } from '../web-clipboard-copy-fallback' +import { callRuntimeEnvelope, callRuntimeResult } from './web-runtime-calls' + +export const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS + +export const MAX_CLIPBOARD_IMAGE_SOURCE_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + +export const MAX_CLIPBOARD_IMAGE_PIXELS = CLIPBOARD_IMAGE_MAX_PIXELS + +export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 + +export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 + +export const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 + +export function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : '' + const commaIndex = result.indexOf(',') + resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1)) + } + reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image')) + reader.readAsDataURL(blob) + }) +} + +export function assertClipboardImageBlobWithinLimit(blob: Blob): void { + assertClipboardImageByteLengthWithinLimit(blob.size) +} + +export async function convertImageBlobToPng(blob: Blob): Promise { + assertClipboardImageBlobWithinLimit(blob) + const bitmap = await createImageBitmap(blob) + try { + assertClipboardImageDimensionsWithinLimit(bitmap) + const canvas = document.createElement('canvas') + canvas.width = bitmap.width + canvas.height = bitmap.height + const context = canvas.getContext('2d') + if (!context || canvas.width <= 0 || canvas.height <= 0) { + throw new Error('Clipboard image could not be decoded') + } + context.drawImage(bitmap, 0, 0) + return await new Promise((resolve, reject) => { + canvas.toBlob((png) => { + if (!png) { + reject(new Error('Clipboard image could not be encoded as PNG')) + return + } + try { + assertClipboardImageBlobWithinLimit(png) + } catch (error) { + reject(error) + return + } + resolve(png) + }, 'image/png') + }) + } finally { + bitmap.close() + } +} + +export async function readClipboardImagePngBase64(): Promise { + const clipboard = navigator.clipboard as + | (Clipboard & { read?: () => Promise }) + | undefined + if (!clipboard?.read) { + return null + } + const items = await clipboard.read() + for (const item of items) { + const imageType = item.types.find((type) => type.startsWith('image/')) + if (!imageType) { + continue + } + const blob = await item.getType(imageType) + assertClipboardImageBlobWithinLimit(blob) + const pngBlob = imageType === 'image/png' ? blob : await convertImageBlobToPng(blob) + return blobToBase64(pngBlob) + } + return null +} + +export async function writeWebClipboardText(text: string): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text) + const clipboard = navigator.clipboard + if (typeof clipboard?.writeText === 'function') { + try { + await clipboard.writeText(text) + return + } catch (error) { + // Preserve the current user-activation turn for the synchronous fallback. + if (copyClipboardTextViaExecCommand(text)) { + return + } + throw error + } + } + if (!copyClipboardTextViaExecCommand(text)) { + throw new Error('Clipboard write is unavailable in this browser context') + } +} + +export async function saveClipboardImageAsTempFileInRuntime( + contentBase64: string, + args?: { connectionId?: string | null; runtimeEnvironmentId?: string | null } +): Promise { + if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) { + throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) + } + const connectionId = args?.connectionId ?? null + const startResponse = await callRuntimeEnvelope<{ uploadId: string }>( + 'clipboard.startImageUpload', + { expectedBase64Length: contentBase64.length, connectionId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + if (!startResponse.ok) { + if ( + startResponse.error.code === 'method_not_found' && + contentBase64.length <= CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + ) { + return callRuntimeResult( + 'clipboard.saveImageAsTempFile', + { contentBase64, connectionId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } + throw new Error(startResponse.error.message) + } + + const { uploadId } = startResponse.result + try { + for ( + let offset = 0; + offset < contentBase64.length; + offset += CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + ) { + await callRuntimeResult( + 'clipboard.appendImageUploadChunk', + { + uploadId, + offset, + contentBase64: contentBase64.slice( + offset, + offset + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + ) + }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } + return await callRuntimeResult( + 'clipboard.commitImageUpload', + { uploadId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } catch (error) { + // Why: after chunked paste holds server-side state, release the bounded slot on failure rather than wait for TTL cleanup. + await callRuntimeResult( + 'clipboard.abortImageUpload', + { uploadId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ).catch(() => {}) + throw error + } +} diff --git a/src/renderer/src/web/preload-api/web-diagnostics-api.ts b/src/renderer/src/web/preload-api/web-diagnostics-api.ts new file mode 100644 index 00000000000..a426edc3a0b --- /dev/null +++ b/src/renderer/src/web/preload-api/web-diagnostics-api.ts @@ -0,0 +1,41 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' + +export function createWebDiagnosticsApi(): Partial { + return { + crashReports: { + getLatestPending: () => Promise.resolve(null), + getLatestReport: () => Promise.resolve(null), + dismiss: () => Promise.resolve(null), + recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }), + recordBreadcrumb: () => {}, + submit: () => + Promise.resolve({ + ok: false, + status: null, + error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') + }), + copyLatestDiagnostics: () => + Promise.resolve({ + ok: false, + error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') + }), + // Why: no Electron process on web; the caller falls back to performance.memory. + readHeapStatistics: () => null + }, + diagnostics: { + getStatus: () => + Promise.resolve({ + localFileEnabled: false, + bundleEnabled: false, + traceFilePath: '', + traceFamilySize: 0 + }), + collectBundle: () => Promise.reject(new Error('Review files are unavailable on web.')), + openBundlePreview: () => Promise.reject(new Error('Review files are unavailable on web.')), + discardBundlePreview: () => Promise.resolve(), + uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')), + deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.')) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-e2e-api.ts b/src/renderer/src/web/preload-api/web-e2e-api.ts new file mode 100644 index 00000000000..98a0694eb13 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-e2e-api.ts @@ -0,0 +1,10 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { webE2EConfig } from './web-e2e-config' + +export function createWebE2EApi(): Partial { + return { + e2e: { + getConfig: () => webE2EConfig + } + } +} diff --git a/src/renderer/src/web/preload-api/web-e2e-config.ts b/src/renderer/src/web/preload-api/web-e2e-config.ts new file mode 100644 index 00000000000..cddf982ad5f --- /dev/null +++ b/src/renderer/src/web/preload-api/web-e2e-config.ts @@ -0,0 +1,11 @@ +import { createE2EConfig } from '../../../../shared/e2e-config' + +// Why: this module stays dependency-light so the query/storage snapshot evaluates before runtime state. +const webE2EExposeStore = String(import.meta.env.VITE_EXPOSE_STORE) === 'true' +const webE2EQuery = webE2EExposeStore ? new URLSearchParams(window.location.search) : null + +export const webE2EConfig = createE2EConfig({ + exposeStore: webE2EExposeStore, + terminalParkingDelayMs: Number(webE2EQuery?.get('orcaE2ETerminalParkingDelayMs')) || null, + terminalRetentionLimit: Number(webE2EQuery?.get('orcaE2ETerminalRetentionLimit')) || null +}) diff --git a/src/renderer/src/web/preload-api/web-fallback-api.ts b/src/renderer/src/web/preload-api/web-fallback-api.ts new file mode 100644 index 00000000000..3cfef000ef1 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-fallback-api.ts @@ -0,0 +1,63 @@ +import { noopUnsubscribe } from './web-storage' + +export function withFallback(target: T, path: string[]): T { + return new Proxy(target, { + get(current, property, receiver) { + if (property in current) { + const value = Reflect.get(current, property, receiver) as unknown + if (value && typeof value === 'object' && !Array.isArray(value)) { + return withFallback(value as object, [...path, String(property)]) + } + return value + } + return createFallbackProxy([...path, String(property)]) + } + }) +} + +export function createFallbackProxy( + path: string[], + applyOverride?: (path: string[], args: unknown[]) => unknown +): never { + const fn = () => undefined + return new Proxy(fn, { + get(_target, property) { + if (property === 'then') { + return undefined + } + return createFallbackProxy([...path, String(property)], applyOverride) + }, + apply(_target, _thisArg, args) { + if (applyOverride) { + return applyOverride(path, args) + } + return getFallbackResult(path, args) + } + }) as never +} + +export function getFallbackResult(path: string[], args: unknown[]): unknown { + const name = path.at(-1) ?? '' + if (name.startsWith('on')) { + return noopUnsubscribe + } + if (name.startsWith('is') || name.startsWith('has') || name === 'pathExists') { + return Promise.resolve(false) + } + if (name.startsWith('list') || name.startsWith('detect')) { + return Promise.resolve([]) + } + if (name.startsWith('preview')) { + return Promise.resolve({ found: false, diff: {}, unsupportedKeys: [] }) + } + if (name.startsWith('get') && name.endsWith('Status')) { + return Promise.resolve([]) + } + if (name === 'write' || name === 'resize' || name === 'reportGeometry') { + return undefined + } + if (args.length === 0 && (name === 'getZoomLevel' || name === 'declarePendingPaneSerializer')) { + return 0 + } + return Promise.resolve(undefined) +} diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts new file mode 100644 index 00000000000..dcf93839828 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -0,0 +1,200 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { SearchResult } from '../../../../shared/code-search-types' +import { assertFileMutationOwnershipCapability } from '../../../../shared/file-mutation-ownership' +import type { DirEntry } from '../../../../shared/filesystem-entry-types' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { createWebFileMutationMethods } from '../web-file-mutation-methods' +import { callRuntimeResult } from './web-runtime-calls' +import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls' +import { + getClientForEnvironment, + requireActiveEnvironment, + requireActiveEnvironmentOrNull, + runtimeCallQueuePool, + updateEnvironmentFromResponse, + webRuntimeState +} from './web-runtime-session' +import { isMissingPathError, resolveRuntimeFilePath } from './web-runtime-worktree-catalog' +import { noopUnsubscribe } from './web-storage' + +export function createFileApi(): NonNullable['fs']> { + return { + readDir: async ({ dirPath }) => { + const file = await resolveRuntimeFilePath(dirPath) + return callRuntimeResult('files.readDir', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + }, + readFile: async ({ filePath }) => { + const file = await resolveRuntimeFilePath(filePath) + return callRuntimeResult('files.readPreview', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + }, + readLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + startLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + stopLocalLogTail: async () => {}, + onLocalLogTailChanged: () => noopUnsubscribe, + downloadFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + downloadFolder: async () => { + throw new Error('Remote folder download is unavailable in paired web clients.') + }, + saveDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + startDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + appendDownloadedFileChunk: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + finishDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + cancelDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + listMarkdownDocuments: async ({ rootPath }) => { + const file = await resolveRuntimeFilePath(rootPath) + return callRuntimeResult('files.listMarkdownDocuments', { + worktree: toRuntimeWorktreeSelector(file.worktree.id) + }) + }, + ...createWebFileMutationMethods({ + captureSession: captureWebFileMutationSession + }), + authorizeExternalPath: () => Promise.resolve(), + stat: async ({ filePath }) => { + const file = await resolveRuntimeFilePath(filePath) + return callRuntimeResult('files.stat', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + }, + pathExists: async ({ filePath }) => { + try { + const file = await resolveRuntimeFilePath(filePath) + await callRuntimeResult('files.stat', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + return true + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } + }, + listFiles: async ({ rootPath, excludePaths }) => { + const file = await resolveRuntimeFilePath(rootPath) + const result = await callRuntimeResult<{ files: { relativePath: string }[] }>( + 'files.listAll', + { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + excludePaths + } + ) + return result.files.map((entry) => entry.relativePath) + }, + cancelListFiles: async () => { + // Why: paired-web lists files over runtime RPC with its own timeout; there's no host-side scan to abort here. + }, + search: async (args) => { + const file = await resolveRuntimeFilePath(args.rootPath) + return callRuntimeResult('files.search', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + query: args.query, + caseSensitive: args.caseSensitive, + wholeWord: args.wholeWord, + useRegex: args.useRegex, + includePattern: args.includePattern, + excludePattern: args.excludePattern, + maxResults: args.maxResults + }) + }, + importExternalPaths: async () => ({ results: [] }), + stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), + resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), + watchWorktree: () => Promise.resolve(), + unwatchWorktree: () => Promise.resolve(), + onFsChanged: () => noopUnsubscribe + } +} + +export function captureWebFileMutationSession(): { + resolveFilePath: (filePath: string) => Promise>> + assertMutationSupported: () => Promise + callRuntimeResult: WebRuntimeResultCaller + getSshState: (targetId: string) => Promise +} { + const environment = requireActiveEnvironment() + const client = getClientForEnvironment(environment) + const assertCurrent = (): void => { + if ( + webRuntimeState.activeClient !== client || + requireActiveEnvironmentOrNull()?.id !== environment.id + ) { + throw new Error('Runtime pairing changed; refresh and try again') + } + } + const callBoundRuntimeEnvelope: WebRuntimeEnvelopeCaller = async ( + method: string, + params?: unknown, + timeoutMs?: number + ): Promise> => { + assertCurrent() + const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + assertCurrent() + return client.call(method, params, { timeoutMs }) + }) + assertCurrent() + updateEnvironmentFromResponse(environment, response) + return response as RuntimeRpcResponse + } + const callBoundRuntimeResult: WebRuntimeResultCaller = async ( + method: string, + params?: unknown, + timeoutMs?: number + ): Promise => { + const response = await callBoundRuntimeEnvelope(method, params, timeoutMs) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } + return { + resolveFilePath: (filePath) => + resolveRuntimeFilePath( + filePath, + undefined, + callBoundRuntimeResult, + callBoundRuntimeEnvelope, + false, + environment.id + ), + assertMutationSupported: async () => { + assertFileMutationOwnershipCapability( + await callBoundRuntimeResult('status.get', undefined, 15_000) + ) + }, + callRuntimeResult: callBoundRuntimeResult, + getSshState: async (targetId) => + ( + await callBoundRuntimeResult<{ state: SshConnectionState | null }>('ssh.getState', { + targetId + }) + ).state + } +} diff --git a/src/renderer/src/web/preload-api/web-git-api.ts b/src/renderer/src/web/preload-api/web-git-api.ts new file mode 100644 index 00000000000..f0b0423270a --- /dev/null +++ b/src/renderer/src/web/preload-api/web-git-api.ts @@ -0,0 +1,291 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { callAbortableRuntimeEnvironment } from '../../runtime/abortable-runtime-environment-call' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { translate } from '@/i18n/i18n' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironment, updateEnvironmentFromResponse } from './web-runtime-session' +import { + resolveRuntimeFilePath, + resolveRuntimeWorktreeByPath +} from './web-runtime-worktree-catalog' + +export const webGitStatusAbortControllers = new Map() + +export async function callAbortableRuntimeStatus( + requestToken: string, + params: unknown +): Promise { + const environment = requireActiveEnvironment() + webGitStatusAbortControllers.get(requestToken)?.abort() + const controller = new AbortController() + webGitStatusAbortControllers.set(requestToken, controller) + try { + const response = await callAbortableRuntimeEnvironment( + environment.id, + 'git.status', + params, + undefined, + controller.signal + ) + updateEnvironmentFromResponse(environment, response) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } finally { + if (webGitStatusAbortControllers.get(requestToken) === controller) { + webGitStatusAbortControllers.delete(requestToken) + } + } +} + +export function createGitApi(): NonNullable['git']> { + return { + status: async ({ + worktreePath, + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats, + branchLineTotalMergeBase, + requestToken + }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + const params = { + worktree: toRuntimeWorktreeSelector(worktree.id), + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats, + ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) + } + // Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort. + if (!requestToken) { + return callRuntimeResult('git.status', params) + } + return callAbortableRuntimeStatus(requestToken, params) + }, + cancelStatus: async ({ requestToken }) => { + webGitStatusAbortControllers.get(requestToken)?.abort() + }, + setStatusUpstreamRefWatch: async () => {}, + submoduleStatus: async ({ worktreePath, submodulePath, area }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.submoduleStatus', { + worktree: toRuntimeWorktreeSelector(worktree.id), + submodulePath, + area + }) + }, + checkIgnored: async ({ worktreePath, paths }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.checkIgnored', { + worktree: toRuntimeWorktreeSelector(worktree.id), + paths + }) + }, + // Why: the "add huge folder to .gitignore" flow is desktop-only; the web runtime makes no offer, so return no candidates. + findHugeFoldersToIgnore: async () => [], + appendGitignore: async () => false, + history: async ({ worktreePath, limit, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.history', { + worktree: toRuntimeWorktreeSelector(worktree.id), + limit, + baseRef + }) + }, + conflictOperation: async ({ worktreePath }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.conflictOperation', { + worktree: toRuntimeWorktreeSelector(worktree.id) + }) + }, + abortMerge: async ({ worktreePath }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.abortMerge', { + worktree: toRuntimeWorktreeSelector(worktree.id) + }) + }, + abortRebase: async ({ worktreePath }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.abortRebase', { + worktree: toRuntimeWorktreeSelector(worktree.id) + }) + }, + diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.diff', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath, + staged, + compareAgainstHead + }) + }, + branchCompare: async ({ worktreePath, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.branchCompare', { + worktree: toRuntimeWorktreeSelector(worktree.id), + baseRef + }) + }, + commitCompare: async ({ worktreePath, commitId }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.commitCompare', { + worktree: toRuntimeWorktreeSelector(worktree.id), + commitId + }) + }, + upstreamStatus: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.upstreamStatus', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + fetch: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.fetch', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + syncFork: async ({ worktreePath, expectedUpstream }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult( + 'git.forkSync', + { + worktree: toRuntimeWorktreeSelector(worktree.id), + expectedUpstream + }, + 60_000 + ) + }, + push: async ({ worktreePath, publish, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.push', { + worktree: toRuntimeWorktreeSelector(worktree.id), + publish, + pushTarget + }) + }, + pull: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.pull', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + fastForward: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.fastForward', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + rebaseFromBase: async ({ worktreePath, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.rebaseFromBase', { + worktree: toRuntimeWorktreeSelector(worktree.id), + baseRef + }) + }, + branchDiff: async ({ worktreePath, filePath, compare, oldPath }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.branchDiff', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath, + compare, + oldPath + }) + }, + commitDiff: async ({ worktreePath, filePath, commitOid, parentOid, oldPath }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.commitDiff', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath, + commitOid, + parentOid, + oldPath + }) + }, + commit: async ({ worktreePath, message }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.commit', { + worktree: toRuntimeWorktreeSelector(worktree.id), + message + }) + }, + generateCommitMessage: async () => ({ + success: false, + error: translate( + 'auto.web.web.preload.api.9fc90740b6', + 'Commit message generation is unavailable in the web client.' + ) + }), + discoverCommitMessageModels: async () => ({ + success: false, + error: translate( + 'auto.web.web.preload.api.e57c82d276', + 'Commit message model discovery is unavailable in the web client.' + ) + }), + cancelGenerateCommitMessage: () => Promise.resolve(), + generatePullRequestFields: async () => ({ + success: false, + error: translate( + 'auto.web.web.preload.api.b8a1618172', + 'Pull request detail generation is unavailable in the web client.' + ) + }), + cancelGeneratePullRequestFields: () => Promise.resolve(), + stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath), + bulkStage: async ({ worktreePath, filePaths }) => + mutateGitPaths('git.bulkStage', worktreePath, filePaths), + unstage: async ({ worktreePath, filePath }) => + mutateGitPath('git.unstage', worktreePath, filePath), + bulkUnstage: async ({ worktreePath, filePaths }) => + mutateGitPaths('git.bulkUnstage', worktreePath, filePaths), + discard: async ({ worktreePath, filePath }) => + mutateGitPath('git.discard', worktreePath, filePath), + bulkDiscard: async ({ worktreePath, filePaths }) => { + for (const filePath of filePaths) { + await mutateGitPath('git.discard', worktreePath, filePath) + } + }, + remoteFileUrl: async ({ worktreePath, relativePath, line }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.remoteFileUrl', { + worktree: toRuntimeWorktreeSelector(worktree.id), + relativePath, + line + }) + }, + remoteCommitUrl: async ({ worktreePath, sha }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.remoteCommitUrl', { + worktree: toRuntimeWorktreeSelector(worktree.id), + sha + }) + } + } +} + +export async function mutateGitPath( + method: string, + worktreePath: string, + filePath: string +): Promise { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + await callRuntimeResult(method, { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath + }) +} + +export async function mutateGitPaths( + method: string, + worktreePath: string, + filePaths: string[] +): Promise { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id), filePaths }) +} diff --git a/src/renderer/src/web/preload-api/web-github-api.ts b/src/renderer/src/web/preload-api/web-github-api.ts new file mode 100644 index 00000000000..ce22933ab30 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-github-api.ts @@ -0,0 +1,193 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { GITHUB_WEB_RPC_METHODS } from './web-github-routes' +import type { WebGitHubRuntimeMethod } from './web-github-routes' +import { mapRepoPathArg } from './web-review-api' +import { callRuntimeResult } from './web-runtime-calls' +import { noopUnsubscribe } from './web-storage' + +export type WebGitHubApi = NonNullable + +export type WebGitHubResult = Awaited> + +export function createGitHubApi(): WebGitHubApi { + const route = (method: WebGitHubRuntimeMethod, args?: unknown): Promise => + callRuntimeResult(method, mapRepoPathArg(args)) + const githubApi = { + viewer: () => Promise.resolve(null), + repoSlug: (args) => route>(GITHUB_WEB_RPC_METHODS.repoSlug, args), + repoUpstream: (args) => + route>(GITHUB_WEB_RPC_METHODS.repoUpstream, args), + prForBranch: (args) => + route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), + refreshPRNow: async ({ candidate }) => { + const acceptMergedFallbackPR = + candidate.linkedPRNumber == null && + candidate.fallbackPRNumber != null && + candidate.fallbackPRSource != null + const pr = await route>(GITHUB_WEB_RPC_METHODS.prForBranch, { + repoPath: candidate.repoPath, + repoId: candidate.repoId, + branch: candidate.branch, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, + ...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}) + }) + return pr + ? { kind: 'found', pr, fetchedAt: Date.now() } + : { kind: 'no-pr', fetchedAt: Date.now() } + }, + enqueuePRRefresh: () => Promise.resolve(false), + reportVisiblePRRefreshCandidates: () => Promise.resolve(false), + onPRRefreshEvent: () => noopUnsubscribe, + issue: (args) => route>(GITHUB_WEB_RPC_METHODS.issue, args), + workItem: (args) => route>(GITHUB_WEB_RPC_METHODS.workItem, args), + workItemByOwnerRepo: ({ repo: ownerRepo, ...args }) => + route>(GITHUB_WEB_RPC_METHODS.workItemByOwnerRepo, { + ...args, + ownerRepo + }), + workItemDetails: (args) => + route>(GITHUB_WEB_RPC_METHODS.workItemDetails, args), + notifyWorkItemMutated: () => Promise.resolve(false), + prFileContents: (args) => + route>(GITHUB_WEB_RPC_METHODS.prFileContents, args), + listIssues: (args) => + route>(GITHUB_WEB_RPC_METHODS.listIssues, args), + createIssue: (args) => + route>(GITHUB_WEB_RPC_METHODS.createIssue, args), + countWorkItems: (args) => + route>(GITHUB_WEB_RPC_METHODS.countWorkItems, args), + listWorkItems: (args) => + route>(GITHUB_WEB_RPC_METHODS.listWorkItems, args), + prChecks: (args) => route>(GITHUB_WEB_RPC_METHODS.prChecks, args), + prCheckDetails: (args) => + route>(GITHUB_WEB_RPC_METHODS.prCheckDetails, args), + rerunPRChecks: (args) => + route>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), + prComments: (args) => + route>(GITHUB_WEB_RPC_METHODS.prComments, args), + setPRCommentReaction: (args) => + route>( + GITHUB_WEB_RPC_METHODS.setPRCommentReaction, + args + ), + resolveReviewThread: (args) => + route>( + GITHUB_WEB_RPC_METHODS.resolveReviewThread, + args + ), + setPRFileViewed: (args) => + route>(GITHUB_WEB_RPC_METHODS.setPRFileViewed, args), + updatePRTitle: (args) => + route>(GITHUB_WEB_RPC_METHODS.updatePRTitle, args), + mergePR: (args) => route>(GITHUB_WEB_RPC_METHODS.mergePR, args), + setPRAutoMerge: (args) => + route>(GITHUB_WEB_RPC_METHODS.setPRAutoMerge, args), + updatePRState: (args) => + route>(GITHUB_WEB_RPC_METHODS.updatePRState, args), + requestPRReviewers: (args) => + route>(GITHUB_WEB_RPC_METHODS.requestPRReviewers, args), + removePRReviewers: (args) => + route>(GITHUB_WEB_RPC_METHODS.removePRReviewers, args), + updateIssue: (args) => + route>(GITHUB_WEB_RPC_METHODS.updateIssue, args), + addIssueComment: (args) => + route>(GITHUB_WEB_RPC_METHODS.addIssueComment, args), + addPRReviewCommentReply: (args) => + route>( + GITHUB_WEB_RPC_METHODS.addPRReviewCommentReply, + args + ), + addPRReviewComment: (args) => + route>(GITHUB_WEB_RPC_METHODS.addPRReviewComment, args), + listLabels: (args) => + route>(GITHUB_WEB_RPC_METHODS.listLabels, args), + listAssignableUsers: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listAssignableUsers, + args + ), + onWorkItemMutated: () => noopUnsubscribe, + checkOrcaStarred: () => Promise.resolve(null), + starOrca: () => Promise.resolve(false), + rateLimit: (args) => + route>(GITHUB_WEB_RPC_METHODS.rateLimit, args), + diagnoseAuth: () => + Promise.resolve({ + ok: false, + message: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') + } as never), + listAccessibleProjects: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listAccessibleProjects, + args + ), + resolveProjectRef: (args) => + route>(GITHUB_WEB_RPC_METHODS.resolveProjectRef, args), + listProjectViews: (args) => + route>(GITHUB_WEB_RPC_METHODS.listProjectViews, args), + getProjectViewTable: (args) => + route>( + GITHUB_WEB_RPC_METHODS.getProjectViewTable, + args + ), + projectWorkItemDetailsBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.projectWorkItemDetailsBySlug, + args + ), + updateProjectItemField: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updateProjectItemField, + args + ), + clearProjectItemField: (args) => + route>( + GITHUB_WEB_RPC_METHODS.clearProjectItemField, + args + ), + updateIssueBySlug: (args) => + route>(GITHUB_WEB_RPC_METHODS.updateIssueBySlug, args), + updatePullRequestBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updatePullRequestBySlug, + args + ), + addIssueCommentBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.addIssueCommentBySlug, + args + ), + updateIssueCommentBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updateIssueCommentBySlug, + args + ), + deleteIssueCommentBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.deleteIssueCommentBySlug, + args + ), + listLabelsBySlug: (args) => + route>(GITHUB_WEB_RPC_METHODS.listLabelsBySlug, args), + listAssignableUsersBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listAssignableUsersBySlug, + args + ), + listIssueTypesBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listIssueTypesBySlug, + args + ), + updateIssueTypeBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updateIssueTypeBySlug, + args + ) + } satisfies WebGitHubApi + + return githubApi +} diff --git a/src/renderer/src/web/preload-api/web-github-cache-api.ts b/src/renderer/src/web/preload-api/web-github-cache-api.ts new file mode 100644 index 00000000000..1881a74c0d6 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-github-cache-api.ts @@ -0,0 +1,19 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { GITHUB_CACHE_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export function createWebGithubCacheApi(): Partial { + return { + cache: { + getGitHub: () => + Promise.resolve( + readJson(GITHUB_CACHE_STORAGE_KEY, { + pr: {}, + issue: {} + }) + ), + setGitHub: async ({ cache }) => { + writeJson(GITHUB_CACHE_STORAGE_KEY, cache) + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-github-routes.ts b/src/renderer/src/web/preload-api/web-github-routes.ts new file mode 100644 index 00000000000..ce688832ec5 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-github-routes.ts @@ -0,0 +1,150 @@ +export type WebGitHubRouteKey = + | 'repoSlug' + | 'repoUpstream' + | 'prForBranch' + | 'issue' + | 'workItem' + | 'workItemByOwnerRepo' + | 'workItemDetails' + | 'prFileContents' + | 'listIssues' + | 'createIssue' + | 'countWorkItems' + | 'listWorkItems' + | 'prChecks' + | 'prCheckDetails' + | 'rerunPRChecks' + | 'prComments' + | 'setPRCommentReaction' + | 'resolveReviewThread' + | 'setPRFileViewed' + | 'updatePRTitle' + | 'mergePR' + | 'setPRAutoMerge' + | 'updatePRState' + | 'requestPRReviewers' + | 'removePRReviewers' + | 'updateIssue' + | 'addIssueComment' + | 'addPRReviewCommentReply' + | 'addPRReviewComment' + | 'listLabels' + | 'listAssignableUsers' + | 'rateLimit' + | 'listAccessibleProjects' + | 'resolveProjectRef' + | 'listProjectViews' + | 'getProjectViewTable' + | 'projectWorkItemDetailsBySlug' + | 'updateProjectItemField' + | 'clearProjectItemField' + | 'updateIssueBySlug' + | 'updatePullRequestBySlug' + | 'addIssueCommentBySlug' + | 'updateIssueCommentBySlug' + | 'deleteIssueCommentBySlug' + | 'listLabelsBySlug' + | 'listAssignableUsersBySlug' + | 'listIssueTypesBySlug' + | 'updateIssueTypeBySlug' + +export type WebGitHubRuntimeMethod = + | 'github.repoSlug' + | 'github.repoUpstream' + | 'github.prForBranch' + | 'github.issue' + | 'github.workItem' + | 'github.workItemByOwnerRepo' + | 'github.workItemDetails' + | 'github.prFileContents' + | 'github.listIssues' + | 'github.createIssue' + | 'github.countWorkItems' + | 'github.listWorkItems' + | 'github.prChecks' + | 'github.prCheckDetails' + | 'github.rerunPRChecks' + | 'github.prComments' + | 'github.setPRCommentReaction' + | 'github.resolveReviewThread' + | 'github.setPRFileViewed' + | 'github.updatePRTitle' + | 'github.mergePR' + | 'github.setPRAutoMerge' + | 'github.updatePRState' + | 'github.requestPRReviewers' + | 'github.removePRReviewers' + | 'github.updateIssue' + | 'github.addIssueComment' + | 'github.addPRReviewCommentReply' + | 'github.addPRReviewComment' + | 'github.listLabels' + | 'github.listAssignableUsers' + | 'github.rateLimit' + | 'github.project.listAccessible' + | 'github.project.resolveRef' + | 'github.project.listViews' + | 'github.project.viewTable' + | 'github.project.workItemDetailsBySlug' + | 'github.project.updateItemField' + | 'github.project.clearItemField' + | 'github.project.updateIssueBySlug' + | 'github.project.updatePullRequestBySlug' + | 'github.project.addIssueCommentBySlug' + | 'github.project.updateIssueCommentBySlug' + | 'github.project.deleteIssueCommentBySlug' + | 'github.project.listLabelsBySlug' + | 'github.project.listAssignableUsersBySlug' + | 'github.project.listIssueTypesBySlug' + | 'github.project.updateIssueTypeBySlug' + +export const GITHUB_WEB_RPC_METHODS = { + repoSlug: 'github.repoSlug', + repoUpstream: 'github.repoUpstream', + prForBranch: 'github.prForBranch', + issue: 'github.issue', + workItem: 'github.workItem', + workItemByOwnerRepo: 'github.workItemByOwnerRepo', + workItemDetails: 'github.workItemDetails', + prFileContents: 'github.prFileContents', + listIssues: 'github.listIssues', + createIssue: 'github.createIssue', + countWorkItems: 'github.countWorkItems', + listWorkItems: 'github.listWorkItems', + prChecks: 'github.prChecks', + prCheckDetails: 'github.prCheckDetails', + rerunPRChecks: 'github.rerunPRChecks', + prComments: 'github.prComments', + setPRCommentReaction: 'github.setPRCommentReaction', + resolveReviewThread: 'github.resolveReviewThread', + setPRFileViewed: 'github.setPRFileViewed', + updatePRTitle: 'github.updatePRTitle', + mergePR: 'github.mergePR', + setPRAutoMerge: 'github.setPRAutoMerge', + updatePRState: 'github.updatePRState', + requestPRReviewers: 'github.requestPRReviewers', + removePRReviewers: 'github.removePRReviewers', + updateIssue: 'github.updateIssue', + addIssueComment: 'github.addIssueComment', + addPRReviewCommentReply: 'github.addPRReviewCommentReply', + addPRReviewComment: 'github.addPRReviewComment', + listLabels: 'github.listLabels', + listAssignableUsers: 'github.listAssignableUsers', + rateLimit: 'github.rateLimit', + listAccessibleProjects: 'github.project.listAccessible', + resolveProjectRef: 'github.project.resolveRef', + listProjectViews: 'github.project.listViews', + getProjectViewTable: 'github.project.viewTable', + projectWorkItemDetailsBySlug: 'github.project.workItemDetailsBySlug', + updateProjectItemField: 'github.project.updateItemField', + clearProjectItemField: 'github.project.clearItemField', + updateIssueBySlug: 'github.project.updateIssueBySlug', + updatePullRequestBySlug: 'github.project.updatePullRequestBySlug', + addIssueCommentBySlug: 'github.project.addIssueCommentBySlug', + updateIssueCommentBySlug: 'github.project.updateIssueCommentBySlug', + deleteIssueCommentBySlug: 'github.project.deleteIssueCommentBySlug', + listLabelsBySlug: 'github.project.listLabelsBySlug', + listAssignableUsersBySlug: 'github.project.listAssignableUsersBySlug', + listIssueTypesBySlug: 'github.project.listIssueTypesBySlug', + updateIssueTypeBySlug: 'github.project.updateIssueTypeBySlug' +} as const satisfies Record diff --git a/src/renderer/src/web/preload-api/web-gitlab-api.ts b/src/renderer/src/web/preload-api/web-gitlab-api.ts new file mode 100644 index 00000000000..a183ee88df2 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-gitlab-api.ts @@ -0,0 +1,71 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { GITLAB_WEB_RPC_METHODS } from './web-gitlab-routes' +import type { WebGitLabRuntimeMethod } from './web-gitlab-routes' +import { mapRepoPathArg } from './web-review-api' +import { callRuntimeResult } from './web-runtime-calls' + +export type WebGitLabApi = NonNullable + +export type WebGitLabResult = Awaited> + +export function createGitLabApi(): WebGitLabApi { + const route = (method: WebGitLabRuntimeMethod, args?: unknown): Promise => + callRuntimeResult(method, mapRepoPathArg(args)) + + const gitLabApi = { + viewer: () => Promise.resolve(null), + diagnoseAuth: () => route>(GITLAB_WEB_RPC_METHODS.diagnoseAuth), + rateLimit: (args) => + route>(GITLAB_WEB_RPC_METHODS.rateLimit, args), + projectSlug: () => Promise.resolve(null), + mrForBranch: () => Promise.resolve(null), + mr: () => Promise.resolve(null), + listMRs: (args) => route>(GITLAB_WEB_RPC_METHODS.listMRs, args), + listWorkItems: (args) => + route>(GITLAB_WEB_RPC_METHODS.listWorkItems, args), + issue: () => Promise.resolve(null), + listIssues: (args) => + route>(GITLAB_WEB_RPC_METHODS.listIssues, args), + createIssue: (args) => + route>(GITLAB_WEB_RPC_METHODS.createIssue, args), + updateIssue: (args) => + route>(GITLAB_WEB_RPC_METHODS.updateIssue, args), + addIssueComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addIssueComment, args), + listLabels: (args) => + route>(GITLAB_WEB_RPC_METHODS.listLabels, args), + listAssignableUsers: () => Promise.resolve([]), + todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), + workItemDetails: (args) => + route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), + closeMR: (args) => + route>(GITLAB_WEB_RPC_METHODS.closeMR, { + ...args, + state: 'closed' + }), + reopenMR: (args) => + route>(GITLAB_WEB_RPC_METHODS.reopenMR, { + ...args, + state: 'opened' + }), + mergeMR: (args) => route>(GITLAB_WEB_RPC_METHODS.mergeMR, args), + updateMR: (args) => route>(GITLAB_WEB_RPC_METHODS.updateMR, args), + updateMRReviewers: (args) => + route>(GITLAB_WEB_RPC_METHODS.updateMRReviewers, args), + addMRComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addMRComment, args), + addMRInlineComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addMRInlineComment, args), + resolveMRDiscussion: (args) => + route>( + GITLAB_WEB_RPC_METHODS.resolveMRDiscussion, + args + ), + jobTrace: (args) => route>(GITLAB_WEB_RPC_METHODS.jobTrace, args), + retryJob: (args) => route>(GITLAB_WEB_RPC_METHODS.retryJob, args), + workItemByPath: (args) => + route>(GITLAB_WEB_RPC_METHODS.workItemByPath, args) + } satisfies WebGitLabApi + + return gitLabApi +} diff --git a/src/renderer/src/web/preload-api/web-gitlab-routes.ts b/src/renderer/src/web/preload-api/web-gitlab-routes.ts new file mode 100644 index 00000000000..e99f0780015 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-gitlab-routes.ts @@ -0,0 +1,71 @@ +export type WebGitLabRouteKey = + | 'diagnoseAuth' + | 'rateLimit' + | 'listMRs' + | 'listWorkItems' + | 'listIssues' + | 'createIssue' + | 'updateIssue' + | 'addIssueComment' + | 'listLabels' + | 'todos' + | 'workItemDetails' + | 'closeMR' + | 'reopenMR' + | 'mergeMR' + | 'updateMR' + | 'updateMRReviewers' + | 'addMRComment' + | 'addMRInlineComment' + | 'resolveMRDiscussion' + | 'jobTrace' + | 'retryJob' + | 'workItemByPath' + +export type WebGitLabRuntimeMethod = + | 'gitlab.diagnoseAuth' + | 'gitlab.rateLimit' + | 'gitlab.listMRs' + | 'gitlab.listWorkItems' + | 'gitlab.listIssues' + | 'gitlab.createIssue' + | 'gitlab.updateIssue' + | 'gitlab.addIssueComment' + | 'gitlab.listLabels' + | 'gitlab.todos' + | 'gitlab.workItemDetails' + | 'gitlab.updateMRState' + | 'gitlab.mergeMR' + | 'gitlab.updateMR' + | 'gitlab.updateMRReviewers' + | 'gitlab.addMRComment' + | 'gitlab.addMRInlineComment' + | 'gitlab.resolveMRDiscussion' + | 'gitlab.jobTrace' + | 'gitlab.retryJob' + | 'gitlab.workItemByPath' + +export const GITLAB_WEB_RPC_METHODS = { + diagnoseAuth: 'gitlab.diagnoseAuth', + rateLimit: 'gitlab.rateLimit', + listMRs: 'gitlab.listMRs', + listWorkItems: 'gitlab.listWorkItems', + listIssues: 'gitlab.listIssues', + createIssue: 'gitlab.createIssue', + updateIssue: 'gitlab.updateIssue', + addIssueComment: 'gitlab.addIssueComment', + listLabels: 'gitlab.listLabels', + todos: 'gitlab.todos', + workItemDetails: 'gitlab.workItemDetails', + closeMR: 'gitlab.updateMRState', + reopenMR: 'gitlab.updateMRState', + mergeMR: 'gitlab.mergeMR', + updateMR: 'gitlab.updateMR', + updateMRReviewers: 'gitlab.updateMRReviewers', + addMRComment: 'gitlab.addMRComment', + addMRInlineComment: 'gitlab.addMRInlineComment', + resolveMRDiscussion: 'gitlab.resolveMRDiscussion', + jobTrace: 'gitlab.jobTrace', + retryJob: 'gitlab.retryJob', + workItemByPath: 'gitlab.workItemByPath' +} as const satisfies Record diff --git a/src/renderer/src/web/preload-api/web-host-capability-api.ts b/src/renderer/src/web/preload-api/web-host-capability-api.ts new file mode 100644 index 00000000000..2b2849baaf6 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-host-capability-api.ts @@ -0,0 +1,195 @@ +import type { + PreflightStatus, + PreloadApi, + RefreshAgentsResult +} from '../../../../preload/api-types' +import type { + ComputerUsePermissionSetupResult, + ComputerUsePermissionStatusResult +} from '../../../../shared/computer-use-permissions-types' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import type { SkillDiscoveryResult } from '../../../../shared/skills' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { getBrowserPlatform } from './web-storage' + +export function createPreflightApi(): NonNullable['preflight']> { + const fallbackStatus: PreflightStatus = { + git: { installed: false }, + gh: { installed: false, authenticated: false }, + glab: { installed: false, authenticated: false }, + bitbucket: { configured: false, authenticated: false, account: null }, + azureDevOps: { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + }, + gitea: { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + } + } + const fallbackRefreshAgents: RefreshAgentsResult = { + agents: [], + addedPathSegments: [], + shellHydrationOk: false, + pathSource: 'sync_seed_only', + pathFailureReason: 'spawn_error' + } + type WindowsTerminalCapabilityBridgeResult = { + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + } + const fallbackWindowsTerminalCapabilities = { + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: null + } + return { + check: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return fallbackStatus + } + return callRuntimeResult('preflight.check', args) + }, + detectAgents: async () => { + if (!requireActiveEnvironmentOrNull()) { + return [] + } + return callRuntimeResult('preflight.detectAgents').catch(() => []) + }, + refreshAgents: () => + requireActiveEnvironmentOrNull() + ? callRuntimeResult('preflight.refreshAgents') + .then((result) => result as RefreshAgentsResult) + .catch(() => fallbackRefreshAgents) + : Promise.resolve(fallbackRefreshAgents), + detectRemoteAgents: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) + : [], + detectRemoteWindowsTerminalCapabilities: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult( + 'preflight.detectRemoteWindowsTerminalCapabilities', + args + ).catch(() => fallbackWindowsTerminalCapabilities) + : Promise.resolve(fallbackWindowsTerminalCapabilities) + } +} + +export function createDeveloperPermissionsApi(): NonNullable< + Partial['developerPermissions'] +> { + return { + getStatus: () => Promise.resolve([]), + request: ({ id }) => + Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const), + openSettings: () => Promise.resolve(), + testLocalNetworkConnection: ({ host, port }) => + Promise.resolve({ + ok: false, + host, + port, + testedAt: Date.now(), + failure: 'unsupported' + } as const) + } +} + +export function createComputerUsePermissionsApi(): NonNullable< + Partial['computerUsePermissions'] +> { + return { + getStatus: () => + callRuntimeResult( + 'computer.permissionsStatus', + {}, + 15_000 + ), + openSetup: (args) => + callRuntimeResult( + 'computer.permissions', + args ?? {}, + 15_000 + ).catch(() => ({ + platform: getBrowserPlatform(), + helperAppPath: null, + openedSettings: false, + launchedHelper: false, + nextStep: 'Computer-use permissions are managed on the Orca server.' + })), + reset: () => + Promise.resolve({ + platform: getBrowserPlatform(), + helperAppPath: null, + helperUnavailableReason: 'web_client', + bundleId: null, + permissions: [] + }) + } +} + +export function createSkillsApi(): NonNullable['skills']> { + return { + discover: (target) => + callRuntimeResult('skills.discover', target, 15_000), + // Why: browser clients have no local skill homes; remote-host freshness stays off until its update rail covers it. + freshnessInventory: (): Promise => + Promise.resolve({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scanIssues: [], + scannedAt: Date.now() + }), + // Why: with no local skill homes there is nothing to update, so the run rail + // reports a permanently idle state rather than spawning anything. + startUpdateRun: () => Promise.resolve({ started: false as const, reason: 'invalid-names' }), + cancelUpdateRun: () => Promise.resolve(), + acknowledgeUpdateRun: () => Promise.resolve(), + getUpdateRun: () => Promise.resolve({ state: 'idle' as const }), + prepareShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), + publishShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), + cancelShare: () => Promise.resolve(), + releaseShare: () => Promise.resolve(), + resolveShare: () => Promise.reject(new Error('Skill share links require the desktop app.')), + installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + installBundleShare: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + installPackageVersion: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + installBundlePackageVersion: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + cancelInstall: () => Promise.resolve({ cancelled: false }), + previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + previewBundleInstall: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + listManagedInstalls: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + listOwnedShares: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + revokeShare: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + deletePackageVersion: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + deletePackage: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + listWslDistros: () => Promise.resolve([]), + onInstallProgress: () => () => {}, + onShareProgress: () => () => {}, + onUpdateRun: () => () => {} + } +} diff --git a/src/renderer/src/web/preload-api/web-keybinding-normalization.ts b/src/renderer/src/web/preload-api/web-keybinding-normalization.ts new file mode 100644 index 00000000000..c795bbfa1b1 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-keybinding-normalization.ts @@ -0,0 +1,174 @@ +import { + findKeybindingConflicts, + isKeybindingActionId, + normalizeKeybindingArrayForAction +} from '../../../../shared/keybindings' +import type { + KeybindingActionId, + KeybindingFileDiagnostic, + KeybindingOverrides, + KeybindingPlatform +} from '../../../../shared/keybindings' +import { translate } from '@/i18n/i18n' + +export type WebKeybindingDocument = { + version: 1 + keybindings: KeybindingOverrides + platforms: Partial> +} + +export const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] + +export function isJsonObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function normalizeStoredWebOverrides( + value: unknown, + section: string, + diagnostics: KeybindingFileDiagnostic[] +): KeybindingOverrides { + if (value === undefined) { + return {} + } + if (!isJsonObject(value)) { + diagnostics.push({ + severity: 'error', + section, + message: translate('auto.web.web.preload.api.d2e43e426a', '{{value0}} must be an object.', { + value0: section + }) + }) + return {} + } + + const overrides: KeybindingOverrides = {} + for (const [actionId, rawBindings] of Object.entries(value)) { + if (!isKeybindingActionId(actionId)) { + diagnostics.push({ + severity: 'warning', + section, + actionId, + message: translate( + 'auto.web.web.preload.api.36761d9604', + 'Unknown keybinding action "{{value0}}" was ignored.', + { value0: actionId } + ) + }) + continue + } + if ( + !Array.isArray(rawBindings) || + !rawBindings.every((binding) => typeof binding === 'string') + ) { + diagnostics.push({ + severity: 'error', + section, + actionId, + message: translate( + 'auto.web.web.preload.api.10898045f3', + 'Shortcut for "{{value0}}" was ignored: Use a string array.', + { value0: actionId } + ) + }) + continue + } + const normalized = normalizeKeybindingArrayForAction(actionId, rawBindings) + if (!Array.isArray(normalized)) { + const error = normalized.ok ? 'Unable to parse shortcut.' : normalized.error + diagnostics.push({ + severity: 'error', + section, + actionId, + message: translate( + 'auto.web.web.preload.api.76122208ca', + 'Shortcut for "{{value0}}" was ignored: {{value1}}', + { value0: actionId, value1: error } + ) + }) + continue + } + overrides[actionId] = normalized + } + return overrides +} + +export function normalizeWebPlatformOverrides( + value: unknown, + diagnostics: KeybindingFileDiagnostic[] +): Partial> { + if (value === undefined) { + return {} + } + if (!isJsonObject(value)) { + diagnostics.push({ + severity: 'error', + section: 'platforms', + message: translate( + 'auto.web.web.preload.api.0a69fcd8bc', + 'platforms must be an object with darwin, linux, or win32 sections.' + ) + }) + return {} + } + + const result: Partial> = {} + for (const [platform, overrides] of Object.entries(value)) { + if (!WEB_KEYBINDING_PLATFORMS.includes(platform as KeybindingPlatform)) { + diagnostics.push({ + severity: 'warning', + section: `platforms.${platform}`, + message: translate( + 'auto.web.web.preload.api.32f15bdb0f', + 'Unknown platform "{{value0}}" was ignored.', + { value0: platform } + ) + }) + continue + } + result[platform as KeybindingPlatform] = normalizeStoredWebOverrides( + overrides, + `platforms.${platform}`, + diagnostics + ) + } + return result +} + +export function removeConflictingWebOverrides( + platform: KeybindingPlatform, + overrides: KeybindingOverrides, + diagnostics: KeybindingFileDiagnostic[] +): KeybindingOverrides { + let next = { ...overrides } + for (let attempt = 0; attempt < 20; attempt++) { + const conflicts = findKeybindingConflicts(platform, next) + const conflictingOverrides = new Set() + for (const conflict of conflicts) { + for (const actionId of conflict.actionIds) { + if (Object.hasOwn(next, actionId)) { + conflictingOverrides.add(actionId) + } + } + } + if (conflictingOverrides.size === 0) { + return next + } + for (const actionId of conflictingOverrides) { + delete next[actionId] + } + diagnostics.push({ + severity: 'error', + message: translate( + 'auto.web.web.preload.api.52bee9d8a0', + 'Conflicting custom shortcuts were ignored: {{value0}}.', + { + value0: Array.from(conflictingOverrides) + .map((actionId) => actionId) + .join(', ') + } + ) + }) + } + return next +} diff --git a/src/renderer/src/web/preload-api/web-keybindings-api.ts b/src/renderer/src/web/preload-api/web-keybindings-api.ts new file mode 100644 index 00000000000..1929d536b9d --- /dev/null +++ b/src/renderer/src/web/preload-api/web-keybindings-api.ts @@ -0,0 +1,174 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + findKeybindingConflicts, + formatKeybindingList, + getKeybindingPlatform, + isKeybindingActionId, + normalizeKeybindingArrayForAction +} from '../../../../shared/keybindings' +import type { + KeybindingActionId, + KeybindingFileDiagnostic, + KeybindingFileSnapshot, + KeybindingOverrides, + KeybindingPlatform +} from '../../../../shared/keybindings' +import { + isJsonObject, + normalizeStoredWebOverrides, + normalizeWebPlatformOverrides, + removeConflictingWebOverrides +} from './web-keybinding-normalization' +import type { WebKeybindingDocument } from './web-keybinding-normalization' +import { KEYBINDINGS_STORAGE_KEY, getBrowserPlatform, readJson, writeJson } from './web-storage' + +export type WebKeybindingsApi = NonNullable + +export const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>() + +export function createEmptyWebKeybindingDocument(): WebKeybindingDocument { + return { + version: 1, + keybindings: {}, + platforms: { + darwin: {}, + linux: {}, + win32: {} + } + } +} + +export function getWebKeybindingPlatform(): KeybindingPlatform { + return getKeybindingPlatform(getBrowserPlatform()) +} + +export function readWebKeybindingDocument(): WebKeybindingDocument { + const document = readJson(KEYBINDINGS_STORAGE_KEY, createEmptyWebKeybindingDocument()) + return { + version: 1, + keybindings: isJsonObject(document.keybindings) + ? (document.keybindings as KeybindingOverrides) + : {}, + platforms: isJsonObject(document.platforms) + ? (document.platforms as Partial>) + : {} + } +} + +export function getWebKeybindingSnapshot(): KeybindingFileSnapshot { + const platform = getWebKeybindingPlatform() + const diagnostics: KeybindingFileDiagnostic[] = [] + const document = readWebKeybindingDocument() + const commonOverrides = normalizeStoredWebOverrides( + document.keybindings, + 'keybindings', + diagnostics + ) + const platformOverrides = normalizeWebPlatformOverrides(document.platforms, diagnostics) + const overrides = removeConflictingWebOverrides( + platform, + { + ...commonOverrides, + ...platformOverrides[platform] + }, + diagnostics + ) + + return { + path: 'Browser local storage', + platform, + exists: window.localStorage.getItem(KEYBINDINGS_STORAGE_KEY) !== null, + overrides, + commonOverrides, + platformOverrides, + diagnostics + } +} + +export function writeWebKeybindingAction( + actionId: KeybindingActionId, + bindings: string[] | null +): KeybindingFileSnapshot { + if (!isKeybindingActionId(actionId)) { + throw new Error(`Unknown keybinding action "${String(actionId)}".`) + } + const normalizedBindings = + bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings) + if (normalizedBindings !== null && !Array.isArray(normalizedBindings)) { + throw new Error(normalizedBindings.ok ? 'Unable to parse shortcut.' : normalizedBindings.error) + } + + const platform = getWebKeybindingPlatform() + const currentSnapshot = getWebKeybindingSnapshot() + const candidateOverrides = { ...currentSnapshot.overrides } + if (normalizedBindings === null) { + delete candidateOverrides[actionId] + } else { + candidateOverrides[actionId] = normalizedBindings + } + const blockingConflict = findKeybindingConflicts(platform, candidateOverrides).find((conflict) => + conflict.actionIds.includes(actionId) + ) + if (blockingConflict) { + throw new Error( + `${formatKeybindingList([blockingConflict.binding], platform)} conflicts with another shortcut.` + ) + } + + const activePlatform: KeybindingOverrides = { ...currentSnapshot.platformOverrides[platform] } + if (normalizedBindings === null) { + delete activePlatform[actionId] + } else { + activePlatform[actionId] = normalizedBindings + } + + writeJson(KEYBINDINGS_STORAGE_KEY, { + version: 1, + keybindings: currentSnapshot.commonOverrides, + platforms: { + ...currentSnapshot.platformOverrides, + darwin: currentSnapshot.platformOverrides.darwin ?? {}, + linux: currentSnapshot.platformOverrides.linux ?? {}, + win32: currentSnapshot.platformOverrides.win32 ?? {}, + [platform]: activePlatform + } + } satisfies WebKeybindingDocument) + + const snapshot = getWebKeybindingSnapshot() + notifyWebKeybindingListeners(snapshot) + return snapshot +} + +export function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void { + for (const listener of webKeybindingListeners) { + listener(snapshot) + } +} + +export function createWebKeybindingsApi(): WebKeybindingsApi { + return { + get: () => Promise.resolve(getWebKeybindingSnapshot()), + ensureFile: () => Promise.resolve(getWebKeybindingSnapshot()), + setAction: async ({ actionId, bindings }) => writeWebKeybindingAction(actionId, bindings), + reload: () => { + const snapshot = getWebKeybindingSnapshot() + notifyWebKeybindingListeners(snapshot) + return Promise.resolve(snapshot) + }, + openFile: () => Promise.resolve(getWebKeybindingSnapshot()), + revealFile: () => Promise.resolve(getWebKeybindingSnapshot()), + onChanged: (callback) => { + webKeybindingListeners.add(callback) + const onStorage = (event: StorageEvent): void => { + if (event.key === KEYBINDINGS_STORAGE_KEY) { + callback(getWebKeybindingSnapshot()) + } + } + window.addEventListener('storage', onStorage) + return () => { + webKeybindingListeners.delete(callback) + window.removeEventListener('storage', onStorage) + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-macos-tcc-api.ts b/src/renderer/src/web/preload-api/web-macos-tcc-api.ts new file mode 100644 index 00000000000..899545822ee --- /dev/null +++ b/src/renderer/src/web/preload-api/web-macos-tcc-api.ts @@ -0,0 +1,13 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { noopUnsubscribe } from './web-storage' + +export function createMacosTccPromptsApi(): NonNullable['macosTccPrompts']> { + // Why: TCC is a macOS-desktop concept; the web client has no log stream to watch. + return { + onThreshold: () => noopUnsubscribe, + consumePending: () => Promise.resolve(null), + acknowledgePending: () => Promise.resolve(), + releasePending: () => Promise.resolve(), + dismiss: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-memory-api.ts b/src/renderer/src/web/preload-api/web-memory-api.ts new file mode 100644 index 00000000000..a99fc7ccfa7 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-memory-api.ts @@ -0,0 +1,24 @@ +import type { MemorySnapshot } from '../../../../shared/process-stats-types' +import { getBrowserPlatform } from './web-storage' + +export function createEmptyMemorySnapshot(): MemorySnapshot { + const emptyUsage = { cpu: 0, memory: 0 } + return { + app: { ...emptyUsage, main: emptyUsage, renderer: emptyUsage, other: emptyUsage, history: [] }, + worktrees: [], + host: { + totalMemory: 0, + freeMemory: 0, + availableMemory: 0, + availableMemorySource: 'free-memory', + usedMemory: 0, + memoryUsagePercent: 0, + cpuCoreCount: navigator.hardwareConcurrency || 1, + loadAverage1m: 0 + }, + processMemoryMetric: getBrowserPlatform() === 'win32' ? 'working-set' : 'rss', + totalCpu: 0, + totalMemory: 0, + collectedAt: Date.now() + } +} diff --git a/src/renderer/src/web/preload-api/web-mobile-api.ts b/src/renderer/src/web/preload-api/web-mobile-api.ts new file mode 100644 index 00000000000..6a631c743dd --- /dev/null +++ b/src/renderer/src/web/preload-api/web-mobile-api.ts @@ -0,0 +1,26 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { webRuntimeState } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createWebMobileApi(): Partial { + return { + mobile: { + listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), + getPairingQR: () => Promise.resolve({ available: false }), + getWindowsFirewallStatus: () => Promise.resolve({ supported: false }), + repairWindowsFirewall: () => Promise.resolve({ ok: false, reason: 'unsupported' }), + openWindowsNetworkSettings: () => Promise.resolve(false), + getRuntimePairingUrl: () => Promise.resolve({ available: false }), + listDevices: () => Promise.resolve({ devices: [] }), + revokeDevice: () => Promise.resolve({ revoked: false }), + listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), + revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), + isWebSocketReady: () => + Promise.resolve({ ready: Boolean(webRuntimeState.activeEnvironment), endpoint: null }), + getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), + onRelayStatusChanged: () => noopUnsubscribe, + consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false), + onUnpairedDeviceAuthFailure: () => noopUnsubscribe + } + } +} diff --git a/src/renderer/src/web/preload-api/web-native-chat-api.ts b/src/renderer/src/web/preload-api/web-native-chat-api.ts new file mode 100644 index 00000000000..31eec6d92fa --- /dev/null +++ b/src/renderer/src/web/preload-api/web-native-chat-api.ts @@ -0,0 +1,156 @@ +import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types' +import { buildNativeChatUnsubscribe } from '../../../../shared/native-chat-stream-unsubscribe' +import { + parseRuntimeNativeChatReadSessionResult, + parseRuntimeNativeChatTurnLifecycle +} from '@/components/native-chat/native-chat-runtime-contract' +import { translate } from '@/i18n/i18n' +import { callRuntimeResult } from './web-runtime-calls' +import { getClientForEnvironment, requireActiveEnvironmentOrNull } from './web-runtime-session' + +export function createWebNativeChatApi(): NativeChatApi { + return { + readSession: async (agent, sessionId, limit, transcriptPath) => + parseRuntimeNativeChatReadSessionResult( + await callRuntimeResult('nativeChat.readSession', { + agent, + sessionId, + limit, + transcriptPath + }) + ), + subscribe: (args, onFrame) => { + // No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed. + const environment = requireActiveEnvironmentOrNull() + if (!environment) { + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: translate( + 'components.native-chat.state.pairHost', + 'Pair a host to view agent chat history.' + ) + }) + return () => {} + } + let handle: { unsubscribe: () => void } | null = null + let cancelled = false + let receivedInitial = false + void getClientForEnvironment(environment) + .subscribe( + 'nativeChat.subscribe', + { + agent: args.agent, + sessionId: args.sessionId, + subscriptionId: args.subscriptionId, + transcriptPath: args.transcriptPath, + limit: args.limit + }, + { + onResponse: (response) => { + if (cancelled) { + return + } + if (!response.ok) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: response.error.message + }) + } + return + } + const result = response.result as { + type?: string + messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string + lifecycle?: unknown + } + const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle) + if ( + (result?.type === 'appended' || + result?.type === 'snapshot' || + result?.type === 'replacement') && + Array.isArray(result.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), + ...(result.error ? { error: result.error } : {}), + ...(lifecycle ? { lifecycle } : {}) + }) + } else if (result.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(result.error ? { error: result.error } : {}), + ...(lifecycle ? { lifecycle } : {}) + }) + } else { + onFrame( + result.type === 'replacement' + ? { + type: 'replacement', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(lifecycle ? { lifecycle } : {}) + } + : { + type: 'appended', + messages: result.messages, + ...(lifecycle ? { lifecycle } : {}) + } + ) + } + } else if (!receivedInitial) { + // Why: an unrecognized ok payload never flips receivedInitial, stranding the view on 'loading'; settle it empty instead. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(result?.error ? { error: result.error } : {}) + }) + } + } + }, + { + // Why: unsubscribe reaps the fs-watcher on view-toggle (leak fix); echo the pane token so two panes don't tear down each other's watcher. + buildUnsubscribe: () => + buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) + } + ) + .then((h) => { + if (cancelled) { + h.unsubscribe() + } else { + handle = h + } + }) + .catch((err: unknown) => { + if (!cancelled && !receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: err instanceof Error ? err.message : String(err) + }) + } + }) + return () => { + cancelled = true + handle?.unsubscribe() + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-notifications-api.ts b/src/renderer/src/web/preload-api/web-notifications-api.ts new file mode 100644 index 00000000000..813d694ba82 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-notifications-api.ts @@ -0,0 +1,14 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform } from './web-storage' + +export function createNotificationsApi(): NonNullable['notifications']> { + return { + dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }), + dismiss: () => Promise.resolve({ dismissed: 0 }), + openSystemSettings: () => Promise.resolve(), + getPermissionStatus: () => + Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }), + probeDelivery: () => Promise.resolve({ state: 'unsupported' as const, authoritative: false }), + playSound: () => Promise.resolve({ played: false, reason: 'missing-path' }) + } +} diff --git a/src/renderer/src/web/preload-api/web-onboarding-api.ts b/src/renderer/src/web/preload-api/web-onboarding-api.ts new file mode 100644 index 00000000000..4fcf9593623 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-onboarding-api.ts @@ -0,0 +1,56 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { ONBOARDING_FLOW_VERSION, getDefaultOnboardingState } from '../../../../shared/constants' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' +import { ONBOARDING_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export function getStoredOnboarding(): OnboardingState { + const storedRaw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY) + if (storedRaw) { + const stored = readJson(ONBOARDING_STORAGE_KEY, getDefaultOnboardingState()) + if (stored.checklist.dismissed) { + return stored + } + const closed = closeWebOnboarding(stored) + writeJson(ONBOARDING_STORAGE_KEY, closed) + return closed + } + const closed = closeWebOnboarding(getDefaultOnboardingState()) + // Why: paired clients already have an Orca server; skip desktop first-run onboarding that would probe browser-local tools. + writeJson(ONBOARDING_STORAGE_KEY, closed) + return closed +} + +export function closeWebOnboarding(base: OnboardingState): OnboardingState { + return { + ...base, + flowVersion: ONBOARDING_FLOW_VERSION, + closedAt: Date.now(), + outcome: 'dismissed', + checklist: { + ...base.checklist, + dismissed: true + } + } +} + +export function createWebOnboardingApi(): Partial { + return { + onboarding: { + get: () => Promise.resolve(getStoredOnboarding()), + update: async (updates) => { + const current = getStoredOnboarding() + const next: OnboardingState = { + ...current, + ...updates, + flowVersion: ONBOARDING_FLOW_VERSION, + checklist: { + ...current.checklist, + ...updates.checklist + } + } + writeJson(ONBOARDING_STORAGE_KEY, next) + return next + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts new file mode 100644 index 00000000000..0b552bf15c8 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts @@ -0,0 +1,70 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + createDefaultLocalOrcaProfile +} from '../../../../shared/orca-profiles' + +export function createWebOrcaProfilesApi(): Partial { + const webOrcaProfileAuthStatus = () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + configured: false, + state: 'unconfigured' as const, + persistence: 'none' as const, + setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.' + }) + return { + orcaProfiles: { + list: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + multiProfileUi: false + }), + authStatus: webOrcaProfileAuthStatus, + createLocal: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + profile: createDefaultLocalOrcaProfile(0) + }), + createCloudLinked: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + switchProfile: () => Promise.resolve({ status: 'already-active' }), + transferProject: (args) => + Promise.resolve({ + status: 'duplicate-target', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + sourceRepoId: args.repoId, + duplicateRepoId: args.repoId + }), + findProjectProfiles: async () => ({ projects: [] }), + connectCurrent: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + refreshAuth: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + signOutCurrent: async () => ({ + status: 'signed-out', + auth: await webOrcaProfileAuthStatus(), + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)] + }), + selectOrg: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + orgMembersList: async () => ({ status: 'unconfigured' }), + orgMemberInvite: async () => ({ status: 'unconfigured' }), + orgInviteRevoke: async () => ({ status: 'unconfigured' }), + orgMemberChangeRole: async () => ({ status: 'unconfigured' }), + orgMemberRemove: async () => ({ status: 'unconfigured' }) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-platform-api.ts b/src/renderer/src/web/preload-api/web-platform-api.ts new file mode 100644 index 00000000000..22e88ff6671 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-platform-api.ts @@ -0,0 +1,16 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform } from './web-storage' + +export function createWebPlatformApi(): Partial { + return { + platform: { + get: () => ({ + platform: getBrowserPlatform(), + osRelease: '', + arch: '', + shell: '', + displayServer: null + }) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts new file mode 100644 index 00000000000..e6dfc33e286 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -0,0 +1,164 @@ +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings' +import { + getDefaultSettings, + normalizeAgentActivityDisplayMode, + normalizeWorktreeCardProperties +} from '../../../../shared/constants' +import { normalizeContextualTourIds } from '../../../../shared/contextual-tours' +import type { ContextualTourId } from '../../../../shared/contextual-tours' +import { normalizeFeatureInteractions } from '../../../../shared/feature-interactions' +import type { + FeatureInteractionId, + FeatureInteractionState +} from '../../../../shared/feature-interactions' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { PairedUiState, PairingLocalUiField } from '../../../../shared/pairing-local-ui-fields' +import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' +import { normalizeStatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../../../../shared/tui-agent-launch-defaults' +import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { normalizeUiLanguage } from '../../../../shared/ui-language' +import { normalizeUsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { mergeWorkspaceCleanupUIState } from '../../../../shared/workspace-cleanup-ui-state' + +export function mergeWebUIState( + base: PersistedUIState, + updates: Partial +): PersistedUIState { + const { featureInteractionTelemetryBuckets: _reserved, ...safeUpdates } = + updates as Partial & { + featureInteractionTelemetryBuckets?: unknown + } + void _reserved + return { + ...base, + ...safeUpdates, + workspaceCleanup: mergeWorkspaceCleanupUIState( + base.workspaceCleanup, + safeUpdates.workspaceCleanup + ), + worktreeCardProperties: normalizeWorktreeCardProperties( + safeUpdates.worktreeCardProperties ?? base.worktreeCardProperties + ), + _worktreeCardModeDefaulted: + safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted, + agentActivityDisplayMode: normalizeAgentActivityDisplayMode( + safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode + ), + usagePercentageDisplay: normalizeUsagePercentageDisplay( + safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay + ), + statusBarUsageMode: normalizeStatusBarUsageMode( + safeUpdates.statusBarUsageMode ?? base.statusBarUsageMode + ) + } +} + +export function mergeHostWebUIState( + local: PersistedUIState, + incoming: PairedUiState +): PersistedUIState { + // Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in + // PersistedUIState, so Pick would accept a literal that silently skipped a newly added member. + const pinned = { + hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, + manualRepoOrder: local.manualRepoOrder, + workspaceHostOrder: local.workspaceHostOrder + } satisfies Record & Partial + return { ...mergeWebUIState(local, incoming), ...pinned } +} + +export function mergeFeatureInteractionState( + current: PersistedUIState['featureInteractions'], + incoming: PersistedUIState['featureInteractions'] +): FeatureInteractionState { + const currentNormalized = normalizeFeatureInteractions(current) + const incomingNormalized = normalizeFeatureInteractions(incoming) + const merged: FeatureInteractionState = { ...currentNormalized } + for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { + const featureId = id as FeatureInteractionId + const currentRecord = currentNormalized[featureId] + merged[featureId] = currentRecord + ? { + firstInteractedAt: Math.min( + currentRecord.firstInteractedAt, + incomingRecord.firstInteractedAt + ), + interactionCount: Math.max( + currentRecord.interactionCount, + incomingRecord.interactionCount + ) + } + : incomingRecord + } + return merged +} + +export function mergeContextualTourSeenIds( + current: PersistedUIState['contextualToursSeenIds'], + incoming: PersistedUIState['contextualToursSeenIds'] +): ContextualTourId[] { + const merged = new Set(normalizeContextualTourIds(current)) + for (const id of normalizeContextualTourIds(incoming)) { + merged.add(id) + } + return [...merged] +} + +export function mergeOsc52ClipboardNoticePending( + current: PersistedUIState, + incoming: PersistedUIState +): boolean { + return ( + current.osc52ClipboardDefaultOnNoticePending === true || + incoming.osc52ClipboardDefaultOnNoticePending === true + ) +} + +export function mergeSettings( + base: GlobalSettings, + updates: Partial, + options: { preserveAutoRenameBranchFromWorkUpdate?: boolean } = {} +): GlobalSettings { + const defaults = getDefaultSettings('~') + const merged = { + ...base, + ...updates, + notifications: { + ...base.notifications, + ...updates.notifications + }, + githubProjects: { + ...(base.githubProjects ?? defaults.githubProjects), + ...updates.githubProjects + } as GlobalSettings['githubProjects'], + disabledTuiAgents: normalizeDisabledTuiAgents( + updates.disabledTuiAgents ?? base.disabledTuiAgents + ), + agentDefaultArgs: normalizeTuiAgentArgsRecord( + updates.agentDefaultArgs ?? base.agentDefaultArgs + ), + agentDefaultEnv: normalizeTuiAgentEnvRecord(updates.agentDefaultEnv ?? base.agentDefaultEnv), + voice: { + ...(base.voice ?? defaults.voice), + ...updates.voice + } as NonNullable, + activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId') + ? (updates.activeRuntimeEnvironmentId ?? null) + : (base.activeRuntimeEnvironmentId ?? null), + terminalCustomThemes: normalizeTerminalCustomThemes( + updates.terminalCustomThemes ?? base.terminalCustomThemes + ), + uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage) + } + return { + ...merged, + ...normalizeAutoRenameBranchFromWorkDefaultOn(merged, { + preserveExplicitValue: options.preserveAutoRenameBranchFromWorkUpdate + }) + } +} diff --git a/src/renderer/src/web/preload-api/web-preferences-store.ts b/src/renderer/src/web/preload-api/web-preferences-store.ts new file mode 100644 index 00000000000..13c755b1d87 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-preferences-store.ts @@ -0,0 +1,297 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings' +import { + getDefaultSettings, + getDefaultUIState, + getWorktreeCardModeProperties +} from '../../../../shared/constants' +import { normalizeWorktreeVisibilityDefaults } from '../../../../shared/external-worktree-visibility' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../../shared/osc52-clipboard-settings' +import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' +import { + applyPRBotAuthorOverride, + normalizePRBotAuthorOverrides +} from '../../../../shared/pr-bot-author-overrides' +import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' +import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' +import { normalizeUiLanguage } from '../../../../shared/ui-language' +import { readStoredWebRuntimeEnvironment } from '../web-runtime-environment' +import { mergeSettings, mergeWebUIState } from './web-preference-normalization' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull, webRuntimeState } from './web-runtime-session' +import { SETTINGS_STORAGE_KEY, UI_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export type WebSettingsApi = NonNullable + +export function getStoredSettings(): GlobalSettings { + webRuntimeState.activeEnvironment = + webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment() + const defaults = getDefaultSettings('~') + const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) + const stored = readJson>(SETTINGS_STORAGE_KEY, {}) + const migratedStored = { + ...stored, + ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), + ...normalizeTerminalCursorStyleDefault(stored), + ...normalizeOsc52ClipboardDefaultOn(stored), + terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), + uiLanguage: normalizeUiLanguage(stored.uiLanguage) + } + if ( + rawStoredSettings && + (stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork || + stored.autoRenameBranchFromWorkDefaultedOn !== + migratedStored.autoRenameBranchFromWorkDefaultedOn || + stored.terminalCursorStyle !== migratedStored.terminalCursorStyle || + stored.terminalCursorStyleDefaultedToBlock !== + migratedStored.terminalCursorStyleDefaultedToBlock || + // Kept even though the terminalCustomThemes reference compare below already forces + // this branch for every stored blob: no migration should rely on that accident. + stored.terminalAllowOsc52Clipboard !== migratedStored.terminalAllowOsc52Clipboard || + stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== + migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers || + stored.terminalCustomThemes !== migratedStored.terminalCustomThemes || + stored.uiLanguage !== migratedStored.uiLanguage) + ) { + try { + const parsed = JSON.parse(rawStoredSettings) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + writeJson(SETTINGS_STORAGE_KEY, migratedStored) + if (osc52ClipboardDefaultOnOverridesPersistedOff(stored)) { + // Why a raw merge, not readLocalWebUIState(): that path calls back into + // getStoredSettings(), and writing through it here would recurse. + writeJson(UI_STORAGE_KEY, { + ...readJson>(UI_STORAGE_KEY, {}), + osc52ClipboardDefaultOnNoticePending: true + }) + } + } + } catch { + // Keep readJson's invalid-JSON fallback non-destructive. + } + } + return mergeSettings( + { + ...defaults, + floatingTerminalEnabled: false, + rightSidebarOpenByDefault: false, + activeRuntimeEnvironmentId: null + }, + migratedStored + ) +} + +export function writeStoredSettings( + settings: GlobalSettings, + explicitActiveRuntimeEnvironmentId?: string | null +): void { + const durable = { ...settings } + if (explicitActiveRuntimeEnvironmentId !== undefined) { + durable.activeRuntimeEnvironmentId = explicitActiveRuntimeEnvironmentId + } else { + const stored = readJson>(SETTINGS_STORAGE_KEY, {}) + if (Object.hasOwn(stored, 'activeRuntimeEnvironmentId')) { + durable.activeRuntimeEnvironmentId = stored.activeRuntimeEnvironmentId ?? null + } else { + delete durable.activeRuntimeEnvironmentId + } + } + writeJson(SETTINGS_STORAGE_KEY, durable) +} + +export async function getRuntimeBackedStoredSettings(): Promise { + const local = getStoredSettings() + const requestedEnvironment = requireActiveEnvironmentOrNull() + if (!requestedEnvironment) { + return local + } + try { + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.get', + undefined, + 15_000 + ) + const runtimeSettings: Partial = {} + const currentEnvironment = requireActiveEnvironmentOrNull() + if (currentEnvironment?.id === requestedEnvironment.id) { + const visibilityDefaults = normalizeWorktreeVisibilityDefaults( + result.settings.worktreeVisibilityDefaults + ) + webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId = visibilityDefaults + ? requestedEnvironment.id + : null + webRuntimeState.worktreeVisibilityDefaultsRuntimeValue = visibilityDefaults ?? null + } + if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') { + runtimeSettings.experimentalNewWorktreeCardStyle = + result.settings.experimentalNewWorktreeCardStyle + } + if (typeof result.settings.compactWorktreeCards === 'boolean') { + runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards + } + if (typeof result.settings.minimaxGroupId === 'string') { + runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId + } + if (typeof result.settings.minimaxUsageModels === 'string') { + runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels + } + if (Array.isArray(result.settings.prBotAuthorOverrides)) { + runtimeSettings.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + result.settings.prBotAuthorOverrides + ) + } + // Read-only mirror: the host owns this capability and `syncRuntimeBackedSettings` never + // sends it back, so web shows what the host enforces instead of a local value it ignores. + if (typeof result.settings.artifactSharingEnabled === 'boolean') { + runtimeSettings.artifactSharingEnabled = result.settings.artifactSharingEnabled + } + if (typeof result.settings.agentSkillSharingEnabled === 'boolean') { + runtimeSettings.agentSkillSharingEnabled = result.settings.agentSkillSharingEnabled + } + const next = mergeSettings(local, runtimeSettings) + writeStoredSettings(next) + return settingsForActiveVisibilityOwner(next) + } catch { + // Why: unpaired/offline web clients keep a local settings fallback. + return settingsForActiveVisibilityOwner(local) + } +} + +export function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings { + const environment = requireActiveEnvironmentOrNull() + if (!environment) { + return settings + } + if ( + environment.id === webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId && + webRuntimeState.worktreeVisibilityDefaultsRuntimeValue + ) { + return { + ...settings, + worktreeVisibilityDefaults: webRuntimeState.worktreeVisibilityDefaultsRuntimeValue + } + } + const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings + return supportedSettings as GlobalSettings +} + +export async function syncRuntimeBackedSettings( + updates: Partial, + localNext: GlobalSettings +): Promise { + const requestedEnvironment = requireActiveEnvironmentOrNull() + if (!requestedEnvironment) { + return localNext + } + const runtimeUpdates: Partial = {} + const visibilityDefaults = normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) + if (visibilityDefaults) { + runtimeUpdates.worktreeVisibilityDefaults = visibilityDefaults + } + if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') { + runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle + } + if (typeof updates.compactWorktreeCards === 'boolean') { + runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards + } + if (typeof updates.minimaxGroupId === 'string') { + runtimeUpdates.minimaxGroupId = updates.minimaxGroupId + } + if (typeof updates.minimaxUsageModels === 'string') { + runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels + } + if (Array.isArray(updates.prBotAuthorOverrides)) { + runtimeUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + updates.prBotAuthorOverrides + ) + } + if (Object.keys(runtimeUpdates).length === 0) { + return localNext + } + try { + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.update', + runtimeUpdates, + 15_000 + ) + const runtimeSettings = { ...result.settings } + delete runtimeSettings.activeRuntimeEnvironmentId + const updatedVisibilityDefaults = normalizeWorktreeVisibilityDefaults( + runtimeSettings.worktreeVisibilityDefaults + ) + if ( + requireActiveEnvironmentOrNull()?.id === requestedEnvironment.id && + updatedVisibilityDefaults + ) { + webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId = requestedEnvironment.id + webRuntimeState.worktreeVisibilityDefaultsRuntimeValue = updatedVisibilityDefaults + } + delete runtimeSettings.worktreeVisibilityDefaults + const next = mergeSettings(localNext, runtimeSettings) + writeStoredSettings(next) + return next + } catch (error) { + if (visibilityDefaults) { + throw error + } + // Why: unpaired/offline web clients still need local settings persistence. + return localNext + } +} + +export async function updateRuntimePRBotAuthorOverride(args: { + author: string + isBot: boolean +}): Promise { + const local = getStoredSettings() + if (requireActiveEnvironmentOrNull()) { + // Why: don't report a successful mark the authoritative runtime failed to persist and will later overwrite. + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.updatePRBotAuthorOverride', + args, + 15_000 + ) + const next = mergeSettings(local, { + prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides) + }) + writeStoredSettings(next) + return next + } + const next = mergeSettings(local, { + prBotAuthorOverrides: applyPRBotAuthorOverride( + local.prBotAuthorOverrides, + args.author, + args.isBot + ) + }) + writeStoredSettings(next) + return next +} + +export function readLocalWebUIState(): PersistedUIState { + const defaults = getDefaultUIState() + // Why settings first: getStoredSettings() runs the OSC 52 migration, which writes the + // notice arm into UI_STORAGE_KEY. Reading before it would snapshot a pre-arm state that + // every caller then writes back, erasing the arm the stamp can never raise again. + const storedSettings = getStoredSettings() + const stored = readJson>(UI_STORAGE_KEY, {}) + const base = { + ...defaults, + // Why: mirror the main-process missing-property seed from legacy card layout mode when runtime ui.get is unavailable. + worktreeCardProperties: getWorktreeCardModeProperties( + storedSettings.compactWorktreeCards ? 'Compact' : 'Default' + ) + } + if (typeof stored.rightSidebarOpen === 'boolean') { + return mergeWebUIState(base, stored) + } + return mergeWebUIState(base, { + ...stored, + // Why: web fallback lacks main-process normalization; migrate the retired setting only when local UI preference is absent. + rightSidebarOpen: storedSettings.rightSidebarOpenByDefault + }) +} diff --git a/src/renderer/src/web/preload-api/web-rate-limits-api.ts b/src/renderer/src/web/preload-api/web-rate-limits-api.ts new file mode 100644 index 00000000000..7587a838660 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-rate-limits-api.ts @@ -0,0 +1,36 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { RateLimitState } from '../../../../shared/rate-limit-types' +import { noopUnsubscribe } from './web-storage' + +export function createRateLimitsApi(): NonNullable['rateLimits']> { + const empty: RateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + antigravity: null, + minimax: null, + grok: null, + minimaxCookieConfigured: false, + grokAuthConfigured: false, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } + return { + get: () => Promise.resolve(empty), + refresh: () => Promise.resolve(empty), + refreshCodexForTarget: () => Promise.resolve(empty), + // Why: web clients don't own local Codex auth; report the safe no-credit outcome since redemption is desktop-only. + consumeCodexResetCredit: () => Promise.resolve({ outcome: 'noCredit', state: empty }), + refreshClaudeForTarget: () => Promise.resolve(empty), + setPollingInterval: () => Promise.resolve(), + fetchInactiveClaudeAccounts: () => Promise.resolve(), + fetchInactiveCodexAccounts: () => Promise.resolve(), + refreshMiniMax: () => Promise.resolve(empty), + refreshGrok: () => Promise.resolve(empty), + onUpdate: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts new file mode 100644 index 00000000000..cf3974ac04d --- /dev/null +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -0,0 +1,130 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { legacyBaseRefSearchResult } from '../../../../shared/base-ref-search-result' +import type { Repo } from '../../../../shared/repo-types' +import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults' +import { + callRuntimeResult, + callRuntimeResultWithOwner, + withRuntimeRepoMutationOwner, + withRuntimeRepoOwner +} from './web-runtime-calls' +import { assertActiveEnvironment, invalidateRuntimeWorktreeCaches } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createReposApi(): NonNullable['repos']> { + return { + list: async () => { + const owned = await callRuntimeResultWithOwner<{ repos: Repo[] }>('repo.list') + return owned.result.repos.map((repo) => withRuntimeRepoOwner(repo, owned.hostId)) + }, + add: async ({ path, kind }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( + 'repo.add', + { path, kind } + ) + return withRuntimeRepoMutationOwner(owned.result, owned.hostId) + }, + remove: async ({ repoId }) => { + await callRuntimeResult('repo.rm', { repo: repoId }) + invalidateRuntimeWorktreeCaches() + }, + // Why: host-scoped forget targets a desktop-owned SSH host; a paired web client has one runtime and no ghost-host state. + removeForHost: () => { + throw new Error('Forgetting a host is unavailable in paired web clients.') + }, + reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }), + // Why: this persists desktop-owned local/SSH rows; paired web clients own one runtime and use repo.reorder directly. + reorderForHost: async () => { + throw new Error('Host-scoped project reordering is unavailable in paired web clients.') + }, + update: async ({ repoId, updates }) => { + const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.update', { + repo: repoId, + updates + }) + return withRuntimeRepoOwner(owned.result.repo, owned.hostId) + }, + pickFolder: () => Promise.resolve(null), + pickFolders: () => Promise.resolve([]), + pickDirectory: () => Promise.resolve(null), + clone: async ({ url, destination }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo }>( + 'repo.clone', + { url, destination }, + 10 * 60_000 + ) + return withRuntimeRepoOwner(owned.result.repo, owned.hostId) + }, + cloneRemote: async () => { + // Why: SSH relay cloning is owned by the desktop main process; paired web clients can't run that local IPC path. + throw new Error('SSH clone is unavailable in paired web clients.') + }, + createRemote: async () => { + // Why: SSH relay project creation is owned by the desktop main process; paired web clients can't use local SSH IPC. + throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.') + }, + cloneAbort: () => Promise.resolve(), + addRemote: async ({ remotePath, displayName, kind }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.add', { + path: remotePath, + kind + }) + const result = { + repo: withRuntimeRepoOwner(owned.result.repo, owned.hostId) + } + if (!displayName) { + return result + } + assertActiveEnvironment(owned.environmentId) + return { + repo: await createReposApi().update({ + repoId: result.repo.id, + updates: { displayName } + }) + } + }, + create: async ({ parentPath, name, kind }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( + 'repo.create', + { parentPath, name, kind } + ) + return withRuntimeRepoMutationOwner(owned.result, owned.hostId) + }, + isGitAvailable: async () => + (await callRuntimeResult<{ available: boolean }>('repo.gitAvailable')).available, + getDefaultCreateProjectParent: async () => { + const result = await callRuntimeResult<{ resolvedPath: string }>('files.browseServerDir', { + path: '~' + }) + return getDefaultCreateProjectParent(result.resolvedPath) + }, + onCloneProgress: () => noopUnsubscribe, + getGitUsername: () => Promise.resolve(''), + getBaseRefDefault: async ({ repoId }) => + callRuntimeResult('repo.baseRefDefault', { repo: repoId }), + searchBaseRefs: async ({ repoId, query, limit }) => + ( + await callRuntimeResult<{ refs: string[] }>('repo.searchRefs', { + repo: repoId, + query, + limit + }) + ).refs, + searchBaseRefDetails: async ({ repoId, query, limit }) => { + const result = await callRuntimeResult<{ + refs: string[] + refDetails?: { refName: string; localBranchName: string }[] + }>('repo.searchRefs', { + repo: repoId, + query, + limit + }) + return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult) + }, + onChanged: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-review-api.ts b/src/renderer/src/web/preload-api/web-review-api.ts new file mode 100644 index 00000000000..4c4541d48d1 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-review-api.ts @@ -0,0 +1,44 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { createFallbackProxy } from './web-fallback-api' +import { callRuntimeResult } from './web-runtime-calls' + +export function createRuntimeNamespaceApi(prefix: string): never { + return createFallbackProxy([prefix], (path, args) => { + const method = `${prefix}.${path.at(-1) ?? ''}` + return callRuntimeResult(method, mapRuntimeNamespaceArg(prefix, args[0])) + }) as never +} + +export function createHooksApi(): NonNullable['hooks']> { + return { + check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }), + inspectSetupScriptImports: async ({ repoId }) => + callRuntimeResult('repo.setupScriptImports', { repo: repoId }), + createIssueCommandRunner: async () => ({ launched: false }) as never, + readIssueCommand: async ({ repoId }) => + callRuntimeResult('repo.issueCommandRead', { repo: repoId }), + writeIssueCommand: async ({ repoId, content }) => { + await callRuntimeResult('repo.issueCommandWrite', { repo: repoId, content }) + } + } +} + +export function mapRepoPathArg(args: unknown): unknown { + if (!args || typeof args !== 'object' || !('repoPath' in args)) { + return args + } + const record = args as Record + const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null + return { + ...record, + // Why: duplicate checked-out repos make path/name selectors ambiguous; prefer the explicit repo id the renderer passes. + repo: repoId ? `id:${repoId}` : record.repoPath + } +} + +export function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown { + if (prefix !== 'hostedReview') { + return args + } + return mapRepoPathArg(args) +} diff --git a/src/renderer/src/web/preload-api/web-runtime-api.ts b/src/renderer/src/web/preload-api/web-runtime-api.ts new file mode 100644 index 00000000000..e0c44484768 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-api.ts @@ -0,0 +1,21 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { RuntimeSyncWindowGraph } from '../../../../shared/runtime-types' +import { callRuntimeEnvelope, getRemoteRuntimeStatus } from './web-runtime-calls' +import { noopUnsubscribe } from './web-storage' + +export function createWebRuntimeApi(): NonNullable['runtime']> { + return { + syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(), + getStatus: () => getRemoteRuntimeStatus(), + call: ({ method, params }) => callRuntimeEnvelope(method, params), + getTerminalFitOverrides: () => Promise.resolve([]), + getTerminalDrivers: () => Promise.resolve([]), + getBrowserDrivers: () => Promise.resolve([]), + restoreTerminalFit: () => Promise.resolve({ restored: false }), + reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }), + onTerminalFitOverrideChanged: () => noopUnsubscribe, + onTerminalDriverChanged: () => noopUnsubscribe, + onNativeChatLaunchDraftResolved: () => noopUnsubscribe, + onBrowserDriverChanged: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-runtime-calls.ts b/src/renderer/src/web/preload-api/web-runtime-calls.ts new file mode 100644 index 00000000000..4470b0be37a --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -0,0 +1,120 @@ +import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getClientForEnvironment, + manuallyDisconnectedEnvironmentIds, + manuallyDisconnectedResponse, + requireActiveEnvironment, + resolveEnvironment, + runtimeCallQueuePool, + updateEnvironmentFromResponse +} from './web-runtime-session' + +export type WebRuntimeResultCaller = ( + method: string, + params?: unknown, + timeoutMs?: number +) => Promise + +export type WebRuntimeEnvelopeCaller = ( + method: string, + params?: unknown, + timeoutMs?: number +) => Promise> + +export async function callRuntimeEnvelope( + method: string, + params?: unknown, + timeoutMs?: number +): Promise> { + const environment = requireActiveEnvironment() + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return Promise.resolve(manuallyDisconnectedResponse(environment)) + } + return getClientForEnvironment(environment).call(method, params, { timeoutMs }) + }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + updateEnvironmentFromResponse(environment, response) + return response as RuntimeRpcResponse +} + +export async function callEnvironmentEnvelope( + selector: string, + method: string, + params?: unknown, + timeoutMs?: number +): Promise> { + const environment = resolveEnvironment(selector) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return Promise.resolve(manuallyDisconnectedResponse(environment)) + } + return getClientForEnvironment(environment).call(method, params, { timeoutMs }) + }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + updateEnvironmentFromResponse(environment, response) + return response as RuntimeRpcResponse +} + +export async function callRuntimeResult( + method: string, + params?: unknown, + timeoutMs?: number +): Promise { + const response = await callRuntimeEnvelope(method, params, timeoutMs) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult +} + +export async function callRuntimeResultWithOwner( + method: string, + params?: unknown, + timeoutMs?: number +): Promise<{ result: TResult; hostId: ExecutionHostId; environmentId: string }> { + const environmentId = requireActiveEnvironment().id + const result = await callRuntimeResult(method, params, timeoutMs) + return { result, hostId: toRuntimeExecutionHostId(environmentId), environmentId } +} + +export function withRuntimeRepoOwner(repo: Repo, hostId: ExecutionHostId): Repo { + return { ...repo, executionHostId: hostId } +} + +export function withRuntimeRepoMutationOwner( + result: { repo: Repo } | { error: string }, + hostId: ExecutionHostId +): { repo: Repo } | { error: string } { + return 'repo' in result ? { ...result, repo: withRuntimeRepoOwner(result.repo, hostId) } : result +} + +export function withRuntimeWorktreeOwner( + worktree: T, + hostId: ExecutionHostId +): T { + const runtimeOwner = parseExecutionHostId(hostId) + if (runtimeOwner?.kind !== 'runtime') { + return worktree + } + return { ...worktree, runtimeOwnerEnvironmentId: runtimeOwner.environmentId } +} + +export async function getRemoteRuntimeStatus(): Promise { + return callRuntimeResult('status.get', undefined, 15_000) +} diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts new file mode 100644 index 00000000000..378ce38c614 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -0,0 +1,198 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' +import { verifyRemotePairingRuntimeStatus } from '../../../../shared/remote-pairing-verification' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { parseWebPairingInput } from '../web-pairing' +import { WebRuntimeClient } from '../web-runtime-client' +import { isWebRuntimeUnauthorizedError } from '../web-runtime-client-error' +import { + createStoredWebRuntimeEnvironment, + redactStoredWebRuntimeEnvironment, + saveStoredWebRuntimeEnvironment +} from '../web-runtime-environment' +import { translate } from '@/i18n/i18n' +import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' +import { callEnvironmentEnvelope } from './web-runtime-calls' +import { + closeActiveRuntimeClients, + disconnectActiveRuntimeEnvironment, + getClientForEnvironment, + manuallyDisconnectedEnvironmentIds, + removeActiveRuntimeEnvironment, + requireActiveEnvironmentOrNull, + resolveEnvironment, + webRuntimeState +} from './web-runtime-session' + +export function createRuntimeEnvironmentsApi(): NonNullable< + Partial['runtimeEnvironments'] +> { + return { + list: async () => { + const environment = requireActiveEnvironmentOrNull() + return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] + }, + addFromPairingCode: async ({ name, pairingCode }) => { + const offer = parseWebPairingInput(pairingCode) + if (!offer) { + throw new Error('Invalid Orca pairing code.') + } + const previousEnvironment = webRuntimeState.activeEnvironment + closeActiveRuntimeClients() + webRuntimeState.activeEnvironment = createStoredWebRuntimeEnvironment({ + name, + offer, + previousEnvironment + }) + manuallyDisconnectedEnvironmentIds.clear() + saveStoredWebRuntimeEnvironment(webRuntimeState.activeEnvironment) + return { environment: redactStoredWebRuntimeEnvironment(webRuntimeState.activeEnvironment) } + }, + verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => { + const parsed = parseHostAccessLink(pairingCode) + if (!parsed.ok) { + return { + ok: false, + kind: 'access-link-invalid', + message: translateHostAccessLinkError(parsed.kind) + } + } + if (parsed.value.endpointKind === 'loopback' && !allowLoopback) { + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.loopbackPairingBlocked', + 'This access link points back to this device.' + ) + } + } + let client: WebRuntimeClient | null = null + let runtimeStatus: RuntimeStatus + try { + client = new WebRuntimeClient(parsed.value.pairing) + const response = (await client.call('status.get', undefined, { + timeoutMs: 15_000 + })) as RuntimeRpcResponse + if (!response.ok) { + return { + ok: false, + kind: 'connection-interrupted', + message: response.error.message + } + } + const statusVerification = verifyRemotePairingRuntimeStatus(response.result) + if (!statusVerification.ok) { + return statusVerification + } + runtimeStatus = statusVerification.runtimeStatus + } catch (error) { + if (error instanceof Error && error.message.startsWith('Invalid public key')) { + return { + ok: false, + kind: 'access-link-invalid', + message: translate( + 'auto.web.webPreloadApi.remotePairingInvalidDetails', + 'This access link contains invalid connection details.' + ) + } + } + if ( + isWebRuntimeUnauthorizedError(error) || + (error instanceof Error && error.message.startsWith('Unauthorized.')) + ) { + return { + ok: false, + kind: 'access-link-invalid', + message: error.message + } + } + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.remotePairingUnreachable', + 'Cannot reach Orca at {{endpoint}}.', + { endpoint: parsed.value.displayEndpoint } + ) + } + } finally { + client?.close() + } + const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true + const nextEnvironment = { + ...createStoredWebRuntimeEnvironment({ + name, + offer: parsed.value.pairing, + previousEnvironment: webRuntimeState.activeEnvironment, + ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) + }), + ...(runtimeStatus.pairedDeviceId ? { pairedDeviceId: runtimeStatus.pairedDeviceId } : {}) + } + // Why: a browser storage failure must leave the currently active host usable. + try { + saveStoredWebRuntimeEnvironment(nextEnvironment) + } catch { + return { + ok: false, + kind: 'environment-save-failed', + message: translate( + 'auto.web.webPreloadApi.remotePairingSaveFailed', + 'Orca verified the host but could not save it. Check browser storage and try again.' + ) + } + } + manuallyDisconnectedEnvironmentIds.clear() + closeActiveRuntimeClients() + webRuntimeState.activeEnvironment = nextEnvironment + return { + ok: true, + environment: redactStoredWebRuntimeEnvironment(nextEnvironment), + runtimeStatus + } + }, + resolve: async ({ selector }) => + redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), + remove: async ({ selector }) => { + const environment = resolveEnvironment(selector) + if (webRuntimeState.activeEnvironment?.id === environment.id) { + removeActiveRuntimeEnvironment() + } + manuallyDisconnectedEnvironmentIds.delete(environment.id) + return { removed: redactStoredWebRuntimeEnvironment(environment) } + }, + disconnect: async ({ selector }) => { + const environment = resolveEnvironment(selector) + if (webRuntimeState.activeEnvironment?.id === environment.id) { + manuallyDisconnectedEnvironmentIds.add(environment.id) + disconnectActiveRuntimeEnvironment() + } + return { disconnected: redactStoredWebRuntimeEnvironment(environment) } + }, + connect: ({ selector, timeoutMs }) => { + const environment = resolveEnvironment(selector) + manuallyDisconnectedEnvironmentIds.delete(environment.id) + return callEnvironmentEnvelope( + environment.id, + 'status.get', + undefined, + timeoutMs + ) + }, + getStatus: ({ selector, timeoutMs }) => + callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), + call: ({ selector, method, params, timeoutMs }) => + callEnvironmentEnvelope(selector, method, params, timeoutMs), + subscribe: async ({ selector, method, params, timeoutMs }, callbacks) => { + const environment = resolveEnvironment(selector) + const client = getClientForEnvironment(environment) + const subscription = await client.subscribe(method, params, callbacks, { timeoutMs }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + subscription.unsubscribe() + throw new Error('runtime_manually_disconnected') + } + return subscription + } + } +} diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts new file mode 100644 index 00000000000..17a19136002 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -0,0 +1,145 @@ +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' +import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { Worktree } from '../../../../shared/worktree/types' +import { WebRuntimeClient } from '../web-runtime-client' +import { + clearStoredWebRuntimeEnvironment, + getPreferredWebPairingOffer, + readStoredWebRuntimeEnvironment, + updateStoredEnvironmentRuntimeId +} from '../web-runtime-environment' +import type { StoredWebRuntimeEnvironment } from '../web-runtime-environment' +import { translate } from '@/i18n/i18n' + +export const webRuntimeState: { + activeEnvironment: StoredWebRuntimeEnvironment | null + worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null + worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null + activeClient: WebRuntimeClient | null + activeClientEnvironmentId: string | null + cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null + cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null +} = { + activeEnvironment: readStoredWebRuntimeEnvironment(), + worktreeVisibilityDefaultsRuntimeEnvironmentId: null, + worktreeVisibilityDefaultsRuntimeValue: null, + activeClient: null, + activeClientEnvironmentId: null, + cachedWorktrees: null, + cachedDetectedWorktrees: null +} + +export const manuallyDisconnectedEnvironmentIds = new Set() + +export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() + +export function invalidateRuntimeWorktreeCaches(): void { + webRuntimeState.cachedWorktrees = null + webRuntimeState.cachedDetectedWorktrees = null +} + +export function getClientForEnvironment( + environment: StoredWebRuntimeEnvironment +): WebRuntimeClient { + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + throw new Error('runtime_manually_disconnected') + } + if ( + !webRuntimeState.activeClient || + webRuntimeState.activeClientEnvironmentId !== environment.id + ) { + webRuntimeState.activeClient?.close() + webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) + webRuntimeState.activeClientEnvironmentId = environment.id + } + return webRuntimeState.activeClient +} + +export function closeActiveRuntimeClients(): void { + webRuntimeState.activeClient?.close() + webRuntimeState.activeClient = null + webRuntimeState.activeClientEnvironmentId = null + invalidateRuntimeWorktreeCaches() +} + +export function disconnectActiveRuntimeEnvironment(): void { + closeActiveRuntimeClients() +} + +export function removeActiveRuntimeEnvironment(): void { + disconnectActiveRuntimeEnvironment() + clearStoredWebRuntimeEnvironment() + webRuntimeState.activeEnvironment = null +} + +export function manuallyDisconnectedResponse( + environment: StoredWebRuntimeEnvironment +): RuntimeRpcResponse { + return { + id: 'runtime.manualDisconnect', + ok: false, + error: { + code: 'runtime_manually_disconnected', + message: translate( + 'auto.web.webPreloadApi.runtimeEnvironmentManuallyDisconnected', + 'Runtime environment is manually disconnected.' + ) + }, + _meta: { runtimeId: environment.runtimeId } + } +} + +export function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment { + const environment = requireActiveEnvironment() + if (selector === environment.id || selector === environment.name || selector === 'active') { + return environment + } + if (environment.compatibleEnvironmentIds?.includes(selector)) { + return environment + } + throw new Error(`Unknown Orca runtime environment: ${selector}`) +} + +export function requireActiveEnvironment(): StoredWebRuntimeEnvironment { + webRuntimeState.activeEnvironment = + webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment() + if (!webRuntimeState.activeEnvironment) { + throw new Error('Pair this web client with an Orca server first.') + } + return webRuntimeState.activeEnvironment +} + +export function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | null { + webRuntimeState.activeEnvironment = + webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment() + return webRuntimeState.activeEnvironment +} + +export function assertActiveEnvironment(environmentId: string): void { + if (requireActiveEnvironment().id !== environmentId) { + throw new Error('The paired Orca server changed while the request was in progress.') + } +} + +export function updateEnvironmentFromResponse( + environment: StoredWebRuntimeEnvironment, + response: RuntimeRpcResponse +): void { + if (webRuntimeState.activeEnvironment?.id !== environment.id) { + return + } + const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null) + const pairedDeviceId = + response.ok && + typeof response.result === 'object' && + response.result !== null && + typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string' + ? (response.result as { pairedDeviceId: string }).pairedDeviceId + : undefined + webRuntimeState.activeEnvironment = updateStoredEnvironmentRuntimeId( + environment, + runtimeId, + pairedDeviceId + ) +} diff --git a/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts b/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts new file mode 100644 index 00000000000..1cc6de1273e --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts @@ -0,0 +1,183 @@ +import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types' +import { + callRuntimeEnvelope, + callRuntimeResult, + callRuntimeResultWithOwner, + withRuntimeWorktreeOwner +} from './web-runtime-calls' +import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls' +import { + assertActiveEnvironment, + requireActiveEnvironment, + webRuntimeState +} from './web-runtime-session' + +export const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 + +export async function listAllRuntimeWorktrees(): Promise { + if ( + webRuntimeState.cachedWorktrees && + Date.now() - webRuntimeState.cachedWorktrees.loadedAt < 5_000 + ) { + return webRuntimeState.cachedWorktrees.worktrees + } + const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { + limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT + }) + const worktrees = owned.result.worktrees.map((worktree) => + withRuntimeWorktreeOwner(worktree, owned.hostId) + ) + assertActiveEnvironment(owned.environmentId) + webRuntimeState.cachedWorktrees = { loadedAt: Date.now(), worktrees } + return worktrees +} + +export async function listAllRuntimeDetectedWorktrees( + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, + useCache = true, + expectedEnvironmentId = requireActiveEnvironment().id +): Promise { + if ( + useCache && + webRuntimeState.cachedDetectedWorktrees && + Date.now() - webRuntimeState.cachedDetectedWorktrees.loadedAt < 5_000 + ) { + return webRuntimeState.cachedDetectedWorktrees.worktrees + } + + assertActiveEnvironment(expectedEnvironmentId) + const repos = (await callResult<{ repos: Repo[] }>('repo.list')).repos + const detectedLists = await Promise.all( + repos.map((repo) => + callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) + ) + ) + const worktrees = detectedLists.flatMap((result) => result.worktrees) + assertActiveEnvironment(expectedEnvironmentId) + if (useCache) { + webRuntimeState.cachedDetectedWorktrees = { loadedAt: Date.now(), worktrees } + } + return worktrees +} + +export async function callRuntimeDetectedWorktrees( + repoId: string, + expectedEnvironmentId = requireActiveEnvironment().id, + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope +): Promise { + assertActiveEnvironment(expectedEnvironmentId) + const hostId = toRuntimeExecutionHostId(expectedEnvironmentId) + const response = await callEnvelope( + 'worktree.detectedList', + { repo: repoId }, + 15_000 + ) + if (response.ok) { + return { + ...response.result, + worktrees: response.result.worktrees.map((worktree) => + withRuntimeWorktreeOwner(worktree, hostId) + ) + } + } + if (response.error.code !== 'method_not_found') { + throw new Error(response.error.message) + } + + assertActiveEnvironment(expectedEnvironmentId) + const legacy = await callResult<{ worktrees: Worktree[] }>( + 'worktree.list', + { repo: repoId, limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT }, + 15_000 + ) + return toLegacyDetectedWorktreeResult( + repoId, + legacy.worktrees.map((worktree) => withRuntimeWorktreeOwner(worktree, hostId)) + ) +} + +export function toLegacyDetectedWorktreeResult( + repoId: string, + worktrees: Worktree[] +): DetectedWorktreeListResult { + return { + repoId, + authoritative: true, + source: 'session-fallback', + worktrees: worktrees.map((worktree) => ({ + ...worktree, + ownership: 'orca-managed', + selectedCheckout: false, + visible: true + })) + } +} + +export function isMissingPathError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + return /\bENOENT\b|not found|no such file/i.test(error.message) +} + +export async function resolveRuntimeWorktreeByPath( + worktreePath: string, + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, + useDetectedWorktreeCache = true, + expectedEnvironmentId = requireActiveEnvironment().id +): Promise { + // Why: hidden-but-open worktrees must still resolve, but `worktree.list` is sidebar-visible only — resolve via detected rows. + const worktrees = await listAllRuntimeDetectedWorktrees( + callResult, + callEnvelope, + useDetectedWorktreeCache, + expectedEnvironmentId + ) + const match = worktrees + .map((worktree) => ({ + worktree, + relativePath: relativePathInsideRoot(worktree.path, worktreePath) + })) + .filter((entry) => entry.relativePath !== null) + .sort((a, b) => b.worktree.path.length - a.worktree.path.length)[0] + if (!match) { + throw new Error(`No runtime worktree owns ${worktreePath}`) + } + return match.worktree +} + +export async function resolveRuntimeFilePath( + filePath: string, + preferredWorktreePath?: string, + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, + useDetectedWorktreeCache = true, + expectedEnvironmentId = requireActiveEnvironment().id +): Promise<{ worktree: Worktree; relativePath: string }> { + const worktree = preferredWorktreePath + ? await resolveRuntimeWorktreeByPath( + preferredWorktreePath, + callResult, + callEnvelope, + useDetectedWorktreeCache, + expectedEnvironmentId + ) + : await resolveRuntimeWorktreeByPath( + filePath, + callResult, + callEnvelope, + useDetectedWorktreeCache, + expectedEnvironmentId + ) + const relativePath = relativePathInsideRoot(worktree.path, filePath) + if (relativePath === null) { + throw new Error(`File is outside runtime worktree: ${filePath}`) + } + return { worktree, relativePath } +} diff --git a/src/renderer/src/web/preload-api/web-settings-api.ts b/src/renderer/src/web/preload-api/web-settings-api.ts new file mode 100644 index 00000000000..f032b0fa9ff --- /dev/null +++ b/src/renderer/src/web/preload-api/web-settings-api.ts @@ -0,0 +1,118 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../../../shared/computer-awake-mode' +import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' +import { mergeSettings } from './web-preference-normalization' +import { + getRuntimeBackedStoredSettings, + getStoredSettings, + settingsForActiveVisibilityOwner, + syncRuntimeBackedSettings, + updateRuntimePRBotAuthorOverride, + writeStoredSettings +} from './web-preferences-store' +import type { WebSettingsApi } from './web-preferences-store' +import { + requireActiveEnvironmentOrNull, + resolveEnvironment, + webRuntimeState +} from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createWebSettingsApi(): Partial { + return { + settings: { + get: async () => getRuntimeBackedStoredSettings(), + // Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop. + getSync: () => settingsForActiveVisibilityOwner(getStoredSettings()), + set: async (updates) => { + const sanitizedUpdates = { ...updates } + const runtimeEnvironment = requireActiveEnvironmentOrNull() + delete sanitizedUpdates.activeRuntimeEnvironmentId + if ( + 'worktreeVisibilityDefaults' in sanitizedUpdates && + runtimeEnvironment && + runtimeEnvironment.id !== webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId + ) { + delete sanitizedUpdates.worktreeVisibilityDefaults + } + if ('worktreeVisibilityDefaults' in sanitizedUpdates) { + sanitizedUpdates.worktreeVisibilityDefaults = { + ...settingsForActiveVisibilityOwner(getStoredSettings()).worktreeVisibilityDefaults, + ...sanitizedUpdates.worktreeVisibilityDefaults + } + } + if ('computerAwakeMode' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedUpdates.computerAwakeMode, + sanitizedUpdates.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off' + ) + ) + } + if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { + sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true + } + if ('terminalCursorStyle' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + normalizeTerminalCursorStyleDefault( + { terminalCursorStyle: sanitizedUpdates.terminalCursorStyle }, + { preserveExplicitValue: true } + ) + ) + } + const localUpdates = { ...sanitizedUpdates } + if (runtimeEnvironment) { + delete localUpdates.worktreeVisibilityDefaults + } + const next = mergeSettings(getStoredSettings(), localUpdates, { + preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates + }) + writeStoredSettings(next) + return settingsForActiveVisibilityOwner( + await syncRuntimeBackedSettings(sanitizedUpdates, next) + ) + }, + setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => { + const requestedEnvironmentId = environmentId?.trim() || null + const activeRuntimeEnvironmentId = requestedEnvironmentId + ? resolveEnvironment(requestedEnvironmentId).id + : null + const next = mergeSettings(getStoredSettings(), { + activeRuntimeEnvironmentId + }) + writeStoredSettings(next, activeRuntimeEnvironmentId) + return next + }, + updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args), + listFonts: () => Promise.resolve([]), + onChanged: () => noopUnsubscribe + } satisfies Partial as unknown as WebSettingsApi, + agentAwake: { + getStatus: async () => { + const settings = getStoredSettings() + return { + mode: normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ), + active: false + } + }, + onChanged: () => noopUnsubscribe + } + } +} diff --git a/src/renderer/src/web/preload-api/web-shell-api.ts b/src/renderer/src/web/preload-api/web-shell-api.ts new file mode 100644 index 00000000000..59b067fb329 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-shell-api.ts @@ -0,0 +1,30 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { resolveRuntimeFilePath } from './web-runtime-worktree-catalog' + +export function createShellApi(): NonNullable['shell']> { + const openResult = { ok: true } as const + return { + openPath: (path) => + Promise.resolve(window.open(path, '_blank', 'noopener,noreferrer') as never), + openInFileManager: () => Promise.resolve(openResult), + openInExternalEditor: () => Promise.resolve(openResult), + openUrl: (url) => Promise.resolve(window.open(url, '_blank', 'noopener,noreferrer') as never), + openFilePath: () => Promise.resolve(false), + openFileUri: (uri) => + Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never), + pathExists: async (path) => { + try { + await resolveRuntimeFilePath(path) + return true + } catch { + return false + } + }, + pickAttachment: () => Promise.resolve(null), + pickImage: () => Promise.resolve(null), + pickRepoIconImage: () => Promise.resolve(null), + pickAudio: () => Promise.resolve(null), + pickDirectory: () => Promise.resolve(null), + copyFile: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-star-nag-api.ts b/src/renderer/src/web/preload-api/web-star-nag-api.ts new file mode 100644 index 00000000000..bbbe722eab3 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-star-nag-api.ts @@ -0,0 +1,21 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { noopUnsubscribe } from './web-storage' + +export function createWebStarNagApi(): Partial { + return { + starNag: { + onShow: () => noopUnsubscribe, + onHide: () => noopUnsubscribe, + dismiss: () => Promise.resolve(), + later: () => Promise.resolve(), + complete: () => Promise.resolve(), + disable: () => Promise.resolve(), + openWeb: () => Promise.resolve(), + starOrca: () => Promise.resolve(false), + forceShow: () => Promise.resolve(), + agentValueMoment: () => Promise.resolve({ status: 'skipped' }), + showAgentValueMoment: () => Promise.resolve(), + onboardingCompleted: () => Promise.resolve() + } + } +} diff --git a/src/renderer/src/web/preload-api/web-storage.ts b/src/renderer/src/web/preload-api/web-storage.ts new file mode 100644 index 00000000000..8f33d0c71ec --- /dev/null +++ b/src/renderer/src/web/preload-api/web-storage.ts @@ -0,0 +1,43 @@ +export const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1' + +export const UI_STORAGE_KEY = 'orca.web.ui.v1' + +export const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1' + +export const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1' + +export const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1' + +export const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1' + +export function getBrowserPlatform(): NodeJS.Platform { + if (navigator.userAgent.includes('Windows')) { + return 'win32' + } + if (navigator.userAgent.includes('Linux')) { + return 'linux' + } + return 'darwin' +} + +export function readJson(key: string, fallback: T): T { + const raw = window.localStorage.getItem(key) + if (!raw) { + return cloneJson(fallback) + } + try { + return { ...cloneJson(fallback), ...JSON.parse(raw) } as T + } catch { + return cloneJson(fallback) + } +} + +export function writeJson(key: string, value: T): void { + window.localStorage.setItem(key, JSON.stringify(value)) +} + +export function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +export function noopUnsubscribe(): void {} diff --git a/src/renderer/src/web/preload-api/web-telemetry-api.ts b/src/renderer/src/web/preload-api/web-telemetry-api.ts new file mode 100644 index 00000000000..1fce49016be --- /dev/null +++ b/src/renderer/src/web/preload-api/web-telemetry-api.ts @@ -0,0 +1,11 @@ +import type { PreloadApi } from '../../../../preload/api-types' + +export function createWebTelemetryApi(): Partial { + return { + telemetryTrack: () => Promise.resolve(), + telemetrySetOptIn: () => Promise.resolve(), + telemetryGetConsentState: () => + Promise.resolve({ optedIn: false, source: 'default', blockedByEnv: false } as never), + telemetryAcknowledgeBanner: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts new file mode 100644 index 00000000000..df0c84f262b --- /dev/null +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -0,0 +1,180 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../../shared/pty-delivery-diagnostics' +import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createPtyApi(): NonNullable['pty']> { + return { + spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')), + write: () => {}, + writeAccepted: () => Promise.resolve(false), + resize: () => {}, + claimViewport: () => {}, + reportGeometry: () => {}, + signal: () => {}, + // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. + clearBuffer: () => {}, + kill: () => Promise.resolve(), + ackColdRestore: () => {}, + ackData: () => {}, + onDeliveryResyncRequest: () => noopUnsubscribe, + respondDeliveryResync: () => {}, + // Why: web terminals bypass main's delivery gate; a zero-in-flight reply keeps the watchdog idle. + reportRendererDeliveryState: () => + Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }), + getPtyDataListenerCount: () => 0, + rendererDispatcherReady: () => {}, + setActiveRendererPty: () => {}, + setRendererPtyVisible: () => {}, + setHiddenRendererPty: () => {}, + setPtyDeliveryInterest: () => {}, + // Why: remote-runtime PTYs are never hidden-gate markable, so there's no main-side responder to feed. + publishTerminalViewAttributes: () => {}, + hasChildProcesses: () => Promise.resolve(false), + getForegroundProcess: () => Promise.resolve(null), + inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), + // Why: paired web panes cannot provide a local post-boundary process scan. + confirmForegroundProcess: () => Promise.resolve(null), + getCwd: () => Promise.resolve('~'), + getSize: () => Promise.resolve(null), + listSessions: () => Promise.resolve([]), + getAuthoritativeBufferSnapshotCapabilities: (ids) => + Promise.resolve(ids.map((id) => ({ id, authoritative: false }))), + hasPty: () => Promise.resolve(null), + getMainBufferSnapshot: () => Promise.resolve(null), + // Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative. + onSideEffect: () => noopUnsubscribe, + getSideEffectSnapshot: () => Promise.resolve(null), + getRendererDeliveryDebugSnapshot: () => + Promise.resolve({ + pendingPtyCount: 0, + pendingChars: 0, + maxPendingCharsByPty: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + maxRendererInFlightCharsByPty: 0, + activeRendererPtyCount: 0, + flushScheduled: false, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererPtyDispatcherReady: false, + rendererDispatcherReadyForcedCount: 0 + }), + resetRendererDeliveryDebug: () => Promise.resolve(), + onData: () => noopUnsubscribe, + onReplay: () => noopUnsubscribe, + onModelRestoreNeeded: () => noopUnsubscribe, + onExit: () => noopUnsubscribe, + onSpawned: () => noopUnsubscribe, + onSerializeBufferRequest: () => noopUnsubscribe, + onClearBufferRequest: () => noopUnsubscribe, + sendSerializedBuffer: () => {}, + declarePendingPaneSerializer: () => Promise.resolve(0), + settlePaneSerializer: () => Promise.resolve(), + clearPendingPaneSerializer: () => Promise.resolve(), + reportRendererSerializerReady: () => Promise.resolve(), + management: { + listSessions: () => Promise.resolve({ sessions: [], degraded: false }), + killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), + killOne: () => Promise.resolve({ success: false }), + restart: () => Promise.resolve({ success: false }), + // Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden. + macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }) + } + } +} + +export function createSshApi(): NonNullable['ssh']> { + return { + // Why: SSH is owned by the paired host; route read/connect to runtime RPC for state/reconnect (STA-1468). Target mgmt is desktop-only. + listTargets: async () => { + if (!requireActiveEnvironmentOrNull()) { + return [] + } + const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>( + 'ssh.listTargetSummaries' + ) + return targets + }, + listRemovedTargetLabels: async () => { + if (!requireActiveEnvironmentOrNull()) { + return {} + } + const { labels } = await callRuntimeResult<{ labels: Record }>( + 'ssh.listRemovedTargetLabels' + ) + return labels + }, + addTarget: () => + Promise.reject(new Error('SSH target management is unavailable in the web client.')), + updateTarget: () => + Promise.reject(new Error('SSH target management is unavailable in the web client.')), + removeTarget: () => Promise.resolve(), + importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }), + listConfigHosts: () => + Promise.resolve({ + hosts: [], + totalHostCount: 0, + newHostCount: 0, + matchCount: 0, + hasMore: false + }), + resolveConfigHost: () => Promise.resolve(null), + connect: async (args) => { + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.connect', + { targetId: args.targetId } + ) + return state + }, + disconnect: () => Promise.resolve(), + terminateSessions: () => Promise.resolve(), + resetRelay: () => Promise.resolve(), + getState: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return null + } + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.getState', + { targetId: args.targetId } + ) + return state + }, + needsPassphrasePrompt: () => Promise.resolve(false), + testConnection: () => + Promise.resolve({ + success: false, + error: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') + }), + onStateChanged: () => noopUnsubscribe, + addPortForward: () => + Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), + updatePortForward: () => + Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), + removePortForward: () => Promise.resolve(null), + listPortForwards: () => Promise.resolve([]), + listDetectedPorts: () => Promise.resolve([]), + onPortForwardsChanged: () => noopUnsubscribe, + onDetectedPortsChanged: () => noopUnsubscribe, + browseDir: () => Promise.resolve({ entries: [], resolvedPath: '', pathFlavor: 'posix' }), + onCredentialRequest: () => noopUnsubscribe, + onCredentialResolved: () => noopUnsubscribe, + submitCredential: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts new file mode 100644 index 00000000000..974132fc49c --- /dev/null +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -0,0 +1,241 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { assertClipboardTextWithinLimitWithYield } from '../../../../shared/clipboard-text' +import type { ReadClipboardTextOptions } from '../../../../shared/clipboard-text' +import { normalizeFeatureInteractions } from '../../../../shared/feature-interactions' +import type { FeatureInteractionId } from '../../../../shared/feature-interactions' +import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' +import type { PairedUiState } from '../../../../shared/pairing-local-ui-fields' +import { + readClipboardImagePngBase64, + saveClipboardImageAsTempFileInRuntime, + writeWebClipboardText +} from './web-clipboard-api' +import { + mergeContextualTourSeenIds, + mergeFeatureInteractionState, + mergeHostWebUIState, + mergeOsc52ClipboardNoticePending, + mergeWebUIState +} from './web-preference-normalization' +import { readLocalWebUIState } from './web-preferences-store' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { UI_STORAGE_KEY, noopUnsubscribe, writeJson } from './web-storage' + +export function createWebUiApi(): NonNullable['ui']> { + let zoomLevel = readLocalWebUIState().uiZoomLevel + return { + get: async () => { + try { + const result = await callRuntimeResult<{ ui: PairedUiState }>('ui.get', undefined, 15_000) + const local = readLocalWebUIState() + const next = { + ...mergeHostWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), + featureInteractions: mergeFeatureInteractionState( + local.featureInteractions, + result.ui.featureInteractions + ), + contextualToursSeenIds: mergeContextualTourSeenIds( + local.contextualToursSeenIds, + result.ui.contextualToursSeenIds + ) + } + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + return next + } catch { + return readLocalWebUIState() + } + }, + set: async (updates) => { + const next = mergeWebUIState(readLocalWebUIState(), updates) + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + // Why strip here too when the host also strips: an old host predating that strip would + // otherwise persist this browser's runtime:web-* keys over the desktop profile's order. + const hostUpdates = omitPairingLocalUiFields(updates) + try { + await callRuntimeResult('ui.set', hostUpdates, 15_000) + } catch { + // Why: unpaired/offline web clients still need local UI persistence. + } + }, + recordFeatureInteraction: async (id: FeatureInteractionId) => { + const current = readLocalWebUIState() + const featureInteractions = normalizeFeatureInteractions(current.featureInteractions) + const existing = featureInteractions[id] + const optimistic = mergeWebUIState(current, { + featureInteractions: { + ...featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + }) + writeJson(UI_STORAGE_KEY, optimistic) + try { + const result = await callRuntimeResult<{ ui: PairedUiState }>( + 'ui.recordFeatureInteraction', + id, + 15_000 + ) + const local = readLocalWebUIState() + const next = { + ...mergeHostWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), + featureInteractions: mergeFeatureInteractionState( + local.featureInteractions, + result.ui.featureInteractions + ), + contextualToursSeenIds: mergeContextualTourSeenIds( + local.contextualToursSeenIds, + result.ui.contextualToursSeenIds + ) + } + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + return next + } catch { + return optimistic + } + }, + readClipboardText: async (options?: ReadClipboardTextOptions) => + assertClipboardTextWithinLimitWithYield( + await (navigator.clipboard?.readText?.() ?? ''), + options + ), + readSelectionClipboardText: () => + Promise.reject(new Error('Selection clipboard is unavailable in the web client')), + saveClipboardImageAsTempFile: async (args?: { + connectionId?: string | null + runtimeEnvironmentId?: string | null + }) => { + if (!requireActiveEnvironmentOrNull()) { + return null + } + const contentBase64 = await readClipboardImagePngBase64() + if (!contentBase64) { + return null + } + return saveClipboardImageAsTempFileInRuntime(contentBase64, args) + }, + writeClipboardText: writeWebClipboardText, + writeTerminalClipboardText: writeWebClipboardText, + writeSelectionClipboardText: () => + Promise.reject(new Error('Selection clipboard is unavailable in the web client')), + writeClipboardImage: () => Promise.resolve(), + writeClipboardFile: () => Promise.resolve({ ok: false, reason: 'unsupported-platform' }), + performNativePaste: () => { + document.execCommand?.('paste') + }, + performNativeSelectionAction: (action) => { + document.execCommand?.(action === 'copy' ? 'copy' : 'selectAll') + }, + onExportPdfRequested: () => noopUnsubscribe, + onAppMenuPaste: () => noopUnsubscribe, + onAppMenuSelectionAction: () => noopUnsubscribe, + onEditableContextPaste: () => noopUnsubscribe, + getZoomLevel: () => zoomLevel, + setZoomLevel: (level) => { + zoomLevel = level + }, + isMaximized: () => Promise.resolve(false), + onOpenSettings: () => noopUnsubscribe, + // Why: the web client has no native tray/menu bar, so there's never a queued open-settings intent to consume. + consumePendingOpenSettings: () => Promise.resolve(false), + onOpenSkillShare: () => noopUnsubscribe, + consumePendingSkillShare: () => Promise.resolve(null), + onOpenSetupGuide: () => noopUnsubscribe, + onOpenFeatureTour: () => noopUnsubscribe, + onOpenCrashReport: () => noopUnsubscribe, + // No desktop main process to push state changes; the web client re-reads via ui.get on interaction. + onStateChanged: () => noopUnsubscribe, + onToggleLeftSidebar: () => noopUnsubscribe, + onToggleRightSidebar: () => noopUnsubscribe, + onToggleWorktreePalette: () => noopUnsubscribe, + onToggleFloatingTerminal: () => noopUnsubscribe, + onTerminalShortcutCaptured: () => noopUnsubscribe, + onOpenQuickOpen: () => noopUnsubscribe, + onToggleQuickCommandsMenu: () => noopUnsubscribe, + onOpenTasks: () => noopUnsubscribe, + onOpenNewWorkspace: () => noopUnsubscribe, + onDeleteCurrentWorkspace: () => noopUnsubscribe, + onOpenWorkspaceBoard: () => noopUnsubscribe, + onToggleAgentDashboard: () => noopUnsubscribe, + onJumpToWorktreeIndex: () => noopUnsubscribe, + onJumpToTabIndex: () => noopUnsubscribe, + onWorktreeHistoryNavigate: () => noopUnsubscribe, + onNewBrowserTab: () => noopUnsubscribe, + onNewMarkdownTab: () => noopUnsubscribe, + onNewSimulatorTab: () => noopUnsubscribe, + onRequestTabCreate: () => noopUnsubscribe, + replyTabCreate: () => {}, + onRequestTabSetProfile: () => noopUnsubscribe, + replyTabSetProfile: () => {}, + onRequestTabClose: () => noopUnsubscribe, + replyTabClose: () => {}, + onNewTerminalTab: () => noopUnsubscribe, + onFocusBrowserAddressBar: () => noopUnsubscribe, + onFindInBrowserPage: () => noopUnsubscribe, + onReloadBrowserPage: () => noopUnsubscribe, + onBrowserHistoryNavigate: () => noopUnsubscribe, + onZoomBrowserPage: () => noopUnsubscribe, + onHardReloadBrowserPage: () => noopUnsubscribe, + onCloseActiveTab: () => noopUnsubscribe, + onCloseFloatingItem: () => noopUnsubscribe, + onSelectFloatingIndex: () => noopUnsubscribe, + onSwitchTab: () => noopUnsubscribe, + onSwitchTabAcrossAllTypes: () => noopUnsubscribe, + onSwitchRecentTab: () => noopUnsubscribe, + onSwitchTerminalTab: () => noopUnsubscribe, + onCtrlTabKeyDown: () => noopUnsubscribe, + onCtrlTabKeyUp: () => noopUnsubscribe, + onToggleStatusBar: () => noopUnsubscribe, + onDictationKeyDown: () => noopUnsubscribe, + onActivateWorktree: () => noopUnsubscribe, + onCreateTerminal: () => noopUnsubscribe, + onRequestTerminalCreate: () => noopUnsubscribe, + onRequestTerminalTabMount: () => noopUnsubscribe, + replyTerminalCreate: () => {}, + onSplitTerminal: () => noopUnsubscribe, + onRenameTerminal: () => noopUnsubscribe, + onFocusTerminal: () => noopUnsubscribe, + onFocusEditorTab: () => noopUnsubscribe, + onCloseSessionTab: () => noopUnsubscribe, + onSessionTabCloseRequest: () => noopUnsubscribe, + respondSessionTabClose: () => {}, + onMoveSessionTab: () => noopUnsubscribe, + onOpenFileFromMobile: () => noopUnsubscribe, + onOpenDiffFromMobile: () => noopUnsubscribe, + onMobileMarkdownRequest: () => noopUnsubscribe, + respondMobileMarkdownRequest: () => {}, + onCloseTerminal: () => noopUnsubscribe, + onTerminalTabCloseRequest: () => noopUnsubscribe, + respondTerminalTabClose: () => {}, + onSleepWorktree: () => noopUnsubscribe, + // Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web. + onResumeSleepingAgents: () => noopUnsubscribe, + onTerminalZoom: () => noopUnsubscribe, + // Why: a paired web client has no OS sleep signal; occlusion-driven visibilitychange already covers wake recovery. + onSystemResumed: () => noopUnsubscribe, + onFileDrop: () => noopUnsubscribe, + syncTrafficLights: () => {}, + setMarkdownEditorFocused: () => {}, + setRichMarkdownContextMenuTarget: () => {}, + setTerminalInputFocused: () => {}, + setFloatingFocus: () => {}, + setShortcutRecorderFocused: () => {}, + onRichMarkdownContextCommand: () => noopUnsubscribe, + onFullscreenChanged: () => noopUnsubscribe, + minimize: () => {}, + maximize: () => {}, + onMaximizeChanged: () => noopUnsubscribe, + requestClose: () => {}, + popupMenu: () => {}, + onWindowCloseRequested: () => noopUnsubscribe, + confirmWindowClose: () => {}, + notifyWindowRevealed: () => {} + } +} diff --git a/src/renderer/src/web/preload-api/web-updater-api.ts b/src/renderer/src/web/preload-api/web-updater-api.ts new file mode 100644 index 00000000000..18d69f2b8e0 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-updater-api.ts @@ -0,0 +1,34 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { noopUnsubscribe } from './web-storage' + +export function createUpdaterApi(): NonNullable['updater']> { + // Why: the linux-package-install recovery status can only originate in the native main process, so + // the web renderer never reaches these branches — reject loudly rather than resolve a fake result. + // A fresh Error per rejection: one shared instance would carry this function's stack, not the caller's. + const desktopOnlyMessage = 'Linux package install recovery is only available in the desktop app.' + return { + getVersion: () => Promise.resolve('web'), + getStatus: () => Promise.resolve({ state: 'idle' } as never), + check: () => Promise.resolve(), + download: () => Promise.resolve(), + quitAndInstall: () => Promise.resolve(), + dismissNudge: () => Promise.resolve(), + dismissAvailableUpdate: () => Promise.resolve(), + getLinuxPackageInstallInstructions: () => Promise.reject(new Error(desktopOnlyMessage)), + showLinuxPackage: () => Promise.reject(new Error(desktopOnlyMessage)), + // Why: the web client cannot install a desktop build, so channel switching + // reports unavailable rather than an empty list that looks like a fetch miss. + listBuilds: (channel) => + Promise.resolve({ + ok: false, + channel, + message: translate( + 'auto.components.settings.ReleaseChannelSection.webUnavailable', + 'Switching builds is only available in the desktop app.' + ) + }), + onStatus: () => noopUnsubscribe, + onClearDismissal: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-workspace-ports-api.ts b/src/renderer/src/web/preload-api/web-workspace-ports-api.ts new file mode 100644 index 00000000000..dcc18462a94 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-workspace-ports-api.ts @@ -0,0 +1,23 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform, noopUnsubscribe } from './web-storage' + +export function createWebWorkspacePortsApi(): Partial { + return { + workspacePorts: { + // Why: browser-local workspaces have no host process to inspect; return capability state instead of the generic undefined fallback. + scan: () => + Promise.resolve({ + platform: getBrowserPlatform(), + scannedAt: Date.now(), + ports: [], + unavailableReason: 'Workspace port scanning is unavailable for browser-local workspaces.' + }), + kill: () => + Promise.resolve({ + ok: false, + reason: 'Workspace port management is unavailable for browser-local workspaces.' + }), + onAdvertisedUrlChanged: () => noopUnsubscribe + } + } +} diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts new file mode 100644 index 00000000000..4150949b802 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -0,0 +1,71 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId +} from '../../../../shared/execution-host' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../../shared/workspace-session-state-types' +import { sanitizeWebRuntimeWorkspaceSession } from '../web-workspace-session' +import { readLocalWebUIState } from './web-preferences-store' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { SESSION_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export function sessionStorageKeyForHost(hostId?: string | null): string { + const resolved = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + return resolved === LOCAL_EXECUTION_HOST_ID + ? SESSION_STORAGE_KEY + : `${SESSION_STORAGE_KEY}.${resolved}` +} + +export function getStoredWorkspaceSession(hostId?: string | null): WorkspaceSessionState { + const resolvedHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + return sanitizeWebRuntimeWorkspaceSession( + readJson(sessionStorageKeyForHost(resolvedHostId), getDefaultWorkspaceSession()) + ) + } + const localSession = sanitizeWebRuntimeWorkspaceSession( + readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession()) + ) + if (!requireActiveEnvironmentOrNull()) { + return localSession + } + const ui = readLocalWebUIState() + // Why: replaying browser-local terminal handles first creates stale remote PTYs; mirror host session-tabs instead. + return sanitizeWebRuntimeWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeRepoId: ui.lastActiveRepoId, + activeWorktreeId: ui.lastActiveWorktreeId, + lastVisitedAtByWorktreeId: localSession.lastVisitedAtByWorktreeId + }) +} + +export function createWebWorkspaceSessionApi(): Partial { + return { + session: { + // Mirrors desktop bridge: non-local hosts persist under a host-suffixed key so their sessions stay isolated from local. + get: (hostId) => Promise.resolve(getStoredWorkspaceSession(hostId)), + set: async (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) + }, + patch: async (patch: WorkspaceSessionPatch, hostId) => { + writeJson( + sessionStorageKeyForHost(hostId), + sanitizeWebRuntimeWorkspaceSession({ + ...getStoredWorkspaceSession(hostId), + ...patch + }) + ) + }, + // localStorage writes synchronously, so there is no deferred web flush. + flush: async () => {}, + readTerminalScrollback: () => null, + setSync: (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts new file mode 100644 index 00000000000..5d3b9bd1bd9 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -0,0 +1,188 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { + ForceDeleteWorktreeBranchResult, + RemoveWorktreeResult +} from '../../../../shared/worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import { readRetiredNameRegistryForRepo } from '../../../../shared/worktree/retired-name-cache' +import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' +import type { Worktree } from '../../../../shared/worktree/types' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { + callRuntimeResult, + callRuntimeResultWithOwner, + withRuntimeWorktreeOwner +} from './web-runtime-calls' +import { invalidateRuntimeWorktreeCaches } from './web-runtime-session' +import { + WEB_RUNTIME_WORKTREE_LIST_LIMIT, + callRuntimeDetectedWorktrees, + listAllRuntimeWorktrees +} from './web-runtime-worktree-catalog' +import { noopUnsubscribe } from './web-storage' + +export function createWorktreesApi(): NonNullable['worktrees']> { + return { + list: async ({ repoId }) => { + const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { + repo: repoId, + limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT + }) + return owned.result.worktrees.map((worktree) => + withRuntimeWorktreeOwner(worktree, owned.hostId) + ) + }, + // Why the catch: a host predating this method answers `method_not_found`, and an empty list + // degrades the suggestion to the pre-existing behavior rather than blocking workspace create. + listRetiredNames: async ({ repoId }) => { + try { + return readRetiredNameRegistryForRepo( + await callRuntimeResult('worktree.listRetiredNames', { repo: repoId }), + repoId + ) + } catch { + return EMPTY_RETIRED_NAME_REGISTRY + } + }, + listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId), + listAll: () => listAllRuntimeWorktrees(), + create: async (args) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.create', { + repo: args.repoId, + name: args.name, + // Absent means user-typed, which is what the host must assume — so send it only when true. + ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), + baseBranch: args.baseBranch, + compareBaseRef: args.compareBaseRef, + branchNameOverride: args.branchNameOverride, + linkedIssue: args.linkedIssue, + linkedPR: args.linkedPR, + linkedLinearIssue: args.linkedLinearIssue, + linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey, + linkedGitLabIssue: args.linkedGitLabIssue, + linkedGitLabMR: args.linkedGitLabMR, + linkedBitbucketPR: args.linkedBitbucketPR, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR, + linkedGiteaPR: args.linkedGiteaPR, + displayName: args.displayName, + sparseCheckout: args.sparseCheckout, + pushTarget: args.pushTarget, + setupDecision: args.setupDecision, + createdWithAgent: args.createdWithAgent, + pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, + ...(args.startup + ? { + startupCommand: args.startup.command, + ...(args.startup.env ? { startupEnv: args.startup.env } : {}), + ...(args.startup.launchConfig + ? { startupLaunchConfig: args.startup.launchConfig } + : {}), + ...(args.startup.startupCommandDelivery + ? { startupCommandDelivery: args.startup.startupCommandDelivery } + : {}), + activate: true + } + : {}), + parentWorkspace: args.parentWorkspace, + workspaceStatus: args.workspaceStatus, + manualOrder: args.manualOrder, + automationProvenanceRequest: args.automationProvenanceRequest + }) + return { + ...owned.result, + worktree: withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) + } + }, + // Why: adoption verifies a desktop-owned hidden SSH target and is intentionally not a remote-server operation. + adoptProvisionedRoot: () => + Promise.reject( + new Error('Provisioned-root recipes require a direct SSH connection from the desktop app.') + ), + // Why: the runtime create path emits no two-phase progress, so the panel falls back to an indeterminate spinner. + onCreateProgress: () => noopUnsubscribe, + prefetchCreateBase: async ({ repoId, baseBranch }) => { + await callRuntimeResult('worktree.prefetchCreateBase', { + repo: repoId, + baseBranch + }) + }, + resolvePrBase: async ({ repoId, prNumber, headRefName, baseRefName, isCrossRepository }) => + callRuntimeResult('worktree.resolvePrBase', { + repo: repoId, + prNumber, + headRefName, + baseRefName, + isCrossRepository + }), + resolveMrBase: async ({ repoId, mrIid, sourceBranch, targetBranch, isCrossRepository }) => + callRuntimeResult('worktree.resolveMrBase', { + repo: repoId, + mrIid, + sourceBranch, + targetBranch, + isCrossRepository + }), + remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => { + invalidateRuntimeWorktreeCaches() + return callRuntimeResult('worktree.rm', { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...(hostId ? { hostId } : {}), + force, + // Why (#11960): the web client renders the same Force Delete affordances, so + // dropping this field here would leave paired clients permanently wedged. + allowUnverifiedPtyStop, + runHooks: skipArchive !== true + }) + }, + // Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state. + forgetLocal: () => { + throw new Error('Forgetting a workspace is unavailable in paired web clients.') + }, + forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead, hostId }) => + callRuntimeResult('worktree.forceDeleteBranch', { + worktree: toRuntimeWorktreeSelector(worktreeId), + branchName, + expectedHead, + ...(hostId ? { hostId } : {}) + }), + updateMeta: async ({ worktreeId, updates }) => { + const rpcUpdates = + Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined + ? { ...updates, pushTarget: null } + : updates + const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.set', { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...rpcUpdates + }) + return withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) + }, + listLineage: async () => + await callRuntimeResult<{ + lineage: Record + workspaceLineage?: Record + }>('worktree.lineageList'), + updateLineage: async ({ worktreeId, parentWorktreeId, noParent }) => { + invalidateRuntimeWorktreeCaches() + const result = await callRuntimeResult<{ + worktree: Worktree & { lineage?: WorktreeLineage | null } + }>('worktree.set', { + worktree: toRuntimeWorktreeSelector(worktreeId), + parentWorktree: parentWorktreeId, + noParent + }) + return result.worktree.lineage ?? null + }, + persistSortOrder: async ({ orderedIds }) => { + await callRuntimeResult('worktree.persistSortOrder', { orderedIds }) + }, + // Why: the capture lives in desktop main memory, unexposed over pairing; the dialog falls back to the persisted excerpt. + getBranchRenameFailureOutput: async () => null, + onChanged: () => noopUnsubscribe, + onGitStatusMetadataChanged: () => noopUnsubscribe, + onHeadIdentitiesChanged: () => noopUnsubscribe, + onBaseStatus: () => noopUnsubscribe, + onRemoteBranchConflict: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/web-preload-api-clipboard.test.ts b/src/renderer/src/web/web-preload-api-clipboard.test.ts index 492a2520e94..f5a6f76f8a0 100644 --- a/src/renderer/src/web/web-preload-api-clipboard.test.ts +++ b/src/renderer/src/web/web-preload-api-clipboard.test.ts @@ -301,8 +301,9 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, installWebPreloadApi } = - await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS } = + await import('./preload-api/web-clipboard-api') const contentBase64 = `${'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS)}AAAA` installClipboardImageBase64(contentBase64) installWebPreloadApi() @@ -414,8 +415,9 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS, installWebPreloadApi } = - await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS } = + await import('./preload-api/web-clipboard-api') installClipboardImageBase64('A'.repeat(CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + 4)) installWebPreloadApi() @@ -578,8 +580,8 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { MAX_CLIPBOARD_IMAGE_SOURCE_BYTES, installWebPreloadApi } = - await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { MAX_CLIPBOARD_IMAGE_SOURCE_BYTES } = await import('./preload-api/web-clipboard-api') const clipboard = installClipboardImageBlob( new Blob([new Uint8Array(MAX_CLIPBOARD_IMAGE_SOURCE_BYTES + 1)], { type: 'image/png' }) ) @@ -624,7 +626,8 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { MAX_CLIPBOARD_IMAGE_PIXELS, installWebPreloadApi } = await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { MAX_CLIPBOARD_IMAGE_PIXELS } = await import('./preload-api/web-clipboard-api') installClipboardImageBlob(new Blob(['small'], { type: 'image/jpeg' })) vi.stubGlobal( 'createImageBitmap', diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts new file mode 100644 index 00000000000..85769ffa45c --- /dev/null +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installBrowserGlobals } from './web-preload-api-test-harness' + +describe('web preload API composition', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.doUnmock('../../../shared/e2e-config') + }) + + it('installs the exact concrete surface without enumerating fallback namespaces', async () => { + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + expect(Object.keys(globals.window.api)).toEqual([ + 'app', + 'starNag', + 'platform', + 'workspacePorts', + 'orcaProfiles', + 'e2e', + 'settings', + 'agentAwake', + 'keybindings', + 'ui', + 'crashReports', + 'diagnostics', + 'session', + 'onboarding', + 'cache', + 'runtime', + 'nativeChat', + 'runtimeEnvironments', + 'repos', + 'worktrees', + 'fs', + 'git', + 'browser', + 'emulator', + 'gh', + 'gl', + 'hostedReview', + 'linear', + 'hooks', + 'stats', + 'memory', + 'aiVault', + 'preflight', + 'notifications', + 'rateLimits', + 'minimaxCredentials', + 'grokAccounts', + 'codexAccounts', + 'claudeAccounts', + 'cli', + 'agentHooks', + 'macosTccPrompts', + 'codexConfigSync', + 'developerPermissions', + 'computerUsePermissions', + 'updater', + 'shell', + 'skills', + 'pty', + 'ssh', + 'wsl', + 'pwsh', + 'gitBash', + 'agentStatus', + 'mobile', + 'telemetryTrack', + 'telemetrySetOptIn', + 'telemetryGetConsentState', + 'telemetryAcknowledgeBanner' + ]) + expect(Object.keys(globals.window.api.projects)).toEqual([]) + expect(Reflect.get(globals.window.api.projects, 'then')).toBeUndefined() + expect(Object.keys(globals.window.electron)).toEqual([]) + }) + + it('snapshots E2E config before runtime storage initialization', async () => { + const evaluationOrder: string[] = [] + const createE2EConfig = vi.fn((config: unknown) => { + evaluationOrder.push('e2e') + return config + }) + vi.doMock('../../../shared/e2e-config', () => ({ createE2EConfig })) + vi.stubEnv('VITE_EXPOSE_STORE', 'true') + const globals = installBrowserGlobals('Linux') + Object.assign(globals.window.location, { + search: '?orcaE2ETerminalParkingDelayMs=23&orcaE2ETerminalRetentionLimit=7' + }) + const readStored = globals.storage.getItem.bind(globals.storage) + vi.spyOn(globals.storage, 'getItem').mockImplementation((key) => { + evaluationOrder.push(`storage:${key}`) + return readStored(key) + }) + + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + expect(evaluationOrder[0]).toBe('e2e') + expect(createE2EConfig).toHaveBeenCalledWith({ + exposeStore: true, + terminalParkingDelayMs: 23, + terminalRetentionLimit: 7 + }) + expect(globals.window.api.e2e.getConfig()).toEqual({ + exposeStore: true, + terminalParkingDelayMs: 23, + terminalRetentionLimit: 7 + }) + }) +}) diff --git a/src/renderer/src/web/web-preload-api-github.test.ts b/src/renderer/src/web/web-preload-api-github.test.ts index 4f0a8312dd4..3483ab8fd40 100644 --- a/src/renderer/src/web/web-preload-api-github.test.ts +++ b/src/renderer/src/web/web-preload-api-github.test.ts @@ -105,7 +105,8 @@ describe('web GitHub preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { GITHUB_WEB_RPC_METHODS, installWebPreloadApi } = await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { GITHUB_WEB_RPC_METHODS } = await import('./preload-api/web-github-routes') installWebPreloadApi() const api = globals.window.api const repoPath = '/workspace/repo' diff --git a/src/renderer/src/web/web-preload-api-gitlab.test.ts b/src/renderer/src/web/web-preload-api-gitlab.test.ts index 899b2f4b596..558c3527773 100644 --- a/src/renderer/src/web/web-preload-api-gitlab.test.ts +++ b/src/renderer/src/web/web-preload-api-gitlab.test.ts @@ -56,7 +56,8 @@ describe('web GitLab preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { GITLAB_WEB_RPC_METHODS, installWebPreloadApi } = await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { GITLAB_WEB_RPC_METHODS } = await import('./preload-api/web-gitlab-routes') installWebPreloadApi() const api = globals.window.api const repoPath = '/workspace/repo' diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts new file mode 100644 index 00000000000..6960ba5b83f --- /dev/null +++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + installBrowserGlobals, + writeStoredRuntimeEnvironment +} from './web-preload-api-test-harness' + +describe('web preload runtime calls', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('preserves success and failure envelopes while persisting response runtime metadata', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + if (method === 'runtime.failure') { + return Promise.resolve({ + id: method, + ok: false, + error: { code: 'remote_failure', message: 'Remote failed', data: { retry: false } }, + _meta: { runtimeId: 'runtime-failure' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { value: 42 }, + _meta: { runtimeId: 'runtime-success' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.runtime.call({ method: 'runtime.success' })).resolves.toEqual({ + id: 'runtime.success', + ok: true, + result: { value: 42 }, + _meta: { runtimeId: 'runtime-success' } + }) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-success' }) + + await expect(globals.window.api.runtime.call({ method: 'runtime.failure' })).resolves.toEqual({ + id: 'runtime.failure', + ok: false, + error: { code: 'remote_failure', message: 'Remote failed', data: { retry: false } }, + _meta: { runtimeId: 'runtime-failure' } + }) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-failure' }) + }) + + it('unwraps domain failures to their message after persisting failure metadata', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: false, + error: { + code: 'repo_unavailable', + message: 'Repository catalog is unavailable', + data: { host: 'runtime-a' } + }, + _meta: { runtimeId: 'runtime-domain-failure' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + let rejection: unknown + try { + await globals.window.api.repos.list() + } catch (error) { + rejection = error + } + + expect(rejection).toEqual(new Error('Repository catalog is unavailable')) + if (!(rejection instanceof Error)) { + throw new Error('Expected a domain Error rejection') + } + expect(Reflect.get(rejection, 'code')).toBeUndefined() + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-domain-failure' }) + }) + + it('surfaces per-environment queue overload without invoking the client', async () => { + let release!: () => void + const blocked = new Promise>((resolve) => { + release = () => + resolve({ + id: 'runtime.blocked', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-a' } + }) + }) + const call = vi.fn(() => blocked) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call = call + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const accepted = Array.from({ length: 264 }, (_, index) => + globals.window.api.runtime.call({ method: `runtime.blocked.${index}` }) + ) + const overloaded = globals.window.api.runtime.call({ method: 'runtime.overloaded' }) + + await expect(overloaded).rejects.toMatchObject({ + code: 'runtime_rpc_queue_overloaded', + scope: 'selector' + }) + expect(call).toHaveBeenCalledTimes(8) + release() + await expect(Promise.all(accepted)).resolves.toHaveLength(264) + }) + + it('does not spend a selected call timeout while queued and forwards it unchanged', async () => { + vi.useFakeTimers() + let release!: () => void + const blocked = new Promise>((resolve) => { + release = () => + resolve({ + id: 'runtime.blocker', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-a' } + }) + }) + const call = vi.fn((method: string): Promise> => { + if (method.startsWith('runtime.blocker')) { + return blocked + } + return Promise.resolve({ + id: method, + ok: true, + result: 'done', + _meta: { runtimeId: 'runtime-a' } + }) + }) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call = call + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const blockers = Array.from({ length: 8 }, (_, index) => + globals.window.api.runtime.call({ method: `runtime.blocker.${index}` }) + ) + const queued = globals.window.api.runtimeEnvironments.call({ + selector: 'web-server-a', + method: 'runtime.queued-timeout', + timeoutMs: 25 + }) + let settled = false + void queued.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.advanceTimersByTimeAsync(60_000) + expect(settled).toBe(false) + expect(call).toHaveBeenCalledTimes(8) + release() + await Promise.all(blockers) + await expect(queued).resolves.toMatchObject({ ok: true, result: 'done' }) + expect(call).toHaveBeenLastCalledWith('runtime.queued-timeout', undefined, { timeoutMs: 25 }) + }) +}) diff --git a/src/renderer/src/web/web-preload-api-runtime-environment.test.ts b/src/renderer/src/web/web-preload-api-runtime-environment.test.ts index 9917a8e97f4..58ecb5e96c0 100644 --- a/src/renderer/src/web/web-preload-api-runtime-environment.test.ts +++ b/src/renderer/src/web/web-preload-api-runtime-environment.test.ts @@ -525,4 +525,59 @@ describe('web runtime environment identity', () => { message: 'Access grant rejected.' }) }) + + it('replaces API closures on reinstall while retaining the runtime client singleton', async () => { + let clientCount = 0 + const call = vi.fn( + (method: string): Promise> => + Promise.resolve({ + id: method, + ok: true, + result: { method }, + _meta: { runtimeId: 'runtime-a' } + }) + ) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + constructor() { + clientCount += 1 + } + + call = call + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const firstApi = globals.window.api + const initialZoom = firstApi.ui.getZoomLevel() + firstApi.ui.setZoomLevel(initialZoom + 1) + await expect(firstApi.runtime.call({ method: 'status.first' })).resolves.toEqual({ + id: 'status.first', + ok: true, + result: { method: 'status.first' }, + _meta: { runtimeId: 'runtime-a' } + }) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-a' }) + + installWebPreloadApi() + const secondApi = globals.window.api + await secondApi.runtime.call({ method: 'status.second' }) + await firstApi.runtime.call({ method: 'status.old-capture' }) + + expect(secondApi).not.toBe(firstApi) + expect(secondApi.ui.getZoomLevel()).toBe(initialZoom) + expect(firstApi.ui.getZoomLevel()).toBe(initialZoom + 1) + expect(clientCount).toBe(1) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'status.first', + 'status.second', + 'status.old-capture' + ]) + }) }) diff --git a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts index 5276e741e52..cb6ca0eb3e8 100644 --- a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts +++ b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts @@ -343,6 +343,40 @@ describe('web worktree preload API', () => { ]) }) + it('reuses the worktree catalog cache until disconnect invalidates its runtime owner', async () => { + const calls: string[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + calls.push(method) + return Promise.resolve({ + id: method, + ok: true, + result: + method === 'status.get' + ? { runtimeId: 'runtime-a', version: '1.0.0' } + : { worktrees: [{ id: 'worktree-a', repoId: 'repo-a', path: '/srv/a' }] }, + _meta: { runtimeId: 'runtime-a' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.worktrees.listAll() + await globals.window.api.worktrees.listAll() + await globals.window.api.runtimeEnvironments.disconnect({ selector: 'web-server-a' }) + await globals.window.api.runtimeEnvironments.connect({ selector: 'web-server-a' }) + await globals.window.api.worktrees.listAll() + + expect(calls).toEqual(['worktree.list', 'status.get', 'worktree.list']) + }) + it('preserves runtime-routed detected-worktree host ownership in the compatibility shape', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 5e492168b58..50a63270d80 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1,881 +1,83 @@ -/* eslint-disable max-lines -- Why: browser-side Electron-preload replacement; compatibility surface centralizes here. */ -import type { - PreloadApi, - PreflightStatus, - RefreshAgentsResult, - NativeChatApi, - NativeChatAppendedMessages -} from '../../../preload/api-types' -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' -import { parseHostAccessLink } from '../../../shared/remote-pairing-address' -import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification' -import type { AiVaultDeleteSessionArgs } from '../../../shared/ai-vault-session-deletion' -import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' -import type { - AiVaultSessionTitlesArgs, - AiVaultSessionTitlesResult -} from '../../../shared/ai-vault-session-title' -import type { - AiVaultPrepareSessionResumeArgs, - AiVaultPrepareSessionResumeResult -} from '../../../shared/ai-vault-resume-preparation' -import { buildNativeChatUnsubscribe } from '../../../shared/native-chat-stream-unsubscribe' -import type { - ComputerUsePermissionSetupResult, - ComputerUsePermissionStatusResult -} from '../../../shared/computer-use-permissions-types' -import type { SearchResult } from '../../../shared/code-search-types' -import type { DirEntry } from '../../../shared/filesystem-entry-types' -import type { - GlobalSettings, - WorktreeVisibilityDefaults -} from '../../../shared/global-settings-types' -import type { OnboardingState } from '../../../shared/onboarding-state-types' -import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import type { PreloadApi } from '../../../preload/api-types' +import type { StatsSummary } from '../../../shared/process-stats-types' +import { createWebE2EApi } from './preload-api/web-e2e-api' import { - omitPairingLocalUiFields, - type PairedUiState, - type PairingLocalUiField -} from '../../../shared/pairing-local-ui-fields' -import type { MemorySnapshot, StatsSummary } from '../../../shared/process-stats-types' -import type { Repo } from '../../../shared/repo-types' -import type { - WorkspaceSessionPatch, - WorkspaceSessionState -} from '../../../shared/workspace-session-state-types' -import type { - ForceDeleteWorktreeBranchResult, - RemoveWorktreeResult -} from '../../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' -import type { DetectedWorktreeListResult, Worktree } from '../../../shared/worktree/types' -import type { SkillDiscoveryResult } from '../../../shared/skills' -import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' -import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' + createAccountsApi, + createGrokAccountsApi, + createMiniMaxCredentialsApi +} from './preload-api/web-agent-accounts-api' +import { createAgentHooksApi } from './preload-api/web-agent-hooks-api' +import { createWebAgentStatusApi } from './preload-api/web-agent-status-api' +import { createWebAiVaultApi } from './preload-api/web-ai-vault-api' +import { createWebAppApi } from './preload-api/web-app-api' +import { createBrowserApi, createEmulatorApi } from './preload-api/web-browser-api' +import { createCliApi } from './preload-api/web-cli-api' +import { createWebDiagnosticsApi } from './preload-api/web-diagnostics-api' +import { createFallbackProxy, withFallback } from './preload-api/web-fallback-api' +import { createFileApi } from './preload-api/web-filesystem-api' +import { createGitApi } from './preload-api/web-git-api' +import { createWebGithubCacheApi } from './preload-api/web-github-cache-api' +import { createGitHubApi } from './preload-api/web-github-api' +import { createGitLabApi } from './preload-api/web-gitlab-api' import { - getDefaultOnboardingState, - getDefaultSettings, - getDefaultUIState, - getDefaultWorkspaceSession, - getWorktreeCardModeProperties, - normalizeAgentActivityDisplayMode, - normalizeWorktreeCardProperties, - ONBOARDING_FLOW_VERSION -} from '../../../shared/constants' -import { - createDefaultLocalOrcaProfile, - DEFAULT_LOCAL_ORCA_PROFILE_ID -} from '../../../shared/orca-profiles' -import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' -import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../shared/pty-delivery-diagnostics' -import { createE2EConfig } from '../../../shared/e2e-config' -import { relativePathInsideRoot } from '../../../shared/cross-platform-path' -import { readRetiredNameRegistryForRepo } from '../../../shared/worktree/retired-name-cache' -import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../shared/worktree/retired-name-registry' -import { - applyPRBotAuthorOverride, - normalizePRBotAuthorOverrides -} from '../../../shared/pr-bot-author-overrides' -import { - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostScope, - normalizeExecutionHostId, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' -import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call' -import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../shared/tui-agent-launch-defaults' -import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' -import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' -import { - normalizeOsc52ClipboardDefaultOn, - osc52ClipboardDefaultOnOverridesPersistedOff -} from '../../../shared/osc52-clipboard-settings' -import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' -import { normalizeUiLanguage } from '../../../shared/ui-language' -import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' -import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode' -import { - computerAwakeSettingsForMode, - normalizeComputerAwakeMode -} from '../../../shared/computer-awake-mode' -import { normalizeWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' -import type { RateLimitState } from '../../../shared/rate-limit-types' -import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' -import { assertFileMutationOwnershipCapability } from '../../../shared/file-mutation-ownership' -import { - findKeybindingConflicts, - formatKeybindingList, - getKeybindingPlatform, - isKeybindingActionId, - normalizeKeybindingArrayForAction, - type KeybindingActionId, - type KeybindingFileDiagnostic, - type KeybindingFileSnapshot, - type KeybindingOverrides, - type KeybindingPlatform -} from '../../../shared/keybindings' -import { - clearStoredWebRuntimeEnvironment, - createStoredWebRuntimeEnvironment, - getPreferredWebPairingOffer, - readStoredWebRuntimeEnvironment, - redactStoredWebRuntimeEnvironment, - saveStoredWebRuntimeEnvironment, - updateStoredEnvironmentRuntimeId, - type StoredWebRuntimeEnvironment -} from './web-runtime-environment' -import { parseWebPairingInput } from './web-pairing' -import { copyClipboardTextViaExecCommand } from './web-clipboard-copy-fallback' -import { WebRuntimeClient } from './web-runtime-client' -import { isWebRuntimeUnauthorizedError } from './web-runtime-client-error' -import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue' -import { - assertClipboardTextWriteWithinLimitWithYield, - assertClipboardTextWithinLimitWithYield, - type ReadClipboardTextOptions -} from '../../../shared/clipboard-text' -import { - CLIPBOARD_IMAGE_MAX_BASE64_CHARS, - CLIPBOARD_IMAGE_MAX_PIXELS, - CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, - CLIPBOARD_IMAGE_TOO_LARGE_ERROR, - assertClipboardImageByteLengthWithinLimit, - assertClipboardImageDimensionsWithinLimit -} from '../../../shared/clipboard-image' -import { sanitizeWebRuntimeWorkspaceSession } from './web-workspace-session' -import { - normalizeFeatureInteractions, - type FeatureInteractionId, - type FeatureInteractionState -} from '../../../shared/feature-interactions' -import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours' -import { translate } from '@/i18n/i18n' -import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' -import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults' -import { - parseRuntimeNativeChatReadSessionResult, - parseRuntimeNativeChatTurnLifecycle -} from '@/components/native-chat/native-chat-runtime-contract' -import { createWebFileMutationMethods } from './web-file-mutation-methods' -import { mergeWorkspaceCleanupUIState } from '../../../shared/workspace-cleanup-ui-state' - -const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1' -const UI_STORAGE_KEY = 'orca.web.ui.v1' -const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1' -const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1' -const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1' -const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1' -// Why: paired web clients lack Electron env/preload state; the E2E build gate keeps URL overrides out of releases. -const webE2EExposeStore = String(import.meta.env.VITE_EXPOSE_STORE) === 'true' -const webE2EQuery = webE2EExposeStore ? new URLSearchParams(window.location.search) : null -const webE2EConfig = createE2EConfig({ - exposeStore: webE2EExposeStore, - terminalParkingDelayMs: Number(webE2EQuery?.get('orcaE2ETerminalParkingDelayMs')) || null, - terminalRetentionLimit: Number(webE2EQuery?.get('orcaE2ETerminalRetentionLimit')) || null -}) -// Why: paired clients need parity for large dev sessions; the runtime default stays capped for lower-level RPC callers. -const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 -const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS -export const MAX_CLIPBOARD_IMAGE_SOURCE_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES -export const MAX_CLIPBOARD_IMAGE_PIXELS = CLIPBOARD_IMAGE_MAX_PIXELS -export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 -export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 -const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 - -let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment() -let worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null = null -let worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null = null -let activeClient: WebRuntimeClient | null = null -let activeClientEnvironmentId: string | null = null -const manuallyDisconnectedEnvironmentIds = new Set() -let cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null -let cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null -const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() - -function blobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - const result = typeof reader.result === 'string' ? reader.result : '' - const commaIndex = result.indexOf(',') - resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1)) - } - reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image')) - reader.readAsDataURL(blob) - }) -} - -function assertClipboardImageBlobWithinLimit(blob: Blob): void { - assertClipboardImageByteLengthWithinLimit(blob.size) -} - -async function convertImageBlobToPng(blob: Blob): Promise { - assertClipboardImageBlobWithinLimit(blob) - const bitmap = await createImageBitmap(blob) - try { - assertClipboardImageDimensionsWithinLimit(bitmap) - const canvas = document.createElement('canvas') - canvas.width = bitmap.width - canvas.height = bitmap.height - const context = canvas.getContext('2d') - if (!context || canvas.width <= 0 || canvas.height <= 0) { - throw new Error('Clipboard image could not be decoded') - } - context.drawImage(bitmap, 0, 0) - return await new Promise((resolve, reject) => { - canvas.toBlob((png) => { - if (!png) { - reject(new Error('Clipboard image could not be encoded as PNG')) - return - } - try { - assertClipboardImageBlobWithinLimit(png) - } catch (error) { - reject(error) - return - } - resolve(png) - }, 'image/png') - }) - } finally { - bitmap.close() - } -} - -async function readClipboardImagePngBase64(): Promise { - const clipboard = navigator.clipboard as - | (Clipboard & { read?: () => Promise }) - | undefined - if (!clipboard?.read) { - return null - } - const items = await clipboard.read() - for (const item of items) { - const imageType = item.types.find((type) => type.startsWith('image/')) - if (!imageType) { - continue - } - const blob = await item.getType(imageType) - assertClipboardImageBlobWithinLimit(blob) - const pngBlob = imageType === 'image/png' ? blob : await convertImageBlobToPng(blob) - return blobToBase64(pngBlob) - } - return null -} - -function invalidateRuntimeWorktreeCaches(): void { - cachedWorktrees = null - cachedDetectedWorktrees = null -} - -type WebSettingsApi = NonNullable -type WebKeybindingsApi = NonNullable -type WebGitHubApi = NonNullable -type WebGitHubResult = Awaited> -type WebRuntimeResultCaller = ( - method: string, - params?: unknown, - timeoutMs?: number -) => Promise -type WebRuntimeEnvelopeCaller = ( - method: string, - params?: unknown, - timeoutMs?: number -) => Promise> -type WebGitHubRouteKey = - | 'repoSlug' - | 'repoUpstream' - | 'prForBranch' - | 'issue' - | 'workItem' - | 'workItemByOwnerRepo' - | 'workItemDetails' - | 'prFileContents' - | 'listIssues' - | 'createIssue' - | 'countWorkItems' - | 'listWorkItems' - | 'prChecks' - | 'prCheckDetails' - | 'rerunPRChecks' - | 'prComments' - | 'setPRCommentReaction' - | 'resolveReviewThread' - | 'setPRFileViewed' - | 'updatePRTitle' - | 'mergePR' - | 'setPRAutoMerge' - | 'updatePRState' - | 'requestPRReviewers' - | 'removePRReviewers' - | 'updateIssue' - | 'addIssueComment' - | 'addPRReviewCommentReply' - | 'addPRReviewComment' - | 'listLabels' - | 'listAssignableUsers' - | 'rateLimit' - | 'listAccessibleProjects' - | 'resolveProjectRef' - | 'listProjectViews' - | 'getProjectViewTable' - | 'projectWorkItemDetailsBySlug' - | 'updateProjectItemField' - | 'clearProjectItemField' - | 'updateIssueBySlug' - | 'updatePullRequestBySlug' - | 'addIssueCommentBySlug' - | 'updateIssueCommentBySlug' - | 'deleteIssueCommentBySlug' - | 'listLabelsBySlug' - | 'listAssignableUsersBySlug' - | 'listIssueTypesBySlug' - | 'updateIssueTypeBySlug' -type WebGitHubRuntimeMethod = - | 'github.repoSlug' - | 'github.repoUpstream' - | 'github.prForBranch' - | 'github.issue' - | 'github.workItem' - | 'github.workItemByOwnerRepo' - | 'github.workItemDetails' - | 'github.prFileContents' - | 'github.listIssues' - | 'github.createIssue' - | 'github.countWorkItems' - | 'github.listWorkItems' - | 'github.prChecks' - | 'github.prCheckDetails' - | 'github.rerunPRChecks' - | 'github.prComments' - | 'github.setPRCommentReaction' - | 'github.resolveReviewThread' - | 'github.setPRFileViewed' - | 'github.updatePRTitle' - | 'github.mergePR' - | 'github.setPRAutoMerge' - | 'github.updatePRState' - | 'github.requestPRReviewers' - | 'github.removePRReviewers' - | 'github.updateIssue' - | 'github.addIssueComment' - | 'github.addPRReviewCommentReply' - | 'github.addPRReviewComment' - | 'github.listLabels' - | 'github.listAssignableUsers' - | 'github.rateLimit' - | 'github.project.listAccessible' - | 'github.project.resolveRef' - | 'github.project.listViews' - | 'github.project.viewTable' - | 'github.project.workItemDetailsBySlug' - | 'github.project.updateItemField' - | 'github.project.clearItemField' - | 'github.project.updateIssueBySlug' - | 'github.project.updatePullRequestBySlug' - | 'github.project.addIssueCommentBySlug' - | 'github.project.updateIssueCommentBySlug' - | 'github.project.deleteIssueCommentBySlug' - | 'github.project.listLabelsBySlug' - | 'github.project.listAssignableUsersBySlug' - | 'github.project.listIssueTypesBySlug' - | 'github.project.updateIssueTypeBySlug' -type WebGitLabApi = NonNullable -type WebGitLabResult = Awaited> -type WebGitLabRouteKey = - | 'diagnoseAuth' - | 'rateLimit' - | 'listMRs' - | 'listWorkItems' - | 'listIssues' - | 'createIssue' - | 'updateIssue' - | 'addIssueComment' - | 'listLabels' - | 'todos' - | 'workItemDetails' - | 'closeMR' - | 'reopenMR' - | 'mergeMR' - | 'updateMR' - | 'updateMRReviewers' - | 'addMRComment' - | 'addMRInlineComment' - | 'resolveMRDiscussion' - | 'jobTrace' - | 'retryJob' - | 'workItemByPath' -type WebGitLabRuntimeMethod = - | 'gitlab.diagnoseAuth' - | 'gitlab.rateLimit' - | 'gitlab.listMRs' - | 'gitlab.listWorkItems' - | 'gitlab.listIssues' - | 'gitlab.createIssue' - | 'gitlab.updateIssue' - | 'gitlab.addIssueComment' - | 'gitlab.listLabels' - | 'gitlab.todos' - | 'gitlab.workItemDetails' - | 'gitlab.updateMRState' - | 'gitlab.mergeMR' - | 'gitlab.updateMR' - | 'gitlab.updateMRReviewers' - | 'gitlab.addMRComment' - | 'gitlab.addMRInlineComment' - | 'gitlab.resolveMRDiscussion' - | 'gitlab.jobTrace' - | 'gitlab.retryJob' - | 'gitlab.workItemByPath' -type WebKeybindingDocument = { - version: 1 - keybindings: KeybindingOverrides - platforms: Partial> -} - -export const GITHUB_WEB_RPC_METHODS = { - repoSlug: 'github.repoSlug', - repoUpstream: 'github.repoUpstream', - prForBranch: 'github.prForBranch', - issue: 'github.issue', - workItem: 'github.workItem', - workItemByOwnerRepo: 'github.workItemByOwnerRepo', - workItemDetails: 'github.workItemDetails', - prFileContents: 'github.prFileContents', - listIssues: 'github.listIssues', - createIssue: 'github.createIssue', - countWorkItems: 'github.countWorkItems', - listWorkItems: 'github.listWorkItems', - prChecks: 'github.prChecks', - prCheckDetails: 'github.prCheckDetails', - rerunPRChecks: 'github.rerunPRChecks', - prComments: 'github.prComments', - setPRCommentReaction: 'github.setPRCommentReaction', - resolveReviewThread: 'github.resolveReviewThread', - setPRFileViewed: 'github.setPRFileViewed', - updatePRTitle: 'github.updatePRTitle', - mergePR: 'github.mergePR', - setPRAutoMerge: 'github.setPRAutoMerge', - updatePRState: 'github.updatePRState', - requestPRReviewers: 'github.requestPRReviewers', - removePRReviewers: 'github.removePRReviewers', - updateIssue: 'github.updateIssue', - addIssueComment: 'github.addIssueComment', - addPRReviewCommentReply: 'github.addPRReviewCommentReply', - addPRReviewComment: 'github.addPRReviewComment', - listLabels: 'github.listLabels', - listAssignableUsers: 'github.listAssignableUsers', - rateLimit: 'github.rateLimit', - listAccessibleProjects: 'github.project.listAccessible', - resolveProjectRef: 'github.project.resolveRef', - listProjectViews: 'github.project.listViews', - getProjectViewTable: 'github.project.viewTable', - projectWorkItemDetailsBySlug: 'github.project.workItemDetailsBySlug', - updateProjectItemField: 'github.project.updateItemField', - clearProjectItemField: 'github.project.clearItemField', - updateIssueBySlug: 'github.project.updateIssueBySlug', - updatePullRequestBySlug: 'github.project.updatePullRequestBySlug', - addIssueCommentBySlug: 'github.project.addIssueCommentBySlug', - updateIssueCommentBySlug: 'github.project.updateIssueCommentBySlug', - deleteIssueCommentBySlug: 'github.project.deleteIssueCommentBySlug', - listLabelsBySlug: 'github.project.listLabelsBySlug', - listAssignableUsersBySlug: 'github.project.listAssignableUsersBySlug', - listIssueTypesBySlug: 'github.project.listIssueTypesBySlug', - updateIssueTypeBySlug: 'github.project.updateIssueTypeBySlug' -} as const satisfies Record - -export const GITLAB_WEB_RPC_METHODS = { - diagnoseAuth: 'gitlab.diagnoseAuth', - rateLimit: 'gitlab.rateLimit', - listMRs: 'gitlab.listMRs', - listWorkItems: 'gitlab.listWorkItems', - listIssues: 'gitlab.listIssues', - createIssue: 'gitlab.createIssue', - updateIssue: 'gitlab.updateIssue', - addIssueComment: 'gitlab.addIssueComment', - listLabels: 'gitlab.listLabels', - todos: 'gitlab.todos', - workItemDetails: 'gitlab.workItemDetails', - closeMR: 'gitlab.updateMRState', - reopenMR: 'gitlab.updateMRState', - mergeMR: 'gitlab.mergeMR', - updateMR: 'gitlab.updateMR', - updateMRReviewers: 'gitlab.updateMRReviewers', - addMRComment: 'gitlab.addMRComment', - addMRInlineComment: 'gitlab.addMRInlineComment', - resolveMRDiscussion: 'gitlab.resolveMRDiscussion', - jobTrace: 'gitlab.jobTrace', - retryJob: 'gitlab.retryJob', - workItemByPath: 'gitlab.workItemByPath' -} as const satisfies Record - -const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] -const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>() + createComputerUsePermissionsApi, + createDeveloperPermissionsApi, + createPreflightApi, + createSkillsApi +} from './preload-api/web-host-capability-api' +import { createWebKeybindingsApi } from './preload-api/web-keybindings-api' +import { createMacosTccPromptsApi } from './preload-api/web-macos-tcc-api' +import { createEmptyMemorySnapshot } from './preload-api/web-memory-api' +import { createWebMobileApi } from './preload-api/web-mobile-api' +import { createWebNativeChatApi } from './preload-api/web-native-chat-api' +import { createNotificationsApi } from './preload-api/web-notifications-api' +import { createWebOnboardingApi } from './preload-api/web-onboarding-api' +import { createWebOrcaProfilesApi } from './preload-api/web-orca-profiles-api' +import { createWebPlatformApi } from './preload-api/web-platform-api' +import { createRateLimitsApi } from './preload-api/web-rate-limits-api' +import { createReposApi } from './preload-api/web-repositories-api' +import { createHooksApi, createRuntimeNamespaceApi } from './preload-api/web-review-api' +import { callRuntimeResult } from './preload-api/web-runtime-calls' +import { createWebRuntimeApi } from './preload-api/web-runtime-api' +import { createRuntimeEnvironmentsApi } from './preload-api/web-runtime-environments-api' +import { webRuntimeState } from './preload-api/web-runtime-session' +import { createWebSettingsApi } from './preload-api/web-settings-api' +import { createShellApi } from './preload-api/web-shell-api' +import { createWebStarNagApi } from './preload-api/web-star-nag-api' +import { createWebTelemetryApi } from './preload-api/web-telemetry-api' +import { createPtyApi, createSshApi } from './preload-api/web-terminal-api' +import { createWebUiApi } from './preload-api/web-ui-api' +import { createUpdaterApi } from './preload-api/web-updater-api' +import { createWebWorkspacePortsApi } from './preload-api/web-workspace-ports-api' +import { createWebWorkspaceSessionApi } from './preload-api/web-workspace-session-api' +import { createWorktreesApi } from './preload-api/web-worktrees-api' +import { readStoredWebRuntimeEnvironment } from './web-runtime-environment' export function installWebPreloadApi(): void { - activeEnvironment = readStoredWebRuntimeEnvironment() + webRuntimeState.activeEnvironment = readStoredWebRuntimeEnvironment() const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean } webWindow.__ORCA_WEB_CLIENT__ = true window.electron = createFallbackProxy(['electron']) as Window['electron'] window.api = withFallback(createWebPreloadApi(), []) as PreloadApi } -async function writeWebClipboardText(text: string): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text) - const clipboard = navigator.clipboard - if (typeof clipboard?.writeText === 'function') { - try { - await clipboard.writeText(text) - return - } catch (error) { - // Preserve the current user-activation turn for the synchronous fallback. - if (copyClipboardTextViaExecCommand(text)) { - return - } - throw error - } - } - if (!copyClipboardTextViaExecCommand(text)) { - throw new Error('Clipboard write is unavailable in this browser context') - } -} - function createWebPreloadApi(): Partial { - const webOrcaProfileAuthStatus = () => - Promise.resolve({ - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - configured: false, - state: 'unconfigured' as const, - persistence: 'none' as const, - setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.' - }) - return { - app: { - getIdentity: () => - Promise.resolve({ - name: 'Orca', - isDev: false, - devLabel: null, - devBranch: null, - devWorktreeName: null, - devRepoRoot: null, - dockBadgeLabel: null - }), - getFeatureWallAssetBaseUrl: () => Promise.resolve('/'), - relaunch: () => Promise.resolve(window.location.reload()), - restart: () => Promise.resolve(window.location.reload()), - reload: () => Promise.resolve(window.location.reload()), - stageBeforeUnloadSync: ({ sessions, ui }) => { - // Why: beforeunload cannot await the paired runtime, so the web adapter - // guarantees immediate browser-local durability for the final snapshot. - for (const { state, hostId } of sessions) { - writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(state)) - } - writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui)) - }, - // Staging already wrote through to browser storage, so there is nothing left to join. - awaitBeforeUnloadCheckpoint: () => Promise.resolve(), - awaitFirstWindowStartupServices: () => Promise.resolve(), - recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), - startupDiagnostic: () => Promise.resolve(), - getKeyboardInputSourceId: () => Promise.resolve(null), - // The web client cannot inspect local Mission Control shortcuts. - getMacCapturedDigitRowChords: () => Promise.resolve([]), - getKeyboardLayoutSnapshot: () => Promise.resolve(null), - onKeyboardLayoutChanged: () => () => undefined, - setUnreadDockBadgeCount: () => Promise.resolve(), - getFloatingTerminalCwd: () => Promise.resolve(''), - getFloatingMarkdownDirectory: () => Promise.resolve(''), - pickFloatingMarkdownDocument: () => Promise.resolve(null), - pickFloatingWorkspaceDirectory: () => Promise.resolve(null), - // Browser fallback has no app-owned userData dir; reject so the sentinel can't claim sensitive evidence was persisted. - writeTerminalRenderDesyncEvidence: () => - Promise.reject( - new Error('Terminal render evidence is unavailable in the browser fallback.') - ) - }, - starNag: { - onShow: () => noopUnsubscribe, - onHide: () => noopUnsubscribe, - dismiss: () => Promise.resolve(), - later: () => Promise.resolve(), - complete: () => Promise.resolve(), - disable: () => Promise.resolve(), - openWeb: () => Promise.resolve(), - starOrca: () => Promise.resolve(false), - forceShow: () => Promise.resolve(), - agentValueMoment: () => Promise.resolve({ status: 'skipped' }), - showAgentValueMoment: () => Promise.resolve(), - onboardingCompleted: () => Promise.resolve() - }, - platform: { - get: () => ({ - platform: getBrowserPlatform(), - osRelease: '', - arch: '', - shell: '', - displayServer: null - }) - }, - workspacePorts: { - // Why: browser-local workspaces have no host process to inspect; return capability state instead of the generic undefined fallback. - scan: () => - Promise.resolve({ - platform: getBrowserPlatform(), - scannedAt: Date.now(), - ports: [], - unavailableReason: 'Workspace port scanning is unavailable for browser-local workspaces.' - }), - kill: () => - Promise.resolve({ - ok: false, - reason: 'Workspace port management is unavailable for browser-local workspaces.' - }), - onAdvertisedUrlChanged: () => noopUnsubscribe - }, - orcaProfiles: { - list: () => - Promise.resolve({ - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - profiles: [createDefaultLocalOrcaProfile(0)], - multiProfileUi: false - }), - authStatus: webOrcaProfileAuthStatus, - createLocal: () => - Promise.resolve({ - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - profiles: [createDefaultLocalOrcaProfile(0)], - profile: createDefaultLocalOrcaProfile(0) - }), - createCloudLinked: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - switchProfile: () => Promise.resolve({ status: 'already-active' }), - transferProject: (args) => - Promise.resolve({ - status: 'duplicate-target', - sourceProfileId: args.sourceProfileId, - targetProfileId: args.targetProfileId, - sourceRepoId: args.repoId, - duplicateRepoId: args.repoId - }), - findProjectProfiles: async () => ({ projects: [] }), - connectCurrent: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - refreshAuth: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - signOutCurrent: async () => ({ - status: 'signed-out', - auth: await webOrcaProfileAuthStatus(), - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - profiles: [createDefaultLocalOrcaProfile(0)] - }), - selectOrg: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - orgMembersList: async () => ({ status: 'unconfigured' }), - orgMemberInvite: async () => ({ status: 'unconfigured' }), - orgInviteRevoke: async () => ({ status: 'unconfigured' }), - orgMemberChangeRole: async () => ({ status: 'unconfigured' }), - orgMemberRemove: async () => ({ status: 'unconfigured' }) - }, - e2e: { - getConfig: () => webE2EConfig - }, - settings: { - get: async () => getRuntimeBackedStoredSettings(), - // Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop. - getSync: () => settingsForActiveVisibilityOwner(getStoredSettings()), - set: async (updates) => { - const sanitizedUpdates = { ...updates } - const runtimeEnvironment = requireActiveEnvironmentOrNull() - delete sanitizedUpdates.activeRuntimeEnvironmentId - if ( - 'worktreeVisibilityDefaults' in sanitizedUpdates && - runtimeEnvironment && - runtimeEnvironment.id !== worktreeVisibilityDefaultsRuntimeEnvironmentId - ) { - delete sanitizedUpdates.worktreeVisibilityDefaults - } - if ('worktreeVisibilityDefaults' in sanitizedUpdates) { - sanitizedUpdates.worktreeVisibilityDefaults = { - ...settingsForActiveVisibilityOwner(getStoredSettings()).worktreeVisibilityDefaults, - ...sanitizedUpdates.worktreeVisibilityDefaults - } - } - if ('computerAwakeMode' in sanitizedUpdates) { - Object.assign( - sanitizedUpdates, - computerAwakeSettingsForMode( - normalizeComputerAwakeMode( - sanitizedUpdates.computerAwakeMode, - sanitizedUpdates.keepComputerAwakeWhileAgentsRun - ) - ) - ) - } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) { - Object.assign( - sanitizedUpdates, - computerAwakeSettingsForMode( - sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off' - ) - ) - } - if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { - sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true - } - if ('terminalCursorStyle' in sanitizedUpdates) { - Object.assign( - sanitizedUpdates, - normalizeTerminalCursorStyleDefault( - { terminalCursorStyle: sanitizedUpdates.terminalCursorStyle }, - { preserveExplicitValue: true } - ) - ) - } - const localUpdates = { ...sanitizedUpdates } - if (runtimeEnvironment) { - delete localUpdates.worktreeVisibilityDefaults - } - const next = mergeSettings(getStoredSettings(), localUpdates, { - preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates - }) - writeStoredSettings(next) - return settingsForActiveVisibilityOwner( - await syncRuntimeBackedSettings(sanitizedUpdates, next) - ) - }, - setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => { - const requestedEnvironmentId = environmentId?.trim() || null - const activeRuntimeEnvironmentId = requestedEnvironmentId - ? resolveEnvironment(requestedEnvironmentId).id - : null - const next = mergeSettings(getStoredSettings(), { - activeRuntimeEnvironmentId - }) - writeStoredSettings(next, activeRuntimeEnvironmentId) - return next - }, - updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args), - listFonts: () => Promise.resolve([]), - onChanged: () => noopUnsubscribe - } satisfies Partial as unknown as WebSettingsApi, - agentAwake: { - getStatus: async () => { - const settings = getStoredSettings() - return { - mode: normalizeComputerAwakeMode( - settings.computerAwakeMode, - settings.keepComputerAwakeWhileAgentsRun - ), - active: false - } - }, - onChanged: () => noopUnsubscribe - }, + ...createWebAppApi(), + ...createWebStarNagApi(), + ...createWebPlatformApi(), + ...createWebWorkspacePortsApi(), + ...createWebOrcaProfilesApi(), + ...createWebE2EApi(), + ...createWebSettingsApi(), keybindings: createWebKeybindingsApi(), ui: createWebUiApi(), - crashReports: { - getLatestPending: () => Promise.resolve(null), - getLatestReport: () => Promise.resolve(null), - dismiss: () => Promise.resolve(null), - recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }), - recordBreadcrumb: () => {}, - submit: () => - Promise.resolve({ - ok: false, - status: null, - error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') - }), - copyLatestDiagnostics: () => - Promise.resolve({ - ok: false, - error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') - }), - // Why: no Electron process on web; the caller falls back to performance.memory. - readHeapStatistics: () => null - }, - diagnostics: { - getStatus: () => - Promise.resolve({ - localFileEnabled: false, - bundleEnabled: false, - traceFilePath: '', - traceFamilySize: 0 - }), - collectBundle: () => Promise.reject(new Error('Review files are unavailable on web.')), - openBundlePreview: () => Promise.reject(new Error('Review files are unavailable on web.')), - discardBundlePreview: () => Promise.resolve(), - uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')), - deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.')) - }, - session: { - // Mirrors desktop bridge: non-local hosts persist under a host-suffixed key so their sessions stay isolated from local. - get: (hostId) => Promise.resolve(getStoredWorkspaceSession(hostId)), - set: async (session, hostId) => { - writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) - }, - patch: async (patch: WorkspaceSessionPatch, hostId) => { - writeJson( - sessionStorageKeyForHost(hostId), - sanitizeWebRuntimeWorkspaceSession({ - ...getStoredWorkspaceSession(hostId), - ...patch - }) - ) - }, - // localStorage writes synchronously, so there is no deferred web flush. - flush: async () => {}, - readTerminalScrollback: () => null, - setSync: (session, hostId) => { - writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) - } - }, - onboarding: { - get: () => Promise.resolve(getStoredOnboarding()), - update: async (updates) => { - const current = getStoredOnboarding() - const next: OnboardingState = { - ...current, - ...updates, - flowVersion: ONBOARDING_FLOW_VERSION, - checklist: { - ...current.checklist, - ...updates.checklist - } - } - writeJson(ONBOARDING_STORAGE_KEY, next) - return next - } - }, - cache: { - getGitHub: () => - Promise.resolve( - readJson(GITHUB_CACHE_STORAGE_KEY, { - pr: {}, - issue: {} - }) - ), - setGitHub: async ({ cache }) => { - writeJson(GITHUB_CACHE_STORAGE_KEY, cache) - } - }, - runtime: createRuntimeApi(), - nativeChat: createNativeChatApi(), + ...createWebDiagnosticsApi(), + ...createWebWorkspaceSessionApi(), + ...createWebOnboardingApi(), + ...createWebGithubCacheApi(), + runtime: createWebRuntimeApi(), + nativeChat: createWebNativeChatApi(), runtimeEnvironments: createRuntimeEnvironmentsApi(), repos: createReposApi(), worktrees: createWorktreesApi(), @@ -900,7 +102,7 @@ function createWebPreloadApi(): Partial { memory: { getSnapshot: () => Promise.resolve(createEmptyMemorySnapshot()) }, - aiVault: createAiVaultApi(), + aiVault: createWebAiVaultApi(), preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), @@ -911,8 +113,6 @@ function createWebPreloadApi(): Partial { cli: createCliApi(), agentHooks: createAgentHooksApi(), macosTccPrompts: createMacosTccPromptsApi(), - // Why: the desktop derives this from the host filesystem, which the web - // client has no view of; reporting synced keeps the warning banner silent. codexConfigSync: { status: () => Promise.resolve({ state: 'synced', reason: null, systemConfigPath: '' } as const) @@ -934,3664 +134,8 @@ function createWebPreloadApi(): Partial { gitBash: { isAvailable: () => callRuntimeResult('host.gitBash.isAvailable').catch(() => false) }, - agentStatus: { - onSet: () => noopUnsubscribe, - onClear: () => noopUnsubscribe, - getSnapshot: () => Promise.resolve([]), - inferInterrupt: () => Promise.resolve(false), - inferQuestionAnswered: () => Promise.resolve(false), - onMigrationUnsupported: () => noopUnsubscribe, - onMigrationUnsupportedClear: () => noopUnsubscribe, - onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, - getMigrationUnsupportedSnapshot: () => Promise.resolve([]), - drop: () => {}, - reconcileEndedProcess: () => {}, - dropByTabPrefix: () => {}, - retirePaneAuthority: () => {}, - restorePaneAuthority: () => {}, - transferPaneAuthority: () => {} - }, - mobile: { - listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), - getPairingQR: () => Promise.resolve({ available: false }), - getWindowsFirewallStatus: () => Promise.resolve({ supported: false }), - repairWindowsFirewall: () => Promise.resolve({ ok: false, reason: 'unsupported' }), - openWindowsNetworkSettings: () => Promise.resolve(false), - getRuntimePairingUrl: () => Promise.resolve({ available: false }), - listDevices: () => Promise.resolve({ devices: [] }), - revokeDevice: () => Promise.resolve({ revoked: false }), - listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), - revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), - isWebSocketReady: () => - Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }), - getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), - onRelayStatusChanged: () => noopUnsubscribe, - consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false), - onUnpairedDeviceAuthFailure: () => noopUnsubscribe - }, - telemetryTrack: () => Promise.resolve(), - telemetrySetOptIn: () => Promise.resolve(), - telemetryGetConsentState: () => - Promise.resolve({ optedIn: false, source: 'default', blockedByEnv: false } as never), - telemetryAcknowledgeBanner: () => Promise.resolve() + ...createWebAgentStatusApi(), + ...createWebMobileApi(), + ...createWebTelemetryApi() } } - -function createEmptyWebKeybindingDocument(): WebKeybindingDocument { - return { - version: 1, - keybindings: {}, - platforms: { - darwin: {}, - linux: {}, - win32: {} - } - } -} - -function getWebKeybindingPlatform(): KeybindingPlatform { - return getKeybindingPlatform(getBrowserPlatform()) -} - -function isJsonObject(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function normalizeStoredWebOverrides( - value: unknown, - section: string, - diagnostics: KeybindingFileDiagnostic[] -): KeybindingOverrides { - if (value === undefined) { - return {} - } - if (!isJsonObject(value)) { - diagnostics.push({ - severity: 'error', - section, - message: translate('auto.web.web.preload.api.d2e43e426a', '{{value0}} must be an object.', { - value0: section - }) - }) - return {} - } - - const overrides: KeybindingOverrides = {} - for (const [actionId, rawBindings] of Object.entries(value)) { - if (!isKeybindingActionId(actionId)) { - diagnostics.push({ - severity: 'warning', - section, - actionId, - message: translate( - 'auto.web.web.preload.api.36761d9604', - 'Unknown keybinding action "{{value0}}" was ignored.', - { value0: actionId } - ) - }) - continue - } - if ( - !Array.isArray(rawBindings) || - !rawBindings.every((binding) => typeof binding === 'string') - ) { - diagnostics.push({ - severity: 'error', - section, - actionId, - message: translate( - 'auto.web.web.preload.api.10898045f3', - 'Shortcut for "{{value0}}" was ignored: Use a string array.', - { value0: actionId } - ) - }) - continue - } - const normalized = normalizeKeybindingArrayForAction(actionId, rawBindings) - if (!Array.isArray(normalized)) { - const error = normalized.ok ? 'Unable to parse shortcut.' : normalized.error - diagnostics.push({ - severity: 'error', - section, - actionId, - message: translate( - 'auto.web.web.preload.api.76122208ca', - 'Shortcut for "{{value0}}" was ignored: {{value1}}', - { value0: actionId, value1: error } - ) - }) - continue - } - overrides[actionId] = normalized - } - return overrides -} - -function normalizeWebPlatformOverrides( - value: unknown, - diagnostics: KeybindingFileDiagnostic[] -): Partial> { - if (value === undefined) { - return {} - } - if (!isJsonObject(value)) { - diagnostics.push({ - severity: 'error', - section: 'platforms', - message: translate( - 'auto.web.web.preload.api.0a69fcd8bc', - 'platforms must be an object with darwin, linux, or win32 sections.' - ) - }) - return {} - } - - const result: Partial> = {} - for (const [platform, overrides] of Object.entries(value)) { - if (!WEB_KEYBINDING_PLATFORMS.includes(platform as KeybindingPlatform)) { - diagnostics.push({ - severity: 'warning', - section: `platforms.${platform}`, - message: translate( - 'auto.web.web.preload.api.32f15bdb0f', - 'Unknown platform "{{value0}}" was ignored.', - { value0: platform } - ) - }) - continue - } - result[platform as KeybindingPlatform] = normalizeStoredWebOverrides( - overrides, - `platforms.${platform}`, - diagnostics - ) - } - return result -} - -function removeConflictingWebOverrides( - platform: KeybindingPlatform, - overrides: KeybindingOverrides, - diagnostics: KeybindingFileDiagnostic[] -): KeybindingOverrides { - let next = { ...overrides } - for (let attempt = 0; attempt < 20; attempt++) { - const conflicts = findKeybindingConflicts(platform, next) - const conflictingOverrides = new Set() - for (const conflict of conflicts) { - for (const actionId of conflict.actionIds) { - if (Object.hasOwn(next, actionId)) { - conflictingOverrides.add(actionId) - } - } - } - if (conflictingOverrides.size === 0) { - return next - } - for (const actionId of conflictingOverrides) { - delete next[actionId] - } - diagnostics.push({ - severity: 'error', - message: translate( - 'auto.web.web.preload.api.52bee9d8a0', - 'Conflicting custom shortcuts were ignored: {{value0}}.', - { - value0: Array.from(conflictingOverrides) - .map((actionId) => actionId) - .join(', ') - } - ) - }) - } - return next -} - -function readWebKeybindingDocument(): WebKeybindingDocument { - const document = readJson(KEYBINDINGS_STORAGE_KEY, createEmptyWebKeybindingDocument()) - return { - version: 1, - keybindings: isJsonObject(document.keybindings) - ? (document.keybindings as KeybindingOverrides) - : {}, - platforms: isJsonObject(document.platforms) - ? (document.platforms as Partial>) - : {} - } -} - -function getWebKeybindingSnapshot(): KeybindingFileSnapshot { - const platform = getWebKeybindingPlatform() - const diagnostics: KeybindingFileDiagnostic[] = [] - const document = readWebKeybindingDocument() - const commonOverrides = normalizeStoredWebOverrides( - document.keybindings, - 'keybindings', - diagnostics - ) - const platformOverrides = normalizeWebPlatformOverrides(document.platforms, diagnostics) - const overrides = removeConflictingWebOverrides( - platform, - { - ...commonOverrides, - ...platformOverrides[platform] - }, - diagnostics - ) - - return { - path: 'Browser local storage', - platform, - exists: window.localStorage.getItem(KEYBINDINGS_STORAGE_KEY) !== null, - overrides, - commonOverrides, - platformOverrides, - diagnostics - } -} - -function writeWebKeybindingAction( - actionId: KeybindingActionId, - bindings: string[] | null -): KeybindingFileSnapshot { - if (!isKeybindingActionId(actionId)) { - throw new Error(`Unknown keybinding action "${String(actionId)}".`) - } - const normalizedBindings = - bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings) - if (normalizedBindings !== null && !Array.isArray(normalizedBindings)) { - throw new Error(normalizedBindings.ok ? 'Unable to parse shortcut.' : normalizedBindings.error) - } - - const platform = getWebKeybindingPlatform() - const currentSnapshot = getWebKeybindingSnapshot() - const candidateOverrides = { ...currentSnapshot.overrides } - if (normalizedBindings === null) { - delete candidateOverrides[actionId] - } else { - candidateOverrides[actionId] = normalizedBindings - } - const blockingConflict = findKeybindingConflicts(platform, candidateOverrides).find((conflict) => - conflict.actionIds.includes(actionId) - ) - if (blockingConflict) { - throw new Error( - `${formatKeybindingList([blockingConflict.binding], platform)} conflicts with another shortcut.` - ) - } - - const activePlatform: KeybindingOverrides = { ...currentSnapshot.platformOverrides[platform] } - if (normalizedBindings === null) { - delete activePlatform[actionId] - } else { - activePlatform[actionId] = normalizedBindings - } - - writeJson(KEYBINDINGS_STORAGE_KEY, { - version: 1, - keybindings: currentSnapshot.commonOverrides, - platforms: { - ...currentSnapshot.platformOverrides, - darwin: currentSnapshot.platformOverrides.darwin ?? {}, - linux: currentSnapshot.platformOverrides.linux ?? {}, - win32: currentSnapshot.platformOverrides.win32 ?? {}, - [platform]: activePlatform - } - } satisfies WebKeybindingDocument) - - const snapshot = getWebKeybindingSnapshot() - notifyWebKeybindingListeners(snapshot) - return snapshot -} - -function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void { - for (const listener of webKeybindingListeners) { - listener(snapshot) - } -} - -function createWebKeybindingsApi(): WebKeybindingsApi { - return { - get: () => Promise.resolve(getWebKeybindingSnapshot()), - ensureFile: () => Promise.resolve(getWebKeybindingSnapshot()), - setAction: async ({ actionId, bindings }) => writeWebKeybindingAction(actionId, bindings), - reload: () => { - const snapshot = getWebKeybindingSnapshot() - notifyWebKeybindingListeners(snapshot) - return Promise.resolve(snapshot) - }, - openFile: () => Promise.resolve(getWebKeybindingSnapshot()), - revealFile: () => Promise.resolve(getWebKeybindingSnapshot()), - onChanged: (callback) => { - webKeybindingListeners.add(callback) - const onStorage = (event: StorageEvent): void => { - if (event.key === KEYBINDINGS_STORAGE_KEY) { - callback(getWebKeybindingSnapshot()) - } - } - window.addEventListener('storage', onStorage) - return () => { - webKeybindingListeners.delete(callback) - window.removeEventListener('storage', onStorage) - } - } - } -} - -// Why: web has no IPC for native-chat transcripts, so route readSession/subscribe through runtime RPC (as mobile does). -function createNativeChatApi(): NativeChatApi { - return { - readSession: async (agent, sessionId, limit, transcriptPath) => - parseRuntimeNativeChatReadSessionResult( - await callRuntimeResult('nativeChat.readSession', { - agent, - sessionId, - limit, - transcriptPath - }) - ), - subscribe: (args, onFrame) => { - // No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed. - const environment = requireActiveEnvironmentOrNull() - if (!environment) { - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - error: translate( - 'components.native-chat.state.pairHost', - 'Pair a host to view agent chat history.' - ) - }) - return () => {} - } - let handle: { unsubscribe: () => void } | null = null - let cancelled = false - let receivedInitial = false - void getClientForEnvironment(environment) - .subscribe( - 'nativeChat.subscribe', - { - agent: args.agent, - sessionId: args.sessionId, - subscriptionId: args.subscriptionId, - transcriptPath: args.transcriptPath, - limit: args.limit - }, - { - onResponse: (response) => { - if (cancelled) { - return - } - if (!response.ok) { - if (!receivedInitial) { - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - error: response.error.message - }) - } - return - } - const result = response.result as { - type?: string - messages?: NativeChatAppendedMessages - hasMore?: boolean - error?: string - lifecycle?: unknown - } - const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle) - if ( - (result?.type === 'appended' || - result?.type === 'snapshot' || - result?.type === 'replacement') && - Array.isArray(result.messages) - ) { - if (!receivedInitial) { - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: result.messages, - hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), - ...(result.error ? { error: result.error } : {}), - ...(lifecycle ? { lifecycle } : {}) - }) - } else if (result.type === 'snapshot') { - onFrame({ - type: 'snapshot', - messages: result.messages, - hasMore: result.hasMore ?? false, - ...(result.error ? { error: result.error } : {}), - ...(lifecycle ? { lifecycle } : {}) - }) - } else { - onFrame( - result.type === 'replacement' - ? { - type: 'replacement', - messages: result.messages, - hasMore: result.hasMore ?? false, - ...(lifecycle ? { lifecycle } : {}) - } - : { - type: 'appended', - messages: result.messages, - ...(lifecycle ? { lifecycle } : {}) - } - ) - } - } else if (!receivedInitial) { - // Why: an unrecognized ok payload never flips receivedInitial, stranding the view on 'loading'; settle it empty instead. - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - ...(result?.error ? { error: result.error } : {}) - }) - } - } - }, - { - // Why: unsubscribe reaps the fs-watcher on view-toggle (leak fix); echo the pane token so two panes don't tear down each other's watcher. - buildUnsubscribe: () => - buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) - } - ) - .then((h) => { - if (cancelled) { - h.unsubscribe() - } else { - handle = h - } - }) - .catch((err: unknown) => { - if (!cancelled && !receivedInitial) { - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - error: err instanceof Error ? err.message : String(err) - }) - } - }) - return () => { - cancelled = true - handle?.unsubscribe() - } - } - } -} - -function createRuntimeApi(): NonNullable['runtime']> { - return { - syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(), - getStatus: () => getRemoteRuntimeStatus(), - call: ({ method, params }) => callRuntimeEnvelope(method, params), - getTerminalFitOverrides: () => Promise.resolve([]), - getTerminalDrivers: () => Promise.resolve([]), - getBrowserDrivers: () => Promise.resolve([]), - restoreTerminalFit: () => Promise.resolve({ restored: false }), - reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }), - onTerminalFitOverrideChanged: () => noopUnsubscribe, - onTerminalDriverChanged: () => noopUnsubscribe, - onNativeChatLaunchDraftResolved: () => noopUnsubscribe, - onBrowserDriverChanged: () => noopUnsubscribe - } -} - -function createRuntimeEnvironmentsApi(): NonNullable['runtimeEnvironments']> { - return { - list: async () => { - const environment = requireActiveEnvironmentOrNull() - return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] - }, - addFromPairingCode: async ({ name, pairingCode }) => { - const offer = parseWebPairingInput(pairingCode) - if (!offer) { - throw new Error('Invalid Orca pairing code.') - } - const previousEnvironment = activeEnvironment - closeActiveRuntimeClients() - activeEnvironment = createStoredWebRuntimeEnvironment({ name, offer, previousEnvironment }) - manuallyDisconnectedEnvironmentIds.clear() - saveStoredWebRuntimeEnvironment(activeEnvironment) - return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) } - }, - verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => { - const parsed = parseHostAccessLink(pairingCode) - if (!parsed.ok) { - return { - ok: false, - kind: 'access-link-invalid', - message: translateHostAccessLinkError(parsed.kind) - } - } - if (parsed.value.endpointKind === 'loopback' && !allowLoopback) { - return { - ok: false, - kind: 'host-unreachable', - message: translate( - 'auto.web.webPreloadApi.loopbackPairingBlocked', - 'This access link points back to this device.' - ) - } - } - let client: WebRuntimeClient | null = null - let runtimeStatus: RuntimeStatus - try { - client = new WebRuntimeClient(parsed.value.pairing) - const response = (await client.call('status.get', undefined, { - timeoutMs: 15_000 - })) as RuntimeRpcResponse - if (!response.ok) { - return { - ok: false, - kind: 'connection-interrupted', - message: response.error.message - } - } - const statusVerification = verifyRemotePairingRuntimeStatus(response.result) - if (!statusVerification.ok) { - return statusVerification - } - runtimeStatus = statusVerification.runtimeStatus - } catch (error) { - if (error instanceof Error && error.message.startsWith('Invalid public key')) { - return { - ok: false, - kind: 'access-link-invalid', - message: translate( - 'auto.web.webPreloadApi.remotePairingInvalidDetails', - 'This access link contains invalid connection details.' - ) - } - } - if ( - isWebRuntimeUnauthorizedError(error) || - (error instanceof Error && error.message.startsWith('Unauthorized.')) - ) { - return { - ok: false, - kind: 'access-link-invalid', - message: error.message - } - } - return { - ok: false, - kind: 'host-unreachable', - message: translate( - 'auto.web.webPreloadApi.remotePairingUnreachable', - 'Cannot reach Orca at {{endpoint}}.', - { endpoint: parsed.value.displayEndpoint } - ) - } - } finally { - client?.close() - } - const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true - const nextEnvironment = { - ...createStoredWebRuntimeEnvironment({ - name, - offer: parsed.value.pairing, - previousEnvironment: activeEnvironment, - ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) - }), - ...(runtimeStatus.pairedDeviceId ? { pairedDeviceId: runtimeStatus.pairedDeviceId } : {}) - } - // Why: a browser storage failure must leave the currently active host usable. - try { - saveStoredWebRuntimeEnvironment(nextEnvironment) - } catch { - return { - ok: false, - kind: 'environment-save-failed', - message: translate( - 'auto.web.webPreloadApi.remotePairingSaveFailed', - 'Orca verified the host but could not save it. Check browser storage and try again.' - ) - } - } - manuallyDisconnectedEnvironmentIds.clear() - closeActiveRuntimeClients() - activeEnvironment = nextEnvironment - return { - ok: true, - environment: redactStoredWebRuntimeEnvironment(nextEnvironment), - runtimeStatus - } - }, - resolve: async ({ selector }) => - redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), - remove: async ({ selector }) => { - const environment = resolveEnvironment(selector) - if (activeEnvironment?.id === environment.id) { - removeActiveRuntimeEnvironment() - } - manuallyDisconnectedEnvironmentIds.delete(environment.id) - return { removed: redactStoredWebRuntimeEnvironment(environment) } - }, - disconnect: async ({ selector }) => { - const environment = resolveEnvironment(selector) - if (activeEnvironment?.id === environment.id) { - manuallyDisconnectedEnvironmentIds.add(environment.id) - disconnectActiveRuntimeEnvironment() - } - return { disconnected: redactStoredWebRuntimeEnvironment(environment) } - }, - connect: ({ selector, timeoutMs }) => { - const environment = resolveEnvironment(selector) - manuallyDisconnectedEnvironmentIds.delete(environment.id) - return callEnvironmentEnvelope( - environment.id, - 'status.get', - undefined, - timeoutMs - ) - }, - getStatus: ({ selector, timeoutMs }) => - callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), - call: ({ selector, method, params, timeoutMs }) => - callEnvironmentEnvelope(selector, method, params, timeoutMs), - subscribe: async ({ selector, method, params, timeoutMs }, callbacks) => { - const environment = resolveEnvironment(selector) - const client = getClientForEnvironment(environment) - const subscription = await client.subscribe(method, params, callbacks, { timeoutMs }) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - subscription.unsubscribe() - throw new Error('runtime_manually_disconnected') - } - return subscription - } - } -} - -function createAiVaultApi(): NonNullable['aiVault']> { - return { - listSessions: (args?: AiVaultListArgs) => { - const environment = requireActiveEnvironment() - const executionHostId = toRuntimeExecutionHostId(environment.id) - const requestedScope = normalizeExecutionHostScope( - args?.executionHostScope ?? executionHostId - ) - if (requestedScope !== 'all' && requestedScope !== executionHostId) { - return Promise.resolve(webAiVaultUnavailableResult(requestedScope)) - } - // Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host. - return callRuntimeResult('aiVault.listSessions', { - limit: args?.limit, - force: args?.force, - scopePaths: args?.scopePaths, - executionHostId - }) - }, - resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => { - const environment = requireActiveEnvironment() - const executionHostId = toRuntimeExecutionHostId(environment.id) - if ( - args.executionHostScope && - normalizeExecutionHostScope(args.executionHostScope) !== executionHostId - ) { - return Promise.resolve({ titles: [] }) - } - return callRuntimeResult('aiVault.resolveSessionTitles', { - requests: args.requests - }).catch(() => ({ titles: [] })) - }, - // Why: the runtime RPC transport has no cancel verb, so the in-flight scan - // settles on its own timeout. The renderer's refreshId guard already drops - // the late result; this only means web pays for a scan nobody reads. - cancelListSessions: () => Promise.resolve(), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => - callRuntimeResult('aiVault.prepareSessionResume', args), - // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. - listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), - // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. - getFirstUserPrompt: () => Promise.resolve({ prompt: null }), - // Why: session deletion is local-only and has no runtime RPC; a web - // client's sessions are runtime-hosted, so report the same non-local - // rejection the UI already gates on rather than pretend to delete. - deleteSession: (args: AiVaultDeleteSessionArgs) => - Promise.resolve({ - outcome: 'rejected', - agent: args.agent, - reason: 'non-local-host' as const - }), - onWindowFocused: () => noopUnsubscribe - } -} - -function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult { - return { - sessions: [], - issues: [ - { - executionHostId, - agent: 'codex', - path: executionHostId, - message: translate( - 'auto.web.webPreloadApi.aiVaultUnavailableForHost', - 'Agent Session History is not available for this execution host.' - ) - } - ], - scannedAt: new Date().toISOString() - } -} - -function createReposApi(): NonNullable['repos']> { - return { - list: async () => { - const owned = await callRuntimeResultWithOwner<{ repos: Repo[] }>('repo.list') - return owned.result.repos.map((repo) => withRuntimeRepoOwner(repo, owned.hostId)) - }, - add: async ({ path, kind }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( - 'repo.add', - { path, kind } - ) - return withRuntimeRepoMutationOwner(owned.result, owned.hostId) - }, - remove: async ({ repoId }) => { - await callRuntimeResult('repo.rm', { repo: repoId }) - invalidateRuntimeWorktreeCaches() - }, - // Why: host-scoped forget targets a desktop-owned SSH host; a paired web client has one runtime and no ghost-host state. - removeForHost: () => { - throw new Error('Forgetting a host is unavailable in paired web clients.') - }, - reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }), - // Why: this persists desktop-owned local/SSH rows; paired web clients own one runtime and use repo.reorder directly. - reorderForHost: async () => { - throw new Error('Host-scoped project reordering is unavailable in paired web clients.') - }, - update: async ({ repoId, updates }) => { - const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.update', { - repo: repoId, - updates - }) - return withRuntimeRepoOwner(owned.result.repo, owned.hostId) - }, - pickFolder: () => Promise.resolve(null), - pickFolders: () => Promise.resolve([]), - pickDirectory: () => Promise.resolve(null), - clone: async ({ url, destination }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo }>( - 'repo.clone', - { url, destination }, - 10 * 60_000 - ) - return withRuntimeRepoOwner(owned.result.repo, owned.hostId) - }, - cloneRemote: async () => { - // Why: SSH relay cloning is owned by the desktop main process; paired web clients can't run that local IPC path. - throw new Error('SSH clone is unavailable in paired web clients.') - }, - createRemote: async () => { - // Why: SSH relay project creation is owned by the desktop main process; paired web clients can't use local SSH IPC. - throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.') - }, - cloneAbort: () => Promise.resolve(), - addRemote: async ({ remotePath, displayName, kind }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.add', { - path: remotePath, - kind - }) - const result = { - repo: withRuntimeRepoOwner(owned.result.repo, owned.hostId) - } - if (!displayName) { - return result - } - assertActiveEnvironment(owned.environmentId) - return { - repo: await createReposApi().update({ - repoId: result.repo.id, - updates: { displayName } - }) - } - }, - create: async ({ parentPath, name, kind }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( - 'repo.create', - { parentPath, name, kind } - ) - return withRuntimeRepoMutationOwner(owned.result, owned.hostId) - }, - isGitAvailable: async () => - (await callRuntimeResult<{ available: boolean }>('repo.gitAvailable')).available, - getDefaultCreateProjectParent: async () => { - const result = await callRuntimeResult<{ resolvedPath: string }>('files.browseServerDir', { - path: '~' - }) - return getDefaultCreateProjectParent(result.resolvedPath) - }, - onCloneProgress: () => noopUnsubscribe, - getGitUsername: () => Promise.resolve(''), - getBaseRefDefault: async ({ repoId }) => - callRuntimeResult('repo.baseRefDefault', { repo: repoId }), - searchBaseRefs: async ({ repoId, query, limit }) => - ( - await callRuntimeResult<{ refs: string[] }>('repo.searchRefs', { - repo: repoId, - query, - limit - }) - ).refs, - searchBaseRefDetails: async ({ repoId, query, limit }) => { - const result = await callRuntimeResult<{ - refs: string[] - refDetails?: { refName: string; localBranchName: string }[] - }>('repo.searchRefs', { - repo: repoId, - query, - limit - }) - return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult) - }, - onChanged: () => noopUnsubscribe - } -} - -function createWorktreesApi(): NonNullable['worktrees']> { - return { - list: async ({ repoId }) => { - const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { - repo: repoId, - limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT - }) - return owned.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, owned.hostId) - ) - }, - // Why the catch: a host predating this method answers `method_not_found`, and an empty list - // degrades the suggestion to the pre-existing behavior rather than blocking workspace create. - listRetiredNames: async ({ repoId }) => { - try { - return readRetiredNameRegistryForRepo( - await callRuntimeResult('worktree.listRetiredNames', { repo: repoId }), - repoId - ) - } catch { - return EMPTY_RETIRED_NAME_REGISTRY - } - }, - listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId), - listAll: () => listAllRuntimeWorktrees(), - create: async (args) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.create', { - repo: args.repoId, - name: args.name, - // Absent means user-typed, which is what the host must assume — so send it only when true. - ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), - baseBranch: args.baseBranch, - compareBaseRef: args.compareBaseRef, - branchNameOverride: args.branchNameOverride, - linkedIssue: args.linkedIssue, - linkedPR: args.linkedPR, - linkedLinearIssue: args.linkedLinearIssue, - linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId, - linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey, - linkedGitLabIssue: args.linkedGitLabIssue, - linkedGitLabMR: args.linkedGitLabMR, - linkedBitbucketPR: args.linkedBitbucketPR, - linkedAzureDevOpsPR: args.linkedAzureDevOpsPR, - linkedGiteaPR: args.linkedGiteaPR, - displayName: args.displayName, - sparseCheckout: args.sparseCheckout, - pushTarget: args.pushTarget, - setupDecision: args.setupDecision, - createdWithAgent: args.createdWithAgent, - pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, - ...(args.startup - ? { - startupCommand: args.startup.command, - ...(args.startup.env ? { startupEnv: args.startup.env } : {}), - ...(args.startup.launchConfig - ? { startupLaunchConfig: args.startup.launchConfig } - : {}), - ...(args.startup.startupCommandDelivery - ? { startupCommandDelivery: args.startup.startupCommandDelivery } - : {}), - activate: true - } - : {}), - parentWorkspace: args.parentWorkspace, - workspaceStatus: args.workspaceStatus, - manualOrder: args.manualOrder, - automationProvenanceRequest: args.automationProvenanceRequest - }) - return { - ...owned.result, - worktree: withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) - } - }, - // Why: adoption verifies a desktop-owned hidden SSH target and is intentionally not a remote-server operation. - adoptProvisionedRoot: () => - Promise.reject( - new Error('Provisioned-root recipes require a direct SSH connection from the desktop app.') - ), - // Why: the runtime create path emits no two-phase progress, so the panel falls back to an indeterminate spinner. - onCreateProgress: () => noopUnsubscribe, - prefetchCreateBase: async ({ repoId, baseBranch }) => { - await callRuntimeResult('worktree.prefetchCreateBase', { - repo: repoId, - baseBranch - }) - }, - resolvePrBase: async ({ repoId, prNumber, headRefName, baseRefName, isCrossRepository }) => - callRuntimeResult('worktree.resolvePrBase', { - repo: repoId, - prNumber, - headRefName, - baseRefName, - isCrossRepository - }), - resolveMrBase: async ({ repoId, mrIid, sourceBranch, targetBranch, isCrossRepository }) => - callRuntimeResult('worktree.resolveMrBase', { - repo: repoId, - mrIid, - sourceBranch, - targetBranch, - isCrossRepository - }), - remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => { - invalidateRuntimeWorktreeCaches() - return callRuntimeResult('worktree.rm', { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...(hostId ? { hostId } : {}), - force, - // Why (#11960): the web client renders the same Force Delete affordances, so - // dropping this field here would leave paired clients permanently wedged. - allowUnverifiedPtyStop, - runHooks: skipArchive !== true - }) - }, - // Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state. - forgetLocal: () => { - throw new Error('Forgetting a workspace is unavailable in paired web clients.') - }, - forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead, hostId }) => - callRuntimeResult('worktree.forceDeleteBranch', { - worktree: toRuntimeWorktreeSelector(worktreeId), - branchName, - expectedHead, - ...(hostId ? { hostId } : {}) - }), - updateMeta: async ({ worktreeId, updates }) => { - const rpcUpdates = - Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined - ? { ...updates, pushTarget: null } - : updates - const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.set', { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...rpcUpdates - }) - return withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) - }, - listLineage: async () => - await callRuntimeResult<{ - lineage: Record - workspaceLineage?: Record - }>('worktree.lineageList'), - updateLineage: async ({ worktreeId, parentWorktreeId, noParent }) => { - invalidateRuntimeWorktreeCaches() - const result = await callRuntimeResult<{ - worktree: Worktree & { lineage?: WorktreeLineage | null } - }>('worktree.set', { - worktree: toRuntimeWorktreeSelector(worktreeId), - parentWorktree: parentWorktreeId, - noParent - }) - return result.worktree.lineage ?? null - }, - persistSortOrder: async ({ orderedIds }) => { - await callRuntimeResult('worktree.persistSortOrder', { orderedIds }) - }, - // Why: the capture lives in desktop main memory, unexposed over pairing; the dialog falls back to the persisted excerpt. - getBranchRenameFailureOutput: async () => null, - onChanged: () => noopUnsubscribe, - onGitStatusMetadataChanged: () => noopUnsubscribe, - onHeadIdentitiesChanged: () => noopUnsubscribe, - onBaseStatus: () => noopUnsubscribe, - onRemoteBranchConflict: () => noopUnsubscribe - } -} - -function createFileApi(): NonNullable['fs']> { - return { - readDir: async ({ dirPath }) => { - const file = await resolveRuntimeFilePath(dirPath) - return callRuntimeResult('files.readDir', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - }, - readFile: async ({ filePath }) => { - const file = await resolveRuntimeFilePath(filePath) - return callRuntimeResult('files.readPreview', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - }, - readLocalLogTail: async () => { - throw new Error('Local log tailing is unavailable in paired web clients.') - }, - startLocalLogTail: async () => { - throw new Error('Local log tailing is unavailable in paired web clients.') - }, - stopLocalLogTail: async () => {}, - onLocalLogTailChanged: () => noopUnsubscribe, - downloadFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - downloadFolder: async () => { - throw new Error('Remote folder download is unavailable in paired web clients.') - }, - saveDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - startDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - appendDownloadedFileChunk: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - finishDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - cancelDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - listMarkdownDocuments: async ({ rootPath }) => { - const file = await resolveRuntimeFilePath(rootPath) - return callRuntimeResult('files.listMarkdownDocuments', { - worktree: toRuntimeWorktreeSelector(file.worktree.id) - }) - }, - ...createWebFileMutationMethods({ - captureSession: captureWebFileMutationSession - }), - authorizeExternalPath: () => Promise.resolve(), - stat: async ({ filePath }) => { - const file = await resolveRuntimeFilePath(filePath) - return callRuntimeResult('files.stat', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - }, - pathExists: async ({ filePath }) => { - try { - const file = await resolveRuntimeFilePath(filePath) - await callRuntimeResult('files.stat', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - return true - } catch (error) { - if (isMissingPathError(error)) { - return false - } - throw error - } - }, - listFiles: async ({ rootPath, excludePaths }) => { - const file = await resolveRuntimeFilePath(rootPath) - const result = await callRuntimeResult<{ files: { relativePath: string }[] }>( - 'files.listAll', - { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - excludePaths - } - ) - return result.files.map((entry) => entry.relativePath) - }, - cancelListFiles: async () => { - // Why: paired-web lists files over runtime RPC with its own timeout; there's no host-side scan to abort here. - }, - search: async (args) => { - const file = await resolveRuntimeFilePath(args.rootPath) - return callRuntimeResult('files.search', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - query: args.query, - caseSensitive: args.caseSensitive, - wholeWord: args.wholeWord, - useRegex: args.useRegex, - includePattern: args.includePattern, - excludePattern: args.excludePattern, - maxResults: args.maxResults - }) - }, - importExternalPaths: async () => ({ results: [] }), - stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), - resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), - watchWorktree: () => Promise.resolve(), - unwatchWorktree: () => Promise.resolve(), - onFsChanged: () => noopUnsubscribe - } -} - -// Why: track the in-flight abortable status request per token so cancelStatus can abort it and close its remote context. -const webGitStatusAbortControllers = new Map() - -async function callAbortableRuntimeStatus( - requestToken: string, - params: unknown -): Promise { - const environment = requireActiveEnvironment() - webGitStatusAbortControllers.get(requestToken)?.abort() - const controller = new AbortController() - webGitStatusAbortControllers.set(requestToken, controller) - try { - const response = await callAbortableRuntimeEnvironment( - environment.id, - 'git.status', - params, - undefined, - controller.signal - ) - updateEnvironmentFromResponse(environment, response) - if (!response.ok) { - throw new Error(response.error.message) - } - return response.result as TResult - } finally { - if (webGitStatusAbortControllers.get(requestToken) === controller) { - webGitStatusAbortControllers.delete(requestToken) - } - } -} - -function createGitApi(): NonNullable['git']> { - return { - status: async ({ - worktreePath, - includeIgnored, - bypassEffectiveUpstreamNegativeCache, - reuseLineStats, - branchLineTotalMergeBase, - requestToken - }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - const params = { - worktree: toRuntimeWorktreeSelector(worktree.id), - includeIgnored, - bypassEffectiveUpstreamNegativeCache, - reuseLineStats, - ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) - } - // Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort. - if (!requestToken) { - return callRuntimeResult('git.status', params) - } - return callAbortableRuntimeStatus(requestToken, params) - }, - cancelStatus: async ({ requestToken }) => { - webGitStatusAbortControllers.get(requestToken)?.abort() - }, - setStatusUpstreamRefWatch: async () => {}, - submoduleStatus: async ({ worktreePath, submodulePath, area }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.submoduleStatus', { - worktree: toRuntimeWorktreeSelector(worktree.id), - submodulePath, - area - }) - }, - checkIgnored: async ({ worktreePath, paths }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.checkIgnored', { - worktree: toRuntimeWorktreeSelector(worktree.id), - paths - }) - }, - // Why: the "add huge folder to .gitignore" flow is desktop-only; the web runtime makes no offer, so return no candidates. - findHugeFoldersToIgnore: async () => [], - appendGitignore: async () => false, - history: async ({ worktreePath, limit, baseRef }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.history', { - worktree: toRuntimeWorktreeSelector(worktree.id), - limit, - baseRef - }) - }, - conflictOperation: async ({ worktreePath }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.conflictOperation', { - worktree: toRuntimeWorktreeSelector(worktree.id) - }) - }, - abortMerge: async ({ worktreePath }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.abortMerge', { - worktree: toRuntimeWorktreeSelector(worktree.id) - }) - }, - abortRebase: async ({ worktreePath }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.abortRebase', { - worktree: toRuntimeWorktreeSelector(worktree.id) - }) - }, - diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - return callRuntimeResult('git.diff', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath, - staged, - compareAgainstHead - }) - }, - branchCompare: async ({ worktreePath, baseRef }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.branchCompare', { - worktree: toRuntimeWorktreeSelector(worktree.id), - baseRef - }) - }, - commitCompare: async ({ worktreePath, commitId }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.commitCompare', { - worktree: toRuntimeWorktreeSelector(worktree.id), - commitId - }) - }, - upstreamStatus: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.upstreamStatus', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - fetch: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.fetch', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - syncFork: async ({ worktreePath, expectedUpstream }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult( - 'git.forkSync', - { - worktree: toRuntimeWorktreeSelector(worktree.id), - expectedUpstream - }, - 60_000 - ) - }, - push: async ({ worktreePath, publish, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.push', { - worktree: toRuntimeWorktreeSelector(worktree.id), - publish, - pushTarget - }) - }, - pull: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.pull', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - fastForward: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.fastForward', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - rebaseFromBase: async ({ worktreePath, baseRef }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.rebaseFromBase', { - worktree: toRuntimeWorktreeSelector(worktree.id), - baseRef - }) - }, - branchDiff: async ({ worktreePath, filePath, compare, oldPath }) => { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - return callRuntimeResult('git.branchDiff', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath, - compare, - oldPath - }) - }, - commitDiff: async ({ worktreePath, filePath, commitOid, parentOid, oldPath }) => { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - return callRuntimeResult('git.commitDiff', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath, - commitOid, - parentOid, - oldPath - }) - }, - commit: async ({ worktreePath, message }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.commit', { - worktree: toRuntimeWorktreeSelector(worktree.id), - message - }) - }, - generateCommitMessage: async () => ({ - success: false, - error: translate( - 'auto.web.web.preload.api.9fc90740b6', - 'Commit message generation is unavailable in the web client.' - ) - }), - discoverCommitMessageModels: async () => ({ - success: false, - error: translate( - 'auto.web.web.preload.api.e57c82d276', - 'Commit message model discovery is unavailable in the web client.' - ) - }), - cancelGenerateCommitMessage: () => Promise.resolve(), - generatePullRequestFields: async () => ({ - success: false, - error: translate( - 'auto.web.web.preload.api.b8a1618172', - 'Pull request detail generation is unavailable in the web client.' - ) - }), - cancelGeneratePullRequestFields: () => Promise.resolve(), - stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath), - bulkStage: async ({ worktreePath, filePaths }) => - mutateGitPaths('git.bulkStage', worktreePath, filePaths), - unstage: async ({ worktreePath, filePath }) => - mutateGitPath('git.unstage', worktreePath, filePath), - bulkUnstage: async ({ worktreePath, filePaths }) => - mutateGitPaths('git.bulkUnstage', worktreePath, filePaths), - discard: async ({ worktreePath, filePath }) => - mutateGitPath('git.discard', worktreePath, filePath), - bulkDiscard: async ({ worktreePath, filePaths }) => { - for (const filePath of filePaths) { - await mutateGitPath('git.discard', worktreePath, filePath) - } - }, - remoteFileUrl: async ({ worktreePath, relativePath, line }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.remoteFileUrl', { - worktree: toRuntimeWorktreeSelector(worktree.id), - relativePath, - line - }) - }, - remoteCommitUrl: async ({ worktreePath, sha }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.remoteCommitUrl', { - worktree: toRuntimeWorktreeSelector(worktree.id), - sha - }) - } - } -} - -function createBrowserApi(): NonNullable['browser']> { - return { - registerGuest: () => Promise.resolve(false), - isGuestRegistered: () => Promise.resolve(false), - repairGuestRegistration: () => Promise.resolve(false), - unregisterGuest: () => Promise.resolve(), - openDevTools: () => Promise.resolve(false), - setViewportOverride: () => Promise.resolve(false), - setAnnotationViewportBridge: () => Promise.resolve(false), - onGuestLoadFailed: () => noopUnsubscribe, - onCertificateFailureChanged: () => noopUnsubscribe, - proceedCertificate: () => Promise.resolve({ ok: false, reason: 'missing' }), - onPermissionDenied: () => noopUnsubscribe, - onPopup: () => noopUnsubscribe, - onDownloadRequested: () => noopUnsubscribe, - onDownloadProgress: () => noopUnsubscribe, - onDownloadFinished: () => noopUnsubscribe, - onContextMenuRequested: () => noopUnsubscribe, - onContextMenuDismissed: () => noopUnsubscribe, - onNavigationUpdate: () => noopUnsubscribe, - onActivateView: () => noopUnsubscribe, - onPaneFocus: () => noopUnsubscribe, - onOpenLinkInOrcaTab: () => noopUnsubscribe, - cancelDownload: () => Promise.resolve(false), - setGrabMode: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.31bea294d5', - 'Grab mode is unavailable in the web client.' - ) - }), - awaitGrabSelection: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.31bea294d5', - 'Grab mode is unavailable in the web client.' - ) - }), - cancelGrab: () => Promise.resolve(false), - captureSelectionScreenshot: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.8dfcb7a351', - 'Selection screenshots are unavailable in the web client.' - ) - }), - extractHoverPayload: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.275a776357', - 'Hover extraction is unavailable in the web client.' - ) - }), - onGrabModeToggle: () => noopUnsubscribe, - onGrabActionShortcut: () => noopUnsubscribe, - sessionListProfiles: () => Promise.resolve([]), - sessionCreateProfile: () => Promise.resolve(null), - sessionDeleteProfile: () => Promise.resolve(false), - sessionImportCookies: () => - Promise.resolve({ - ok: false, - summary: null, - error: translate( - 'auto.web.web.preload.api.67ec964791', - 'Cookie import is unavailable in the web client.' - ) - }), - sessionResolvePartition: () => Promise.resolve(null), - sessionDetectBrowsers: () => Promise.resolve([]), - sessionImportFromBrowser: () => - Promise.resolve({ - ok: false, - summary: null, - error: translate( - 'auto.web.web.preload.api.67ec964791', - 'Cookie import is unavailable in the web client.' - ) - }), - sessionClearDefaultCookies: () => Promise.resolve(false), - notifyActiveTabChanged: () => Promise.resolve(false) - } as unknown as NonNullable['browser']> -} - -function createEmulatorApi(): NonNullable['emulator']> { - return { - onPaneFocus: () => noopUnsubscribe, - onAutoAttach: () => noopUnsubscribe, - startFrameStream: () => Promise.reject(new Error('Mobile emulator is unavailable on web.')), - stopFrameStream: () => Promise.resolve(), - onFrameStreamFrame: () => noopUnsubscribe, - onFrameStreamError: () => noopUnsubscribe - } as unknown as NonNullable['emulator']> -} - -function createGitHubApi(): WebGitHubApi { - const route = (method: WebGitHubRuntimeMethod, args?: unknown): Promise => - callRuntimeResult(method, mapRepoPathArg(args)) - const githubApi = { - viewer: () => Promise.resolve(null), - repoSlug: (args) => route>(GITHUB_WEB_RPC_METHODS.repoSlug, args), - repoUpstream: (args) => - route>(GITHUB_WEB_RPC_METHODS.repoUpstream, args), - prForBranch: (args) => - route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), - refreshPRNow: async ({ candidate }) => { - const acceptMergedFallbackPR = - candidate.linkedPRNumber == null && - candidate.fallbackPRNumber != null && - candidate.fallbackPRSource != null - const pr = await route>(GITHUB_WEB_RPC_METHODS.prForBranch, { - repoPath: candidate.repoPath, - repoId: candidate.repoId, - branch: candidate.branch, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - currentHeadOid: candidate.currentHeadOid ?? null, - ...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}) - }) - return pr - ? { kind: 'found', pr, fetchedAt: Date.now() } - : { kind: 'no-pr', fetchedAt: Date.now() } - }, - enqueuePRRefresh: () => Promise.resolve(false), - reportVisiblePRRefreshCandidates: () => Promise.resolve(false), - onPRRefreshEvent: () => noopUnsubscribe, - issue: (args) => route>(GITHUB_WEB_RPC_METHODS.issue, args), - workItem: (args) => route>(GITHUB_WEB_RPC_METHODS.workItem, args), - workItemByOwnerRepo: ({ repo: ownerRepo, ...args }) => - route>(GITHUB_WEB_RPC_METHODS.workItemByOwnerRepo, { - ...args, - ownerRepo - }), - workItemDetails: (args) => - route>(GITHUB_WEB_RPC_METHODS.workItemDetails, args), - notifyWorkItemMutated: () => Promise.resolve(false), - prFileContents: (args) => - route>(GITHUB_WEB_RPC_METHODS.prFileContents, args), - listIssues: (args) => - route>(GITHUB_WEB_RPC_METHODS.listIssues, args), - createIssue: (args) => - route>(GITHUB_WEB_RPC_METHODS.createIssue, args), - countWorkItems: (args) => - route>(GITHUB_WEB_RPC_METHODS.countWorkItems, args), - listWorkItems: (args) => - route>(GITHUB_WEB_RPC_METHODS.listWorkItems, args), - prChecks: (args) => route>(GITHUB_WEB_RPC_METHODS.prChecks, args), - prCheckDetails: (args) => - route>(GITHUB_WEB_RPC_METHODS.prCheckDetails, args), - rerunPRChecks: (args) => - route>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), - prComments: (args) => - route>(GITHUB_WEB_RPC_METHODS.prComments, args), - setPRCommentReaction: (args) => - route>( - GITHUB_WEB_RPC_METHODS.setPRCommentReaction, - args - ), - resolveReviewThread: (args) => - route>( - GITHUB_WEB_RPC_METHODS.resolveReviewThread, - args - ), - setPRFileViewed: (args) => - route>(GITHUB_WEB_RPC_METHODS.setPRFileViewed, args), - updatePRTitle: (args) => - route>(GITHUB_WEB_RPC_METHODS.updatePRTitle, args), - mergePR: (args) => route>(GITHUB_WEB_RPC_METHODS.mergePR, args), - setPRAutoMerge: (args) => - route>(GITHUB_WEB_RPC_METHODS.setPRAutoMerge, args), - updatePRState: (args) => - route>(GITHUB_WEB_RPC_METHODS.updatePRState, args), - requestPRReviewers: (args) => - route>(GITHUB_WEB_RPC_METHODS.requestPRReviewers, args), - removePRReviewers: (args) => - route>(GITHUB_WEB_RPC_METHODS.removePRReviewers, args), - updateIssue: (args) => - route>(GITHUB_WEB_RPC_METHODS.updateIssue, args), - addIssueComment: (args) => - route>(GITHUB_WEB_RPC_METHODS.addIssueComment, args), - addPRReviewCommentReply: (args) => - route>( - GITHUB_WEB_RPC_METHODS.addPRReviewCommentReply, - args - ), - addPRReviewComment: (args) => - route>(GITHUB_WEB_RPC_METHODS.addPRReviewComment, args), - listLabels: (args) => - route>(GITHUB_WEB_RPC_METHODS.listLabels, args), - listAssignableUsers: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listAssignableUsers, - args - ), - onWorkItemMutated: () => noopUnsubscribe, - checkOrcaStarred: () => Promise.resolve(null), - starOrca: () => Promise.resolve(false), - rateLimit: (args) => - route>(GITHUB_WEB_RPC_METHODS.rateLimit, args), - diagnoseAuth: () => - Promise.resolve({ - ok: false, - message: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') - } as never), - listAccessibleProjects: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listAccessibleProjects, - args - ), - resolveProjectRef: (args) => - route>(GITHUB_WEB_RPC_METHODS.resolveProjectRef, args), - listProjectViews: (args) => - route>(GITHUB_WEB_RPC_METHODS.listProjectViews, args), - getProjectViewTable: (args) => - route>( - GITHUB_WEB_RPC_METHODS.getProjectViewTable, - args - ), - projectWorkItemDetailsBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.projectWorkItemDetailsBySlug, - args - ), - updateProjectItemField: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updateProjectItemField, - args - ), - clearProjectItemField: (args) => - route>( - GITHUB_WEB_RPC_METHODS.clearProjectItemField, - args - ), - updateIssueBySlug: (args) => - route>(GITHUB_WEB_RPC_METHODS.updateIssueBySlug, args), - updatePullRequestBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updatePullRequestBySlug, - args - ), - addIssueCommentBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.addIssueCommentBySlug, - args - ), - updateIssueCommentBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updateIssueCommentBySlug, - args - ), - deleteIssueCommentBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.deleteIssueCommentBySlug, - args - ), - listLabelsBySlug: (args) => - route>(GITHUB_WEB_RPC_METHODS.listLabelsBySlug, args), - listAssignableUsersBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listAssignableUsersBySlug, - args - ), - listIssueTypesBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listIssueTypesBySlug, - args - ), - updateIssueTypeBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updateIssueTypeBySlug, - args - ) - } satisfies WebGitHubApi - - return githubApi -} - -function createGitLabApi(): WebGitLabApi { - const route = (method: WebGitLabRuntimeMethod, args?: unknown): Promise => - callRuntimeResult(method, mapRepoPathArg(args)) - - const gitLabApi = { - viewer: () => Promise.resolve(null), - diagnoseAuth: () => route>(GITLAB_WEB_RPC_METHODS.diagnoseAuth), - rateLimit: (args) => - route>(GITLAB_WEB_RPC_METHODS.rateLimit, args), - projectSlug: () => Promise.resolve(null), - mrForBranch: () => Promise.resolve(null), - mr: () => Promise.resolve(null), - listMRs: (args) => route>(GITLAB_WEB_RPC_METHODS.listMRs, args), - listWorkItems: (args) => - route>(GITLAB_WEB_RPC_METHODS.listWorkItems, args), - issue: () => Promise.resolve(null), - listIssues: (args) => - route>(GITLAB_WEB_RPC_METHODS.listIssues, args), - createIssue: (args) => - route>(GITLAB_WEB_RPC_METHODS.createIssue, args), - updateIssue: (args) => - route>(GITLAB_WEB_RPC_METHODS.updateIssue, args), - addIssueComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addIssueComment, args), - listLabels: (args) => - route>(GITLAB_WEB_RPC_METHODS.listLabels, args), - listAssignableUsers: () => Promise.resolve([]), - todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), - workItemDetails: (args) => - route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), - closeMR: (args) => - route>(GITLAB_WEB_RPC_METHODS.closeMR, { - ...args, - state: 'closed' - }), - reopenMR: (args) => - route>(GITLAB_WEB_RPC_METHODS.reopenMR, { - ...args, - state: 'opened' - }), - mergeMR: (args) => route>(GITLAB_WEB_RPC_METHODS.mergeMR, args), - updateMR: (args) => route>(GITLAB_WEB_RPC_METHODS.updateMR, args), - updateMRReviewers: (args) => - route>(GITLAB_WEB_RPC_METHODS.updateMRReviewers, args), - addMRComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addMRComment, args), - addMRInlineComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addMRInlineComment, args), - resolveMRDiscussion: (args) => - route>( - GITLAB_WEB_RPC_METHODS.resolveMRDiscussion, - args - ), - jobTrace: (args) => route>(GITLAB_WEB_RPC_METHODS.jobTrace, args), - retryJob: (args) => route>(GITLAB_WEB_RPC_METHODS.retryJob, args), - workItemByPath: (args) => - route>(GITLAB_WEB_RPC_METHODS.workItemByPath, args) - } satisfies WebGitLabApi - - return gitLabApi -} - -function createRuntimeNamespaceApi(prefix: string): never { - return createFallbackProxy([prefix], (path, args) => { - const method = `${prefix}.${path.at(-1) ?? ''}` - return callRuntimeResult(method, mapRuntimeNamespaceArg(prefix, args[0])) - }) as never -} - -function createHooksApi(): NonNullable['hooks']> { - return { - check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }), - inspectSetupScriptImports: async ({ repoId }) => - callRuntimeResult('repo.setupScriptImports', { repo: repoId }), - createIssueCommandRunner: async () => ({ launched: false }) as never, - readIssueCommand: async ({ repoId }) => - callRuntimeResult('repo.issueCommandRead', { repo: repoId }), - writeIssueCommand: async ({ repoId, content }) => { - await callRuntimeResult('repo.issueCommandWrite', { repo: repoId, content }) - } - } -} - -function createWebUiApi(): NonNullable['ui']> { - let zoomLevel = readLocalWebUIState().uiZoomLevel - return { - get: async () => { - try { - const result = await callRuntimeResult<{ ui: PairedUiState }>('ui.get', undefined, 15_000) - const local = readLocalWebUIState() - const next = { - ...mergeHostWebUIState(local, result.ui), - osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), - featureInteractions: mergeFeatureInteractionState( - local.featureInteractions, - result.ui.featureInteractions - ), - contextualToursSeenIds: mergeContextualTourSeenIds( - local.contextualToursSeenIds, - result.ui.contextualToursSeenIds - ) - } - writeJson(UI_STORAGE_KEY, next) - zoomLevel = next.uiZoomLevel - return next - } catch { - return readLocalWebUIState() - } - }, - set: async (updates) => { - const next = mergeWebUIState(readLocalWebUIState(), updates) - writeJson(UI_STORAGE_KEY, next) - zoomLevel = next.uiZoomLevel - // Why strip here too when the host also strips: an old host predating that strip would - // otherwise persist this browser's runtime:web-* keys over the desktop profile's order. - const hostUpdates = omitPairingLocalUiFields(updates) - try { - await callRuntimeResult('ui.set', hostUpdates, 15_000) - } catch { - // Why: unpaired/offline web clients still need local UI persistence. - } - }, - recordFeatureInteraction: async (id: FeatureInteractionId) => { - const current = readLocalWebUIState() - const featureInteractions = normalizeFeatureInteractions(current.featureInteractions) - const existing = featureInteractions[id] - const optimistic = mergeWebUIState(current, { - featureInteractions: { - ...featureInteractions, - [id]: { - firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), - interactionCount: (existing?.interactionCount ?? 0) + 1 - } - } - }) - writeJson(UI_STORAGE_KEY, optimistic) - try { - const result = await callRuntimeResult<{ ui: PairedUiState }>( - 'ui.recordFeatureInteraction', - id, - 15_000 - ) - const local = readLocalWebUIState() - const next = { - ...mergeHostWebUIState(local, result.ui), - osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), - featureInteractions: mergeFeatureInteractionState( - local.featureInteractions, - result.ui.featureInteractions - ), - contextualToursSeenIds: mergeContextualTourSeenIds( - local.contextualToursSeenIds, - result.ui.contextualToursSeenIds - ) - } - writeJson(UI_STORAGE_KEY, next) - zoomLevel = next.uiZoomLevel - return next - } catch { - return optimistic - } - }, - readClipboardText: async (options?: ReadClipboardTextOptions) => - assertClipboardTextWithinLimitWithYield( - await (navigator.clipboard?.readText?.() ?? ''), - options - ), - readSelectionClipboardText: () => - Promise.reject(new Error('Selection clipboard is unavailable in the web client')), - saveClipboardImageAsTempFile: async (args?: { - connectionId?: string | null - runtimeEnvironmentId?: string | null - }) => { - if (!requireActiveEnvironmentOrNull()) { - return null - } - const contentBase64 = await readClipboardImagePngBase64() - if (!contentBase64) { - return null - } - return saveClipboardImageAsTempFileInRuntime(contentBase64, args) - }, - writeClipboardText: writeWebClipboardText, - writeTerminalClipboardText: writeWebClipboardText, - writeSelectionClipboardText: () => - Promise.reject(new Error('Selection clipboard is unavailable in the web client')), - writeClipboardImage: () => Promise.resolve(), - writeClipboardFile: () => Promise.resolve({ ok: false, reason: 'unsupported-platform' }), - performNativePaste: () => { - document.execCommand?.('paste') - }, - performNativeSelectionAction: (action) => { - document.execCommand?.(action === 'copy' ? 'copy' : 'selectAll') - }, - onExportPdfRequested: () => noopUnsubscribe, - onAppMenuPaste: () => noopUnsubscribe, - onAppMenuSelectionAction: () => noopUnsubscribe, - onEditableContextPaste: () => noopUnsubscribe, - getZoomLevel: () => zoomLevel, - setZoomLevel: (level) => { - zoomLevel = level - }, - isMaximized: () => Promise.resolve(false), - onOpenSettings: () => noopUnsubscribe, - // Why: the web client has no native tray/menu bar, so there's never a queued open-settings intent to consume. - consumePendingOpenSettings: () => Promise.resolve(false), - onOpenSkillShare: () => noopUnsubscribe, - consumePendingSkillShare: () => Promise.resolve(null), - onOpenSetupGuide: () => noopUnsubscribe, - onOpenFeatureTour: () => noopUnsubscribe, - onOpenCrashReport: () => noopUnsubscribe, - // No desktop main process to push state changes; the web client re-reads via ui.get on interaction. - onStateChanged: () => noopUnsubscribe, - onToggleLeftSidebar: () => noopUnsubscribe, - onToggleRightSidebar: () => noopUnsubscribe, - onToggleWorktreePalette: () => noopUnsubscribe, - onToggleFloatingTerminal: () => noopUnsubscribe, - onTerminalShortcutCaptured: () => noopUnsubscribe, - onOpenQuickOpen: () => noopUnsubscribe, - onToggleQuickCommandsMenu: () => noopUnsubscribe, - onOpenTasks: () => noopUnsubscribe, - onOpenNewWorkspace: () => noopUnsubscribe, - onDeleteCurrentWorkspace: () => noopUnsubscribe, - onOpenWorkspaceBoard: () => noopUnsubscribe, - onToggleAgentDashboard: () => noopUnsubscribe, - onJumpToWorktreeIndex: () => noopUnsubscribe, - onJumpToTabIndex: () => noopUnsubscribe, - onWorktreeHistoryNavigate: () => noopUnsubscribe, - onNewBrowserTab: () => noopUnsubscribe, - onNewMarkdownTab: () => noopUnsubscribe, - onNewSimulatorTab: () => noopUnsubscribe, - onRequestTabCreate: () => noopUnsubscribe, - replyTabCreate: () => {}, - onRequestTabSetProfile: () => noopUnsubscribe, - replyTabSetProfile: () => {}, - onRequestTabClose: () => noopUnsubscribe, - replyTabClose: () => {}, - onNewTerminalTab: () => noopUnsubscribe, - onFocusBrowserAddressBar: () => noopUnsubscribe, - onFindInBrowserPage: () => noopUnsubscribe, - onReloadBrowserPage: () => noopUnsubscribe, - onBrowserHistoryNavigate: () => noopUnsubscribe, - onZoomBrowserPage: () => noopUnsubscribe, - onHardReloadBrowserPage: () => noopUnsubscribe, - onCloseActiveTab: () => noopUnsubscribe, - onCloseFloatingItem: () => noopUnsubscribe, - onSelectFloatingIndex: () => noopUnsubscribe, - onSwitchTab: () => noopUnsubscribe, - onSwitchTabAcrossAllTypes: () => noopUnsubscribe, - onSwitchRecentTab: () => noopUnsubscribe, - onSwitchTerminalTab: () => noopUnsubscribe, - onCtrlTabKeyDown: () => noopUnsubscribe, - onCtrlTabKeyUp: () => noopUnsubscribe, - onToggleStatusBar: () => noopUnsubscribe, - onDictationKeyDown: () => noopUnsubscribe, - onActivateWorktree: () => noopUnsubscribe, - onCreateTerminal: () => noopUnsubscribe, - onRequestTerminalCreate: () => noopUnsubscribe, - onRequestTerminalTabMount: () => noopUnsubscribe, - replyTerminalCreate: () => {}, - onSplitTerminal: () => noopUnsubscribe, - onRenameTerminal: () => noopUnsubscribe, - onFocusTerminal: () => noopUnsubscribe, - onFocusEditorTab: () => noopUnsubscribe, - onCloseSessionTab: () => noopUnsubscribe, - onSessionTabCloseRequest: () => noopUnsubscribe, - respondSessionTabClose: () => {}, - onMoveSessionTab: () => noopUnsubscribe, - onOpenFileFromMobile: () => noopUnsubscribe, - onOpenDiffFromMobile: () => noopUnsubscribe, - onMobileMarkdownRequest: () => noopUnsubscribe, - respondMobileMarkdownRequest: () => {}, - onCloseTerminal: () => noopUnsubscribe, - onTerminalTabCloseRequest: () => noopUnsubscribe, - respondTerminalTabClose: () => {}, - onSleepWorktree: () => noopUnsubscribe, - // Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web. - onResumeSleepingAgents: () => noopUnsubscribe, - onTerminalZoom: () => noopUnsubscribe, - // Why: a paired web client has no OS sleep signal; occlusion-driven visibilitychange already covers wake recovery. - onSystemResumed: () => noopUnsubscribe, - onFileDrop: () => noopUnsubscribe, - syncTrafficLights: () => {}, - setMarkdownEditorFocused: () => {}, - setRichMarkdownContextMenuTarget: () => {}, - setTerminalInputFocused: () => {}, - setFloatingFocus: () => {}, - setShortcutRecorderFocused: () => {}, - onRichMarkdownContextCommand: () => noopUnsubscribe, - onFullscreenChanged: () => noopUnsubscribe, - minimize: () => {}, - maximize: () => {}, - onMaximizeChanged: () => noopUnsubscribe, - requestClose: () => {}, - popupMenu: () => {}, - onWindowCloseRequested: () => noopUnsubscribe, - confirmWindowClose: () => {}, - notifyWindowRevealed: () => {} - } -} - -function createPreflightApi(): NonNullable['preflight']> { - const fallbackStatus: PreflightStatus = { - git: { installed: false }, - gh: { installed: false, authenticated: false }, - glab: { installed: false, authenticated: false }, - bitbucket: { configured: false, authenticated: false, account: null }, - azureDevOps: { - configured: false, - authenticated: false, - account: null, - baseUrl: null, - tokenConfigured: false - }, - gitea: { - configured: false, - authenticated: false, - account: null, - baseUrl: null, - tokenConfigured: false - } - } - const fallbackRefreshAgents: RefreshAgentsResult = { - agents: [], - addedPathSegments: [], - shellHydrationOk: false, - pathSource: 'sync_seed_only', - pathFailureReason: 'spawn_error' - } - type WindowsTerminalCapabilityBridgeResult = { - wslAvailable: boolean - wslDistros: string[] - pwshAvailable: boolean - gitBashAvailable: boolean - hostPlatform: NodeJS.Platform | null - } - const fallbackWindowsTerminalCapabilities = { - wslAvailable: false, - wslDistros: [], - pwshAvailable: false, - gitBashAvailable: false, - hostPlatform: null - } - return { - check: async (args) => { - if (!requireActiveEnvironmentOrNull()) { - return fallbackStatus - } - return callRuntimeResult('preflight.check', args) - }, - detectAgents: async () => { - if (!requireActiveEnvironmentOrNull()) { - return [] - } - return callRuntimeResult('preflight.detectAgents').catch(() => []) - }, - refreshAgents: () => - requireActiveEnvironmentOrNull() - ? callRuntimeResult('preflight.refreshAgents') - .then((result) => result as RefreshAgentsResult) - .catch(() => fallbackRefreshAgents) - : Promise.resolve(fallbackRefreshAgents), - detectRemoteAgents: async (args) => - requireActiveEnvironmentOrNull() - ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) - : [], - detectRemoteWindowsTerminalCapabilities: async (args) => - requireActiveEnvironmentOrNull() - ? callRuntimeResult( - 'preflight.detectRemoteWindowsTerminalCapabilities', - args - ).catch(() => fallbackWindowsTerminalCapabilities) - : Promise.resolve(fallbackWindowsTerminalCapabilities) - } -} - -function createCliApi(): NonNullable['cli']> { - const status = { - platform: getBrowserPlatform(), - commandName: getBrowserPlatform() === 'linux' ? 'orca-ide' : 'orca', - commandPath: null, - pathDirectory: null, - pathConfigured: false, - launcherPath: null, - installMethod: null, - supported: false, - state: 'unsupported', - currentTarget: null, - unsupportedReason: 'launch_mode_unavailable', - detail: 'CLI registration is managed on the Orca server, not in the web browser.' - } as const - return { - getInstallStatus: () => Promise.resolve(status), - install: () => Promise.resolve(status), - remove: () => Promise.resolve(status), - getWslInstallStatus: (_args?: { distro?: string | null }) => Promise.resolve(status), - installWsl: (_args?: { distro?: string | null }) => Promise.resolve(status), - removeWsl: (_args?: { distro?: string | null }) => Promise.resolve(status) - } as NonNullable['cli']> -} - -function createAgentHooksApi(): NonNullable['agentHooks']> { - const status = ( - agent: - | 'claude' - | 'openclaude' - | 'codex' - | 'gemini' - | 'antigravity' - | 'amp' - | 'cursor' - | 'droid' - | 'command-code' - | 'grok' - | 'copilot' - | 'hermes' - | 'devin' - ) => - Promise.resolve({ - agent, - state: 'not_installed', - configPath: '', - managedHooksPresent: false, - detail: 'Agent hook status is only available on the Orca server.' - } as const) - return { - claudeStatus: () => status('claude'), - openClaudeStatus: () => status('openclaude'), - codexStatus: () => status('codex'), - geminiStatus: () => status('gemini'), - antigravityStatus: () => status('antigravity'), - ampStatus: () => status('amp'), - cursorStatus: () => status('cursor'), - droidStatus: () => status('droid'), - commandCodeStatus: () => status('command-code'), - grokStatus: () => status('grok'), - copilotStatus: () => status('copilot'), - hermesStatus: () => status('hermes'), - devinStatus: () => status('devin') - } -} - -function createMacosTccPromptsApi(): NonNullable['macosTccPrompts']> { - // Why: TCC is a macOS-desktop concept; the web client has no log stream to watch. - return { - onThreshold: () => noopUnsubscribe, - consumePending: () => Promise.resolve(null), - acknowledgePending: () => Promise.resolve(), - releasePending: () => Promise.resolve(), - dismiss: () => Promise.resolve() - } -} - -function createDeveloperPermissionsApi(): NonNullable['developerPermissions']> { - return { - getStatus: () => Promise.resolve([]), - request: ({ id }) => - Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const), - openSettings: () => Promise.resolve(), - testLocalNetworkConnection: ({ host, port }) => - Promise.resolve({ - ok: false, - host, - port, - testedAt: Date.now(), - failure: 'unsupported' - } as const) - } -} - -function createComputerUsePermissionsApi(): NonNullable< - Partial['computerUsePermissions'] -> { - return { - getStatus: () => - callRuntimeResult( - 'computer.permissionsStatus', - {}, - 15_000 - ), - openSetup: (args) => - callRuntimeResult( - 'computer.permissions', - args ?? {}, - 15_000 - ).catch(() => ({ - platform: getBrowserPlatform(), - helperAppPath: null, - openedSettings: false, - launchedHelper: false, - nextStep: 'Computer-use permissions are managed on the Orca server.' - })), - reset: () => - Promise.resolve({ - platform: getBrowserPlatform(), - helperAppPath: null, - helperUnavailableReason: 'web_client', - bundleId: null, - permissions: [] - }) - } -} - -function createSkillsApi(): NonNullable['skills']> { - return { - discover: (target) => - callRuntimeResult('skills.discover', target, 15_000), - // Why: browser clients have no local skill homes; remote-host freshness stays off until its update rail covers it. - freshnessInventory: (): Promise => - Promise.resolve({ - schemaVersion: 1, - installations: [], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: Date.now() - }), - // Why: with no local skill homes there is nothing to update, so the run rail - // reports a permanently idle state rather than spawning anything. - startUpdateRun: () => Promise.resolve({ started: false as const, reason: 'invalid-names' }), - cancelUpdateRun: () => Promise.resolve(), - acknowledgeUpdateRun: () => Promise.resolve(), - getUpdateRun: () => Promise.resolve({ state: 'idle' as const }), - prepareShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), - publishShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), - cancelShare: () => Promise.resolve(), - releaseShare: () => Promise.resolve(), - resolveShare: () => Promise.reject(new Error('Skill share links require the desktop app.')), - installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - installBundleShare: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - installPackageVersion: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - installBundlePackageVersion: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - cancelInstall: () => Promise.resolve({ cancelled: false }), - previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - previewBundleInstall: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - listManagedInstalls: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - listOwnedShares: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - revokeShare: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - deletePackageVersion: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - deletePackage: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - listWslDistros: () => Promise.resolve([]), - onInstallProgress: () => () => {}, - onShareProgress: () => () => {}, - onUpdateRun: () => () => {} - } -} - -function createNotificationsApi(): NonNullable['notifications']> { - return { - dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }), - dismiss: () => Promise.resolve({ dismissed: 0 }), - openSystemSettings: () => Promise.resolve(), - getPermissionStatus: () => - Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }), - probeDelivery: () => Promise.resolve({ state: 'unsupported' as const, authoritative: false }), - playSound: () => Promise.resolve({ played: false, reason: 'missing-path' }) - } -} - -function createRateLimitsApi(): NonNullable['rateLimits']> { - const empty: RateLimitState = { - claude: null, - codex: null, - gemini: null, - opencodeGo: null, - kimi: null, - antigravity: null, - minimax: null, - grok: null, - minimaxCookieConfigured: false, - grokAuthConfigured: false, - claudeTarget: { runtime: 'host', wslDistro: null }, - codexTarget: { runtime: 'host', wslDistro: null }, - inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] - } - return { - get: () => Promise.resolve(empty), - refresh: () => Promise.resolve(empty), - refreshCodexForTarget: () => Promise.resolve(empty), - // Why: web clients don't own local Codex auth; report the safe no-credit outcome since redemption is desktop-only. - consumeCodexResetCredit: () => Promise.resolve({ outcome: 'noCredit', state: empty }), - refreshClaudeForTarget: () => Promise.resolve(empty), - setPollingInterval: () => Promise.resolve(), - fetchInactiveClaudeAccounts: () => Promise.resolve(), - fetchInactiveCodexAccounts: () => Promise.resolve(), - refreshMiniMax: () => Promise.resolve(empty), - refreshGrok: () => Promise.resolve(empty), - onUpdate: () => noopUnsubscribe - } -} - -function createMiniMaxCredentialsApi(): NonNullable['minimaxCredentials']> { - const notConfigured = { configured: false } - const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') - return { - getStatus: () => Promise.resolve(notConfigured), - saveCookie: () => Promise.reject(unsupportedError), - clearCookie: () => Promise.resolve(notConfigured) - } -} - -function createGrokAccountsApi(): NonNullable['grokAccounts']> { - const unsigned = { - signedIn: false, - email: null, - teamId: null, - tokenFresh: false, - error: null - } - return { - getStatus: () => Promise.resolve(unsigned) - } -} - -function createAccountsApi(): never { - const empty = { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - return { - list: () => Promise.resolve(empty), - add: () => Promise.resolve(empty), - cancelPendingLogin: () => Promise.resolve(false), - reauthenticate: () => Promise.resolve(empty), - remove: () => Promise.resolve(empty), - select: () => Promise.resolve(empty), - // Why: launch accounts are recorded on the host that owns the PTY, which the - // web client never is — report no stale panes rather than reject the sweep. - listStalePanes: () => Promise.resolve([]), - // Why empty rather than absent: the same host owns both records, so a web - // client has no recorded lane to offer and every pane falls to derivation. - listRecordedPaneLanes: () => Promise.resolve({}), - forgetStalePanes: () => Promise.resolve() - } as never -} - -function createUpdaterApi(): NonNullable['updater']> { - // Why: the linux-package-install recovery status can only originate in the native main process, so - // the web renderer never reaches these branches — reject loudly rather than resolve a fake result. - // A fresh Error per rejection: one shared instance would carry this function's stack, not the caller's. - const desktopOnlyMessage = 'Linux package install recovery is only available in the desktop app.' - return { - getVersion: () => Promise.resolve('web'), - getStatus: () => Promise.resolve({ state: 'idle' } as never), - check: () => Promise.resolve(), - download: () => Promise.resolve(), - quitAndInstall: () => Promise.resolve(), - dismissNudge: () => Promise.resolve(), - dismissAvailableUpdate: () => Promise.resolve(), - getLinuxPackageInstallInstructions: () => Promise.reject(new Error(desktopOnlyMessage)), - showLinuxPackage: () => Promise.reject(new Error(desktopOnlyMessage)), - // Why: the web client cannot install a desktop build, so channel switching - // reports unavailable rather than an empty list that looks like a fetch miss. - listBuilds: (channel) => - Promise.resolve({ - ok: false, - channel, - message: translate( - 'auto.components.settings.ReleaseChannelSection.webUnavailable', - 'Switching builds is only available in the desktop app.' - ) - }), - onStatus: () => noopUnsubscribe, - onClearDismissal: () => noopUnsubscribe - } -} - -function createShellApi(): NonNullable['shell']> { - const openResult = { ok: true } as const - return { - openPath: (path) => - Promise.resolve(window.open(path, '_blank', 'noopener,noreferrer') as never), - openInFileManager: () => Promise.resolve(openResult), - openInExternalEditor: () => Promise.resolve(openResult), - openUrl: (url) => Promise.resolve(window.open(url, '_blank', 'noopener,noreferrer') as never), - openFilePath: () => Promise.resolve(false), - openFileUri: (uri) => - Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never), - pathExists: async (path) => { - try { - await resolveRuntimeFilePath(path) - return true - } catch { - return false - } - }, - pickAttachment: () => Promise.resolve(null), - pickImage: () => Promise.resolve(null), - pickRepoIconImage: () => Promise.resolve(null), - pickAudio: () => Promise.resolve(null), - pickDirectory: () => Promise.resolve(null), - copyFile: () => Promise.resolve() - } -} - -function createPtyApi(): NonNullable['pty']> { - return { - spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')), - write: () => {}, - writeAccepted: () => Promise.resolve(false), - resize: () => {}, - claimViewport: () => {}, - reportGeometry: () => {}, - signal: () => {}, - // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. - clearBuffer: () => {}, - kill: () => Promise.resolve(), - ackColdRestore: () => {}, - ackData: () => {}, - onDeliveryResyncRequest: () => noopUnsubscribe, - respondDeliveryResync: () => {}, - // Why: web terminals bypass main's delivery gate; a zero-in-flight reply keeps the watchdog idle. - reportRendererDeliveryState: () => - Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }), - getPtyDataListenerCount: () => 0, - rendererDispatcherReady: () => {}, - setActiveRendererPty: () => {}, - setRendererPtyVisible: () => {}, - setHiddenRendererPty: () => {}, - setPtyDeliveryInterest: () => {}, - // Why: remote-runtime PTYs are never hidden-gate markable, so there's no main-side responder to feed. - publishTerminalViewAttributes: () => {}, - hasChildProcesses: () => Promise.resolve(false), - getForegroundProcess: () => Promise.resolve(null), - inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), - // Why: paired web panes cannot provide a local post-boundary process scan. - confirmForegroundProcess: () => Promise.resolve(null), - getCwd: () => Promise.resolve('~'), - getSize: () => Promise.resolve(null), - listSessions: () => Promise.resolve([]), - getAuthoritativeBufferSnapshotCapabilities: (ids) => - Promise.resolve(ids.map((id) => ({ id, authoritative: false }))), - hasPty: () => Promise.resolve(null), - getMainBufferSnapshot: () => Promise.resolve(null), - // Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative. - onSideEffect: () => noopUnsubscribe, - getSideEffectSnapshot: () => Promise.resolve(null), - getRendererDeliveryDebugSnapshot: () => - Promise.resolve({ - pendingPtyCount: 0, - pendingChars: 0, - maxPendingCharsByPty: 0, - rendererInFlightPtyCount: 0, - rendererInFlightChars: 0, - maxRendererInFlightCharsByPty: 0, - activeRendererPtyCount: 0, - flushScheduled: false, - peakPendingChars: 0, - peakMaxPendingCharsByPty: 0, - peakRendererInFlightChars: 0, - peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0, - hiddenDeliveryGatedPtyCount: 0, - hiddenDeliveryGatedVisiblePtyCount: 0, - hiddenDeliveryGatedActivePtyCount: 0, - deliveryInterestPtyCount: 0, - hiddenDeliveryDroppedChars: 0, - hiddenDeliveryDroppedChunks: 0, - pendingDroppedChars: 0, - diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, - rendererLifecycleResetCount: 0, - lastLifecycleResetClearedChars: 0, - rendererPtyDispatcherReady: false, - rendererDispatcherReadyForcedCount: 0 - }), - resetRendererDeliveryDebug: () => Promise.resolve(), - onData: () => noopUnsubscribe, - onReplay: () => noopUnsubscribe, - onModelRestoreNeeded: () => noopUnsubscribe, - onExit: () => noopUnsubscribe, - onSpawned: () => noopUnsubscribe, - onSerializeBufferRequest: () => noopUnsubscribe, - onClearBufferRequest: () => noopUnsubscribe, - sendSerializedBuffer: () => {}, - declarePendingPaneSerializer: () => Promise.resolve(0), - settlePaneSerializer: () => Promise.resolve(), - clearPendingPaneSerializer: () => Promise.resolve(), - reportRendererSerializerReady: () => Promise.resolve(), - management: { - listSessions: () => Promise.resolve({ sessions: [], degraded: false }), - killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), - killOne: () => Promise.resolve({ success: false }), - restart: () => Promise.resolve({ success: false }), - // Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden. - macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }) - } - } -} - -function createSshApi(): NonNullable['ssh']> { - return { - // Why: SSH is owned by the paired host; route read/connect to runtime RPC for state/reconnect (STA-1468). Target mgmt is desktop-only. - listTargets: async () => { - if (!requireActiveEnvironmentOrNull()) { - return [] - } - const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>( - 'ssh.listTargetSummaries' - ) - return targets - }, - listRemovedTargetLabels: async () => { - if (!requireActiveEnvironmentOrNull()) { - return {} - } - const { labels } = await callRuntimeResult<{ labels: Record }>( - 'ssh.listRemovedTargetLabels' - ) - return labels - }, - addTarget: () => - Promise.reject(new Error('SSH target management is unavailable in the web client.')), - updateTarget: () => - Promise.reject(new Error('SSH target management is unavailable in the web client.')), - removeTarget: () => Promise.resolve(), - importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }), - listConfigHosts: () => - Promise.resolve({ - hosts: [], - totalHostCount: 0, - newHostCount: 0, - matchCount: 0, - hasMore: false - }), - resolveConfigHost: () => Promise.resolve(null), - connect: async (args) => { - const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( - 'ssh.connect', - { targetId: args.targetId } - ) - return state - }, - disconnect: () => Promise.resolve(), - terminateSessions: () => Promise.resolve(), - resetRelay: () => Promise.resolve(), - getState: async (args) => { - if (!requireActiveEnvironmentOrNull()) { - return null - } - const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( - 'ssh.getState', - { targetId: args.targetId } - ) - return state - }, - needsPassphrasePrompt: () => Promise.resolve(false), - testConnection: () => - Promise.resolve({ - success: false, - error: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') - }), - onStateChanged: () => noopUnsubscribe, - addPortForward: () => - Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), - updatePortForward: () => - Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), - removePortForward: () => Promise.resolve(null), - listPortForwards: () => Promise.resolve([]), - listDetectedPorts: () => Promise.resolve([]), - onPortForwardsChanged: () => noopUnsubscribe, - onDetectedPortsChanged: () => noopUnsubscribe, - browseDir: () => Promise.resolve({ entries: [], resolvedPath: '', pathFlavor: 'posix' }), - onCredentialRequest: () => noopUnsubscribe, - onCredentialResolved: () => noopUnsubscribe, - submitCredential: () => Promise.resolve() - } -} - -async function callRuntimeEnvelope( - method: string, - params?: unknown, - timeoutMs?: number -): Promise> { - const environment = requireActiveEnvironment() - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return Promise.resolve(manuallyDisconnectedResponse(environment)) - } - return getClientForEnvironment(environment).call(method, params, { timeoutMs }) - }) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - updateEnvironmentFromResponse(environment, response) - return response as RuntimeRpcResponse -} - -async function callEnvironmentEnvelope( - selector: string, - method: string, - params?: unknown, - timeoutMs?: number -): Promise> { - const environment = resolveEnvironment(selector) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return Promise.resolve(manuallyDisconnectedResponse(environment)) - } - return getClientForEnvironment(environment).call(method, params, { timeoutMs }) - }) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - updateEnvironmentFromResponse(environment, response) - return response as RuntimeRpcResponse -} - -async function callRuntimeResult( - method: string, - params?: unknown, - timeoutMs?: number -): Promise { - const response = await callRuntimeEnvelope(method, params, timeoutMs) - if (!response.ok) { - throw new Error(response.error.message) - } - return response.result as TResult -} - -async function callRuntimeResultWithOwner( - method: string, - params?: unknown, - timeoutMs?: number -): Promise<{ result: TResult; hostId: ExecutionHostId; environmentId: string }> { - const environmentId = requireActiveEnvironment().id - const result = await callRuntimeResult(method, params, timeoutMs) - return { result, hostId: toRuntimeExecutionHostId(environmentId), environmentId } -} - -function withRuntimeRepoOwner(repo: Repo, hostId: ExecutionHostId): Repo { - return { ...repo, executionHostId: hostId } -} - -function withRuntimeRepoMutationOwner( - result: { repo: Repo } | { error: string }, - hostId: ExecutionHostId -): { repo: Repo } | { error: string } { - return 'repo' in result ? { ...result, repo: withRuntimeRepoOwner(result.repo, hostId) } : result -} - -function withRuntimeWorktreeOwner(worktree: T, hostId: ExecutionHostId): T { - const runtimeOwner = parseExecutionHostId(hostId) - if (runtimeOwner?.kind !== 'runtime') { - return worktree - } - return { ...worktree, runtimeOwnerEnvironmentId: runtimeOwner.environmentId } -} - -function captureWebFileMutationSession(): { - resolveFilePath: (filePath: string) => Promise>> - assertMutationSupported: () => Promise - callRuntimeResult: WebRuntimeResultCaller - getSshState: (targetId: string) => Promise -} { - const environment = requireActiveEnvironment() - const client = getClientForEnvironment(environment) - const assertCurrent = (): void => { - if (activeClient !== client || requireActiveEnvironmentOrNull()?.id !== environment.id) { - throw new Error('Runtime pairing changed; refresh and try again') - } - } - const callBoundRuntimeEnvelope: WebRuntimeEnvelopeCaller = async ( - method: string, - params?: unknown, - timeoutMs?: number - ): Promise> => { - assertCurrent() - const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { - assertCurrent() - return client.call(method, params, { timeoutMs }) - }) - assertCurrent() - updateEnvironmentFromResponse(environment, response) - return response as RuntimeRpcResponse - } - const callBoundRuntimeResult: WebRuntimeResultCaller = async ( - method: string, - params?: unknown, - timeoutMs?: number - ): Promise => { - const response = await callBoundRuntimeEnvelope(method, params, timeoutMs) - if (!response.ok) { - throw new Error(response.error.message) - } - return response.result as TResult - } - return { - resolveFilePath: (filePath) => - resolveRuntimeFilePath( - filePath, - undefined, - callBoundRuntimeResult, - callBoundRuntimeEnvelope, - false, - environment.id - ), - assertMutationSupported: async () => { - assertFileMutationOwnershipCapability( - await callBoundRuntimeResult('status.get', undefined, 15_000) - ) - }, - callRuntimeResult: callBoundRuntimeResult, - getSshState: async (targetId) => - ( - await callBoundRuntimeResult<{ state: SshConnectionState | null }>('ssh.getState', { - targetId - }) - ).state - } -} - -async function saveClipboardImageAsTempFileInRuntime( - contentBase64: string, - args?: { connectionId?: string | null; runtimeEnvironmentId?: string | null } -): Promise { - if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) { - throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) - } - const connectionId = args?.connectionId ?? null - const startResponse = await callRuntimeEnvelope<{ uploadId: string }>( - 'clipboard.startImageUpload', - { expectedBase64Length: contentBase64.length, connectionId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - if (!startResponse.ok) { - if ( - startResponse.error.code === 'method_not_found' && - contentBase64.length <= CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS - ) { - return callRuntimeResult( - 'clipboard.saveImageAsTempFile', - { contentBase64, connectionId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - } - throw new Error(startResponse.error.message) - } - - const { uploadId } = startResponse.result - try { - for ( - let offset = 0; - offset < contentBase64.length; - offset += CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS - ) { - await callRuntimeResult( - 'clipboard.appendImageUploadChunk', - { - uploadId, - offset, - contentBase64: contentBase64.slice( - offset, - offset + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS - ) - }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - } - return await callRuntimeResult( - 'clipboard.commitImageUpload', - { uploadId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - } catch (error) { - // Why: after chunked paste holds server-side state, release the bounded slot on failure rather than wait for TTL cleanup. - await callRuntimeResult( - 'clipboard.abortImageUpload', - { uploadId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ).catch(() => {}) - throw error - } -} - -async function getRemoteRuntimeStatus(): Promise { - return callRuntimeResult('status.get', undefined, 15_000) -} - -function getClientForEnvironment(environment: StoredWebRuntimeEnvironment): WebRuntimeClient { - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - throw new Error('runtime_manually_disconnected') - } - if (!activeClient || activeClientEnvironmentId !== environment.id) { - activeClient?.close() - activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) - activeClientEnvironmentId = environment.id - } - return activeClient -} - -function closeActiveRuntimeClients(): void { - activeClient?.close() - activeClient = null - activeClientEnvironmentId = null - invalidateRuntimeWorktreeCaches() -} - -function disconnectActiveRuntimeEnvironment(): void { - closeActiveRuntimeClients() -} - -function removeActiveRuntimeEnvironment(): void { - disconnectActiveRuntimeEnvironment() - clearStoredWebRuntimeEnvironment() - activeEnvironment = null -} - -function manuallyDisconnectedResponse( - environment: StoredWebRuntimeEnvironment -): RuntimeRpcResponse { - return { - id: 'runtime.manualDisconnect', - ok: false, - error: { - code: 'runtime_manually_disconnected', - message: translate( - 'auto.web.webPreloadApi.runtimeEnvironmentManuallyDisconnected', - 'Runtime environment is manually disconnected.' - ) - }, - _meta: { runtimeId: environment.runtimeId } - } -} - -function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment { - const environment = requireActiveEnvironment() - if (selector === environment.id || selector === environment.name || selector === 'active') { - return environment - } - if (environment.compatibleEnvironmentIds?.includes(selector)) { - return environment - } - throw new Error(`Unknown Orca runtime environment: ${selector}`) -} - -function requireActiveEnvironment(): StoredWebRuntimeEnvironment { - activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() - if (!activeEnvironment) { - throw new Error('Pair this web client with an Orca server first.') - } - return activeEnvironment -} - -function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | null { - activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() - return activeEnvironment -} - -function assertActiveEnvironment(environmentId: string): void { - if (requireActiveEnvironment().id !== environmentId) { - throw new Error('The paired Orca server changed while the request was in progress.') - } -} - -function updateEnvironmentFromResponse( - environment: StoredWebRuntimeEnvironment, - response: RuntimeRpcResponse -): void { - if (activeEnvironment?.id !== environment.id) { - return - } - const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null) - const pairedDeviceId = - response.ok && - typeof response.result === 'object' && - response.result !== null && - typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string' - ? (response.result as { pairedDeviceId: string }).pairedDeviceId - : undefined - activeEnvironment = updateStoredEnvironmentRuntimeId(environment, runtimeId, pairedDeviceId) -} - -function getStoredSettings(): GlobalSettings { - activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() - const defaults = getDefaultSettings('~') - const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) - const stored = readJson>(SETTINGS_STORAGE_KEY, {}) - const migratedStored = { - ...stored, - ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), - ...normalizeTerminalCursorStyleDefault(stored), - ...normalizeOsc52ClipboardDefaultOn(stored), - terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), - uiLanguage: normalizeUiLanguage(stored.uiLanguage) - } - if ( - rawStoredSettings && - (stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork || - stored.autoRenameBranchFromWorkDefaultedOn !== - migratedStored.autoRenameBranchFromWorkDefaultedOn || - stored.terminalCursorStyle !== migratedStored.terminalCursorStyle || - stored.terminalCursorStyleDefaultedToBlock !== - migratedStored.terminalCursorStyleDefaultedToBlock || - // Kept even though the terminalCustomThemes reference compare below already forces - // this branch for every stored blob: no migration should rely on that accident. - stored.terminalAllowOsc52Clipboard !== migratedStored.terminalAllowOsc52Clipboard || - stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== - migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers || - stored.terminalCustomThemes !== migratedStored.terminalCustomThemes || - stored.uiLanguage !== migratedStored.uiLanguage) - ) { - try { - const parsed = JSON.parse(rawStoredSettings) as unknown - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - writeJson(SETTINGS_STORAGE_KEY, migratedStored) - if (osc52ClipboardDefaultOnOverridesPersistedOff(stored)) { - // Why a raw merge, not readLocalWebUIState(): that path calls back into - // getStoredSettings(), and writing through it here would recurse. - writeJson(UI_STORAGE_KEY, { - ...readJson>(UI_STORAGE_KEY, {}), - osc52ClipboardDefaultOnNoticePending: true - }) - } - } - } catch { - // Keep readJson's invalid-JSON fallback non-destructive. - } - } - return mergeSettings( - { - ...defaults, - floatingTerminalEnabled: false, - rightSidebarOpenByDefault: false, - activeRuntimeEnvironmentId: null - }, - migratedStored - ) -} - -function writeStoredSettings( - settings: GlobalSettings, - explicitActiveRuntimeEnvironmentId?: string | null -): void { - const durable = { ...settings } - if (explicitActiveRuntimeEnvironmentId !== undefined) { - durable.activeRuntimeEnvironmentId = explicitActiveRuntimeEnvironmentId - } else { - const stored = readJson>(SETTINGS_STORAGE_KEY, {}) - if (Object.hasOwn(stored, 'activeRuntimeEnvironmentId')) { - durable.activeRuntimeEnvironmentId = stored.activeRuntimeEnvironmentId ?? null - } else { - delete durable.activeRuntimeEnvironmentId - } - } - writeJson(SETTINGS_STORAGE_KEY, durable) -} - -async function getRuntimeBackedStoredSettings(): Promise { - const local = getStoredSettings() - const requestedEnvironment = requireActiveEnvironmentOrNull() - if (!requestedEnvironment) { - return local - } - try { - const result = await callRuntimeResult<{ settings: Partial }>( - 'settings.get', - undefined, - 15_000 - ) - const runtimeSettings: Partial = {} - const currentEnvironment = requireActiveEnvironmentOrNull() - if (currentEnvironment?.id === requestedEnvironment.id) { - const visibilityDefaults = normalizeWorktreeVisibilityDefaults( - result.settings.worktreeVisibilityDefaults - ) - worktreeVisibilityDefaultsRuntimeEnvironmentId = visibilityDefaults - ? requestedEnvironment.id - : null - worktreeVisibilityDefaultsRuntimeValue = visibilityDefaults ?? null - } - if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') { - runtimeSettings.experimentalNewWorktreeCardStyle = - result.settings.experimentalNewWorktreeCardStyle - } - if (typeof result.settings.compactWorktreeCards === 'boolean') { - runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards - } - if (typeof result.settings.minimaxGroupId === 'string') { - runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId - } - if (typeof result.settings.minimaxUsageModels === 'string') { - runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels - } - if (Array.isArray(result.settings.prBotAuthorOverrides)) { - runtimeSettings.prBotAuthorOverrides = normalizePRBotAuthorOverrides( - result.settings.prBotAuthorOverrides - ) - } - // Read-only mirror: the host owns this capability and `syncRuntimeBackedSettings` never - // sends it back, so web shows what the host enforces instead of a local value it ignores. - if (typeof result.settings.artifactSharingEnabled === 'boolean') { - runtimeSettings.artifactSharingEnabled = result.settings.artifactSharingEnabled - } - if (typeof result.settings.agentSkillSharingEnabled === 'boolean') { - runtimeSettings.agentSkillSharingEnabled = result.settings.agentSkillSharingEnabled - } - const next = mergeSettings(local, runtimeSettings) - writeStoredSettings(next) - return settingsForActiveVisibilityOwner(next) - } catch { - // Why: unpaired/offline web clients keep a local settings fallback. - return settingsForActiveVisibilityOwner(local) - } -} - -function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings { - const environment = requireActiveEnvironmentOrNull() - if (!environment) { - return settings - } - if ( - environment.id === worktreeVisibilityDefaultsRuntimeEnvironmentId && - worktreeVisibilityDefaultsRuntimeValue - ) { - return { ...settings, worktreeVisibilityDefaults: worktreeVisibilityDefaultsRuntimeValue } - } - const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings - return supportedSettings as GlobalSettings -} - -async function syncRuntimeBackedSettings( - updates: Partial, - localNext: GlobalSettings -): Promise { - const requestedEnvironment = requireActiveEnvironmentOrNull() - if (!requestedEnvironment) { - return localNext - } - const runtimeUpdates: Partial = {} - const visibilityDefaults = normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) - if (visibilityDefaults) { - runtimeUpdates.worktreeVisibilityDefaults = visibilityDefaults - } - if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') { - runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle - } - if (typeof updates.compactWorktreeCards === 'boolean') { - runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards - } - if (typeof updates.minimaxGroupId === 'string') { - runtimeUpdates.minimaxGroupId = updates.minimaxGroupId - } - if (typeof updates.minimaxUsageModels === 'string') { - runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels - } - if (Array.isArray(updates.prBotAuthorOverrides)) { - runtimeUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( - updates.prBotAuthorOverrides - ) - } - if (Object.keys(runtimeUpdates).length === 0) { - return localNext - } - try { - const result = await callRuntimeResult<{ settings: Partial }>( - 'settings.update', - runtimeUpdates, - 15_000 - ) - const runtimeSettings = { ...result.settings } - delete runtimeSettings.activeRuntimeEnvironmentId - const updatedVisibilityDefaults = normalizeWorktreeVisibilityDefaults( - runtimeSettings.worktreeVisibilityDefaults - ) - if ( - requireActiveEnvironmentOrNull()?.id === requestedEnvironment.id && - updatedVisibilityDefaults - ) { - worktreeVisibilityDefaultsRuntimeEnvironmentId = requestedEnvironment.id - worktreeVisibilityDefaultsRuntimeValue = updatedVisibilityDefaults - } - delete runtimeSettings.worktreeVisibilityDefaults - const next = mergeSettings(localNext, runtimeSettings) - writeStoredSettings(next) - return next - } catch (error) { - if (visibilityDefaults) { - throw error - } - // Why: unpaired/offline web clients still need local settings persistence. - return localNext - } -} - -async function updateRuntimePRBotAuthorOverride(args: { - author: string - isBot: boolean -}): Promise { - const local = getStoredSettings() - if (requireActiveEnvironmentOrNull()) { - // Why: don't report a successful mark the authoritative runtime failed to persist and will later overwrite. - const result = await callRuntimeResult<{ settings: Partial }>( - 'settings.updatePRBotAuthorOverride', - args, - 15_000 - ) - const next = mergeSettings(local, { - prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides) - }) - writeStoredSettings(next) - return next - } - const next = mergeSettings(local, { - prBotAuthorOverrides: applyPRBotAuthorOverride( - local.prBotAuthorOverrides, - args.author, - args.isBot - ) - }) - writeStoredSettings(next) - return next -} - -function getStoredOnboarding(): OnboardingState { - const storedRaw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY) - if (storedRaw) { - const stored = readJson(ONBOARDING_STORAGE_KEY, getDefaultOnboardingState()) - if (stored.checklist.dismissed) { - return stored - } - const closed = closeWebOnboarding(stored) - writeJson(ONBOARDING_STORAGE_KEY, closed) - return closed - } - const closed = closeWebOnboarding(getDefaultOnboardingState()) - // Why: paired clients already have an Orca server; skip desktop first-run onboarding that would probe browser-local tools. - writeJson(ONBOARDING_STORAGE_KEY, closed) - return closed -} - -/** Resolve the localStorage key for a session partition; non-'local' hosts get a host-suffixed key so they never clobber the local one. */ -function sessionStorageKeyForHost(hostId?: string | null): string { - const resolved = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - return resolved === LOCAL_EXECUTION_HOST_ID - ? SESSION_STORAGE_KEY - : `${SESSION_STORAGE_KEY}.${resolved}` -} - -function getStoredWorkspaceSession(hostId?: string | null): WorkspaceSessionState { - const resolvedHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - return sanitizeWebRuntimeWorkspaceSession( - readJson(sessionStorageKeyForHost(resolvedHostId), getDefaultWorkspaceSession()) - ) - } - const localSession = sanitizeWebRuntimeWorkspaceSession( - readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession()) - ) - if (!requireActiveEnvironmentOrNull()) { - return localSession - } - const ui = readLocalWebUIState() - // Why: replaying browser-local terminal handles first creates stale remote PTYs; mirror host session-tabs instead. - return sanitizeWebRuntimeWorkspaceSession({ - ...getDefaultWorkspaceSession(), - activeRepoId: ui.lastActiveRepoId, - activeWorktreeId: ui.lastActiveWorktreeId, - lastVisitedAtByWorktreeId: localSession.lastVisitedAtByWorktreeId - }) -} - -function closeWebOnboarding(base: OnboardingState): OnboardingState { - return { - ...base, - flowVersion: ONBOARDING_FLOW_VERSION, - closedAt: Date.now(), - outcome: 'dismissed', - checklist: { - ...base.checklist, - dismissed: true - } - } -} - -function readLocalWebUIState(): PersistedUIState { - const defaults = getDefaultUIState() - // Why settings first: getStoredSettings() runs the OSC 52 migration, which writes the - // notice arm into UI_STORAGE_KEY. Reading before it would snapshot a pre-arm state that - // every caller then writes back, erasing the arm the stamp can never raise again. - const storedSettings = getStoredSettings() - const stored = readJson>(UI_STORAGE_KEY, {}) - const base = { - ...defaults, - // Why: mirror the main-process missing-property seed from legacy card layout mode when runtime ui.get is unavailable. - worktreeCardProperties: getWorktreeCardModeProperties( - storedSettings.compactWorktreeCards ? 'Compact' : 'Default' - ) - } - if (typeof stored.rightSidebarOpen === 'boolean') { - return mergeWebUIState(base, stored) - } - return mergeWebUIState(base, { - ...stored, - // Why: web fallback lacks main-process normalization; migrate the retired setting only when local UI preference is absent. - rightSidebarOpen: storedSettings.rightSidebarOpenByDefault - }) -} - -function mergeWebUIState( - base: PersistedUIState, - updates: Partial -): PersistedUIState { - const { featureInteractionTelemetryBuckets: _reserved, ...safeUpdates } = - updates as Partial & { - featureInteractionTelemetryBuckets?: unknown - } - void _reserved - return { - ...base, - ...safeUpdates, - workspaceCleanup: mergeWorkspaceCleanupUIState( - base.workspaceCleanup, - safeUpdates.workspaceCleanup - ), - worktreeCardProperties: normalizeWorktreeCardProperties( - safeUpdates.worktreeCardProperties ?? base.worktreeCardProperties - ), - _worktreeCardModeDefaulted: - safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted, - agentActivityDisplayMode: normalizeAgentActivityDisplayMode( - safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode - ), - usagePercentageDisplay: normalizeUsagePercentageDisplay( - safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay - ), - statusBarUsageMode: normalizeStatusBarUsageMode( - safeUpdates.statusBarUsageMode ?? base.statusBarUsageMode - ) - } -} - -// Why pin instead of trusting the host's strip: an old host still returns these fields, and for a -// profile a pre-fix drag poisoned they echo back the same runtime:web-* keys this browser minted, -// which then match its own repos and beat every newer drag. -function mergeHostWebUIState(local: PersistedUIState, incoming: PairedUiState): PersistedUIState { - // Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in - // PersistedUIState, so Pick would accept a literal that silently skipped a newly added member. - const pinned = { - hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, - manualRepoOrder: local.manualRepoOrder, - workspaceHostOrder: local.workspaceHostOrder - } satisfies Record & Partial - return { ...mergeWebUIState(local, incoming), ...pinned } -} - -function mergeFeatureInteractionState( - current: PersistedUIState['featureInteractions'], - incoming: PersistedUIState['featureInteractions'] -): FeatureInteractionState { - const currentNormalized = normalizeFeatureInteractions(current) - const incomingNormalized = normalizeFeatureInteractions(incoming) - const merged: FeatureInteractionState = { ...currentNormalized } - for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { - const featureId = id as FeatureInteractionId - const currentRecord = currentNormalized[featureId] - merged[featureId] = currentRecord - ? { - firstInteractedAt: Math.min( - currentRecord.firstInteractedAt, - incomingRecord.firstInteractedAt - ), - interactionCount: Math.max( - currentRecord.interactionCount, - incomingRecord.interactionCount - ) - } - : incomingRecord - } - return merged -} - -function mergeContextualTourSeenIds( - current: PersistedUIState['contextualToursSeenIds'], - incoming: PersistedUIState['contextualToursSeenIds'] -): ContextualTourId[] { - const merged = new Set(normalizeContextualTourIds(current)) - for (const id of normalizeContextualTourIds(incoming)) { - merged.add(id) - } - return [...merged] -} - -/** Why OR rather than let the host win: the web client migrates its own localStorage - * settings, so an arm raised here has no counterpart on the host, and the plain spread - * would drop it — flipping the opt-out in silence, which is what the notice exists to stop. */ -function mergeOsc52ClipboardNoticePending( - current: PersistedUIState, - incoming: PersistedUIState -): boolean { - return ( - current.osc52ClipboardDefaultOnNoticePending === true || - incoming.osc52ClipboardDefaultOnNoticePending === true - ) -} - -function mergeSettings( - base: GlobalSettings, - updates: Partial, - options: { preserveAutoRenameBranchFromWorkUpdate?: boolean } = {} -): GlobalSettings { - const defaults = getDefaultSettings('~') - const merged = { - ...base, - ...updates, - notifications: { - ...base.notifications, - ...updates.notifications - }, - githubProjects: { - ...(base.githubProjects ?? defaults.githubProjects), - ...updates.githubProjects - } as GlobalSettings['githubProjects'], - disabledTuiAgents: normalizeDisabledTuiAgents( - updates.disabledTuiAgents ?? base.disabledTuiAgents - ), - agentDefaultArgs: normalizeTuiAgentArgsRecord( - updates.agentDefaultArgs ?? base.agentDefaultArgs - ), - agentDefaultEnv: normalizeTuiAgentEnvRecord(updates.agentDefaultEnv ?? base.agentDefaultEnv), - voice: { - ...(base.voice ?? defaults.voice), - ...updates.voice - } as NonNullable, - activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId') - ? (updates.activeRuntimeEnvironmentId ?? null) - : (base.activeRuntimeEnvironmentId ?? null), - terminalCustomThemes: normalizeTerminalCustomThemes( - updates.terminalCustomThemes ?? base.terminalCustomThemes - ), - uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage) - } - return { - ...merged, - ...normalizeAutoRenameBranchFromWorkDefaultOn(merged, { - preserveExplicitValue: options.preserveAutoRenameBranchFromWorkUpdate - }) - } -} - -async function listAllRuntimeWorktrees(): Promise { - if (cachedWorktrees && Date.now() - cachedWorktrees.loadedAt < 5_000) { - return cachedWorktrees.worktrees - } - const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { - limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT - }) - const worktrees = owned.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, owned.hostId) - ) - assertActiveEnvironment(owned.environmentId) - cachedWorktrees = { loadedAt: Date.now(), worktrees } - return worktrees -} - -async function listAllRuntimeDetectedWorktrees( - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, - useCache = true, - expectedEnvironmentId = requireActiveEnvironment().id -): Promise { - if ( - useCache && - cachedDetectedWorktrees && - Date.now() - cachedDetectedWorktrees.loadedAt < 5_000 - ) { - return cachedDetectedWorktrees.worktrees - } - - assertActiveEnvironment(expectedEnvironmentId) - const repos = (await callResult<{ repos: Repo[] }>('repo.list')).repos - const detectedLists = await Promise.all( - repos.map((repo) => - callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) - ) - ) - const worktrees = detectedLists.flatMap((result) => result.worktrees) - assertActiveEnvironment(expectedEnvironmentId) - if (useCache) { - cachedDetectedWorktrees = { loadedAt: Date.now(), worktrees } - } - return worktrees -} - -async function callRuntimeDetectedWorktrees( - repoId: string, - expectedEnvironmentId = requireActiveEnvironment().id, - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope -): Promise { - assertActiveEnvironment(expectedEnvironmentId) - const hostId = toRuntimeExecutionHostId(expectedEnvironmentId) - const response = await callEnvelope( - 'worktree.detectedList', - { repo: repoId }, - 15_000 - ) - if (response.ok) { - return { - ...response.result, - worktrees: response.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, hostId) - ) - } - } - if (response.error.code !== 'method_not_found') { - throw new Error(response.error.message) - } - - assertActiveEnvironment(expectedEnvironmentId) - const legacy = await callResult<{ worktrees: Worktree[] }>( - 'worktree.list', - { repo: repoId, limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT }, - 15_000 - ) - return toLegacyDetectedWorktreeResult( - repoId, - legacy.worktrees.map((worktree) => withRuntimeWorktreeOwner(worktree, hostId)) - ) -} - -function toLegacyDetectedWorktreeResult( - repoId: string, - worktrees: Worktree[] -): DetectedWorktreeListResult { - return { - repoId, - authoritative: true, - source: 'session-fallback', - worktrees: worktrees.map((worktree) => ({ - ...worktree, - ownership: 'orca-managed', - selectedCheckout: false, - visible: true - })) - } -} - -function isMissingPathError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - return /\bENOENT\b|not found|no such file/i.test(error.message) -} - -async function resolveRuntimeWorktreeByPath( - worktreePath: string, - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, - useDetectedWorktreeCache = true, - expectedEnvironmentId = requireActiveEnvironment().id -): Promise { - // Why: hidden-but-open worktrees must still resolve, but `worktree.list` is sidebar-visible only — resolve via detected rows. - const worktrees = await listAllRuntimeDetectedWorktrees( - callResult, - callEnvelope, - useDetectedWorktreeCache, - expectedEnvironmentId - ) - const match = worktrees - .map((worktree) => ({ - worktree, - relativePath: relativePathInsideRoot(worktree.path, worktreePath) - })) - .filter((entry) => entry.relativePath !== null) - .sort((a, b) => b.worktree.path.length - a.worktree.path.length)[0] - if (!match) { - throw new Error(`No runtime worktree owns ${worktreePath}`) - } - return match.worktree -} - -async function resolveRuntimeFilePath( - filePath: string, - preferredWorktreePath?: string, - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, - useDetectedWorktreeCache = true, - expectedEnvironmentId = requireActiveEnvironment().id -): Promise<{ worktree: Worktree; relativePath: string }> { - const worktree = preferredWorktreePath - ? await resolveRuntimeWorktreeByPath( - preferredWorktreePath, - callResult, - callEnvelope, - useDetectedWorktreeCache, - expectedEnvironmentId - ) - : await resolveRuntimeWorktreeByPath( - filePath, - callResult, - callEnvelope, - useDetectedWorktreeCache, - expectedEnvironmentId - ) - const relativePath = relativePathInsideRoot(worktree.path, filePath) - if (relativePath === null) { - throw new Error(`File is outside runtime worktree: ${filePath}`) - } - return { worktree, relativePath } -} - -async function mutateGitPath( - method: string, - worktreePath: string, - filePath: string -): Promise { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - await callRuntimeResult(method, { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath - }) -} - -async function mutateGitPaths( - method: string, - worktreePath: string, - filePaths: string[] -): Promise { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id), filePaths }) -} - -function mapRepoPathArg(args: unknown): unknown { - if (!args || typeof args !== 'object' || !('repoPath' in args)) { - return args - } - const record = args as Record - const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null - return { - ...record, - // Why: duplicate checked-out repos make path/name selectors ambiguous; prefer the explicit repo id the renderer passes. - repo: repoId ? `id:${repoId}` : record.repoPath - } -} - -function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown { - if (prefix !== 'hostedReview') { - return args - } - return mapRepoPathArg(args) -} - -function createEmptyMemorySnapshot(): MemorySnapshot { - const emptyUsage = { cpu: 0, memory: 0 } - return { - app: { ...emptyUsage, main: emptyUsage, renderer: emptyUsage, other: emptyUsage, history: [] }, - worktrees: [], - host: { - totalMemory: 0, - freeMemory: 0, - availableMemory: 0, - availableMemorySource: 'free-memory', - usedMemory: 0, - memoryUsagePercent: 0, - cpuCoreCount: navigator.hardwareConcurrency || 1, - loadAverage1m: 0 - }, - processMemoryMetric: getBrowserPlatform() === 'win32' ? 'working-set' : 'rss', - totalCpu: 0, - totalMemory: 0, - collectedAt: Date.now() - } -} - -function getBrowserPlatform(): NodeJS.Platform { - if (navigator.userAgent.includes('Windows')) { - return 'win32' - } - if (navigator.userAgent.includes('Linux')) { - return 'linux' - } - return 'darwin' -} - -function readJson(key: string, fallback: T): T { - const raw = window.localStorage.getItem(key) - if (!raw) { - return cloneJson(fallback) - } - try { - return { ...cloneJson(fallback), ...JSON.parse(raw) } as T - } catch { - return cloneJson(fallback) - } -} - -function writeJson(key: string, value: T): void { - window.localStorage.setItem(key, JSON.stringify(value)) -} - -function cloneJson(value: T): T { - return JSON.parse(JSON.stringify(value)) as T -} - -function withFallback(target: T, path: string[]): T { - return new Proxy(target, { - get(current, property, receiver) { - if (property in current) { - const value = Reflect.get(current, property, receiver) as unknown - if (value && typeof value === 'object' && !Array.isArray(value)) { - return withFallback(value as object, [...path, String(property)]) - } - return value - } - return createFallbackProxy([...path, String(property)]) - } - }) -} - -function createFallbackProxy( - path: string[], - applyOverride?: (path: string[], args: unknown[]) => unknown -): never { - const fn = () => undefined - return new Proxy(fn, { - get(_target, property) { - if (property === 'then') { - return undefined - } - return createFallbackProxy([...path, String(property)], applyOverride) - }, - apply(_target, _thisArg, args) { - if (applyOverride) { - return applyOverride(path, args) - } - return getFallbackResult(path, args) - } - }) as never -} - -function getFallbackResult(path: string[], args: unknown[]): unknown { - const name = path.at(-1) ?? '' - if (name.startsWith('on')) { - return noopUnsubscribe - } - if (name.startsWith('is') || name.startsWith('has') || name === 'pathExists') { - return Promise.resolve(false) - } - if (name.startsWith('list') || name.startsWith('detect')) { - return Promise.resolve([]) - } - if (name.startsWith('preview')) { - return Promise.resolve({ found: false, diff: {}, unsupportedKeys: [] }) - } - if (name.startsWith('get') && name.endsWith('Status')) { - return Promise.resolve([]) - } - if (name === 'write' || name === 'resize' || name === 'reportGeometry') { - return undefined - } - if (args.length === 0 && (name === 'getZoomLevel' || name === 'declarePendingPaneSerializer')) { - return 0 - } - return Promise.resolve(undefined) -} - -function noopUnsubscribe(): void {} diff --git a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts new file mode 100644 index 00000000000..4696643e08a --- /dev/null +++ b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebRuntimeClient } from './web-runtime-client' + +class TimeoutBudgetWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + readyState = TimeoutBudgetWebSocket.CONNECTING + binaryType = 'arraybuffer' + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + close = vi.fn() + send = vi.fn() +} + +describe('WebRuntimeClient timeout budget', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') + }) + vi.stubGlobal('WebSocket', TimeoutBudgetWebSocket) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('starts a fresh request timeout after the connection wait completes', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + client.close() + let resolveConnection!: () => void + const connection = new Promise((resolve) => { + resolveConnection = resolve + }) + // Test seam: isolate call's connection and request phases from socket handshaking. + const internals = client as unknown as { + waitForConnected: (timeoutMs?: number) => Promise + sendEncrypted: (message: unknown) => boolean + } + internals.waitForConnected = vi.fn(() => connection) + internals.sendEncrypted = vi.fn(() => true) + + const call = client.call('runtime.two-phase-timeout', undefined, { timeoutMs: 25 }) + let settled = false + void call.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.advanceTimersByTimeAsync(60_000) + expect(settled).toBe(false) + expect(internals.waitForConnected).toHaveBeenCalledWith(25) + + resolveConnection() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(24) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(call).rejects.toThrow('Request timed out: runtime.two-phase-timeout') + }) +}) From f57114a106dc0be85c5927130d707e0a2b3777fe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:08 -0700 Subject: [PATCH 03/10] refactor(renderer): split terminal store slice (#16184) --- config/max-lines-baseline.txt | 1 - .../codex-restart-status-summary.ts | 2 +- src/renderer/src/lib/use-tab-agent.ts | 2 +- .../direct-ssh-terminal-recovery-types.ts | 2 +- .../slices/direct-ssh-terminal-recovery.ts | 2 +- .../store-session-terminal-reconnect.test.ts | 187 + .../slices/store-sleep-shutdown-order.test.ts | 134 + .../terminal-tab-retirement-store.test.ts | 83 + src/renderer/src/store/slices/terminals.ts | 4313 +---------------- .../terminals/direct-ssh-terminal-bindings.ts | 105 + .../src/store/terminals/terminal-actions.ts | 271 ++ .../terminal-active-workspace-creation.ts | 69 + .../src/store/terminals/terminal-contracts.ts | 38 + .../terminals/terminal-ephemeral-state.ts | 237 + .../store/terminals/terminal-layout-state.ts | 154 + .../terminals/terminal-pane-hibernation.ts | 243 + .../store/terminals/terminal-pty-bindings.ts | 240 + .../terminals/terminal-pty-identities.ts | 102 + .../store/terminals/terminal-pty-release.ts | 161 + .../store/terminals/terminal-restart-state.ts | 202 + .../terminals/terminal-shutdown-guards.ts | 185 + .../terminals/terminal-shutdown-state.ts | 194 + .../src/store/terminals/terminal-shutdown.ts | 172 + .../terminals/terminal-startup-queues.ts | 133 + .../src/store/terminals/terminal-state.ts | 125 + .../store/terminals/terminal-tab-attention.ts | 126 + .../terminals/terminal-tab-close-providers.ts | 68 + .../src/store/terminals/terminal-tab-close.ts | 266 + .../store/terminals/terminal-tab-creation.ts | 245 + .../terminals/terminal-tab-navigation.ts | 121 + .../terminals/terminal-tab-presentation.ts | 203 + .../terminals/terminal-workspace-routing.ts | 115 + .../workspace-terminal-hydration-patch.ts | 222 + .../terminals/workspace-terminal-hydration.ts | 209 + .../workspace-terminal-layout-plan.ts | 58 + .../workspace-terminal-placeholders.ts | 91 + .../workspace-terminal-reconnect-plan.ts | 88 + .../terminals/workspace-terminal-reconnect.ts | 195 + .../terminals/workspace-terminal-row-plan.ts | 101 + .../workspace-terminal-ssh-placeholders.ts | 51 + src/renderer/src/store/types.ts | 2 +- 41 files changed, 5234 insertions(+), 4284 deletions(-) create mode 100644 src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts create mode 100644 src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts create mode 100644 src/renderer/src/store/terminals/terminal-actions.ts create mode 100644 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts create mode 100644 src/renderer/src/store/terminals/terminal-contracts.ts create mode 100644 src/renderer/src/store/terminals/terminal-ephemeral-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-layout-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-pane-hibernation.ts create mode 100644 src/renderer/src/store/terminals/terminal-pty-bindings.ts create mode 100644 src/renderer/src/store/terminals/terminal-pty-identities.ts create mode 100644 src/renderer/src/store/terminals/terminal-pty-release.ts create mode 100644 src/renderer/src/store/terminals/terminal-restart-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-shutdown-guards.ts create mode 100644 src/renderer/src/store/terminals/terminal-shutdown-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-shutdown.ts create mode 100644 src/renderer/src/store/terminals/terminal-startup-queues.ts create mode 100644 src/renderer/src/store/terminals/terminal-state.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-attention.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-close-providers.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-close.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-creation.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-navigation.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-presentation.ts create mode 100644 src/renderer/src/store/terminals/terminal-workspace-routing.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-hydration.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-placeholders.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-reconnect.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-row-plan.ts create mode 100644 src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index ae05468b04a..67f8212e06d 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -143,7 +143,6 @@ inline src/renderer/src/store/slices/jira.ts inline src/renderer/src/store/slices/linear.ts inline src/renderer/src/store/slices/repos.ts inline src/renderer/src/store/slices/tabs.ts -inline src/renderer/src/store/slices/terminals.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/store/slices/workspace-cleanup.ts inline src/renderer/src/web/web-runtime-client.ts diff --git a/src/renderer/src/components/status-bar/codex-restart-status-summary.ts b/src/renderer/src/components/status-bar/codex-restart-status-summary.ts index 8d7c44d458f..9a26d136c9b 100644 --- a/src/renderer/src/components/status-bar/codex-restart-status-summary.ts +++ b/src/renderer/src/components/status-bar/codex-restart-status-summary.ts @@ -1,5 +1,5 @@ import { awaitsCodexRestartAnswer } from '@/components/codex-restart-notice-state' -import type { CodexRestartNotice } from '@/store/slices/terminals' +import type { CodexRestartNotice } from '@/store/terminals/terminal-contracts' type TabLookup = Record diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 09afd63c43d..b6c88400632 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' import { isShellProcess } from '../../../shared/agent-detection' -import { worktreeUsesRemoteConnection } from '@/store/slices/terminals' +import { worktreeUsesRemoteConnection } from '@/store/terminals/terminal-workspace-routing' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts index f59015b6ee7..7ca0b189071 100644 --- a/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery-types.ts @@ -1,6 +1,6 @@ import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { CodexRestartNotice } from './terminals' +import type { CodexRestartNotice } from '../terminals/terminal-contracts' export type DirectSshPaneRetryAttemptId = string & { readonly __directSshPaneRetryAttemptId: unique symbol diff --git a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts index 4bc678a0437..ef67b0ce642 100644 --- a/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts +++ b/src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts @@ -1,6 +1,6 @@ import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { CodexRestartNotice } from './terminals' +import type { CodexRestartNotice } from '../terminals/terminal-contracts' import { directSshAuthoritiesEqual, liveBindingMatches, diff --git a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts index 26ed7a6c9f9..81c6350680d 100644 --- a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts +++ b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts @@ -5,6 +5,7 @@ import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test import { computeVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { createStoreSessionMockApi } from './store-session-test-harness' +import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -458,4 +459,190 @@ describe('reconnectPersistedTerminals', () => { }) ).toEqual([]) }) + it('leaves reconnect state untouched when cancellation is already signaled', async () => { + const store = createDaemonEnabledStore() + const worktreeId = 'repo1::/path/wt1' + const tab = makeTab({ id: 'tab1', worktreeId, ptyId: null }) + store.setState({ + tabsByWorktree: { [worktreeId]: [tab] }, + ptyIdsByTabId: { tab1: [] }, + pendingReconnectWorktreeIds: [worktreeId], + pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, + pendingReconnectPtyIdByTabId: { tab1: 'old-pty-1' }, + deferredSshSessionIdsByTabId: { tab1: 'deferred-pty-1' }, + workspaceSessionReady: false + }) + const before = store.getState() + const controller = new AbortController() + controller.abort() + + await store.getState().reconnectPersistedTerminals(controller.signal) + + const after = store.getState() + expect(after.tabsByWorktree).toBe(before.tabsByWorktree) + expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) + expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) + expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) + expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) + expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) + expect(after.workspaceSessionReady).toBe(false) + }) + it('leaves reconnect state untouched when cancellation arrives before final publication', async () => { + const store = createDaemonEnabledStore() + const worktreeId = 'repo1::/path/wt1' + store.setState({ + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] + }, + ptyIdsByTabId: { tab1: [] }, + pendingReconnectWorktreeIds: [worktreeId], + pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, + pendingReconnectPtyIdByTabId: { tab1: 'old-pty-1' }, + deferredSshSessionIdsByTabId: { tab1: 'deferred-pty-1' }, + workspaceSessionReady: false + }) + const before = store.getState() + const controller = new AbortController() + let abortReads = 0 + Object.defineProperty(controller.signal, 'aborted', { + configurable: true, + get: () => { + abortReads += 1 + return abortReads > 1 + } + }) + + await store.getState().reconnectPersistedTerminals(controller.signal) + + const after = store.getState() + expect(abortReads).toBe(2) + expect(after.tabsByWorktree).toBe(before.tabsByWorktree) + expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) + expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) + expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) + expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) + expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) + expect(after.workspaceSessionReady).toBe(false) + }) + + it('leaves scoped reconnect state untouched when direct SSH authority is stale at entry', async () => { + const store = createDaemonEnabledStore() + const targetId = 'ssh-1' + const worktreeId = 'repo1::/remote/wt1' + const staleAuthority: DirectSshAuthority = { + targetId, + providerEpoch: 'epoch-1' as SshProviderEpoch, + connectionGeneration: 1 + } + store.setState({ + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] + }, + ptyIdsByTabId: { tab1: [] }, + pendingReconnectWorktreeIds: [worktreeId], + pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, + pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' }, + deferredSshSessionIdsByTabId: { tab1: 'ssh:ssh-1@@deferred-1' }, + sshConnectionStates: new Map([ + [ + targetId, + { + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: staleAuthority.providerEpoch, + connectionGeneration: 2 + } + ] + ]), + workspaceSessionReady: false + }) + const before = store.getState() + + await store.getState().reconnectPersistedTerminals(undefined, { + directSshAuthority: staleAuthority, + workspaceKeys: [worktreeId] + }) + + const after = store.getState() + expect(after.tabsByWorktree).toBe(before.tabsByWorktree) + expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) + expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) + expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) + expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) + expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) + expect(after.workspaceSessionReady).toBe(before.workspaceSessionReady) + }) + + it('leaves scoped reconnect state untouched when direct SSH authority rotates before publication', async () => { + const store = createDaemonEnabledStore() + const targetId = 'ssh-1' + const worktreeId = 'repo1::/remote/wt1' + const authority: DirectSshAuthority = { + targetId, + providerEpoch: 'epoch-1' as SshProviderEpoch, + connectionGeneration: 1 + } + let authorityReads = 0 + const currentAuthorityState = { + targetId, + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + } + const rotatedAuthorityState = { + ...currentAuthorityState, + connectionGeneration: 2 + } + const sshConnectionStates = new Map([[targetId, currentAuthorityState]]) + const originalGet = sshConnectionStates.get.bind(sshConnectionStates) + sshConnectionStates.get = ((key: string) => { + authorityReads += 1 + return authorityReads >= 4 ? rotatedAuthorityState : originalGet(key) + }) as typeof sshConnectionStates.get + store.setState({ + repos: [ + { + id: 'repo1', + path: '/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + connectionId: targetId + } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/remote/wt1' })] + }, + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] + }, + ptyIdsByTabId: { tab1: [] }, + pendingReconnectWorktreeIds: [worktreeId], + pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, + pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' }, + deferredSshSessionIdsByTabId: {}, + sshConnectionStates, + workspaceSessionReady: false + }) + const before = store.getState() + + await store.getState().reconnectPersistedTerminals(undefined, { + directSshAuthority: authority, + workspaceKeys: [worktreeId] + }) + + const after = store.getState() + expect(authorityReads).toBeGreaterThanOrEqual(4) + expect(after.tabsByWorktree).toBe(before.tabsByWorktree) + expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) + expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) + expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) + expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) + expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) + expect(after.workspaceSessionReady).toBe(false) + }) }) diff --git a/src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts b/src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts new file mode 100644 index 00000000000..9e4bf3d9970 --- /dev/null +++ b/src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { getDefaultSettings } from '../../../../shared/constants' +import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/runtime-compatibility-test-fixture' +import { createTestStore, makeRuntimeOwnedWorktree, makeTab, seedStore } from './store-test-helpers' +import { + applySleepRuntimeRpcDefault, + createStoreCascadesMockApi +} from './store-cascades-test-harness' + +const orderHarness = vi.hoisted(() => { + const events: string[] = [] + return { + events, + unregister: vi.fn(() => [ + { + ptyId: 'pty-1', + commit: () => events.push('handler-commit'), + rollback: vi.fn() + } + ]), + settleDeferred: vi.fn(() => events.push('deferred-exit-settlement')) + } +}) + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: orderHarness.unregister +})) + +vi.mock('@/components/terminal-pane/pty-shutdown-exit-deferral', () => ({ + clearCommittedPtyShutdownSettlements: vi.fn(), + hasCommittedPtyShutdownSettlement: vi.fn(() => false), + markCommittedPtyShutdowns: vi.fn(), + noteCommittedPtyShutdownSettlements: vi.fn(), + settleDeferredPtyShutdownExits: orderHarness.settleDeferred +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = createStoreCascadesMockApi() + +describe('shutdownWorktreeTerminals ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + orderHarness.events.length = 0 + shutdownBufferCaptures.clear() + applySleepRuntimeRpcDefault(mockApi) + }) + + it('orders capture, guards, handlers, owner stop, renderer kill, commit, cleanup, and exit settlement', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' }, + worktreesByRepo: { + repo1: [makeRuntimeOwnedWorktree({ id: worktreeId, repoId: 'repo1' })] + }, + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId: 'pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + dropAgentStatusByWorktree: vi.fn(() => orderHarness.events.push('agent-cleanup')), + clearPaneForegroundAgentByWorktree: vi.fn() + }) + shutdownBufferCaptures.set('tab-1', () => orderHarness.events.push('buffer-capture')) + const unsubscribe = store.subscribe((state, previous) => { + if (!previous.pendingPtyShutdownIds['pty-1'] && state.pendingPtyShutdownIds['pty-1']) { + orderHarness.events.push('shutdown-guard-publication') + } + if (previous.ptyIdsByTabId['tab-1']?.length && state.ptyIdsByTabId['tab-1']?.length === 0) { + orderHarness.events.push('terminal-state-cleanup') + } + }) + orderHarness.unregister.mockImplementationOnce(() => { + orderHarness.events.push('handler-unregister') + return [ + { + ptyId: 'pty-1', + commit: () => orderHarness.events.push('handler-commit'), + rollback: vi.fn() + } + ] + }) + mockApi.runtimeEnvironments.call.mockImplementation(async (args: { method: string }) => { + const compatibilityResponse = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatibilityResponse) { + return compatibilityResponse + } + if (args.method === 'terminal.sleep') { + orderHarness.events.push('owner-runtime-rpc') + } + return { + id: 'rpc-order', + ok: true, + result: + args.method === 'terminal.sleep' + ? { + stopped: 0, + stoppedPtyIds: [], + livePtyIds: [], + postStopVerified: true + } + : {}, + _meta: { runtimeId: 'runtime-1' } + } + }) + mockApi.pty.kill.mockImplementationOnce(async () => { + orderHarness.events.push('renderer-kill') + }) + + await store.getState().shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) + expect(orderHarness.events).toEqual([ + 'buffer-capture', + 'shutdown-guard-publication', + 'handler-unregister', + 'owner-runtime-rpc', + 'renderer-kill', + 'handler-commit', + 'terminal-state-cleanup', + 'agent-cleanup', + 'deferred-exit-settlement' + ]) + unsubscribe() + }) +}) diff --git a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts index 5727f5474c6..b6a2bab7b4a 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts @@ -244,6 +244,89 @@ describe('terminal tab retirement store boundary', () => { expect(store.getState().tabsByWorktree['wt-1']).toEqual([]) }) + it('retires parked ownership before teardown and sweeps published state before unified close', () => { + const store = createRetirementStore() + const events: string[] = [] + const unified = makeUnifiedTab({ + id: 'unified-tab-1', + entityId: 'tab-1', + worktreeId: 'wt-1', + groupId: 'group-1' + }) + mockKill.mockImplementationOnce(() => { + events.push('provider-kill') + return Promise.resolve() + }) + seedStore(store, { + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + unifiedTabsByWorktree: { 'wt-1': [unified] }, + dropAgentStatusByTabPrefix: vi.fn(() => { + expect(store.getState().tabsByWorktree['wt-1']).toEqual([]) + events.push('state-sweep') + }), + closeUnifiedTab: vi.fn(() => { + events.push('unified-close') + return null + }) + }) + parkedWatchersByTabId.set('tab-1', { + worktreeId: 'wt-1', + tabPtyId: 'pty-1', + paneIdByPtyId: new Map([['pty-1', 1]]), + disposersByPtyId: new Map([['pty-1', () => events.push('parked-retire')]]) + }) + const unsubscribe = store.subscribe((state, previous) => { + const wasPresent = previous.tabsByWorktree['wt-1']?.some((tab) => tab.id === 'tab-1') + const isPresent = state.tabsByWorktree['wt-1']?.some((tab) => tab.id === 'tab-1') + if (wasPresent && !isPresent) { + events.push('row-removal') + } + }) + + expect(() => store.getState().closeTab('tab-1')).not.toThrow() + + expect(events).toEqual([ + 'parked-retire', + 'provider-kill', + 'row-removal', + 'state-sweep', + 'unified-close' + ]) + unsubscribe() + }) + + it('starts mixed provider teardown and classifies both rejection kinds after synchronous retirement', async () => { + const store = createRetirementStore() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockKill.mockRejectedValueOnce(new Error('local unavailable')) + mockRuntimeCall.mockRejectedValueOnce(new Error('runtime unavailable')) + seedStore(store, { + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-local' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-local', 'remote:runtime-terminal'] } + }) + + expect(() => store.getState().closeTab('tab-1')).not.toThrow() + expect(mockKill).toHaveBeenCalledWith('pty-local') + expect(mockRuntimeCall).toHaveBeenCalledWith({ + method: 'terminal.close', + params: { terminal: 'runtime-terminal' } + }) + expect(store.getState().tabsByWorktree['wt-1']).toEqual([]) + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith('[terminal-retirement] provider teardown failed', { + tabId: 'tab-1', + localOrSshFailures: 1, + runtimeFailures: 1 + }) + ) + warn.mockRestore() + }) + it('keeps the tab retired and reports provider rejection without an unhandled promise', async () => { const store = createRetirementStore() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 90cea807728..4286dd0ac53 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1,1030 +1,23 @@ -/* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { WorkspaceKey } from '../../../../shared/folder-workspace-types' -import type { Repo } from '../../../../shared/repo-types' -import type { Tab } from '../../../../shared/tab-types' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' -import type { Worktree } from '../../../../shared/worktree/types' -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig -} from '../../../../shared/agent-session-resume' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' -import { - DEFAULT_REPO_BADGE_COLOR, - FLOATING_TERMINAL_WORKTREE_ID -} from '../../../../shared/constants' -import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { - folderWorkspaceKey, - parseWorkspaceKey, - worktreeWorkspaceKey -} from '../../../../shared/workspace-scope' -import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' -import { - isTerminalLeafId, - makePaneKey, - parseLegacyNumericPaneKey, - parsePaneKey -} from '../../../../shared/stable-pane-id' -import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' -import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index' -import { resolveActiveTabOwnerWorktreeId } from './active-tab-owner-worktree' -import { isSameCodexRestartNoticeAccount } from './codex-restart-notice-account-identity' -import { - getRepoIdFromWorktreeId, - splitWorktreeIdForFilesystem -} from '../../../../shared/worktree/id' -import { isWslUncPath } from '../../../../shared/wsl-paths' -import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' -import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' -import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' -import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' -import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' -import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' -import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' -import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' -import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' -import { sweepRetiredTerminalTabState } from './retired-terminal-tab-state-sweep' -import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' -import { - collectReleasedLeafIds, - hydrateWorkspaceTerminalRows, - releaseTerminalLayoutPtyIds -} from './terminal-session-row-hydration' -import { - getRecentlyClosedTabPosition, - pushClosedTerminalTabSnapshot, - pushRecentlyClosedTabKind -} from './recently-closed-tabs' -import { isClaudeAgent } from '@/lib/agent-status' -import { recordTerminalInputActivity } from '@/lib/terminal-input-activity-coalescing' -import { classifyTitleActivity } from '@/lib/pane-agent-evidence' -import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' -import { - applyGeneratedTabTitleUpdates, - applyTerminalTabTitleUpdates, - type GeneratedTabTitleUpdate, - type TerminalTabTitleUpdate -} from './terminal-tab-title-batch' -import { - adoptTerminalTabOwnerMetadataOnlyBuckets, - getTerminalTabOwnerWorktreeId -} from './terminal-tab-owner-index' -import { - dedupeTabOrder, - ensureGroup, - findTabByEntityInGroup, - pushRecentTabId, - sanitizeRecentTabIds, - updateGroup -} from './tab-group-state' -import { - restorePtyDataHandlersAfterFailedShutdown, - unregisterPtyDataHandlers -} from '@/components/terminal-pane/pty-transport' -// Why: use the store-free registry (not terminal-parked-tab-watchers, which imports @/store) to avoid re-entering store creation during this slice's eval. -import { - disposeParkedTerminalWatchersForPtyIds, - retireParkedTerminalTab -} from '@/components/terminal-pane/terminal-parked-watcher-registry' -import { - clearCommittedPtyShutdownSettlements, - hasCommittedPtyShutdownSettlement, - markCommittedPtyShutdowns, - noteCommittedPtyShutdownSettlements, - settleDeferredPtyShutdownExits -} from '@/components/terminal-pane/pty-shutdown-exit-deferral' -import { - normalizeTerminalLayoutSnapshot, - resolvePtyBoundActiveLeafId -} from '@/components/terminal-pane/terminal-layout-leaf-ids' -import { - normalizeTerminalLayoutPtyOwnership, - resolveTerminalLayoutPtyOwnershipTransfers -} from '@/components/terminal-pane/terminal-layout-pty-ownership' -import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { requestRemoteWorktreeSleep } from '@/runtime/remote-worktree-sleep' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' -import { - clearDirectSshTerminalBindings, - invalidateStaleDirectSshTerminalBindings, - type DirectSshLivePtyBinding, - type DirectSshPaneRetryAttempt, - type DirectSshPaneRetryAttemptId, - type DirectSshPaneRetryHistory, - type DirectSshPaneRetryResult -} from './direct-ssh-terminal-recovery' -import { - retryDirectSshTerminalPanes, - retrySettledDirectSshTerminalPane -} from './direct-ssh-pane-retry-ledger' -import { - directSshAuthoritiesEqual, - settleDirectSshPaneRetryState, - transferDirectSshPaneDetachLedger -} from './direct-ssh-terminal-authority-ledger' -import { resolveDirectSshTerminalWorkspaceKeys } from './direct-ssh-terminal-workspace-scope' -import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' -import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' -import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' -import { isWebClientLocation } from '@/lib/web-client-location' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' -import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' -import { - addAdditionalValidWorkspaceKeys, - type WorkspaceSessionHydrationOptions -} from '@/lib/workspace-session-hydration-keys' -import { - buildValidWorktreeIdsForSessionHydration, - collectPersistedWorktreeIdsForSessionHydration -} from './degraded-repo-worktree-validity' -import { - collectHibernatedCompletionEvidenceForWorktree, - collectSleepingAgentSessionRecordsForWorktree, - removeSleepingRecordsReplacedByManualWorktreeSleep, - type AgentStatusWorktreeShutdownReason -} from './agent-status' -import { - buildTerminalTabRetirementPlan, - classifyTerminalRetirementWorktree, - isTerminalTabPresent, - removeSleepingAgentSessionsForTab, - type TerminalTabCloseReason, - type TerminalTabRetirementPlan -} from './terminal-tab-retirement' - -function getNextTerminalOrdinal(tabs: TerminalTab[]): number { - const usedOrdinals = new Set() - for (const tab of tabs) { - const match = /^Terminal (\d+)$/.exec(tab.defaultTitle ?? tab.title) - if (!match) { - continue - } - usedOrdinals.add(Number(match[1])) - } - - let nextOrdinal = 1 - while (usedOrdinals.has(nextOrdinal)) { - nextOrdinal += 1 - } - return nextOrdinal -} - -function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { - return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null -} - -function isCurrentDirectSshAuthority(state: AppState, authority: DirectSshAuthority): boolean { - const current = state.sshConnectionStates.get(authority.targetId) - return Boolean( - current?.status === 'connected' && - current.providerEpoch === authority.providerEpoch && - current.connectionGeneration === authority.connectionGeneration - ) -} - -function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set { - return resolveDirectSshTerminalWorkspaceKeys( - { - targetId, - catalogRevision: 0, - repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, - folderWorkspaces: state.folderWorkspaces, - projectGroups: state.projectGroups, - restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey - }, - state.tabsByWorktree, - state.lastKnownRelayPtyIdByTabId - ) -} - -function getPendingActivationSpawnCount(value: boolean | number | undefined): number { - if (value === true) { - return 1 - } - return typeof value === 'number' && value > 0 ? value : 0 -} - -function consumePendingActivationSpawn( - value: boolean | number | undefined -): boolean | number | undefined { - const count = getPendingActivationSpawnCount(value) - if (count <= 1) { - return undefined - } - return count === 2 ? true : count - 1 -} - -function getPathDisplayName(path: string, fallback: string): string { - const normalized = path.trim().replace(/[\\/]+$/g, '') - const basename = normalized.split(/[\\/]/).findLast(Boolean)?.trim() - return basename || fallback -} - -function buildRuntimeSessionPlaceholders({ - repos, - runtimeHostIdByWorkspaceSessionKey, - worktreesByRepo -}: { - repos: readonly Repo[] - runtimeHostIdByWorkspaceSessionKey: Record - worktreesByRepo: Record -}): { repos: Repo[]; worktreesByRepo: Record } { - let nextRepos = repos.slice() - let nextWorktreesByRepo = worktreesByRepo - for (const workspaceSessionKey of Object.keys(runtimeHostIdByWorkspaceSessionKey)) { - const hostId = runtimeHostIdByWorkspaceSessionKey[workspaceSessionKey] - if (parseExecutionHostId(hostId)?.kind !== 'runtime') { - continue - } - const workspaceScope = parseWorkspaceKey(workspaceSessionKey) - if (workspaceScope?.type === 'folder') { - continue - } - const worktreeId = - workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey - // Why: strip the synthetic `::workspace:` suffix so path is the real folder — Git callers must not spawn against a nonexistent cwd. - const parsed = splitWorktreeIdForFilesystem(worktreeId) - if (!parsed) { - continue - } - const existingRepo = nextRepos.some((repo) => repo.id === parsed.repoId) - if (!existingRepo) { - // Why: remote catalogs load after hydration but host-split session writes need owner metadata; skip if the repo id already exists to avoid duplicates. - nextRepos = [ - ...nextRepos, - { - id: parsed.repoId, - path: parsed.worktreePath, - displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), - badgeColor: DEFAULT_REPO_BADGE_COLOR, - addedAt: 0, - connectionId: null, - executionHostId: hostId - } - ] - } - const current = nextWorktreesByRepo[parsed.repoId] ?? [] - if (current.some((worktree) => worktree.id === worktreeId)) { - continue - } - const placeholder: Worktree = { - id: worktreeId, - repoId: parsed.repoId, - hostId, - displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - path: parsed.worktreePath, - head: '', - branch: '', - isBare: false, - isMainWorktree: false - } - nextWorktreesByRepo = - nextWorktreesByRepo === worktreesByRepo ? { ...worktreesByRepo } : nextWorktreesByRepo - nextWorktreesByRepo[parsed.repoId] = [...current, placeholder] - } - return { repos: nextRepos, worktreesByRepo: nextWorktreesByRepo } -} - -function getTabIdFromPaneKey(paneKey: string): string | null { - return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null -} - -function isWindowsRendererRuntime(): boolean { - return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') -} - -function isAllowedRemoteWindowsTerminalShell(shell: string | undefined): boolean { - return ( - shell === 'powershell.exe' || - shell === 'pwsh.exe' || - shell === 'cmd.exe' || - shell === 'wsl.exe' || - shell === WINDOWS_GIT_BASH_SHELL - ) -} - -function resolveCreatedTabShellOverride( - explicitShellOverride: string | undefined, - defaultWindowsShell: string | undefined, - isRemoteWorktree: boolean, - remotePlatform: NodeJS.Platform | null, - isWslWorktree: boolean, - projectRuntime: ProjectExecutionRuntimeResolution | undefined -): string | undefined { - if (isRemoteWorktree) { - if (remotePlatform === 'win32' && isAllowedRemoteWindowsTerminalShell(explicitShellOverride)) { - return explicitShellOverride - } - return undefined - } - if (isWindowsRendererRuntime()) { - return resolveLocalWindowsTerminalShellOverrideForTab({ - explicitShellOverride, - defaultWindowsShell, - isWslWorktree, - projectRuntime - }) - } - if (explicitShellOverride !== undefined) { - return explicitShellOverride - } - return undefined -} - -function worktreeUsesWslPath( - state: Pick, - worktreeId: string -): boolean { - const parsed = parseWorkspaceKey(worktreeId) - if (parsed?.type === 'folder') { - const folderWorkspace = state.folderWorkspaces.find( - (workspace) => workspace.id === parsed.folderWorkspaceId - ) - return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false - } - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - return worktree ? isWslUncPath(worktree.path) : false -} - -export function worktreeUsesRemoteConnection( - state: Pick, - worktreeId: string -): boolean { - const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) - if (parsedWorkspaceKey?.type === 'folder') { - return Boolean(getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)) - } - const directRepoId = getRepoIdFromWorktreeId(worktreeId) - const directRepo = state.repos.find((repo) => repo.id === directRepoId) - if (directRepo) { - return Boolean(directRepo.connectionId) - } - - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null - return Boolean(repo?.connectionId) -} - -function getRemoteConnectionIdForWorktree( - state: Pick, - worktreeId: string -): string | null { - const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) - if (parsedWorkspaceKey?.type === 'folder') { - return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) ?? null - } - const directRepoId = getRepoIdFromWorktreeId(worktreeId) - const directRepo = state.repos.find((repo) => repo.id === directRepoId) - if (directRepo) { - return directRepo.connectionId?.trim() || null - } - - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === worktreeId) - const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null - return repo?.connectionId?.trim() || null -} - -function resolveTerminalStopRuntimeEnvironmentId( - state: Pick, - worktreeId: string -): string | null { - return getRuntimeEnvironmentIdForWorktree(state, worktreeId) -} - -function sortedUniquePtyIds(ptyIds: readonly string[] | undefined): string[] { - return [...new Set((ptyIds ?? []).filter((ptyId) => ptyId.length > 0))].sort() -} - -function equalStringSets(a: readonly string[], b: readonly string[]): boolean { - if (a.length !== b.length) { - return false - } - const bSet = new Set(b) - return a.every((value) => bSet.has(value)) -} - -function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] { - return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] -} - -function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { - const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} - const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined - return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null -} - -function withTerminalTabPtyId( - tabsByWorktree: Record, - tabId: string, - ptyId: string | null -): Record { - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - const index = tabs.findIndex((tab) => tab.id === tabId) - if (index === -1) { - continue - } - if (tabs[index]?.ptyId === ptyId) { - return tabsByWorktree - } - const nextTabs = [...tabs] - nextTabs[index] = { ...nextTabs[index]!, ptyId } - return { ...tabsByWorktree, [worktreeId]: nextTabs } - } - return tabsByWorktree -} - -export type AutomaticAgentResumeClaim = { - worktreeId: string - launchAgent: TuiAgent - providerSession: AgentProviderSessionMetadata -} - -export type CodexRestartNotice = { - previousAccountLabel: string - nextAccountLabel: string - /** Ids behind the two labels, when the caller knows them (`null` is the system - * default). Two accounts can share a label, so only these can decide whether - * a re-mark actually points back at the account the pane launched under. */ - previousAccountId?: string | null - nextAccountId?: string | null - /** The launch account still matches, but its baked CODEX_HOME route is retired. */ - homeRouteChanged?: true - /** Set once the user asks for the restart. The record outlives the prompt - * because the previous-account fields are the only memory of the account this - * pane actually launched under, which drives the A -> B -> A collapse. */ - restartRequested?: true - /** Set when the user answers "Keep old account". Same reason the record has to - * survive: deleting it erased the launch account, so re-selecting it looked - * like a fresh switch and raised an inverted prompt that killed pane input. */ - dismissed?: true -} - -export type TerminalSlice = { - tabsByWorktree: Record - activeTabId: string | null - /** Per-worktree last-active tab, restored on worktree switch so the user returns to where they left, not tabs[0]. */ - activeTabIdByWorktree: Record - ptyIdsByTabId: Record - /** Live pane titles by tabId then paneId; preserves per-pane agent status (unlike the legacy tab title) while TerminalPane is mounted. */ - runtimePaneTitlesByTabId: Record> - /** Per-tab unread flags (BEL or agent-complete); ephemeral UI state, not persisted. Cleared when the user activates/interacts with the tab. */ - unreadTerminalTabs: Record - /** Pane-keyed attention marker (narrower than unreadTerminalTabs); clears when the user interacts with the exact pane that raised it. */ - unreadTerminalPanes: Record - /** Agent-completion marker for focus-return auto-ack; separate from unreadTerminalPanes so generic bells still show until interact. */ - unreadAgentCompletionPanes: Record - // Remote guard keys must use renderer-visible, environment-scoped PTY ids; raw runtime handles are only valid at the RPC boundary. - suppressedPtyExitIds: Record - /** Reference-counted so overlapping shutdowns retain renderer PTY bindings until every owner settles. */ - pendingPtyShutdownIds: Record - pendingCodexPaneRestartIds: Record - codexRestartNoticeByPtyId: Record - directSshPaneRetryByTabId: Record - directSshLivePtyBindingByTabId: Record - directSshPaneRetryHistoryByTabId: Record - expandedPaneByTabId: Record - canExpandPaneByTabId: Record - terminalLayoutsByTabId: Record - /** Most recent quick-command id per group; in-memory only so a deleted command's stale id can't surface as the split-button label. */ - recentQuickCommandIdByGroup: Record - setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void - /** Runtime-only claim for auto sleeping-session recovery tabs; bridges the gap between startup payload consumption and hooks going live. */ - automaticAgentResumeClaimsByTabId: Record - claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void - /** Launch-time native-chat prompt echo, keyed by terminal tab. In-memory only. */ - nativeChatLaunchPromptByTabId: Record - seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void - markNativeChatLaunchPromptFailed: (tabId: string) => void - clearNativeChatLaunchPrompt: (tabId: string) => void - /** Launch context prefilled into the TUI input as an unsent draft; the chat composer adopts it. In-memory only. */ - nativeChatLaunchDraftByTabId: Record - seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void - markNativeChatLaunchDraftAdopted: (tabId: string) => void - resolveNativeChatLaunchDraft: ( - tabId: string, - resolution: Pick - ) => void - clearNativeChatLaunchDraft: (tabId: string) => void - pendingStartupByTabId: Record< - string, - { - command: string - /** Renderer-delivered startup input for callers needing xterm paste semantics before the submit Enter. */ - delivery?: 'terminal-paste' - startupCommandDelivery?: StartupCommandDelivery - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - /** Explicit CLI override for host-owned agent launches; omission uses host settings. */ - agentArgsOverride?: string | null - draftPrompt?: string - sessionOptions?: Record - /** Initial prompt-start status for agents that lack native prompt hooks. */ - initialAgentStatus?: { agent: TuiAgent; prompt: string } - /** Show the restored-session banner when this startup command mounts. */ - showSessionRestoredBanner?: boolean - /** Telemetry for the `agent_started` event; threaded to the pty:spawn handler so it fires only after spawn confirms, not on click-intent. */ - telemetry?: AgentStartedTelemetry - } - > - pendingInitialCwdByTabId: Record - /** Queued setup-split requests; TerminalPane splits and runs the command in a new pane so the main terminal stays immediately interactive. */ - pendingSetupSplitByTabId: Record< - string, - { command: string; env?: Record; direction: SetupSplitDirection } - > - /** Queued issue-command-split requests, triggered when an issue is linked at worktree creation and the repo's issue automation is enabled. */ - pendingIssueCommandSplitByTabId: Record }> - tabBarOrderByWorktree: Record - workspaceSessionReady: boolean - restoredRuntimeHostIdByWorkspaceSessionKey: Record - defaultTerminalTabsAppliedByWorktreeId: Record - markDefaultTerminalTabsApplied: (worktreeId: string) => void - /** True only after hydrateWorkspaceSession loaded real orca-data.json; guards the session writer so an early-startup crash can't overwrite good data on disk. */ - hydrationSucceeded: boolean - setHydrationSucceeded: (value: boolean) => void - pendingReconnectWorktreeIds: string[] - pendingReconnectTabByWorktree: Record - /** tabId → previous session's ptyId; for daemon backends it doubles as the sessionId, so spawn createOrAttach returns the surviving terminal. */ - pendingReconnectPtyIdByTabId: Record - // Why: clearTabPtyId nulls tab.ptyId on disconnect; keep the last relay ID here so session save can still capture it for reattach after restart. - lastKnownRelayPtyIdByTabId: Record - /** ANSI snapshots from daemon reattach, keyed by new ptyId; TerminalPane writes them to xterm.js to restore visual state. */ - pendingSnapshotByPtyId: Record< - string, - { snapshot: string; cols?: number; rows?: number; isAlternateScreen?: boolean } - > - consumePendingSnapshot: ( - ptyId: string - ) => { snapshot: string; cols?: number; rows?: number; isAlternateScreen?: boolean } | null - /** Cold-restore data (read-only scrollback shown above the fresh prompt) from disk history after a daemon crash, keyed by the new ptyId. */ - pendingColdRestoreByPtyId: Record - consumePendingColdRestore: (ptyId: string) => { scrollback: string; cwd: string } | null - createTab: ( - worktreeId: string, - targetGroupId?: string, - shellOverride?: string, - options?: { - pendingActivationSpawn?: boolean - initialPtyId?: string - activate?: boolean - recordInteraction?: boolean - /** Pre-allocated tab id (main mints it for CLI/runtime PTYs with a baked pane key); minted fresh on omit or cross-worktree collision. */ - id?: string - /** Coding-harness agent launched here, recorded so the tab bar shows the provider icon before the agent's first hook event. */ - launchAgent?: TuiAgent - quickCommandLabel?: string | null - /** Initial native-chat view mode; agent launches pass 'chat' when openAgentTabsInChatByDefault is on, else omitted for the 'terminal' default. */ - viewMode?: Tab['viewMode'] - startupCwd?: string - forceHostRuntime?: boolean - } - ) => TerminalTab - openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise - closeTab: ( - tabId: string, - opts?: { - recordInteraction?: boolean - reason?: TerminalTabCloseReason - captureRecentlyClosed?: boolean - remoteCloseOwnedByHost?: boolean - localPtyTeardownOwnedExternally?: boolean - precomputedRetirementPlan?: TerminalTabRetirementPlan - } - ) => void - reorderTabs: (worktreeId: string, tabIds: string[]) => void - setTabBarOrder: (worktreeId: string, order: string[]) => void - setActiveTab: (tabId: string) => void - setActiveTabForWorktree: (worktreeId: string, tabId: string) => void - /** Resolve the canonical legacy terminal-tab owner key for renderer lifecycle guards. */ - getTerminalTabOwnerWorktreeId?: (tabId: string) => string | null - updateTabTitle: (tabId: string, title: string) => void - updateTabTitles: (updates: readonly TerminalTabTitleUpdate[]) => void - setAiVaultTabTitle: (tabId: string, aiVaultTitle: AiVaultSessionTitle | null) => void - setGeneratedTabTitleFromAgentPrompt: ( - paneKey: string, - prompt: string, - options?: { replaceExistingGeneratedTitle?: boolean } - ) => void - setGeneratedTabTitlesFromAgentPrompts: (updates: readonly GeneratedTabTitleUpdate[]) => void - clearTabLaunchAgent: (tabId: string) => void - setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void - clearRuntimePaneTitle: (tabId: string, paneId: number) => void - /** Mark a tab unread (agent working→idle); skipped when the tab is visible, since a "seen" flag would never clear. */ - markTerminalTabUnread: (tabId: string) => void - markTerminalPaneUnread: (paneKey: string) => void - markAgentCompletionPaneUnread: (paneKey: string) => void - /** Clear a tab's unread indicator on user interaction (ghostty "show until interact" model). */ - clearTerminalTabUnread: (tabId: string) => void - clearTerminalPaneUnread: (paneKey: string) => void - setTabCustomTitle: ( - tabId: string, - title: string | null, - opts?: { recordInteraction?: boolean } - ) => void - setTabColor: (tabId: string, color: string | null) => void - updateTabPtyId: ( - tabId: string, - ptyId: string, - replacedPtyId?: string, - directSshRetryAttemptId?: DirectSshPaneRetryAttemptId - ) => void - clearTabPtyId: (tabId: string, ptyId?: string) => void - clearDirectSshTargetPtyBindings: (targetId: string) => number - invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number - retryDirectSshTargetPanes: (authority: DirectSshAuthority, now?: number) => number - settleDirectSshPaneRetry: (result: DirectSshPaneRetryResult, now?: number) => void - shutdownWorktreeTerminals: ( - worktreeId: string, - opts?: { - keepIdentifiers?: boolean - shutdownReason?: AgentStatusWorktreeShutdownReason - sleepingPaneKeys?: string[] - expectedRuntimePtyIds?: string[] - /** Opt-in for callers that already tore the workspace down backend-side; omitting it keeps the runtime stop. */ - backendOwnsPtyTeardown?: boolean - } - ) => Promise - shutdownCompletedAgentPaneForHibernation: ( - worktreeId: string, - opts: { - paneKey: string - tabId: string - leafId: string - ptyId: string - expectedRuntimePtyId?: string - } - ) => Promise - suppressPtyExit: (ptyId: string) => void - consumeSuppressedPtyExit: (ptyId: string) => boolean - isPtyShutdownPending: (ptyId: string) => boolean - queueCodexPaneRestarts: (ptyIds: string[]) => void - consumePendingCodexPaneRestart: (ptyId: string) => boolean - /** Returns the ptyIds left holding a notice, so callers can tell a raised - * prompt from one the A -> B -> A collapse dropped. */ - markCodexRestartNotices: ( - notices: (Pick & - Partial> & { - /** Explicit false replaces a restored route notice after main rechecks it. */ - homeRouteChanged?: boolean - ptyId: string - })[] - ) => string[] - clearCodexRestartNotice: (ptyId: string) => void - dismissCodexRestartNotices: (ptyIds: string[]) => void - /** Puts an accepted-but-unexecuted restart back to the unanswered prompt, so a - * failed execution never leaves a pane input-blocked with nothing visible. */ - reopenCodexRestartPrompt: (ptyId: string) => void - /** Rebinds one layout leaf to a replacement PTY without a mounted pane. */ - replaceTerminalLayoutPanePtyId: (tabId: string, leafId: string, ptyId: string) => void - setTabPaneExpanded: (tabId: string, expanded: boolean) => void - setTabCanExpandPane: (tabId: string, canExpand: boolean) => void - setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void - syncPaneDetachPtyOwnership: (args: { - detachedLeafId: string - detachedPtyId: string | null - sourceLayout: TerminalLayoutSnapshot - sourceTabId: string - targetTabId: string - }) => void - queueTabStartupCommand: ( - tabId: string, - startup: { - command: string - delivery?: 'terminal-paste' - startupCommandDelivery?: StartupCommandDelivery - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - agentArgsOverride?: string | null - draftPrompt?: string - sessionOptions?: Record - initialAgentStatus?: { agent: TuiAgent; prompt: string } - showSessionRestoredBanner?: boolean - telemetry?: AgentStartedTelemetry - } - ) => void - queueTabInitialCwd: (tabId: string, cwd: string) => void - consumeTabInitialCwd: (tabId: string) => string | null - consumeTabStartupCommand: (tabId: string) => { - command: string - delivery?: 'terminal-paste' - startupCommandDelivery?: StartupCommandDelivery - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - agentArgsOverride?: string | null - draftPrompt?: string - sessionOptions?: Record - initialAgentStatus?: { agent: TuiAgent; prompt: string } - showSessionRestoredBanner?: boolean - telemetry?: AgentStartedTelemetry - } | null - queueTabSetupSplit: ( - tabId: string, - startup: { command: string; env?: Record; direction: SetupSplitDirection } - ) => void - consumeTabSetupSplit: ( - tabId: string - ) => { command: string; env?: Record; direction: SetupSplitDirection } | null - queueTabIssueCommandSplit: ( - tabId: string, - issueCommand: { command: string; env?: Record } - ) => void - consumeTabIssueCommandSplit: ( - tabId: string - ) => { command: string; env?: Record } | null - /** `${tabId}:${leafId}` → ms when the prompt-cache countdown started (agent idle); null means no active timer for that pane. */ - cacheTimerByKey: Record - setCacheTimerStartedAt: (key: string, ts: number | null) => void - /** paneKey → wall-clock user input time; hibernation uses it to avoid sleeping a completed agent pane the user turned into a shell. */ - lastTerminalInputAtByPaneKey: Record - recordTerminalInput: (paneKey: string, timestamp?: number) => void - /** Seed cache timers for idle Claude sessions missing one; called when the feature is enabled mid-session. */ - seedCacheTimersForIdleTabs: () => void - /** SSH target IDs needing a passphrase; reconnect is deferred until the user focuses an affected terminal tab. */ - deferredSshReconnectTargets: string[] - /** tabId → remote PTY session ID for deferred (passphrase) SSH tabs; survives the startup clear because reconnect runs later, on focus. */ - deferredSshSessionIdsByTabId: Record - setDeferredSshReconnectTargets: (targetIds: string[]) => void - removeDeferredSshReconnectTarget: (targetId: string) => void - removeDeferredSshSessionId: (tabId: string) => void - hydrateWorkspaceSession: ( - session: WorkspaceSessionState, - options?: HydrateWorkspaceSessionOptions - ) => void - reconnectPersistedTerminals: ( - signal?: AbortSignal, - options?: ReconnectPersistedTerminalsOptions - ) => Promise -} - -export type HydrateWorkspaceSessionOptions = { - directSshAuthority?: DirectSshAuthority - runtimeHostIdByWorkspaceSessionKey?: Record -} & WorkspaceSessionHydrationOptions - -export type ReconnectPersistedTerminalsOptions = { - directSshAuthority: DirectSshAuthority - workspaceKeys: readonly string[] -} - -type WorkspaceHydrationPatch = Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'activeWorkspaceKey' - | 'activeWorkspaceExecutionHostId' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'restoredRuntimeHostIdByWorkspaceSessionKey' - | 'repos' - | 'tabsByWorktree' - | 'worktreesByRepo' - | 'lastVisitedAtByWorktreeId' - | 'defaultTerminalTabsAppliedByWorktreeId' - | 'automaticAgentResumeClaimsByTabId' - | 'sleepingAgentSessionsByPaneKey' - | 'pendingReconnectWorktreeIds' - | 'pendingReconnectTabByWorktree' - | 'pendingReconnectPtyIdByTabId' - | 'everActivatedWorktreeIds' - | 'worktreeNavHistory' - | 'worktreeNavHistoryIndex' - | 'ptyIdsByTabId' - | 'terminalLayoutsByTabId' -> - -function replaceHydratedRecordKeys( - current: Record, - hydrated: Record, - replaceKeys: ReadonlySet -): Record { - return { - ...Object.fromEntries(Object.entries(current).filter(([key]) => !replaceKeys.has(key))), - ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => replaceKeys.has(key))) - } -} - -type TerminalLayoutPtyOwnershipTransfer = ReturnType< - typeof resolveTerminalLayoutPtyOwnershipTransfers ->[number] - -function transferNormalizedTerminalLayoutPtyOwnership( - state: Pick, - tabId: string, - transfers: readonly TerminalLayoutPtyOwnershipTransfer[] -): void { - if (transfers.length === 0) { - return - } - const ptyIdsOwnedByOtherTabs = new Set() - for (const [candidateTabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { - if (candidateTabId === tabId) { - continue - } - for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { - ptyIdsOwnedByOtherTabs.add(ptyId) - } - } - for (const { removedLeafId, retainedLeafId, ptyId } of transfers) { - if ( - !isTerminalLeafId(removedLeafId) || - !isTerminalLeafId(retainedLeafId) || - ptyIdsOwnedByOtherTabs.has(ptyId) - ) { - continue - } - const fromPaneKey = makePaneKey(tabId, removedLeafId) - const currentOwner = parsePaneKey(resolveAgentPaneAuthorityKey(fromPaneKey)) - if (currentOwner && currentOwner.tabId !== tabId) { - continue - } - state.transferAgentPaneAuthority({ - fromPaneKey, - toPaneKey: makePaneKey(tabId, retainedLeafId), - ptyId - }) - } -} - -function targetScopedWorkspaceHydrationPatch( - state: AppState, - hydrated: WorkspaceHydrationPatch, - session: WorkspaceSessionState, - options: HydrateWorkspaceSessionOptions -): Partial { - const workspaceKeys = new Set(options.replaceWorkspaceKeys) - const targetTabIds = new Set( - [...workspaceKeys].flatMap((workspaceKey) => [ - ...(state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id), - ...(session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ]) - ) - const retainedTargetTabIds = new Set( - [...workspaceKeys].flatMap((workspaceKey) => - (hydrated.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ) - ) - const deletedTargetTabIds = new Set( - [...workspaceKeys] - .flatMap((workspaceKey) => (state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)) - .filter((tabId) => !retainedTargetTabIds.has(tabId)) - ) - const pendingReconnectPtyIdByTabId = replaceHydratedRecordKeys( - state.pendingReconnectPtyIdByTabId, - {}, - targetTabIds - ) - const authority = options.directSshAuthority - if (authority) { - for (const workspaceKey of hydrated.pendingReconnectWorktreeIds) { - if (!workspaceKeys.has(workspaceKey)) { - continue - } - for (const tab of session.tabsByWorktree[workspaceKey] ?? []) { - // Why: rows hydration dropped (invalid id, canonical duplicate) would leak reconnect keys nothing owns. - if (!retainedTargetTabIds.has(tab.id)) { - continue - } - const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId - if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) { - pendingReconnectPtyIdByTabId[tab.id] = ptyId - } - } - } - } - const activeOutsideScope = - state.activeWorktreeId != null && !workspaceKeys.has(state.activeWorktreeId) - const sleepingAgentSessionsByPaneKey = Object.fromEntries([ - ...Object.entries(state.sleepingAgentSessionsByPaneKey).filter( - ([, record]) => !workspaceKeys.has(record.worktreeId) - ), - ...Object.entries(hydrated.sleepingAgentSessionsByPaneKey).filter(([, record]) => - workspaceKeys.has(record.worktreeId) - ) - ]) - const everActivatedWorktreeIds = new Set(state.everActivatedWorktreeIds) - for (const workspaceKey of hydrated.everActivatedWorktreeIds) { - if (workspaceKeys.has(workspaceKey)) { - everActivatedWorktreeIds.add(workspaceKey) - } - } - return { - activeRepoId: activeOutsideScope ? state.activeRepoId : hydrated.activeRepoId, - activeWorktreeId: activeOutsideScope ? state.activeWorktreeId : hydrated.activeWorktreeId, - activeWorkspaceKey: activeOutsideScope ? state.activeWorkspaceKey : hydrated.activeWorkspaceKey, - activeWorkspaceExecutionHostId: activeOutsideScope - ? state.activeWorkspaceExecutionHostId - : hydrated.activeWorkspaceExecutionHostId, - activeTabId: activeOutsideScope ? state.activeTabId : hydrated.activeTabId, - activeTabIdByWorktree: replaceHydratedRecordKeys( - state.activeTabIdByWorktree, - hydrated.activeTabIdByWorktree, - workspaceKeys - ), - tabsByWorktree: replaceHydratedRecordKeys( - state.tabsByWorktree, - hydrated.tabsByWorktree, - workspaceKeys - ), - lastVisitedAtByWorktreeId: replaceHydratedRecordKeys( - state.lastVisitedAtByWorktreeId, - hydrated.lastVisitedAtByWorktreeId, - workspaceKeys - ), - defaultTerminalTabsAppliedByWorktreeId: replaceHydratedRecordKeys( - state.defaultTerminalTabsAppliedByWorktreeId, - hydrated.defaultTerminalTabsAppliedByWorktreeId, - workspaceKeys - ), - automaticAgentResumeClaimsByTabId: replaceHydratedRecordKeys( - state.automaticAgentResumeClaimsByTabId, - hydrated.automaticAgentResumeClaimsByTabId, - targetTabIds - ), - sleepingAgentSessionsByPaneKey, - pendingReconnectWorktreeIds: [ - ...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)), - ...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key)) - ], - pendingReconnectTabByWorktree: replaceHydratedRecordKeys( - state.pendingReconnectTabByWorktree, - hydrated.pendingReconnectTabByWorktree, - workspaceKeys - ), - pendingReconnectPtyIdByTabId, - everActivatedWorktreeIds, - directSshPaneRetryByTabId: replaceHydratedRecordKeys( - state.directSshPaneRetryByTabId, - {}, - deletedTargetTabIds - ), - directSshLivePtyBindingByTabId: replaceHydratedRecordKeys( - state.directSshLivePtyBindingByTabId, - {}, - deletedTargetTabIds - ), - directSshPaneRetryHistoryByTabId: replaceHydratedRecordKeys( - state.directSshPaneRetryHistoryByTabId, - {}, - deletedTargetTabIds - ), - ptyIdsByTabId: replaceHydratedRecordKeys( - state.ptyIdsByTabId, - hydrated.ptyIdsByTabId, - targetTabIds - ), - terminalLayoutsByTabId: replaceHydratedRecordKeys( - state.terminalLayoutsByTabId, - hydrated.terminalLayoutsByTabId, - targetTabIds - ) - } -} +import type { TerminalSlice } from '../terminals/terminal-state' +import { createTerminalEphemeralActions } from '../terminals/terminal-ephemeral-state' +import { createTerminalTabCreationActions } from '../terminals/terminal-tab-creation' +import { createActiveWorkspaceTerminalActions } from '../terminals/terminal-active-workspace-creation' +import { createTerminalTabCloseActions } from '../terminals/terminal-tab-close' +import { createTerminalTabNavigationActions } from '../terminals/terminal-tab-navigation' +import { createTerminalTabPresentationActions } from '../terminals/terminal-tab-presentation' +import { createTerminalTabAttentionActions } from '../terminals/terminal-tab-attention' +import { createTerminalPtyBindingActions } from '../terminals/terminal-pty-bindings' +import { createTerminalPtyReleaseActions } from '../terminals/terminal-pty-release' +import { createTerminalPaneHibernationActions } from '../terminals/terminal-pane-hibernation' +import { createDirectSshTerminalBindingActions } from '../terminals/direct-ssh-terminal-bindings' +import { createTerminalShutdownActions } from '../terminals/terminal-shutdown' +import { createTerminalRestartActions } from '../terminals/terminal-restart-state' +import { createTerminalLayoutActions } from '../terminals/terminal-layout-state' +import { createTerminalStartupQueueActions } from '../terminals/terminal-startup-queues' +import { createWorkspaceTerminalHydrationActions } from '../terminals/workspace-terminal-hydration' +import { createWorkspaceTerminalReconnectActions } from '../terminals/workspace-terminal-reconnect' export const createTerminalSlice: StateCreator = (set, get) => ({ tabsByWorktree: {}, @@ -1056,22 +49,7 @@ export const createTerminalSlice: StateCreator workspaceSessionReady: false, restoredRuntimeHostIdByWorkspaceSessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, - markDefaultTerminalTabsApplied: (worktreeId) => - set((s) => { - if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { - return {} - } - return { - defaultTerminalTabsAppliedByWorktreeId: { - ...s.defaultTerminalTabsAppliedByWorktreeId, - [worktreeId]: true - } - } - }), hydrationSucceeded: false, - setHydrationSucceeded: (value) => { - set({ hydrationSucceeded: value }) - }, pendingReconnectWorktreeIds: [], pendingReconnectTabByWorktree: {}, pendingReconnectPtyIdByTabId: {}, @@ -1083,3242 +61,21 @@ export const createTerminalSlice: StateCreator cacheTimerByKey: {}, lastTerminalInputAtByPaneKey: {}, recentQuickCommandIdByGroup: {}, - - setRecentQuickCommandForGroup: (groupId, quickCommandId) => { - set((s) => ({ - recentQuickCommandIdByGroup: { - ...s.recentQuickCommandIdByGroup, - [groupId]: quickCommandId - } - })) - }, - - claimAutomaticAgentResume: (tabId, claim) => { - set((s) => ({ - automaticAgentResumeClaimsByTabId: { - ...s.automaticAgentResumeClaimsByTabId, - [tabId]: claim - } - })) - }, - - seedNativeChatLaunchPrompt: (prompt) => { - set((s) => ({ - nativeChatLaunchPromptByTabId: { - ...s.nativeChatLaunchPromptByTabId, - [prompt.tabId]: prompt - } - })) - }, - - markNativeChatLaunchPromptFailed: (tabId) => { - set((s) => { - const current = s.nativeChatLaunchPromptByTabId[tabId] - if (!current || current.failed) { - return {} - } - return { - nativeChatLaunchPromptByTabId: { - ...s.nativeChatLaunchPromptByTabId, - [tabId]: { ...current, failed: true } - } - } - }) - }, - - clearNativeChatLaunchPrompt: (tabId) => { - set((s) => { - if (!s.nativeChatLaunchPromptByTabId[tabId]) { - return {} - } - const next = { ...s.nativeChatLaunchPromptByTabId } - delete next[tabId] - return { nativeChatLaunchPromptByTabId: next } - }) - }, - - seedNativeChatLaunchDraft: (draft) => { - set((s) => ({ - nativeChatLaunchDraftByTabId: { - ...s.nativeChatLaunchDraftByTabId, - [draft.tabId]: draft - } - })) - }, - - markNativeChatLaunchDraftAdopted: (tabId) => { - set((s) => { - const current = s.nativeChatLaunchDraftByTabId[tabId] - if (!current || current.adopted) { - return {} - } - return { - nativeChatLaunchDraftByTabId: { - ...s.nativeChatLaunchDraftByTabId, - [tabId]: { ...current, adopted: true } - } - } - }) - }, - - resolveNativeChatLaunchDraft: (tabId, resolution) => { - set((s) => { - const current = s.nativeChatLaunchDraftByTabId[tabId] - if ( - !current || - current.resolved || - current.createdAt !== resolution.createdAt || - current.text !== resolution.text - ) { - return {} - } - return { - nativeChatLaunchDraftByTabId: { - ...s.nativeChatLaunchDraftByTabId, - [tabId]: { ...current, resolved: true } - } - } - }) - }, - - clearNativeChatLaunchDraft: (tabId) => { - set((s) => { - if (!s.nativeChatLaunchDraftByTabId[tabId]) { - return {} - } - const next = { ...s.nativeChatLaunchDraftByTabId } - delete next[tabId] - return { nativeChatLaunchDraftByTabId: next } - }) - }, - - recordTerminalInput: (paneKey, timestamp = Date.now()) => { - if (!paneKey || !Number.isFinite(timestamp)) { - return - } - recordTerminalInputActivity({ - paneKey, - timestamp, - // Why: the first stamp for a pane must land synchronously; automation take-over - // detection subscribes and compares undefined→value across a launch. - forceWrite: get().lastTerminalInputAtByPaneKey[paneKey] === undefined, - commit: { - insert: (key, at) => - set((s) => ({ - lastTerminalInputAtByPaneKey: { ...s.lastTerminalInputAtByPaneKey, [key]: at } - })), - refreshExisting: (entries) => - set((s) => { - let next: Record | null = null - for (const [key, at] of entries) { - // Why: teardown (close pane/tab/worktree purge) deletes keys; a late flush must not resurrect them. - const current = s.lastTerminalInputAtByPaneKey[key] - if (current === undefined || current >= at) { - continue - } - next ??= { ...s.lastTerminalInputAtByPaneKey } - next[key] = at - } - return next ? { lastTerminalInputAtByPaneKey: next } : {} - }) - } - }) - }, - - setCacheTimerStartedAt: (key, ts) => { - set((s) => { - // Why: a real pane write clears any ':seed' sentinel from seedCacheTimersForIdleTabs, avoiding phantom timers when the seed key doesn't match the real pane. - const colonIdx = key.indexOf(':') - const suffix = colonIdx === -1 ? null : key.slice(colonIdx + 1) - const seedKey = colonIdx !== -1 && suffix !== 'seed' ? `${key.slice(0, colonIdx)}:seed` : null - const hasStaleSeed = seedKey !== null && seedKey in s.cacheTimerByKey - // Why: parked-pane watchers replay null-over-null on every working/exit transition; each redundant write runs every subscriber's selector. - if (s.cacheTimerByKey[key] === ts && !hasStaleSeed) { - return s - } - const next = { ...s.cacheTimerByKey, [key]: ts } - if (seedKey !== null) { - delete next[seedKey] - } - return { cacheTimerByKey: next } - }) - }, - - seedCacheTimersForIdleTabs: () => { - // Why: tabs already idle when the feature is enabled mid-session missed their working→idle transition, so seed timers for them. - const s = get() - const now = Date.now() - const updates: Record = {} - for (const tabs of Object.values(s.tabsByWorktree)) { - for (const tab of tabs) { - if (!tab.title || !isClaudeAgent(tab.title)) { - continue - } - const status = classifyTitleActivity(tab.title) - if (status === null || status === 'working') { - continue - } - // Why: the store doesn't know which pane holds the idle session, so use a ':seed' sentinel; setCacheTimerStartedAt clears it on any real pane write. - const key = `${tab.id}:seed` - if (s.cacheTimerByKey[key] == null) { - updates[key] = now - } - } - } - if (Object.keys(updates).length > 0) { - set((s) => ({ - cacheTimerByKey: { ...s.cacheTimerByKey, ...updates } - })) - } - }, - - setDeferredSshReconnectTargets: (targetIds) => set({ deferredSshReconnectTargets: targetIds }), - removeDeferredSshReconnectTarget: (targetId) => - set((s) => ({ - deferredSshReconnectTargets: s.deferredSshReconnectTargets.filter((id) => id !== targetId) - })), - removeDeferredSshSessionId: (tabId) => - set((s) => { - if (!s.deferredSshSessionIdsByTabId[tabId]) { - return {} - } - const next = { ...s.deferredSshSessionIdsByTabId } - delete next[tabId] - return { deferredSshSessionIdsByTabId: next } - }), - - createTab: (worktreeId, targetGroupId, shellOverride, options) => { - let tab!: TerminalTab - set((s) => { - const orphanTerminalIds = getOrphanTerminalIds(s, worktreeId) - const orphanCleanupPatch = buildOrphanTerminalCleanupPatch(s, worktreeId, orphanTerminalIds) - const existing = (s.tabsByWorktree[worktreeId] ?? []).filter( - (entry) => !orphanTerminalIds.has(entry.id) - ) - // Why: honor a caller-supplied tab id but mint on collision — aliasing two PTYs to one id corrupts agent-status routing. See docs/cli-terminal-hook-pane-key.md. - // Why: only honor a non-empty trimmed hint; useIpcEvents spreads `id` whenever tabId !== undefined, so a stray '' would break paneKey routing. - const trimmedHint = typeof options?.id === 'string' ? options.id.trim() : '' - const hintedId = - trimmedHint.length > 0 && isValidHostTerminalTabId(trimmedHint) ? trimmedHint : undefined - const idCollides = - hintedId !== undefined && - Object.values(s.tabsByWorktree).some((tabs) => tabs.some((entry) => entry.id === hintedId)) - if (idCollides) { - console.warn( - `[createTab] tabId hint ${hintedId} already exists; minting a fresh id (hook attribution will degrade for this terminal)` - ) - } - const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid() - const shouldActivate = options?.activate !== false - const nextOrdinal = getNextTerminalOrdinal(existing) - const defaultTitle = `Terminal ${nextOrdinal}` - const quickCommandLabel = options?.quickCommandLabel?.trim() - const startupCwd = options?.startupCwd - const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId) - const isRemoteWorktree = Boolean(remoteConnectionId) - const isWslWorktree = worktreeUsesWslPath(s, worktreeId) - const createdShellOverride = resolveCreatedTabShellOverride( - shellOverride, - s.settings?.terminalWindowsShell, - // Why: SSH PTYs ignore local Windows shell selection; a local shell icon would mislabel a remote terminal. - isRemoteWorktree, - remoteConnectionId - ? ((s.sshConnectionStates.get(remoteConnectionId) - ?.remotePlatform as NodeJS.Platform | null) ?? null) - : null, - // Why: new terminals enter the worktree's repo-scoped WSL distro even when the global Windows shell is PowerShell/cmd.exe. - isWslWorktree, - isRemoteWorktree || options?.forceHostRuntime - ? undefined - : getLocalProjectExecutionRuntimeContext(s, worktreeId) - ) - tab = { - id, - // Why: CLI-created background sessions already own a PTY, so reveal attaches instead of spawning a duplicate. - ptyId: options?.initialPtyId ?? null, - worktreeId, - // Why: reuse the lowest free ordinal so a fresh terminal stays "Terminal 1" after older tabs close, not a monotonic counter. - title: defaultTitle, - defaultTitle, - ...(quickCommandLabel ? { quickCommandLabel } : {}), - customTitle: null, - color: null, - sortOrder: existing.length, - createdAt: Date.now(), - ...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}), - ...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}), - ...(options?.forceHostRuntime ? { forceHostRuntime: true } : {}), - ...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}), - // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. - ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) - } - const validTargetGroupId = - targetGroupId && s.groupsByWorktree[worktreeId]?.some((group) => group.id === targetGroupId) - ? targetGroupId - : undefined - const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( - s.groupsByWorktree, - s.activeGroupIdByWorktree, - worktreeId, - validTargetGroupId ?? s.activeGroupIdByWorktree[worktreeId] - ) - const nextActiveGroupIdByWorktree = - shouldActivate && validTargetGroupId - ? { ...activeGroupIdByWorktree, [worktreeId]: validTargetGroupId } - : activeGroupIdByWorktree - const existingUnifiedTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - const existingTerminalTab = findTabByEntityInGroup( - s.unifiedTabsByWorktree, - worktreeId, - group.id, - id, - 'terminal' - ) - const groupsForWorktree = groupsByWorktree[worktreeId] ?? [] - const cleanedGroups = - orphanTerminalIds.size === 0 - ? groupsForWorktree - : groupsForWorktree.map((entry) => { - // Why: repair every group before adding the new tab, or inactive/background creation can revive stale focus. - const tabOrder = dedupeTabOrder(entry.tabOrder).filter( - (tabId) => !orphanTerminalIds.has(tabId) - ) - const recentTabIds = sanitizeRecentTabIds(entry.recentTabIds, tabOrder) - const replacedActiveTabId = Boolean( - entry.activeTabId && orphanTerminalIds.has(entry.activeTabId) - ) - const fallbackActiveTabId = recentTabIds.at(-1) ?? tabOrder[0] ?? null - const activeTabId = replacedActiveTabId ? fallbackActiveTabId : entry.activeTabId - return { - ...entry, - activeTabId, - tabOrder, - recentTabIds: - replacedActiveTabId && activeTabId - ? pushRecentTabId(recentTabIds, activeTabId) - : recentTabIds - } - }) - const cleanedTargetGroup = cleanedGroups.find((entry) => entry.id === group.id) ?? group - const cleanedGroupOrder = dedupeTabOrder(cleanedTargetGroup.tabOrder).filter( - (tabId) => !orphanTerminalIds.has(tabId) - ) - const unifiedTab = existingTerminalTab ?? { - id, - entityId: id, - groupId: group.id, - worktreeId, - contentType: 'terminal' as const, - label: tab.title, - ...(tab.quickCommandLabel?.trim() - ? { quickCommandLabel: tab.quickCommandLabel.trim() } - : {}), - customLabel: tab.customTitle, - color: tab.color, - sortOrder: cleanedGroupOrder.length, - createdAt: tab.createdAt, - // Why: omit for non-agent tabs so they keep the implicit 'terminal' view mode. - ...(options?.viewMode ? { viewMode: options.viewMode } : {}) - } - const nextGroupOrder = dedupeTabOrder([...cleanedGroupOrder, unifiedTab.id]) - const nextRecent = shouldActivate - ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), unifiedTab.id) - : sanitizeRecentTabIds(cleanedTargetGroup.recentTabIds, nextGroupOrder) - const cleanedActiveTabIdForWorktree = orphanCleanupPatch.activeTabIdByWorktree[worktreeId] - const cleanedGroupActiveTabId = - cleanedTargetGroup.activeTabId && !orphanTerminalIds.has(cleanedTargetGroup.activeTabId) - ? cleanedTargetGroup.activeTabId - : null - const nextActiveTabIdForWorktree = shouldActivate - ? tab.id - : (cleanedActiveTabIdForWorktree ?? cleanedGroupActiveTabId ?? tab.id) - return { - ...orphanCleanupPatch, - tabsByWorktree: { - ...orphanCleanupPatch.tabsByWorktree, - [worktreeId]: [...existing, tab] - }, - // Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host. - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [worktreeId]: existingTerminalTab - ? existingUnifiedTabs - : [...existingUnifiedTabs, unifiedTab] - }, - groupsByWorktree: { - ...groupsByWorktree, - [worktreeId]: updateGroup(cleanedGroups, { - ...cleanedTargetGroup, - activeTabId: shouldActivate - ? unifiedTab.id - : (cleanedGroupActiveTabId ?? unifiedTab.id), - tabOrder: nextGroupOrder, - recentTabIds: nextRecent - }) - }, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, - layoutByWorktree: { - ...s.layoutByWorktree, - [worktreeId]: s.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } - }, - activeTabId: shouldActivate ? tab.id : orphanCleanupPatch.activeTabId, - activeTabIdByWorktree: { - ...orphanCleanupPatch.activeTabIdByWorktree, - [worktreeId]: nextActiveTabIdForWorktree - }, - ptyIdsByTabId: { - ...orphanCleanupPatch.ptyIdsByTabId, - [tab.id]: options?.initialPtyId ? [options.initialPtyId] : [] - }, - terminalLayoutsByTabId: { - ...orphanCleanupPatch.terminalLayoutsByTabId, - [tab.id]: emptyLayoutSnapshot() - } - } - }) - const shouldRecordInteraction = - options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) - if (shouldRecordInteraction) { - get().recordFeatureInteraction?.('terminal-tabs') - } - return tab - }, - - getTerminalTabOwnerWorktreeId: (tabId) => - getTerminalTabOwnerWorktreeId(get().tabsByWorktree, tabId), - - openNewTerminalTabInActiveWorkspace: async (groupId) => { - const state = get() - const worktreeId = state.activeWorktreeId - if (!worktreeId) { - return - } - const workspaceScope = parseWorkspaceKey(worktreeId) - const worktreeRoute = - worktreeId === FLOATING_TERMINAL_WORKTREE_ID || workspaceScope?.type === 'folder' - ? null - : resolveWorktreeOperationRouteResult(state, worktreeId) - if (worktreeRoute && worktreeRoute.kind !== 'resolved') { - return - } - const runtimeEnvironmentId = worktreeRoute - ? worktreeRoute.route.runtimeEnvironmentId - : getRuntimeEnvironmentIdForWorktree(state, worktreeId) - if (runtimeEnvironmentId) { - const { createWebRuntimeSessionTerminal } = await import('@/runtime/web-runtime-session') - await createWebRuntimeSessionTerminal({ - worktreeId, - environmentId: runtimeEnvironmentId, - targetGroupId: groupId, - activate: true - }) - return - } - if (isWebClientLocation() && worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - return - } - const terminal = get().createTab(worktreeId, groupId) - get().setActiveTab(terminal.id) - get().setActiveTabType('terminal') - const latest = get() - const currentTerminals = latest.tabsByWorktree[worktreeId] ?? [] - const currentEditors = latest.openFiles.filter((file) => file.worktreeId === worktreeId) - const currentBrowsers = latest.browserTabsByWorktree[worktreeId] ?? [] - const stored = latest.tabBarOrderByWorktree[worktreeId] - const validIds = new Set([ - ...currentTerminals.map((tab) => tab.id), - ...currentEditors.map((file) => file.id), - ...currentBrowsers.map((tab) => tab.id) - ]) - const base = (stored ?? []).filter((id) => validIds.has(id)) - const inBase = new Set(base) - for (const id of validIds) { - if (!inBase.has(id)) { - base.push(id) - } - } - // Why: Cmd+J shares the titlebar-button creation path, so append the new terminal after mixed editor/browser tabs, not first. - get().setTabBarOrder(worktreeId, [...base.filter((id) => id !== terminal.id), terminal.id]) - focusTerminalTabSurface(terminal.id) - }, - - closeTab: (tabId, opts) => { - const closeReason = opts?.reason ?? 'user' - const retiresSession = closeReason === 'user' || closeReason === 'cleanup' - const retirementPlan = - opts?.precomputedRetirementPlan?.tabId === tabId - ? opts.precomputedRetirementPlan - : buildTerminalTabRetirementPlan(get(), tabId) - let closingWorktreeId: string | null = null - - // Why: a parked tab has no mounted TerminalPane cleanup, so revoke its observer/candidate state before provider exit races. - retireParkedTerminalTab(tabId) - if (retiresSession) { - const fallbackWorktreeRoute = retirementPlan.worktreeId - ? resolveTerminalWorktreeRoute(get(), retirementPlan.worktreeId) - : { runtimeEnvironmentId: null } - const retirementTasks: Promise[] = opts?.localPtyTeardownOwnedExternally - ? [] - : retirementPlan.localOrSshPtyIds.map(async (ptyId) => window.api.pty.kill(ptyId)) - const localOrSshTaskCount = retirementTasks.length - if (!opts?.remoteCloseOwnedByHost) { - for (const terminal of retirementPlan.runtimeTerminals) { - if (!terminal.environmentId && !fallbackWorktreeRoute) { - continue - } - const environmentId = - terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId - retirementTasks.push( - callRuntimeRpc( - environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, - 'terminal.close', - { terminal: terminal.handle } - ) - ) - } - } - if (retirementPlan.unroutablePtyIds.length > 0) { - // Why: log the worktree SHAPE, never the id — worktree ids embed absolute paths. The old - // "runtime handles" wording described ids that are usually plain local ones, hiding STA-2639. - console.warn('[terminal-retirement] skipped PTYs with no resolvable owner', { - tabId, - worktreeKind: classifyTerminalRetirementWorktree(retirementPlan.worktreeId), - count: retirementPlan.unroutablePtyIds.length - }) - } - // Why: keep close synchronous and idempotent — provider failures must not reject into the UI or block ownership revocation. - void Promise.allSettled(retirementTasks).then((results) => { - const localOrSshFailures = results - .slice(0, localOrSshTaskCount) - .filter((result) => result.status === 'rejected').length - const runtimeFailures = results - .slice(localOrSshTaskCount) - .filter((result) => result.status === 'rejected').length - if (localOrSshFailures > 0 || runtimeFailures > 0) { - console.warn('[terminal-retirement] provider teardown failed', { - tabId, - localOrSshFailures, - runtimeFailures - }) - } - }) - } - - set((s) => { - const next = { ...s.tabsByWorktree } - let closedTab: TerminalTab | null = null - let closedWorktreeId: string | null = null - for (const wId of Object.keys(next)) { - const before = next[wId] - const closing = before.find((t) => t.id === tabId) - if (closing) { - closingWorktreeId = wId - // Why: capture the first-matched tab's snapshot for the Cmd+Shift+T reopen stack (see capturedSnapshot below). - if (!closedTab) { - closedTab = closing - closedWorktreeId = wId - } - } - const after = before.filter((t) => t.id !== tabId) - if (after.length !== before.length) { - next[wId] = after - } - } - // Why: only explicit user closes feed the Cmd+Shift+T reopen stack; cleanup/PTY-exit closes must not pollute undo history. - const closedPosition = - closedWorktreeId && closedTab - ? getRecentlyClosedTabPosition(s, closedWorktreeId, closedTab.id) - : undefined - const capturedSnapshot = - closeReason === 'user' && - opts?.captureRecentlyClosed !== false && - closedTab && - closedWorktreeId - ? { - ...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}), - ...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}), - ...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}), - ...(closedTab.color ? { color: closedTab.color } : {}), - ...(closedPosition ? { position: closedPosition } : {}) - } - : null - const nextExpanded = { ...s.expandedPaneByTabId } - delete nextExpanded[tabId] - const nextCanExpand = { ...s.canExpandPaneByTabId } - delete nextCanExpand[tabId] - const nextLayouts = { ...s.terminalLayoutsByTabId } - delete nextLayouts[tabId] - const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - delete nextPtyIdsByTabId[tabId] - const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } - delete nextLastKnownRelay[tabId] - const nextDeferredSshSessionIdsByTabId = { ...s.deferredSshSessionIdsByTabId } - delete nextDeferredSshSessionIdsByTabId[tabId] - const nextPendingReconnectPtyIdByTabId = { ...s.pendingReconnectPtyIdByTabId } - delete nextPendingReconnectPtyIdByTabId[tabId] - const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } - delete nextRuntimePaneTitlesByTabId[tabId] - const nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } - delete nextDirectSshPaneRetryByTabId[tabId] - const nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId - } - delete nextDirectSshLivePtyBindingByTabId[tabId] - const nextDirectSshPaneRetryHistoryByTabId = { - ...s.directSshPaneRetryHistoryByTabId - } - delete nextDirectSshPaneRetryHistoryByTabId[tabId] - // Why: keep the same reference when the closing tab had no unread flag, so unrelated closes don't force full-state selector re-eval. - let nextUnreadTerminalTabs = s.unreadTerminalTabs - if (s.unreadTerminalTabs[tabId]) { - nextUnreadTerminalTabs = { ...s.unreadTerminalTabs } - delete nextUnreadTerminalTabs[tabId] - } - let nextUnreadTerminalPanes = s.unreadTerminalPanes - for (const paneKey of Object.keys(s.unreadTerminalPanes)) { - if (paneKey.startsWith(`${tabId}:`)) { - if (nextUnreadTerminalPanes === s.unreadTerminalPanes) { - nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } - } - delete nextUnreadTerminalPanes[paneKey] - } - } - let nextUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes - for (const paneKey of Object.keys(s.unreadAgentCompletionPanes)) { - if (paneKey.startsWith(`${tabId}:`)) { - if (nextUnreadAgentCompletionPanes === s.unreadAgentCompletionPanes) { - nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } - } - delete nextUnreadAgentCompletionPanes[paneKey] - } - } - const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } - for (const paneKey of Object.keys(nextLastTerminalInputAtByPaneKey)) { - if (paneKey.startsWith(`${tabId}:`)) { - delete nextLastTerminalInputAtByPaneKey[paneKey] - } - } - const nextSleepingAgentSessionsByPaneKey = retiresSession - ? removeSleepingAgentSessionsForTab(s.sleepingAgentSessionsByPaneKey, tabId) - : s.sleepingAgentSessionsByPaneKey - const nextPendingStartupByTabId = { ...s.pendingStartupByTabId } - delete nextPendingStartupByTabId[tabId] - const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId } - delete nextAutomaticAgentResumeClaimsByTabId[tabId] - const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } - delete nextNativeChatLaunchPromptByTabId[tabId] - const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } - delete nextNativeChatLaunchDraftByTabId[tabId] - const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId } - delete nextPendingInitialCwdByTabId[tabId] - const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } - delete nextPendingSetupSplitByTabId[tabId] - const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId } - delete nextPendingIssueCommandSplitByTabId[tabId] - const nextCacheTimer = { ...s.cacheTimerByKey } - // Why: cache timer keys are `${tabId}:${leafId}` composites; remove all entries for the closing tab. - for (const key of Object.keys(nextCacheTimer)) { - if (key.startsWith(`${tabId}:`)) { - delete nextCacheTimer[key] - } - } - // Why: keep activeTabIdByWorktree in sync when closing a background-worktree tab, else the stale remembered tab falls back to tabs[0] on switch. - const nextActiveTabIdByWorktree = { ...s.activeTabIdByWorktree } - for (const [wId, tabs] of Object.entries(next)) { - if (nextActiveTabIdByWorktree[wId] === tabId) { - nextActiveTabIdByWorktree[wId] = tabs[0]?.id ?? null - } - } - - // Why: keep tabBarOrderByWorktree in sync so stale terminal IDs don't linger and shift positions on later tab operations. - const nextTabBarOrderByWorktree: Record = { - ...s.tabBarOrderByWorktree - } - for (const wId of Object.keys(nextTabBarOrderByWorktree)) { - const order = nextTabBarOrderByWorktree[wId] - if (order?.includes(tabId)) { - nextTabBarOrderByWorktree[wId] = order.filter((entryId) => entryId !== tabId) - } - } - - // Why: clean up unconsumed snapshot/cold-restore data (e.g. tab closed before TerminalPane mounted) to prevent unbounded store growth across restarts. - let nextSnapshots = s.pendingSnapshotByPtyId - let nextColdRestores = s.pendingColdRestoreByPtyId - const closingPtyIds = new Set([ - ...retirementPlan.localOrSshPtyIds, - ...retirementPlan.runtimeTerminals.map((terminal) => terminal.ptyId), - ...retirementPlan.cleanupOnlyPtyIds, - ...retirementPlan.unroutablePtyIds - ]) - for (const closingId of closingPtyIds) { - if (closingId in nextSnapshots) { - nextSnapshots = { ...nextSnapshots } - delete nextSnapshots[closingId] - } - if (closingId in nextColdRestores) { - nextColdRestores = { ...nextColdRestores } - delete nextColdRestores[closingId] - } - } - - return { - tabsByWorktree: next, - activeTabId: s.activeTabId === tabId ? null : s.activeTabId, - activeTabIdByWorktree: nextActiveTabIdByWorktree, - ptyIdsByTabId: nextPtyIdsByTabId, - lastKnownRelayPtyIdByTabId: nextLastKnownRelay, - deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId, - pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId, - runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, - directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, - directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, - directSshPaneRetryHistoryByTabId: nextDirectSshPaneRetryHistoryByTabId, - ...(nextSleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey - ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessionsByPaneKey } - : {}), - // Why: skip writing unreadTerminalTabs when unchanged to avoid a no-op state allocation that re-evaluates full-state selectors. Mirrors tabs.ts. - ...(nextUnreadTerminalTabs !== s.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextUnreadTerminalPanes !== s.unreadTerminalPanes - ? { unreadTerminalPanes: nextUnreadTerminalPanes } - : {}), - ...(nextUnreadAgentCompletionPanes !== s.unreadAgentCompletionPanes - ? { unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes } - : {}), - lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey, - expandedPaneByTabId: nextExpanded, - canExpandPaneByTabId: nextCanExpand, - terminalLayoutsByTabId: nextLayouts, - pendingStartupByTabId: nextPendingStartupByTabId, - automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, - nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, - nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, - pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, - pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, - pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, - cacheTimerByKey: nextCacheTimer, - tabBarOrderByWorktree: nextTabBarOrderByWorktree, - pendingSnapshotByPtyId: nextSnapshots, - pendingColdRestoreByPtyId: nextColdRestores, - ...(capturedSnapshot && closedWorktreeId - ? { - recentlyClosedTerminalTabsByWorktree: pushClosedTerminalTabSnapshot( - s.recentlyClosedTerminalTabsByWorktree, - closedWorktreeId, - capturedSnapshot - ), - recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind( - s.recentlyClosedTabKindsByWorktree, - closedWorktreeId, - 'terminal' - ) - } - : {}) - } - }) - // Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry. - sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId) - for (const tabs of Object.values(get().unifiedTabsByWorktree)) { - const workspaceItem = tabs.find( - (entry) => entry.contentType === 'terminal' && entry.entityId === tabId - ) - if (workspaceItem) { - get().closeUnifiedTab(workspaceItem.id, { - recordInteraction: opts?.recordInteraction, - terminalRetirementHandled: true - }) - } - } - }, - - reorderTabs: (worktreeId, tabIds) => { - set((s) => { - const tabs = s.tabsByWorktree[worktreeId] ?? [] - const tabMap = new Map(tabs.map((t) => [t.id, t])) - const orderedSet = new Set(tabIds) - const missingTabs = tabs.filter((t) => !orderedSet.has(t.id)) - - const reordered = [ - ...tabIds.map((id) => tabMap.get(id)!).filter(Boolean), - ...missingTabs - ].map((tab, i) => ({ ...tab, sortOrder: i })) - - return { - tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: reordered } - } - }) - }, - - setTabBarOrder: (worktreeId, order) => { - set((s) => { - // Update unified visual order - const newTabBarOrder = { ...s.tabBarOrderByWorktree, [worktreeId]: order } - - // Keep terminal tab sortOrder in sync for persistence - const tabs = s.tabsByWorktree[worktreeId] - if (!tabs) { - return { tabBarOrderByWorktree: newTabBarOrder } - } - const tabMap = new Map(tabs.map((t) => [t.id, t])) - // Extract terminal IDs in their new relative order - const terminalIdsInOrder = order.filter((id) => tabMap.has(id)) - const orderedSet = new Set(terminalIdsInOrder) - const missingTabs = tabs.filter((t) => !orderedSet.has(t.id)) - - const updatedTabs = [ - ...terminalIdsInOrder.map((id) => tabMap.get(id)!).filter(Boolean), - ...missingTabs - ].map((tab, i) => ({ ...tab, sortOrder: i })) - - return { - tabBarOrderByWorktree: newTabBarOrder, - tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: updatedTabs } - } - }) - }, - - setActiveTab: (tabId) => { - let tabOwnerWorktreeId: string | null = null - set((s) => { - // Why: clear bells only for visible tabs; background bells must persist. - tabOwnerWorktreeId = resolveActiveTabOwnerWorktreeId( - s.tabsByWorktree, - s.activeWorktreeId, - tabId - ) - const isActiveWorktreeTab = - tabOwnerWorktreeId !== null && tabOwnerWorktreeId === s.activeWorktreeId - const nextUnreadTerminalTabs = - isActiveWorktreeTab && s.unreadTerminalTabs[tabId] - ? (() => { - const copy = { ...s.unreadTerminalTabs } - delete copy[tabId] - return copy - })() - : s.unreadTerminalTabs - // Why: activeTabId marks the visible tab, so background bells remain unread. - return { - activeTabId: isActiveWorktreeTab ? tabId : s.activeTabId, - // Why: a redundant activation must not reallocate this map — Terminal's - // active-terminal repair effect depends on it, so a re-activation that - // can't converge activeTabId (tab id reused by an earlier-scanned - // worktree) would otherwise re-trigger itself into React error #185. - activeTabIdByWorktree: - tabOwnerWorktreeId !== null && s.activeTabIdByWorktree[tabOwnerWorktreeId] !== tabId - ? { ...s.activeTabIdByWorktree, [tabOwnerWorktreeId]: tabId } - : s.activeTabIdByWorktree, - unreadTerminalTabs: nextUnreadTerminalTabs - } - }) - const state = get() - const ownerUnifiedTabs = - tabOwnerWorktreeId !== null && Object.hasOwn(state.unifiedTabsByWorktree, tabOwnerWorktreeId) - ? state.unifiedTabsByWorktree[tabOwnerWorktreeId] - : [] - // Why: a duplicated entity id must activate the same owner chosen above. - const item = - ownerUnifiedTabs.find( - (entry) => entry.contentType === 'terminal' && entry.entityId === tabId - ) ?? - Object.values(state.unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) - if (item) { - state.activateTab( - item.id, - tabOwnerWorktreeId !== null ? { worktreeId: tabOwnerWorktreeId } : undefined - ) - } - }, - - setActiveTabForWorktree: (worktreeId, tabId) => { - set((s) => ({ - activeTabIdByWorktree: { - ...s.activeTabIdByWorktree, - [worktreeId]: tabId - } - })) - }, - - updateTabTitle: (tabId, title) => { - set((state) => { - const result = applyTerminalTabTitleUpdates(state, [{ tabId, title }]) - if (result.runtimeGraphChanged) { - scheduleRuntimeGraphSync() - } - return result.patch ?? state - }) - }, - - updateTabTitles: (updates) => { - if (updates.length === 0) { - return - } - set((state) => { - const result = applyTerminalTabTitleUpdates(state, updates) - if (result.runtimeGraphChanged) { - scheduleRuntimeGraphSync() - } - return result.patch ?? state - }) - }, - - setAiVaultTabTitle: (tabId, aiVaultTitle) => { - set((s) => { - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - if (!ownerWorktreeId) { - return s - } - const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] - const current = tabs.find((tab) => tab.id === tabId) - const sameTitle = - current?.aiVaultTitle?.agent === aiVaultTitle?.agent && - current?.aiVaultTitle?.sessionId === aiVaultTitle?.sessionId && - current?.aiVaultTitle?.title === aiVaultTitle?.title - if (!current || sameTitle) { - return s - } - const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab)) - const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs } - const unifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] - const nextUnifiedTabs = unifiedTabs.map((tab) => - tab.contentType === 'terminal' && tab.entityId === tabId ? { ...tab, aiVaultTitle } : tab - ) - adoptTerminalTabOwnerMetadataOnlyBuckets(s.tabsByWorktree, nextTabsByWorktree, [ - ownerWorktreeId - ]) - scheduleRuntimeGraphSync() - return { - tabsByWorktree: nextTabsByWorktree, - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [ownerWorktreeId]: nextUnifiedTabs - } - } - }) - }, - - setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => { - // Why: setAgentStatus is high-frequency; skip derive/set unless the feature is on and this tab still needs a (re)generated title. - const state = get() - const tabId = getTabIdFromPaneKey(paneKey) - if (!tabId || prompt.length === 0 || state.settings?.tabAutoGenerateTitle !== true) { - return - } - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) - if (!ownerWorktreeId) { - return - } - const tabs = state.tabsByWorktree[ownerWorktreeId] ?? [] - const currentTab = tabs.find((tab) => tab.id === tabId) - if (!currentTab || currentTab.customTitle?.trim() || currentTab.quickCommandLabel?.trim()) { - return - } - const existingGeneratedTitle = currentTab.generatedTitle?.trim() - if (existingGeneratedTitle && options?.replaceExistingGeneratedTitle !== true) { - return - } - set((latestState) => { - const result = applyGeneratedTabTitleUpdates(latestState, [{ paneKey, prompt, options }]) - if (result.runtimeGraphChanged) { - scheduleRuntimeGraphSync() - } - return result.patch ?? latestState - }) - }, - - setGeneratedTabTitlesFromAgentPrompts: (updates) => { - if (updates.length === 0) { - return - } - set((state) => { - const result = applyGeneratedTabTitleUpdates(state, updates) - if (result.runtimeGraphChanged) { - scheduleRuntimeGraphSync() - } - return result.patch ?? state - }) - }, - - clearTabLaunchAgent: (tabId) => { - set((s) => { - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - if (!ownerWorktreeId) { - return s - } - const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] - const tabIndex = tabs.findIndex((t) => t.id === tabId) - const currentTab = tabs[tabIndex] - if (!currentTab?.launchAgent) { - return s - } - const { launchAgent: _launchAgent, ...tabWithoutLaunchAgent } = currentTab - void _launchAgent - const nextTabs = [...tabs] - nextTabs[tabIndex] = tabWithoutLaunchAgent - scheduleRuntimeGraphSync() - return { tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: nextTabs } } - }) - }, - - setRuntimePaneTitle: (tabId, paneId, title) => { - set((s) => { - const currentByPane = s.runtimePaneTitlesByTabId[tabId] ?? {} - const prevTitle = currentByPane[paneId] - if (prevTitle === title) { - return s - } - if (prevTitle && isDecorativeAgentTitleFrameChange(prevTitle, title)) { - return s - } - // Why: re-sort hookless title changes only when their activity classification changes. - const classificationChanged = - classifyTitleActivity(prevTitle ?? '') !== classifyTitleActivity(title) - // Why: skip active-worktree remount side effects and orphaned panes. - const ownerWorktreeId = classificationChanged - ? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - : null - const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId - const shouldBump = classificationChanged && ownerWorktreeId !== null && !isActive - return { - runtimePaneTitlesByTabId: { - ...s.runtimePaneTitlesByTabId, - [tabId]: { ...currentByPane, [paneId]: title } - }, - ...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {}) - } - }) - }, - - clearRuntimePaneTitle: (tabId, paneId) => { - set((s) => { - const currentByPane = s.runtimePaneTitlesByTabId[tabId] - if (!currentByPane || !(paneId in currentByPane)) { - return s - } - const prevTitle = currentByPane[paneId] - const nextByPane = { ...currentByPane } - delete nextByPane[paneId] - - const next = { ...s.runtimePaneTitlesByTabId } - if (Object.keys(nextByPane).length > 0) { - next[tabId] = nextByPane - } else { - delete next[tabId] - } - - // Why: clearing a classified title changes the smart-sort title-heuristic verdict, so it needs a re-sort. See setRuntimePaneTitle. - const hadClassification = classifyTitleActivity(prevTitle ?? '') !== null - // Why: same active-worktree gate as setRuntimePaneTitle — click-driven teardown clears must not re-rank the sidebar; skip when owner is missing (orphaned). - const ownerWorktreeId = hadClassification - ? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - : null - const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId - const shouldBump = hadClassification && ownerWorktreeId !== null && !isActive - return { - runtimePaneTitlesByTabId: next, - ...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {}) - } - }) - }, - - markTerminalTabUnread: (tabId) => { - const state = get() - const ownerTab = Object.values(state.tabsByWorktree ?? {}) - .flat() - .find((t) => t.id === tabId) - if (!ownerTab) { - return - } - // Why: terminal attention persists until real interaction. - set((s) => { - if (s.unreadTerminalTabs[tabId]) { - return s - } - return { unreadTerminalTabs: { ...s.unreadTerminalTabs, [tabId]: true as const } } - }) - }, - - markTerminalPaneUnread: (paneKey) => { - set((s) => { - if (s.unreadTerminalPanes[paneKey]) { - return s - } - return { unreadTerminalPanes: { ...s.unreadTerminalPanes, [paneKey]: true as const } } - }) - }, - - markAgentCompletionPaneUnread: (paneKey) => { - set((s) => { - if (s.unreadAgentCompletionPanes[paneKey]) { - return s - } - return { - unreadAgentCompletionPanes: { - ...s.unreadAgentCompletionPanes, - [paneKey]: true as const - } - } - }) - }, - - clearTerminalTabUnread: (tabId) => { - set((s) => { - if (!s.unreadTerminalTabs[tabId]) { - return s - } - const copy = { ...s.unreadTerminalTabs } - delete copy[tabId] - return { unreadTerminalTabs: copy } - }) - }, - - clearTerminalPaneUnread: (paneKey) => { - set((s) => { - if (!s.unreadTerminalPanes[paneKey] && !s.unreadAgentCompletionPanes[paneKey]) { - return s - } - const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } - const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } - delete nextUnreadTerminalPanes[paneKey] - delete nextUnreadAgentCompletionPanes[paneKey] - return { - unreadTerminalPanes: nextUnreadTerminalPanes, - unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes - } - }) - }, - - setTabCustomTitle: (tabId, title, opts) => { - set((s) => { - const next = { ...s.tabsByWorktree } - for (const wId of Object.keys(next)) { - next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t)) - } - scheduleRuntimeGraphSync() - return { tabsByWorktree: next } - }) - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) - if (item) { - get().setTabCustomLabel(item.id, title, opts) - } - }, - - setTabColor: (tabId, color) => { - set((s) => { - const next = { ...s.tabsByWorktree } - for (const wId of Object.keys(next)) { - next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, color } : t)) - } - return { tabsByWorktree: next } - }) - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) - if (item) { - get().setUnifiedTabColor(item.id, color) - // Why: tab color is host-authoritative for remote-server tabs; mirror it so it persists instead of reverting on the next snapshot. - const state = get() - const owningWorktreeId = Object.keys(state.unifiedTabsByWorktree).find((wId) => - (state.unifiedTabsByWorktree[wId] ?? []).some((entry) => entry.id === item.id) - ) - if ( - owningWorktreeId && - resolveTerminalWorktreeRoute(state, owningWorktreeId)?.runtimeEnvironmentId - ) { - void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => - setWebRuntimeTabProps({ worktreeId: owningWorktreeId, tabId: item.id, color }) - ) - } - } - }, - - updateTabPtyId: (tabId, ptyId, replacedPtyId, directSshRetryAttemptId) => { - // Why: final guard preventing a late caller from recreating retired tab maps (async spawn owners still do their own provider teardown). - if (!isTerminalTabPresent(get(), tabId)) { - return - } - let worktreeId: string | null = null - let wasActivationSpawn = false - const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) - set((s) => { - if (directSshRetryAttemptId) { - const pendingRetry = s.directSshPaneRetryByTabId[tabId] - const liveRetry = s.directSshLivePtyBindingByTabId[tabId] - const retryLease = - pendingRetry?.attemptId === directSshRetryAttemptId - ? pendingRetry - : liveRetry?.attemptId === directSshRetryAttemptId - ? liveRetry - : undefined - const boundTab = Object.values(s.tabsByWorktree) - .flat() - .find((candidate) => candidate.id === tabId) - if ( - !retryLease || - !boundTab || - parseAppSshPtyId(ptyId)?.connectionId !== retryLease.authority.targetId || - !isCurrentDirectSshAuthority(s, retryLease.authority) || - (boundTab.generation ?? 0) !== retryLease.tabGeneration - ) { - return s - } - } - const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] - const remote = parseRemoteRuntimePtyId(ptyId) - const legacyRemotePtyId = remote?.environmentId ? toRemoteRuntimePtyId(remote.handle) : null - const hasLegacyPtyBinding = legacyRemotePtyId - ? existingPtyIds.includes(legacyRemotePtyId) - : false - const explicitReplacementPtyId = replacedPtyId !== ptyId ? replacedPtyId : undefined - const replacementPtyId = - explicitReplacementPtyId ?? (hasLegacyPtyBinding ? legacyRemotePtyId : null) - const boundReplacementPtyId = - replacementPtyId && existingPtyIds.includes(replacementPtyId) ? replacementPtyId : null - const nextPtyIds = boundReplacementPtyId - ? [...new Set(existingPtyIds.map((id) => (id === boundReplacementPtyId ? ptyId : id)))] - : existingPtyIds.includes(ptyId) - ? existingPtyIds - : [...existingPtyIds, ptyId] - let nextTabsByWorktree = s.tabsByWorktree - for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { - const index = tabs.findIndex((t) => t.id === tabId) - if (index === -1) { - continue - } - worktreeId = wId - const tab = tabs[index] - if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { - wasActivationSpawn = true - } - // Why: consume one suppression per split-pane activation callback. - const { pendingActivationSpawn: _unused, ...rest } = tab - void _unused - // Why: tab.ptyId is the single-pane fallback for legacy attach; later split-pane spawns must not steal it or remount/close reattaches the tab to the wrong PTY. - const currentTabPtyId = tab.ptyId === replacementPtyId ? ptyId : tab.ptyId - const nextTabPtyId = currentTabPtyId ?? nextPtyIds[0] ?? null - const nextPendingActivationSpawn = consumePendingActivationSpawn(tab.pendingActivationSpawn) - if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) { - const nextTabs = [...tabs] - nextTabs[index] = { - ...rest, - ...(nextPendingActivationSpawn - ? { pendingActivationSpawn: nextPendingActivationSpawn } - : {}), - ptyId: nextTabPtyId - } - nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs } - } - break - } - // Why: the first active PTY changes sorting, except activation-spawn side effects. - const isFirstPty = existingPtyIds.length === 0 - const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId - const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn - const shouldRetainSuppressedExit = Boolean( - explicitReplacementPtyId && - (s.suppressedPtyExitIds[ptyId] || - (replacementPtyId && s.suppressedPtyExitIds[replacementPtyId])) - ) - const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds } - delete nextSuppressedPtyExitIds[ptyId] - if (replacementPtyId) { - delete nextSuppressedPtyExitIds[replacementPtyId] - } - if (shouldRetainSuppressedExit) { - // Why: handle rotation keeps the same terminal lifecycle; an intentional exit racing the rotation must stay suppressed once. - nextSuppressedPtyExitIds[ptyId] = true - } - const hasReplacementPendingRestart = replacementPtyId - ? replacementPtyId in s.pendingCodexPaneRestartIds - : false - const hasReplacementRestartNotice = replacementPtyId - ? replacementPtyId in s.codexRestartNoticeByPtyId - : false - const hasReplacementMigrationUnsupported = replacementPtyId - ? replacementPtyId in s.migrationUnsupportedByPtyId - : false - const nextPendingCodexPaneRestartIds = hasReplacementPendingRestart - ? { ...s.pendingCodexPaneRestartIds } - : s.pendingCodexPaneRestartIds - const nextCodexRestartNoticeByPtyId = hasReplacementRestartNotice - ? { ...s.codexRestartNoticeByPtyId } - : s.codexRestartNoticeByPtyId - const nextMigrationUnsupportedByPtyId = hasReplacementMigrationUnsupported - ? { ...s.migrationUnsupportedByPtyId } - : s.migrationUnsupportedByPtyId - if (replacementPtyId) { - if (hasReplacementPendingRestart) { - nextPendingCodexPaneRestartIds[ptyId] = true - delete nextPendingCodexPaneRestartIds[replacementPtyId] - } - if (hasReplacementRestartNotice) { - const replacedNotice = nextCodexRestartNoticeByPtyId[replacementPtyId] - nextCodexRestartNoticeByPtyId[ptyId] ??= replacedNotice - delete nextCodexRestartNoticeByPtyId[replacementPtyId] - } - if (hasReplacementMigrationUnsupported) { - const replacedMigrationUnsupported = nextMigrationUnsupportedByPtyId[replacementPtyId] - nextMigrationUnsupportedByPtyId[ptyId] ??= { - ...replacedMigrationUnsupported, - ptyId - } - delete nextMigrationUnsupportedByPtyId[replacementPtyId] - } - } - const pendingRetry = s.directSshPaneRetryByTabId[tabId] - const liveRetry = s.directSshLivePtyBindingByTabId[tabId] - const retryLease = - pendingRetry?.attemptId === directSshRetryAttemptId - ? pendingRetry - : liveRetry?.attemptId === directSshRetryAttemptId - ? liveRetry - : undefined - const boundTab = worktreeId - ? nextTabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) - : undefined - const parsedSshPty = parseAppSshPtyId(ptyId) - const acknowledgesDirectSshRetry = Boolean( - retryLease && - boundTab && - parsedSshPty?.connectionId === retryLease.authority.targetId && - isCurrentDirectSshAuthority(s, retryLease.authority) && - (boundTab.generation ?? 0) === retryLease.tabGeneration && - nextPtyIds.includes(ptyId) - ) - let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId - let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId - if (acknowledgesDirectSshRetry && retryLease && boundTab?.ptyId) { - if (pendingRetry?.attemptId === directSshRetryAttemptId) { - nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } - delete nextDirectSshPaneRetryByTabId[tabId] - } - if (!liveRetry || liveRetry.attemptId !== directSshRetryAttemptId) { - nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId, - [tabId]: { - attemptId: retryLease.attemptId, - authority: retryLease.authority, - tabGeneration: retryLease.tabGeneration, - ptyId: boundTab.ptyId - } - } - } else if ( - (replacementPtyId === liveRetry.ptyId && boundTab.ptyId === ptyId) || - !nextPtyIds.includes(liveRetry.ptyId) - ) { - nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId, - [tabId]: { ...liveRetry, ptyId: boundTab.ptyId } - } - } - } else { - const liveBinding = s.directSshLivePtyBindingByTabId[tabId] - if (liveBinding) { - if ( - replacementPtyId === liveBinding.ptyId && - boundTab?.ptyId === ptyId && - isCurrentDirectSshAuthority(s, liveBinding.authority) - ) { - nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId, - [tabId]: { ...liveBinding, ptyId } - } - } - } - } - return { - ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), - ptyIdsByTabId: { - ...s.ptyIdsByTabId, - [tabId]: nextPtyIds - }, - lastKnownRelayPtyIdByTabId: { - ...s.lastKnownRelayPtyIdByTabId, - [tabId]: ptyId - }, - suppressedPtyExitIds: nextSuppressedPtyExitIds, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, - migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, - directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, - directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, - ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) - } - }) - - // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. - if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { - get().bumpWorktreeActivity(worktreeId) - } - }, - - clearTabPtyId: (tabId, ptyId) => { - if (ptyId && get().pendingPtyShutdownIds[ptyId]) { - // Why: an owner exit can arrive before its post-stop inventory; keep the renderer binding retryable until verification commits. - return - } - let worktreeId: string | null = null - let wasActivationSpawn = false - let preservesDirectSshContinuationGap = false - let isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) - set((s) => { - const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] - const remainingPtyIds = ptyId ? existingPtyIds.filter((id) => id !== ptyId) : [] - const liveBinding = s.directSshLivePtyBindingByTabId[tabId] - let nextTabsByWorktree = s.tabsByWorktree - for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { - const index = tabs.findIndex((t) => t.id === tabId) - if (index === -1) { - continue - } - worktreeId = wId - const tab = tabs[index] - if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { - wasActivationSpawn = true - } - if (!ptyId) { - isRemoteRuntimeMirror = - existingPtyIds.length > 0 && existingPtyIds.every((id) => isRemoteRuntimePtyId(id)) - } - // Why: consume pendingActivationSpawn on real activation clears, but keep it when clearing a stale wake-hint id — its fallback spawn still needs the suppression. - const { pendingActivationSpawn: _unused, ...rest } = tab - void _unused - const nextTabPtyId = remainingPtyIds.at(-1) ?? null - preservesDirectSshContinuationGap = Boolean( - ptyId && - remainingPtyIds.length === 0 && - liveBinding?.ptyId === ptyId && - getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0 && - isCurrentDirectSshAuthority(s, liveBinding.authority) - ) - const shouldRetainActivationSpawn = - preservesDirectSshContinuationGap || - (wasActivationSpawn && ptyId != null && !existingPtyIds.includes(ptyId)) - const nextPendingActivationSpawn = shouldRetainActivationSpawn - ? tab.pendingActivationSpawn - : consumePendingActivationSpawn(tab.pendingActivationSpawn) - if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) { - const nextTabs = [...tabs] - nextTabs[index] = { - ...rest, - ...(nextPendingActivationSpawn - ? { pendingActivationSpawn: nextPendingActivationSpawn } - : {}), - ptyId: nextTabPtyId - } - nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs } - } - break - } - const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - if (worktreeId) { - nextPtyIdsByTabId[tabId] = remainingPtyIds - } else { - // Why: repo purge can retire the owning tab before its async exit arrives; don't resurrect an orphan PTY index. - delete nextPtyIdsByTabId[tabId] - } - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - if (ptyId) { - delete nextPendingCodexPaneRestartIds[ptyId] - delete nextCodexRestartNoticeByPtyId[ptyId] - } else { - for (const currentPtyId of s.ptyIdsByTabId[tabId] ?? []) { - delete nextPendingCodexPaneRestartIds[currentPtyId] - delete nextCodexRestartNoticeByPtyId[currentPtyId] - } - } - // Why: an explicit exit drops the dead relay ID; bulk clears retain it for relay grace. - const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } - if (ptyId && nextLastKnownRelay[tabId] === ptyId) { - // Why: the relay slot holds ONE id per tab (the last pane to bind). If - // that pane exits, promote a surviving pane instead of clearing — else the - // survivor is left visible only in the layout leaf map, and a later - // relay-drop bulk-clear lets the orphan sweep delete the still-live tab - // (the orphan predicate reads this map but not layout leaves) (#9911). - const survivingPtyId = remainingPtyIds.at(-1) - if (survivingPtyId) { - nextLastKnownRelay[tabId] = survivingPtyId - } else { - delete nextLastKnownRelay[tabId] - } - } - let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId - const pendingRetry = s.directSshPaneRetryByTabId[tabId] - if ( - pendingRetry && - (!ptyId || - (existingPtyIds.includes(ptyId) && - parseAppSshPtyId(ptyId)?.connectionId === pendingRetry.authority.targetId)) - ) { - nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } - delete nextDirectSshPaneRetryByTabId[tabId] - } - let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId - if (liveBinding && (!ptyId || liveBinding.ptyId === ptyId)) { - nextDirectSshLivePtyBindingByTabId = { - ...s.directSshLivePtyBindingByTabId - } - const promotedPtyId = ptyId ? remainingPtyIds.at(-1) : undefined - if ( - promotedPtyId && - parseAppSshPtyId(promotedPtyId)?.connectionId === liveBinding.authority.targetId && - isCurrentDirectSshAuthority(s, liveBinding.authority) - ) { - nextDirectSshLivePtyBindingByTabId[tabId] = { - ...liveBinding, - ptyId: promotedPtyId - } - } else if (!preservesDirectSshContinuationGap) { - delete nextDirectSshLivePtyBindingByTabId[tabId] - } - } - - return { - ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), - ptyIdsByTabId: nextPtyIdsByTabId, - lastKnownRelayPtyIdByTabId: nextLastKnownRelay, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, - directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, - directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId - } - }) - - // Bump activity on PTY exit, but skip intentional shutdowns (suppressed exits) and click-driven pane unmounts (pendingActivationSpawn). - if ( - worktreeId && - !wasActivationSpawn && - !isRemoteRuntimeMirror && - !hasWorktreeSleepIntent(worktreeId) && - !(ptyId && get().suppressedPtyExitIds[ptyId]) - ) { - get().bumpWorktreeActivity(worktreeId) - } - }, - - shutdownCompletedAgentPaneForHibernation: async (worktreeId, opts) => { - const paneKeys = [opts.paneKey] - const expectedRuntimePtyIds = sortedUniquePtyIds( - opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : [] - ) - const rendererShutdownPtyIds = [opts.ptyId] - const state = get() - const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(state, worktreeId) - // Why: pane transports emit renderer PTY ids, not raw exact-stop handles; guard only the identity that can deliver an exit callback. - const exitGuardPtyIds = [opts.ptyId] - const tab = (state.tabsByWorktree[worktreeId] ?? []).find( - (candidate) => candidate.id === opts.tabId - ) - const parsed = parsePaneKey(opts.paneKey) - const layout = state.terminalLayoutsByTabId[opts.tabId] - const liveTabPtyIds = state.ptyIdsByTabId[opts.tabId] ?? [] - if ( - !tab || - !parsed || - parsed.tabId !== opts.tabId || - parsed.leafId !== opts.leafId || - layout?.ptyIdsByLeafId?.[opts.leafId] !== opts.ptyId || - (expectedRuntimePtyIds.length === 0 && !liveTabPtyIds.includes(opts.ptyId)) - ) { - throw new Error('agent_hibernation_pane_binding_mismatch') - } - - const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree( - state, - worktreeId, - { - paneKeys, - captureMode: 'completed-agent-hibernation' - } - ) - const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree( - state, - worktreeId, - paneKeys - ) - if (!sleepingAgentSessionRecords[opts.paneKey]) { - // Why: killing the PTY with no persisted resume record strands the pane unwakeable; abort instead of hibernating unrecoverably. - throw new Error('agent_hibernation_capture_missing') - } - - const capture = shutdownBufferCaptures.get(opts.tabId) - if (capture) { - try { - capture({ includeLocalBuffers: false }) - } catch { - // Don't let one tab's capture failure block the pane hibernation. - } - } - - // Why: store sleeping records before kill, since pty:exit can arrive first. - const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords) - const replacedSleepingRecords: Record = {} - for (const key of sleepingRecordKeys) { - const existing = state.sleepingAgentSessionsByPaneKey[key] - if (existing) { - replacedSleepingRecords[key] = existing - } - } - - const rollbackTargetShutdownState = (): void => { - set((s) => { - const next = { ...s.suppressedPtyExitIds } - for (const ptyId of exitGuardPtyIds) { - delete next[ptyId] - } - const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } - for (const key of sleepingRecordKeys) { - const replaced = replacedSleepingRecords[key] - if (replaced) { - nextSleeping[key] = replaced - } else { - delete nextSleeping[key] - } - } - return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping } - }) - } - - set((s) => ({ - suppressedPtyExitIds: { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - }, - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - ...sleepingAgentSessionRecords - } - })) - - if (expectedRuntimePtyIds.length > 0) { - if (!runtimeEnvironmentId) { - rollbackTargetShutdownState() - throw new Error('missing_runtime_for_exact_terminal_stop') - } - let stopResult: { - stoppedPtyIds?: string[] - livePtyIds?: string[] - postStopVerified?: boolean - postStopFailure?: string - } - try { - stopResult = await callRuntimeRpc<{ - stoppedPtyIds?: string[] - livePtyIds?: string[] - postStopVerified?: boolean - postStopFailure?: string - }>( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'terminal.stopExact', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - expectedPtyIds: expectedRuntimePtyIds, - keepHistory: true, - targetOnly: true - }, - { timeoutMs: 15_000 } - ) - } catch (err) { - rollbackTargetShutdownState() - throw err - } - const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) - const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) - const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId)) - if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) { - rollbackTargetShutdownState() - throw new Error('exact_terminal_stop_mismatch') - } - if (stopResult.postStopVerified !== true) { - rollbackTargetShutdownState() - throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') - } - for (const snapshot of unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? []) { - snapshot.commit?.() - } - } else if (!opts.ptyId.startsWith('remote:')) { - // Why: pty.kill can flush final data before exit; unregister first so stale handlers can't fire phantom notifications during hibernation. - const handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? [] - try { - await window.api.pty.kill(opts.ptyId, { keepHistory: true }) - } catch (err) { - restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) - rollbackTargetShutdownState() - throw err - } - for (const snapshot of handlerSnapshots) { - snapshot.commit?.() - } - } - - set((s) => { - const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? [] - const shutdownPtyIdSet = new Set(rendererShutdownPtyIds) - const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId)) - const nextTabsByWorktree = { ...s.tabsByWorktree } - const tabs = nextTabsByWorktree[worktreeId] ?? [] - const tabIndex = tabs.findIndex((candidate) => candidate.id === opts.tabId) - if (tabIndex !== -1) { - const nextTabs = [...tabs] - nextTabs[tabIndex] = { - ...nextTabs[tabIndex], - ptyId: remainingPtyIds.at(-1) ?? null - } - nextTabsByWorktree[worktreeId] = nextTabs - } - - const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - for (const ptyId of exitGuardPtyIds) { - delete nextCodexRestartNoticeByPtyId[ptyId] - } - const nextLastKnownRelay = - remainingPtyIds.length === 0 - ? { ...s.lastKnownRelayPtyIdByTabId } - : s.lastKnownRelayPtyIdByTabId - if (remainingPtyIds.length === 0) { - delete nextLastKnownRelay[opts.tabId] - } - - let nextRuntimePaneTitlesByTabId = s.runtimePaneTitlesByTabId - const numericPaneId = Number(opts.leafId) - if ( - Number.isInteger(numericPaneId) && - s.runtimePaneTitlesByTabId[opts.tabId]?.[numericPaneId] - ) { - const nextByPane = { ...s.runtimePaneTitlesByTabId[opts.tabId] } - delete nextByPane[numericPaneId] - nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } - if (Object.keys(nextByPane).length > 0) { - nextRuntimePaneTitlesByTabId[opts.tabId] = nextByPane - } else { - delete nextRuntimePaneTitlesByTabId[opts.tabId] - } - } - - const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } - const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } - const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } - delete nextUnreadTerminalPanes[opts.paneKey] - delete nextUnreadAgentCompletionPanes[opts.paneKey] - delete nextLastTerminalInputAtByPaneKey[opts.paneKey] - - return { - tabsByWorktree: nextTabsByWorktree, - ptyIdsByTabId: { - ...s.ptyIdsByTabId, - [opts.tabId]: remainingPtyIds - }, - lastKnownRelayPtyIdByTabId: nextLastKnownRelay, - suppressedPtyExitIds: { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - }, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, - ...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId - ? { runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId } - : {}), - unreadTerminalPanes: nextUnreadTerminalPanes, - unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes, - lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey - } - }) - - get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, { - retainedCompletionEvidence - }) - }, - - clearDirectSshTargetPtyBindings: (targetId) => { - let clearedCount = 0 - set((s) => { - const result = clearDirectSshTerminalBindings(s, resolveDirectSshTerminalKeys(s, targetId)) - clearedCount = result.clearedCount - return result.patch ?? s - }) - return clearedCount - }, - - invalidateStaleDirectSshTargetPtyBindings: (authority) => { - let clearedCount = 0 - set((s) => { - if (!isCurrentDirectSshAuthority(s, authority)) { - return s - } - const result = invalidateStaleDirectSshTerminalBindings( - s, - resolveDirectSshTerminalKeys(s, authority.targetId), - authority - ) - clearedCount = result.clearedCount - return result.patch ?? s - }) - return clearedCount - }, - - retryDirectSshTargetPanes: (authority, now = Date.now()) => { - let retriedCount = 0 - set((s) => { - if (!isCurrentDirectSshAuthority(s, authority)) { - return s - } - const result = retryDirectSshTerminalPanes( - s, - resolveDirectSshTerminalKeys(s, authority.targetId), - authority, - now - ) - retriedCount = result.retriedCount - return result.patch ?? s - }) - return retriedCount - }, - - settleDirectSshPaneRetry: (result, now = Date.now()) => { - set((s) => { - if (!isCurrentDirectSshAuthority(s, result.authority)) { - return s - } - const history = s.directSshPaneRetryHistoryByTabId[result.tabId] - const preservesExhaustedSplitAttempt = - (result.status === 'failed' || result.status === 'timed-out') && - history != null && - directSshAuthoritiesEqual(history.authority, result.authority) && - history.attemptedAt.length >= 2 - if (preservesExhaustedSplitAttempt) { - return s - } - const settlement = settleDirectSshPaneRetryState(s, result) - if (!settlement) { - return s - } - const settledState = { ...s, ...settlement } - if (result.status !== 'failed' && result.status !== 'timed-out') { - return settledState - } - const retry = retrySettledDirectSshTerminalPane( - settledState, - resolveDirectSshTerminalKeys(settledState, result.authority.targetId), - result.authority, - result.tabId, - now - ) - return retry.patch ? { ...settledState, ...retry.patch } : settledState - }) - }, - - shutdownWorktreeTerminals: async (worktreeId, opts) => { - const keepIdentifiers = opts?.keepIdentifiers ?? false - const shutdownReason: AgentStatusWorktreeShutdownReason = - opts?.shutdownReason ?? (keepIdentifiers ? 'manual-sleep' : 'remove-worktree') - const tabs = get().tabsByWorktree[worktreeId] ?? [] - const ptyIds = tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? []) - const rendererShutdownPtyIds = sortedUniquePtyIds(ptyIds) - const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) - const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) - // Why: only renderer-bound ids emit pane exit callbacks, so they are the complete guard set (expectedRuntimePtyIds are raw RPC handles). - const exitGuardPtyIds = rendererShutdownPtyIds - const sleepingAgentSessionRecords = keepIdentifiers - ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, { - paneKeys: opts?.sleepingPaneKeys, - ...(shutdownReason === 'manual-sleep' ? { captureMode: 'manual-worktree-sleep' } : {}), - ...(shutdownReason === 'auto-hibernate-completed-agent' - ? { captureMode: 'completed-agent-hibernation' } - : {}) - }) - : {} - const retainedCompletionEvidence = - shutdownReason === 'auto-hibernate-completed-agent' - ? collectHibernatedCompletionEvidenceForWorktree(get(), worktreeId, opts?.sleepingPaneKeys) - : [] - let handlerSnapshots: ReturnType = [] - let partialRendererStopSettled = false - const markShutdownPending = (): void => { - set((s) => { - const nextPending = { ...s.pendingPtyShutdownIds } - for (const ptyId of exitGuardPtyIds) { - nextPending[ptyId] = (nextPending[ptyId] ?? 0) + 1 - } - return { - suppressedPtyExitIds: { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - }, - pendingPtyShutdownIds: nextPending - } - }) - } - const rollbackShutdown = (): void => { - if (handlerSnapshots.length > 0) { - restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) - } - set((s) => { - const nextSuppressed = { ...s.suppressedPtyExitIds } - const nextPending = { ...s.pendingPtyShutdownIds } - for (const ptyId of exitGuardPtyIds) { - const remainingOwners = (nextPending[ptyId] ?? 0) - 1 - if (remainingOwners > 0) { - nextPending[ptyId] = remainingOwners - } else { - delete nextPending[ptyId] - if (!hasCommittedPtyShutdownSettlement(ptyId)) { - delete nextSuppressed[ptyId] - } - } - } - return { - suppressedPtyExitIds: nextSuppressed, - pendingPtyShutdownIds: nextPending - } - }) - const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId)) - const committedPtyIds = settledPtyIds.filter(hasCommittedPtyShutdownSettlement) - const rolledBackPtyIds = settledPtyIds.filter( - (ptyId) => !hasCommittedPtyShutdownSettlement(ptyId) - ) - markCommittedPtyShutdowns(committedPtyIds) - settleDeferredPtyShutdownExits(committedPtyIds, 'committed') - settleDeferredPtyShutdownExits(rolledBackPtyIds, 'rolled-back') - clearCommittedPtyShutdownSettlements(settledPtyIds) - } - const stopRendererPtys = async (): Promise<{ - stoppedPtyIds: string[] - failure?: PromiseRejectedResult - }> => { - const localPtyIds = rendererShutdownPtyIds.filter((ptyId) => !ptyId.startsWith('remote:')) - const results = await Promise.allSettled( - localPtyIds.map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) - ) - const stoppedPtyIds = [ - ...(runtimeEnvironmentId - ? rendererShutdownPtyIds.filter((ptyId) => ptyId.startsWith('remote:')) - : []), - ...localPtyIds.filter((_, index) => results[index]?.status === 'fulfilled') - ] - disposeParkedTerminalWatchersForPtyIds(stoppedPtyIds) - return { - stoppedPtyIds, - failure: results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - } - } - const settlePartialRendererStop = (stoppedPtyIds: readonly string[]): void => { - partialRendererStopSettled = true - const stopped = new Set(stoppedPtyIds) - const stoppedSnapshots = handlerSnapshots.filter((snapshot) => stopped.has(snapshot.ptyId)) - const failedSnapshots = handlerSnapshots.filter((snapshot) => !stopped.has(snapshot.ptyId)) - for (const snapshot of stoppedSnapshots) { - snapshot.commit?.() - } - restorePtyDataHandlersAfterFailedShutdown(failedSnapshots) - noteCommittedPtyShutdownSettlements(stoppedPtyIds) - set((s) => { - const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - for (const tab of tabs) { - nextPtyIdsByTabId[tab.id] = (s.ptyIdsByTabId[tab.id] ?? []).filter( - (ptyId) => !stopped.has(ptyId) - ) - } - const nextPending = { ...s.pendingPtyShutdownIds } - const nextSuppressed = { ...s.suppressedPtyExitIds } - for (const ptyId of exitGuardPtyIds) { - const remainingOwners = (nextPending[ptyId] ?? 0) - 1 - if (remainingOwners > 0) { - nextPending[ptyId] = remainingOwners - } else { - delete nextPending[ptyId] - if (!stopped.has(ptyId)) { - delete nextSuppressed[ptyId] - } - } - } - return { - ptyIdsByTabId: nextPtyIdsByTabId, - pendingPtyShutdownIds: nextPending, - suppressedPtyExitIds: nextSuppressed - } - }) - const failedPtyIds = exitGuardPtyIds.filter((ptyId) => !stopped.has(ptyId)) - markCommittedPtyShutdowns(stoppedPtyIds) - settleDeferredPtyShutdownExits(stoppedPtyIds, 'committed') - settleDeferredPtyShutdownExits(failedPtyIds, 'rolled-back') - clearCommittedPtyShutdownSettlements(exitGuardPtyIds) - } - - // Why: capture buffers before kill, which unmounts panes and drops SSH relay history. - // Why: capture() updates layouts, so the following updater must merge current state. - if (keepIdentifiers) { - for (const tab of tabs) { - const capture = shutdownBufferCaptures.get(tab.id) - if (capture) { - try { - capture({ includeLocalBuffers: false }) - } catch { - // Don't let one tab's capture failure block the rest. - } - } - } - } - - if (expectedRuntimePtyIds.length === 0) { - markShutdownPending() - handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? [] - try { - // Why an opt-in, not the reason: defaulting to the stop keeps callers without backend teardown from leaking remote PTYs. - if (runtimeEnvironmentId && opts?.backendOwnsPtyTeardown !== true) { - await (shutdownReason === 'manual-sleep' - ? requestRemoteWorktreeSleep({ - environmentId: runtimeEnvironmentId, - worktreeId - }) - : callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'terminal.stop', - { worktree: toRuntimeWorktreeSelector(worktreeId) }, - { timeoutMs: 15_000 } - )) - } - - // Why: client-owned teardown waits for the owner RPC so a failure leaves renderer bindings retryable. - const rendererStop = await stopRendererPtys() - if (rendererStop.failure) { - settlePartialRendererStop(rendererStop.stoppedPtyIds) - throw rendererStop.failure.reason - } - } catch (err) { - if (!partialRendererStopSettled) { - rollbackShutdown() - } - throw err - } - } - - // Exact-stop callers own physical teardown and must not use the remove-worktree reason. - if (expectedRuntimePtyIds.length > 0) { - if (!runtimeEnvironmentId) { - throw new Error('missing_runtime_for_exact_terminal_stop') - } - markShutdownPending() - handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? [] - let stopResult: { - stoppedPtyIds?: string[] - livePtyIds?: string[] - postStopVerified?: boolean - postStopFailure?: string - remainingLivePtyIds?: string[] - } - try { - stopResult = await callRuntimeRpc<{ - stoppedPtyIds?: string[] - livePtyIds?: string[] - }>( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'terminal.stopExact', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - expectedPtyIds: expectedRuntimePtyIds, - keepHistory: keepIdentifiers - }, - { timeoutMs: 15_000 } - ) - } catch (err) { - rollbackShutdown() - throw err - } - const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) - const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) - if ( - !equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || - !equalStringSets(livePtyIds, expectedRuntimePtyIds) - ) { - rollbackShutdown() - throw new Error('exact_terminal_stop_mismatch') - } - if (stopResult.postStopVerified !== true) { - rollbackShutdown() - throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') - } - try { - const rendererStop = await stopRendererPtys() - if (rendererStop.failure) { - settlePartialRendererStop(rendererStop.stoppedPtyIds) - throw rendererStop.failure.reason - } - } catch (err) { - if (!partialRendererStopSettled) { - rollbackShutdown() - } - throw err - } - } - - for (const snapshot of handlerSnapshots) { - snapshot.commit?.() - } - noteCommittedPtyShutdownSettlements(exitGuardPtyIds) - - set((s) => { - const nextTabsByWorktree = keepIdentifiers - ? s.tabsByWorktree - : { - ...s.tabsByWorktree, - [worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((tab, index) => - clearTransientTerminalState(tab, index) - ) - } - const nextPtyIdsByTabId = { - ...s.ptyIdsByTabId, - ...Object.fromEntries(tabs.map((tab) => [tab.id, [] as string[]] as const)) - } - const nextRuntimePaneTitlesByTabId = keepIdentifiers - ? s.runtimePaneTitlesByTabId - : { ...s.runtimePaneTitlesByTabId } - const nextSuppressedPtyExitIds = { - ...s.suppressedPtyExitIds, - ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) - } - const nextPendingPtyShutdownIds = { ...s.pendingPtyShutdownIds } - for (const ptyId of exitGuardPtyIds) { - const remainingOwners = (nextPendingPtyShutdownIds[ptyId] ?? 0) - 1 - if (remainingOwners > 0) { - nextPendingPtyShutdownIds[ptyId] = remainingOwners - } else { - delete nextPendingPtyShutdownIds[ptyId] - } - } - // Why: keep pendingCodexPaneRestartIds (same ptyId survives sleep→wake), but clear codexRestartNoticeByPtyId since wake's post-spawn ptyId may differ. - const nextPendingCodexPaneRestartIds = keepIdentifiers - ? s.pendingCodexPaneRestartIds - : { ...s.pendingCodexPaneRestartIds } - const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - for (const ptyId of exitGuardPtyIds) { - if (!keepIdentifiers) { - delete nextPendingCodexPaneRestartIds[ptyId] - } - delete nextCodexRestartNoticeByPtyId[ptyId] - } - // Why: setup-split and issue-command-split are transient new-tab one-shots, not sleep-recovery state; clear in both cases. - const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } - const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId } - // Why: remove-worktree must clear ptyIdsByLeafId (dead IDs → zombie reattach pane on remount); sleep preserves it so wake can reattach via pty.spawn. - const nextTerminalLayoutsByTabId = { ...s.terminalLayoutsByTabId } - // Why: unread dots survive worktree switches, but shutdown/sleep both kill the PTYs behind them, so dead-ptyId unread state is stale — clear in both. - // Why: preserve the unreadTerminalTabs reference when no shutting-down tab was unread, avoiding a no-op state allocation that re-evaluates selectors. - let nextUnreadTerminalTabs = s.unreadTerminalTabs - let nextUnreadTerminalPanes = s.unreadTerminalPanes - let nextUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes - let nextLastTerminalInputAtByPaneKey = s.lastTerminalInputAtByPaneKey - for (const tab of tabs) { - if (!keepIdentifiers) { - delete nextRuntimePaneTitlesByTabId[tab.id] - } - delete nextPendingSetupSplitByTabId[tab.id] - delete nextPendingIssueCommandSplitByTabId[tab.id] - if (nextUnreadTerminalTabs[tab.id]) { - if (nextUnreadTerminalTabs === s.unreadTerminalTabs) { - nextUnreadTerminalTabs = { ...s.unreadTerminalTabs } - } - delete nextUnreadTerminalTabs[tab.id] - } - for (const paneKey of Object.keys(nextUnreadTerminalPanes)) { - if (paneKey.startsWith(`${tab.id}:`)) { - if (nextUnreadTerminalPanes === s.unreadTerminalPanes) { - nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } - } - delete nextUnreadTerminalPanes[paneKey] - } - } - for (const paneKey of Object.keys(nextUnreadAgentCompletionPanes)) { - if (paneKey.startsWith(`${tab.id}:`)) { - if (nextUnreadAgentCompletionPanes === s.unreadAgentCompletionPanes) { - nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } - } - delete nextUnreadAgentCompletionPanes[paneKey] - } - } - for (const paneKey of Object.keys(nextLastTerminalInputAtByPaneKey)) { - if (paneKey.startsWith(`${tab.id}:`)) { - if (nextLastTerminalInputAtByPaneKey === s.lastTerminalInputAtByPaneKey) { - nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } - } - delete nextLastTerminalInputAtByPaneKey[paneKey] - } - } - if (!keepIdentifiers) { - const existingLayout = nextTerminalLayoutsByTabId[tab.id] - if (existingLayout?.ptyIdsByLeafId) { - nextTerminalLayoutsByTabId[tab.id] = { - ...existingLayout, - ptyIdsByLeafId: {} - } - } - } - } - - // Why: remove-worktree kills the relay PTY, so a persisted session ID would fail reattach; sleep preserves it for wake's re-spawn over the relay. - const nextLastKnownRelay = keepIdentifiers - ? s.lastKnownRelayPtyIdByTabId - : { ...s.lastKnownRelayPtyIdByTabId } - if (!keepIdentifiers) { - for (const tab of tabs) { - delete nextLastKnownRelay[tab.id] - } - } - - return { - tabsByWorktree: nextTabsByWorktree, - ptyIdsByTabId: nextPtyIdsByTabId, - lastKnownRelayPtyIdByTabId: nextLastKnownRelay, - runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, - suppressedPtyExitIds: nextSuppressedPtyExitIds, - pendingPtyShutdownIds: nextPendingPtyShutdownIds, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, - pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, - pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, - terminalLayoutsByTabId: nextTerminalLayoutsByTabId, - // Why: skip writing unreadTerminalTabs when unchanged to avoid a no-op state allocation that re-evaluates full-state selectors. Mirrors tabs.ts. - ...(nextUnreadTerminalTabs !== s.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextUnreadTerminalPanes !== s.unreadTerminalPanes - ? { unreadTerminalPanes: nextUnreadTerminalPanes } - : {}), - ...(nextUnreadAgentCompletionPanes !== s.unreadAgentCompletionPanes - ? { unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes } - : {}), - ...(nextLastTerminalInputAtByPaneKey !== s.lastTerminalInputAtByPaneKey - ? { lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey } - : {}) - } - }) - - if (keepIdentifiers) { - set((s) => { - const base = - shutdownReason === 'manual-sleep' - ? removeSleepingRecordsReplacedByManualWorktreeSleep( - s.sleepingAgentSessionsByPaneKey, - worktreeId, - opts?.sleepingPaneKeys, - sleepingAgentSessionRecords - ).records - : s.sleepingAgentSessionsByPaneKey - return { - sleepingAgentSessionsByPaneKey: { - ...base, - ...sleepingAgentSessionRecords - } - } - }) - } else { - get().clearSleepingAgentSessionsByWorktree(worktreeId) - } - - // Why: only automatic completed-agent sleep keeps passive completion evidence; manual sleep/remove fold the whole worktree surface. - get().dropAgentStatusByWorktree(worktreeId, { - shutdownReason, - sleepingPaneKeys: opts?.sleepingPaneKeys, - retainedCompletionEvidence - }) - get().clearPaneForegroundAgentByWorktree(worktreeId) - const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId)) - markCommittedPtyShutdowns(settledPtyIds) - settleDeferredPtyShutdownExits(settledPtyIds, 'committed') - clearCommittedPtyShutdownSettlements(settledPtyIds) - }, - - consumeSuppressedPtyExit: (ptyId) => { - let wasSuppressed = false - set((s) => { - if (!s.suppressedPtyExitIds[ptyId]) { - return {} - } - wasSuppressed = true - const next = { ...s.suppressedPtyExitIds } - delete next[ptyId] - return { suppressedPtyExitIds: next } - }) - return wasSuppressed - }, - - isPtyShutdownPending: (ptyId) => (get().pendingPtyShutdownIds[ptyId] ?? 0) > 0, - - suppressPtyExit: (ptyId) => { - set((s) => ({ - suppressedPtyExitIds: { ...s.suppressedPtyExitIds, [ptyId]: true } - })) - }, - - queueCodexPaneRestarts: (ptyIds) => { - if (ptyIds.length === 0) { - return - } - set((s) => { - // Why: the prompt is answered the moment the user asks for a restart. A - // pane whose tab isn't mounted can only restart when it next mounts, and - // leaving the prompt up re-showed a button that now does nothing. - const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } - for (const ptyId of ptyIds) { - const notice = nextCodexRestartNoticeByPtyId[ptyId] - if (notice) { - // Why: mirror of the strip in dismissCodexRestartNotices. A queued - // restart leaves the pane on the old account until it relaunches, so - // it must re-block input that an earlier dismissal had freed. - const { dismissed: _dismissed, ...kept } = notice - nextCodexRestartNoticeByPtyId[ptyId] = { ...kept, restartRequested: true } - } - } - return { - pendingCodexPaneRestartIds: { - ...s.pendingCodexPaneRestartIds, - ...Object.fromEntries(ptyIds.map((ptyId) => [ptyId, true] as const)) - }, - codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId - } - }) - }, - - consumePendingCodexPaneRestart: (ptyId) => { - let wasQueued = false - set((s) => { - if (!s.pendingCodexPaneRestartIds[ptyId]) { - return {} - } - wasQueued = true - const next = { ...s.pendingCodexPaneRestartIds } - delete next[ptyId] - return { pendingCodexPaneRestartIds: next } - }) - return wasQueued - }, - - markCodexRestartNotices: (notices) => { - if (notices.length === 0) { - return [] - } - const noticedPtyIds: string[] = [] - set((s) => { - const next = { ...s.codexRestartNoticeByPtyId } - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - for (const notice of notices) { - const existing = next[notice.ptyId] - // Why one record rather than two lookups: the label and the id of the - // launch account have to come from the same source, or they can end up - // describing two different accounts. - const launch = existing ?? notice - const target = { id: notice.nextAccountId, label: notice.nextAccountLabel } - const homeRouteChanged = - notice.homeRouteChanged === undefined - ? existing?.homeRouteChanged === true - : notice.homeRouteChanged - - // Why: a live Codex pane keeps its original launch account until it actually restarts, so A -> B -> A must not leave a stale restart notice. - if ( - !homeRouteChanged && - isSameCodexRestartNoticeAccount( - { id: launch.previousAccountId, label: launch.previousAccountLabel }, - target - ) - ) { - delete next[notice.ptyId] - delete nextPendingCodexPaneRestartIds[notice.ptyId] - continue - } - - next[notice.ptyId] = { - previousAccountLabel: launch.previousAccountLabel, - nextAccountLabel: notice.nextAccountLabel, - ...(launch.previousAccountId === undefined - ? {} - : { previousAccountId: launch.previousAccountId }), - ...(notice.nextAccountId === undefined ? {} : { nextAccountId: notice.nextAccountId }), - ...(homeRouteChanged ? { homeRouteChanged: true as const } : {}), - // Why: a queued restart relaunches under whatever account is selected - // when it runs, so a later switch does not reopen an answered prompt. - ...(existing?.restartRequested ? { restartRequested: true as const } : {}), - // Why: a dismissal answered one question — "keep the launch account - // instead of this one". Only a genuinely different target re-asks it, - // so a later C reopens the prompt while adding an account or - // reauthenticating the active one (both re-mark live panes with the - // selection unchanged) must not resurrect it and re-mute the pane. - ...(existing?.dismissed && - isSameCodexRestartNoticeAccount( - { id: existing.nextAccountId, label: existing.nextAccountLabel }, - target - ) - ? { dismissed: true as const } - : {}) - } - noticedPtyIds.push(notice.ptyId) - } - return { - codexRestartNoticeByPtyId: next, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds - } - }) - return noticedPtyIds - }, - - clearCodexRestartNotice: (ptyId) => { - set((s) => { - if (!s.codexRestartNoticeByPtyId[ptyId]) { - return {} - } - const next = { ...s.codexRestartNoticeByPtyId } - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - delete next[ptyId] - delete nextPendingCodexPaneRestartIds[ptyId] - return { - codexRestartNoticeByPtyId: next, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds - } - }) - }, - - dismissCodexRestartNotices: (ptyIds) => { - set((s) => { - // Why: keeping the old account is an answer, not a restart — the record - // stays so `previousAccountLabel` still names the pane's launch account, - // but every consumer treats it as answered (prompt hidden, input freed). - const next = { ...s.codexRestartNoticeByPtyId } - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - let changed = false - for (const ptyId of ptyIds) { - const notice = next[ptyId] - if (!notice || notice.dismissed) { - continue - } - const { restartRequested: _restartRequested, ...kept } = notice - next[ptyId] = { ...kept, dismissed: true } - delete nextPendingCodexPaneRestartIds[ptyId] - changed = true - } - if (!changed) { - return {} - } - return { - codexRestartNoticeByPtyId: next, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds - } - }) - }, - - reopenCodexRestartPrompt: (ptyId) => { - set((s) => { - const notice = s.codexRestartNoticeByPtyId[ptyId] - if (!notice?.restartRequested) { - return {} - } - const { restartRequested: _restartRequested, ...kept } = notice - const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } - delete nextPendingCodexPaneRestartIds[ptyId] - return { - codexRestartNoticeByPtyId: { ...s.codexRestartNoticeByPtyId, [ptyId]: kept }, - pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds - } - }) - }, - - replaceTerminalLayoutPanePtyId: (tabId, leafId, ptyId) => { - set((s) => { - const layout = s.terminalLayoutsByTabId[tabId] - if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { - return {} - } - return { - terminalLayoutsByTabId: { - ...s.terminalLayoutsByTabId, - [tabId]: { - ...layout, - ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId } - } - } - } - }) - }, - - setTabPaneExpanded: (tabId, expanded) => { - set((s) => ({ - expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded } - })) - }, - - setTabCanExpandPane: (tabId, canExpand) => { - set((s) => ({ - canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand } - })) - }, - - setTabLayout: (tabId, layout) => { - let ownershipTransfers: ReturnType = [] - set((s) => { - if (!layout) { - if (!(tabId in s.terminalLayoutsByTabId)) { - return s - } - const next = { ...s.terminalLayoutsByTabId } - delete next[tabId] - return { terminalLayoutsByTabId: next } - } - const normalized = normalizeTerminalLayoutPtyOwnership(layout) - // Resolved before the bailout: normalization can transfer pane ownership even when the stored snapshot is untouched. - if (normalized.changed) { - ownershipTransfers = resolveTerminalLayoutPtyOwnershipTransfers(layout, normalized.snapshot) - } - // Why: pane-title churn re-persists structurally identical snapshots; bailing keeps every pane selector asleep. - const existing = s.terminalLayoutsByTabId[tabId] - if (existing && terminalLayoutEqual(existing, normalized.snapshot)) { - return s - } - return { - terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot } - } - }) - transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers) - }, - - syncPaneDetachPtyOwnership: ({ - detachedLeafId, - detachedPtyId, - sourceLayout, - sourceTabId, - targetTabId - }) => { - const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId) - const targetPaneKey = makePaneKey(targetTabId, detachedLeafId) - set((s) => { - const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) - const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( - (ptyId) => ptyId !== detachedPtyId - ) - const sourcePtyIds = layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds - const sourcePrimaryPtyId = resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null - const nextPtyIdsByTabId = { - ...s.ptyIdsByTabId, - [sourceTabId]: sourcePtyIds - } - if (detachedPtyId) { - nextPtyIdsByTabId[targetTabId] = uniquePtyIds([ - ...(nextPtyIdsByTabId[targetTabId] ?? []), - detachedPtyId - ]) - } - - const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId } - if (sourcePrimaryPtyId) { - nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId - } else { - delete nextLastKnownRelayPtyIdByTabId[sourceTabId] - } - if (detachedPtyId) { - nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId - } - - // Why: pane-to-tab detach moves a live PTY without spawning or exiting, so transfer identity without activity bumps. - const sourceTabsByWorktree = withTerminalTabPtyId( - s.tabsByWorktree, - sourceTabId, - sourcePrimaryPtyId - ) - const nextTabsByWorktree = detachedPtyId - ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) - : sourceTabsByWorktree - const directSshLedger = transferDirectSshPaneDetachLedger(s, { - detachedPtyId, - sourcePtyId: sourcePrimaryPtyId, - sourceTabId, - targetTabId, - isAuthorityCurrent: (authority) => isCurrentDirectSshAuthority(s, authority) - }) - - return { - ptyIdsByTabId: nextPtyIdsByTabId, - lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, - ...directSshLedger, - ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) - } - }) - // Why: detach keeps the process and its pane key alive, so move resume/status authority to the new surface before the source closes. - get().transferAgentPaneAuthority({ - fromPaneKey: sourcePaneKey, - toPaneKey: targetPaneKey, - ptyId: detachedPtyId - }) - }, - - queueTabStartupCommand: (tabId, startup) => { - // Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token. - const launchToken = startup.launchConfig - ? (startup.launchToken ?? createBrowserUuid()) - : undefined - set((s) => ({ - pendingStartupByTabId: { - ...s.pendingStartupByTabId, - [tabId]: { - ...startup, - ...(launchToken ? { launchToken } : {}) - } - } - })) - }, - - queueTabInitialCwd: (tabId, cwd) => { - set((s) => ({ - pendingInitialCwdByTabId: { - ...s.pendingInitialCwdByTabId, - [tabId]: cwd - } - })) - }, - - consumeTabInitialCwd: (tabId) => { - const pending = get().pendingInitialCwdByTabId[tabId] - if (!pending) { - return null - } - set((s) => { - const next = { ...s.pendingInitialCwdByTabId } - delete next[tabId] - return { pendingInitialCwdByTabId: next } - }) - return pending - }, - - consumeTabStartupCommand: (tabId) => { - const pending = get().pendingStartupByTabId[tabId] - if (!pending) { - return null - } - - set((s) => { - const next = { ...s.pendingStartupByTabId } - delete next[tabId] - return { pendingStartupByTabId: next } - }) - - return pending - }, - - queueTabSetupSplit: (tabId, startup) => { - set((s) => ({ - pendingSetupSplitByTabId: { - ...s.pendingSetupSplitByTabId, - [tabId]: startup - } - })) - }, - - consumeTabSetupSplit: (tabId) => { - const pending = get().pendingSetupSplitByTabId[tabId] - if (!pending) { - return null - } - - set((s) => { - const next = { ...s.pendingSetupSplitByTabId } - delete next[tabId] - return { pendingSetupSplitByTabId: next } - }) - - return pending - }, - - queueTabIssueCommandSplit: (tabId, issueCommand) => { - set((s) => ({ - pendingIssueCommandSplitByTabId: { - ...s.pendingIssueCommandSplitByTabId, - [tabId]: issueCommand - } - })) - }, - - consumeTabIssueCommandSplit: (tabId) => { - const pending = get().pendingIssueCommandSplitByTabId[tabId] - if (!pending) { - return null - } - - set((s) => { - const next = { ...s.pendingIssueCommandSplitByTabId } - delete next[tabId] - return { pendingIssueCommandSplitByTabId: next } - }) - - return pending - }, - - consumePendingSnapshot: (ptyId) => { - const snapshot = get().pendingSnapshotByPtyId[ptyId] - if (!snapshot) { - return null - } - set((s) => { - const next = { ...s.pendingSnapshotByPtyId } - delete next[ptyId] - return { pendingSnapshotByPtyId: next } - }) - return snapshot - }, - - consumePendingColdRestore: (ptyId) => { - const data = get().pendingColdRestoreByPtyId[ptyId] - if (!data) { - return null - } - set((s) => { - const next = { ...s.pendingColdRestoreByPtyId } - delete next[ptyId] - return { pendingColdRestoreByPtyId: next } - }) - return data - }, - - hydrateWorkspaceSession: (session, options) => { - const ownershipTransferTabIds = options?.replaceWorkspaceKeys - ? new Set( - options.replaceWorkspaceKeys.flatMap((workspaceKey) => - (session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ) - ) - : null - const ownershipTransfersByTabId = new Map() - set((s) => { - const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({ - repos: s.repos, - runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {}, - worktreesByRepo: s.worktreesByRepo - }) - const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( - { - repos: runtimeSessionPlaceholders.repos, - worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo, - detectedWorktreesByRepo: s.detectedWorktreesByRepo - }, - collectPersistedWorktreeIdsForSessionHydration(session) - ) - const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id)) - // Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart. - validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of s.folderWorkspaces) { - validWorktreeIds.add(folderWorkspaceKey(workspace.id)) - } - addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - // Why: rows for these keys came off the remote wire, which carries no unifiedTabs, so the - // session's canonical list describes a different snapshot and must not arbitrate their PTYs. - const remoteSnapshotWorkspaceKeys = new Set( - options?.directSshAuthority ? (options.replaceWorkspaceKeys ?? []) : [] - ) - const rowHydrationByWorktree = Object.entries(session.tabsByWorktree) - .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) - .map( - ([worktreeId, tabs]) => - [ - worktreeId, - hydrateWorkspaceTerminalRows(session, worktreeId, tabs, { - rowsFromRemoteSnapshot: remoteSnapshotWorkspaceKeys.has(worktreeId) - }) - ] as const - ) - const tabsByWorktree: Record = Object.fromEntries( - rowHydrationByWorktree - .map(([worktreeId, hydration]) => [worktreeId, hydration.rows] as const) - .filter( - ([worktreeId, tabs]) => - tabs.length > 0 || session.tabsByWorktree[worktreeId]?.length === 0 - ) - ) - const releasedPtyIdsByTabId = new Map>( - rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.releasedPtyIdsByTabId]) - ) - const reconnectPtyIdByRetainedTabId = new Map( - rowHydrationByWorktree.flatMap(([, hydration]) => [ - ...hydration.reconnectPtyIdByRetainedTabId - ]) - ) - const canonicalTabIdBySubsumedTabId = new Map( - rowHydrationByWorktree.flatMap(([, hydration]) => [ - ...hydration.canonicalTabIdBySubsumedTabId - ]) - ) - - const validTabIds = new Set( - Object.values(tabsByWorktree) - .flat() - .map((tab) => tab.id) - ) - let sleepingAgentSessionsByPaneKey = Object.fromEntries( - Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter(([, record]) => - validWorktreeIds.has(record.worktreeId) - ) - ) - // Why: a dropped row is never retired, so nothing else clears the sleeping records keyed to - // it — its worktree is still valid — and the workspace keeps a pane nobody can wake. - for (const [, hydration] of rowHydrationByWorktree) { - for (const tabId of [...hydration.subsumedTabIds, ...hydration.invalidTabIds]) { - sleepingAgentSessionsByPaneKey = removeSleepingAgentSessionsForTab( - sleepingAgentSessionsByPaneKey, - tabId - ) - } - } - // Why: a released leaf's agent session belongs to the canonical row now; leaving the record here - // would cold-restore the same provider session on a pane that no longer owns its PTY. - const releasedPaneKeys = new Set( - [...releasedPtyIdsByTabId].flatMap(([tabId, releasedPtyIds]) => - collectReleasedLeafIds(session.terminalLayoutsByTabId[tabId], releasedPtyIds) - // Why: persisted ptyIdsByLeafId keys are unvalidated strings, and makePaneKey throws on non-UUIDs. - .filter(isTerminalLeafId) - .map((leafId) => makePaneKey(tabId, leafId)) - ) - ) - if (releasedPaneKeys.size > 0) { - sleepingAgentSessionsByPaneKey = Object.fromEntries( - Object.entries(sleepingAgentSessionsByPaneKey).filter( - ([paneKey]) => !releasedPaneKeys.has(paneKey) - ) - ) - } - const fallbackActiveWorktreeId = - !session.activeWorktreeId && session.activeRepoId && knownRepoIds.has(session.activeRepoId) - ? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find( - (worktree) => worktree.isMainWorktree - )?.id ?? - runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.[0]?.id ?? - null) - : null - const activeWorktreeId = (() => { - if (session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)) { - return session.activeWorktreeId - } - // Why: a workspace with no tabs is still valid; fall back from the active repo to avoid a blank landing screen when tabs were pruned or never created. - return fallbackActiveWorktreeId - })() - const activeWorkspaceKey: WorkspaceKey | null = - session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey) - ? session.activeWorkspaceKey - : activeWorktreeId - ? parseWorkspaceKey(activeWorktreeId) - ? (activeWorktreeId as WorkspaceKey) - : worktreeWorkspaceKey(activeWorktreeId) - : null - const activeWorkspaceExecutionHostId = - activeWorkspaceKey && session.activeWorkspaceExecutionHostId - ? session.activeWorkspaceExecutionHostId - : null - // Why: follow a subsumed row to the canonical twin that inherited its PTY, else the app - // restarts with no active terminal even though the same session is still mounted. - const restoredActiveTabId = session.activeTabId - ? (canonicalTabIdBySubsumedTabId.get(session.activeTabId) ?? session.activeTabId) - : null - const activeTabId = - restoredActiveTabId && validTabIds.has(restoredActiveTabId) ? restoredActiveTabId : null - const activeRepoId = - session.activeRepoId && - runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId) - ? session.activeRepoId - : null - - // Why: reconnect enables the session after eager spawns to prevent duplicate PTYs. - // Why: activeWorktreeIdsOnShutdown is authoritative when present; persisted tab/layout PTY IDs are only wake hints, not a full active-workspace list. - const shutdownIds = - session.activeWorktreeIdsOnShutdown ?? - Object.entries(session.tabsByWorktree) - .filter(([, tabs]) => tabs.some((t) => t.ptyId)) - .map(([wId]) => wId) - const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id)) - - // Why: capture live PTY tabs before transient state clears their IDs. - // Also include tabs whose relay session id survived in remoteSessionIdsByTabId (ptyId was null but the relay PTY is alive). - const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} - const pendingReconnectTabByWorktree: Record = {} - for (const worktreeId of pendingReconnectWorktreeIds) { - const rawTabs = session.tabsByWorktree[worktreeId] ?? [] - const liveTabIds = rawTabs - .filter( - (t) => - // Why: a row that gave its tab-level PTY to the canonical twin still owns leaf sessions to advertise. - (t.ptyId || remoteSessionIds[t.id] || reconnectPtyIdByRetainedTabId.has(t.id)) && - validTabIds.has(t.id) - ) - .map((t) => t.id) - if (liveTabIds.length > 0) { - pendingReconnectTabByWorktree[worktreeId] = liveTabIds - } - } - - // Why: preserve each tab's prior ptyId so reconnect passes it as sessionId to the daemon's createOrAttach, triggering reattach instead of a fresh spawn. - const pendingReconnectPtyIdByTabId: Record = {} - const placeholderWorktreeById = buildWorktreeByIdIndex( - runtimeSessionPlaceholders.worktreesByRepo - ) - const placeholderRepoById = buildByIdIndex(runtimeSessionPlaceholders.repos) - for (const worktreeId of pendingReconnectWorktreeIds) { - const worktree = placeholderWorktreeById.get(worktreeId) - const repo = worktree ? placeholderRepoById.get(worktree.repoId) : null - if (repo?.connectionId) { - continue - } - const rawTabs = session.tabsByWorktree[worktreeId] ?? [] - for (const tab of rawTabs) { - // Why: a released PTY belongs to the canonical row now; reattaching it here would restore the duplicate ownership. - if ( - tab.ptyId && - validTabIds.has(tab.id) && - !releasedPtyIdsByTabId.get(tab.id)?.has(tab.ptyId) - ) { - pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId - } - } - } - - // Why: remote PTY reattach uses the relay's pty.attach RPC, not the local daemon; the loop above skips SSH repos, so no overlap. - for (const [tabId, sessionId] of Object.entries(remoteSessionIds)) { - if (validTabIds.has(tabId) && !releasedPtyIdsByTabId.get(tabId)?.has(sessionId)) { - pendingReconnectPtyIdByTabId[tabId] = sessionId - } - } - - // Why: hydration nulls tab.ptyId and orphan detection ignores layout bindings, so a row whose - // tab-level PTY went to its canonical twin reads as dead and is swept before its own surviving - // pane can reattach. Anchor it on the PTY it still owns (#10486). - for (const [tabId, ptyId] of reconnectPtyIdByRetainedTabId) { - if (validTabIds.has(tabId) && !pendingReconnectPtyIdByTabId[tabId]) { - pendingReconnectPtyIdByTabId[tabId] = ptyId - } - } - - // Restore per-worktree active tab; validate ids when the map exists, else derive for legacy sessions. - let activeTabIdByWorktree: Record = {} - if (session.activeTabIdByWorktree) { - for (const [wId, tabId] of Object.entries(session.activeTabIdByWorktree)) { - if (!validWorktreeIds.has(wId) || !tabId) { - continue - } - // Why: a subsumed row's canonical twin holds the same PTY, so follow the pointer there - // instead of forgetting which terminal the workspace last focused. - const restored = validTabIds.has(tabId) ? tabId : canonicalTabIdBySubsumedTabId.get(tabId) - if (restored && validTabIds.has(restored)) { - activeTabIdByWorktree[wId] = restored - } - } - } else { - // Legacy sessions: best-effort derivation - if (activeWorktreeId && activeTabId) { - activeTabIdByWorktree[activeWorktreeId] = activeTabId - } - for (const [wId, tabs] of Object.entries(tabsByWorktree)) { - if (!activeTabIdByWorktree[wId] && tabs.length > 0) { - activeTabIdByWorktree[wId] = tabs[0].id - } - } - } - - // Why: synthesize SSH worktrees from session tabs until relay discovery replaces them. - // Why: only SSH gets placeholders; local metadata comes from the next successful fetch. - const sshRepoIds = new Set( - runtimeSessionPlaceholders.repos.filter((r) => r.connectionId).map((r) => r.id) - ) - const worktreesByRepo = { ...runtimeSessionPlaceholders.worktreesByRepo } - for (const worktreeId of Object.keys(tabsByWorktree)) { - const repoId = getRepoIdFromWorktreeId(worktreeId) - if (!sshRepoIds.has(repoId)) { - continue - } - const existing = (worktreesByRepo[repoId] ?? []).find((w) => w.id === worktreeId) - if (existing) { - continue - } - // Why: strip the synthetic `::workspace:` folder suffix so the placeholder path is a real cwd; `id` above keeps it for identity. - const path = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? '' - // Why: SSH worktree paths may use backslash separators on Windows remotes. - const displayName = path.split(/[/\\]/).pop() || path - const placeholder: Worktree = { - id: worktreeId, - repoId, - displayName, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - path, - head: '', - branch: '', - isBare: false, - isMainWorktree: false - } - worktreesByRepo[repoId] = [...(worktreesByRepo[repoId] ?? []), placeholder] - } - - // Why: record restored active worktrees to avoid suppressing later real activity. - const nextEverActivated = new Set(s.everActivatedWorktreeIds) - if (activeWorktreeId) { - nextEverActivated.add(activeWorktreeId) - } - - // Why indexed: the layout map below looks up a tab per persisted layout, and - // re-flattening tabsByWorktree per entry is O(tabs x layouts). - const allTabs = Object.values(tabsByWorktree).flat() - const tabById = buildByIdIndex(allTabs) - - const hydrated: WorkspaceHydrationPatch = { - activeRepoId, - activeWorktreeId, - activeWorkspaceKey, - activeWorkspaceExecutionHostId, - activeTabId, - activeTabIdByWorktree, - restoredRuntimeHostIdByWorkspaceSessionKey: - options?.runtimeHostIdByWorkspaceSessionKey ?? {}, - repos: runtimeSessionPlaceholders.repos, - tabsByWorktree, - worktreesByRepo, - // Why: restore the focus-recency map; pruning is deferred to App.tsx (post-hydration) because SSH worktrees may still be appearing in worktreesByRepo. - lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {}, - defaultTerminalTabsAppliedByWorktreeId: - session.defaultTerminalTabsAppliedByWorktreeId ?? {}, - automaticAgentResumeClaimsByTabId: {}, - sleepingAgentSessionsByPaneKey, - pendingReconnectWorktreeIds, - pendingReconnectTabByWorktree, - pendingReconnectPtyIdByTabId, - everActivatedWorktreeIds: nextEverActivated, - // Why: seed hydrated active worktrees so the first activation has a Back target. - worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [], - worktreeNavHistoryIndex: activeWorktreeId ? 0 : -1, - ptyIdsByTabId: Object.fromEntries(allTabs.map((tab) => [tab.id, []] as const)), - // Why: preserve daemon leaf PTY IDs to reattach every split-pane session. - terminalLayoutsByTabId: Object.fromEntries( - Object.entries(session.terminalLayoutsByTabId) - .filter(([tabId]) => validTabIds.has(tabId)) - .map(([tabId, persisted]) => { - const releasedPtyIds = releasedPtyIdsByTabId.get(tabId) - const layout = releasedPtyIds - ? releaseTerminalLayoutPtyIds(persisted, releasedPtyIds) - : persisted - // Why: old sessions can contain renderer-local pane:1-style leaf ids; normalize before runtime/mobile surfaces read them. - const normalization = normalizeTerminalLayoutSnapshot(layout) - const normalized = normalization.snapshot - if ( - normalization.changed && - (!ownershipTransferTabIds || ownershipTransferTabIds.has(tabId)) - ) { - ownershipTransfersByTabId.set( - tabId, - resolveTerminalLayoutPtyOwnershipTransfers(layout, normalized) - ) - } - const tab = tabById.get(tabId) - const sanitized = tab ? sanitizeTerminalLayoutPaneTitles(normalized, tab) : normalized - const activeLeafId = sanitized.root - ? resolvePtyBoundActiveLeafId({ - root: sanitized.root, - activeLeafId: sanitized.activeLeafId, - ptyIdsByLeafId: sanitized.ptyIdsByLeafId - }) - : sanitized.activeLeafId - return [tabId, { ...sanitized, activeLeafId }] - }) - ) - } - return options?.replaceWorkspaceKeys - ? targetScopedWorkspaceHydrationPatch(s, hydrated, session, options) - : hydrated - }) - for (const [tabId, transfers] of ownershipTransfersByTabId) { - transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, transfers) - } - }, - - reconnectPersistedTerminals: async (signal, options) => { - if ( - signal?.aborted || - (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) - ) { - return - } - const { - pendingReconnectWorktreeIds, - pendingReconnectTabByWorktree, - pendingReconnectPtyIdByTabId, - terminalLayoutsByTabId, - tabsByWorktree, - ptyIdsByTabId - } = get() - const scopedWorkspaceKeys = options ? new Set(options.workspaceKeys) : null - const ids = (pendingReconnectWorktreeIds ?? []).filter( - (id) => !scopedWorkspaceKeys || scopedWorkspaceKeys.has(id) - ) - - if (ids.length === 0) { - if (options) { - return - } - set({ - workspaceSessionReady: true, - pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {} - }) - return - } - - // Why: defer daemon attachment for real dimensions; eager 80×24 flushes garble output. - let reconnectedTabsByWorktree: Record | null = null - let reconnectedPtyIdsByTabId: Record | null = null - // Why indexed: the loop neither sets state nor awaits, so one index over the - // whole store snapshot serves every iteration. - const worktreeById = buildWorktreeByIdIndex(get().worktreesByRepo) - const repoById = buildByIdIndex(get().repos) - for (const worktreeId of ids) { - const tabs = tabsByWorktree[worktreeId] ?? [] - const worktree = worktreeById.get(worktreeId) - const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null - // Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail. - const sshTargetId = options?.directSshAuthority.targetId ?? repo?.connectionId ?? null - const sshState = sshTargetId ? get().sshConnectionStates.get(sshTargetId) : null - const sshConnected = sshTargetId != null && sshState?.status === 'connected' - const supportsDeferredReattach = options ? sshConnected : !repo?.connectionId || sshConnected - console.debug( - `[reconnect-terminals] worktree=${worktreeId} connectionId=${repo?.connectionId} sshStatus=${sshState?.status} supportsDeferredReattach=${supportsDeferredReattach}` - ) - const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? [] - const tabsToReconnect: TerminalTab[] = - targetTabIds.length > 0 - ? targetTabIds - .map((id) => tabs.find((t) => t.id === id)) - .filter((t): t is TerminalTab => t != null) - : tabs.slice(0, 1) - if (tabsToReconnect.length === 0) { - continue - } - - for (const tab of tabsToReconnect) { - const tabId = tab.id - const layout = terminalLayoutsByTabId[tabId] - const leafPtyMap = layout?.ptyIdsByLeafId ?? {} - const pendingPtyId = pendingReconnectPtyIdByTabId[tabId] - const tabLevelPtyId = - options && - parseAppSshPtyId(pendingPtyId ?? '')?.connectionId !== options.directSshAuthority.targetId - ? undefined - : pendingPtyId - const hasLeafMappings = Object.keys(leafPtyMap).length > 0 - - // Why: publish live PTY hints before mount; pty-connection reattaches later. - console.debug( - `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` - ) - // Why: populate ptyIdsByTabId so the sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. - // A row whose tab.ptyId went to the canonical row has no tab-level id left, but its own leaf PTYs still need advertising. - const allPtyIds = hasLeafMappings - ? (Object.values(leafPtyMap).filter(Boolean) as string[]) - : tabLevelPtyId - ? [tabLevelPtyId] - : [] - if (allPtyIds.length > 0) { - // Why: hide-sleeping reads ptyIdsByTabId for liveness; restored daemon sessions run before their pane remounts, so advertise them. - reconnectedPtyIdsByTabId ??= { ...ptyIdsByTabId } - reconnectedPtyIdsByTabId[tabId] = allPtyIds - } - if (tabLevelPtyId) { - reconnectedTabsByWorktree ??= { ...tabsByWorktree } - const nextTabs = reconnectedTabsByWorktree[worktreeId] - if (!nextTabs) { - continue - } - reconnectedTabsByWorktree[worktreeId] = nextTabs.map((t) => - t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t - ) - } - } - } - - // Why: keep deferred SSH session IDs for post-cleanup reconnect. - const scopedTabIds = new Set( - [...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) => - (tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ) - ) - const deferredSshSessionIdsByTabId: Record = options - ? Object.fromEntries( - Object.entries(get().deferredSshSessionIdsByTabId).filter( - ([tabId]) => !scopedTabIds.has(tabId) - ) - ) - : {} - for (const worktreeId of ids) { - const worktree = worktreeById.get(worktreeId) - // Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map. - const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) - const repo = repoId ? (repoById.get(repoId) ?? null) : null - const connectionId = options?.directSshAuthority.targetId ?? repo?.connectionId - if (!connectionId) { - continue - } - // Why: a repo can outlive its SSH target when the target was removed out of - // band (a crash between removal and cleanup, or edited out of the config). - // Once the authoritative target list has loaded, don't re-defer sessions for - // a target it no longer lists — a stranded deferred id reads as liveness and - // the orphan sweep could never remove the dead tab. Defer while the list is - // still unknown so a normal cold-start reconnect isn't dropped (#9911). - if (get().sshTargetsHydrated && !get().sshTargetLabels.has(connectionId)) { - continue - } - const sshConnected = get().sshConnectionStates.get(connectionId)?.status === 'connected' - if (sshConnected) { - continue - } - const tabs = tabsByWorktree[worktreeId] ?? [] - for (const tab of tabs) { - const sessionId = pendingReconnectPtyIdByTabId[tab.id] - if (sessionId && (!options || parseAppSshPtyId(sessionId)?.connectionId === connectionId)) { - deferredSshSessionIdsByTabId[tab.id] = sessionId - } - } - } - - if ( - signal?.aborted || - (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) - ) { - return - } - const remainingReconnectWorktreeIds = options - ? pendingReconnectWorktreeIds.filter((id) => !scopedWorkspaceKeys?.has(id)) - : [] - const remainingReconnectTabByWorktree = options - ? Object.fromEntries( - Object.entries(pendingReconnectTabByWorktree).filter( - ([workspaceKey]) => !scopedWorkspaceKeys?.has(workspaceKey) - ) - ) - : {} - const remainingReconnectPtyIdByTabId = options - ? Object.fromEntries( - Object.entries(pendingReconnectPtyIdByTabId).filter(([tabId]) => !scopedTabIds.has(tabId)) - ) - : {} - set({ - ...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}), - ...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}), - ...(options ? {} : { workspaceSessionReady: true }), - pendingReconnectWorktreeIds: remainingReconnectWorktreeIds, - pendingReconnectTabByWorktree: remainingReconnectTabByWorktree, - pendingReconnectPtyIdByTabId: remainingReconnectPtyIdByTabId, - deferredSshSessionIdsByTabId - }) - } + ...createTerminalEphemeralActions(set, get), + ...createTerminalTabCreationActions(set, get), + ...createActiveWorkspaceTerminalActions(set, get), + ...createTerminalTabCloseActions(set, get), + ...createTerminalTabNavigationActions(set, get), + ...createTerminalTabPresentationActions(set, get), + ...createTerminalTabAttentionActions(set, get), + ...createTerminalPtyBindingActions(set, get), + ...createTerminalPtyReleaseActions(set, get), + ...createTerminalPaneHibernationActions(set, get), + ...createDirectSshTerminalBindingActions(set, get), + ...createTerminalShutdownActions(set, get), + ...createTerminalRestartActions(set, get), + ...createTerminalLayoutActions(set, get), + ...createTerminalStartupQueueActions(set, get), + ...createWorkspaceTerminalHydrationActions(set, get), + ...createWorkspaceTerminalReconnectActions(set, get) }) diff --git a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts new file mode 100644 index 00000000000..26c3bfd2760 --- /dev/null +++ b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts @@ -0,0 +1,105 @@ +import { + clearDirectSshTerminalBindings, + invalidateStaleDirectSshTerminalBindings +} from '../slices/direct-ssh-terminal-recovery' +import { + retryDirectSshTerminalPanes, + retrySettledDirectSshTerminalPane +} from '../slices/direct-ssh-pane-retry-ledger' +import { + directSshAuthoritiesEqual, + settleDirectSshPaneRetryState +} from '../slices/direct-ssh-terminal-authority-ledger' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + isCurrentDirectSshAuthority, + resolveDirectSshTerminalKeys +} from './terminal-pty-identities' + +export function createDirectSshTerminalBindingActions( + set: TerminalStoreSet, + _get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'clearDirectSshTargetPtyBindings' + | 'invalidateStaleDirectSshTargetPtyBindings' + | 'retryDirectSshTargetPanes' + | 'settleDirectSshPaneRetry' +> { + return { + clearDirectSshTargetPtyBindings: (targetId) => { + let clearedCount = 0 + set((s) => { + const result = clearDirectSshTerminalBindings(s, resolveDirectSshTerminalKeys(s, targetId)) + clearedCount = result.clearedCount + return result.patch ?? s + }) + return clearedCount + }, + invalidateStaleDirectSshTargetPtyBindings: (authority) => { + let clearedCount = 0 + set((s) => { + if (!isCurrentDirectSshAuthority(s, authority)) { + return s + } + const result = invalidateStaleDirectSshTerminalBindings( + s, + resolveDirectSshTerminalKeys(s, authority.targetId), + authority + ) + clearedCount = result.clearedCount + return result.patch ?? s + }) + return clearedCount + }, + retryDirectSshTargetPanes: (authority, now = Date.now()) => { + let retriedCount = 0 + set((s) => { + if (!isCurrentDirectSshAuthority(s, authority)) { + return s + } + const result = retryDirectSshTerminalPanes( + s, + resolveDirectSshTerminalKeys(s, authority.targetId), + authority, + now + ) + retriedCount = result.retriedCount + return result.patch ?? s + }) + return retriedCount + }, + settleDirectSshPaneRetry: (result, now = Date.now()) => { + set((s) => { + if (!isCurrentDirectSshAuthority(s, result.authority)) { + return s + } + const history = s.directSshPaneRetryHistoryByTabId[result.tabId] + const preservesExhaustedSplitAttempt = + (result.status === 'failed' || result.status === 'timed-out') && + history != null && + directSshAuthoritiesEqual(history.authority, result.authority) && + history.attemptedAt.length >= 2 + if (preservesExhaustedSplitAttempt) { + return s + } + const settlement = settleDirectSshPaneRetryState(s, result) + if (!settlement) { + return s + } + const settledState = { ...s, ...settlement } + if (result.status !== 'failed' && result.status !== 'timed-out') { + return settledState + } + const retry = retrySettledDirectSshTerminalPane( + settledState, + resolveDirectSshTerminalKeys(settledState, result.authority.targetId), + result.authority, + result.tabId, + now + ) + return retry.patch ? { ...settledState, ...retry.patch } : settledState + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts new file mode 100644 index 00000000000..3779ea88cc0 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -0,0 +1,271 @@ +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' +import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' +import type { + GeneratedTabTitleUpdate, + TerminalTabTitleUpdate +} from '../slices/terminal-tab-title-batch' +import type { + DirectSshPaneRetryAttemptId, + DirectSshPaneRetryResult +} from '../slices/direct-ssh-terminal-recovery' +import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import type { AgentStatusWorktreeShutdownReason } from '../slices/agent-status' +import type { + TerminalTabCloseReason, + TerminalTabRetirementPlan +} from '../slices/terminal-tab-retirement' +import type { + AutomaticAgentResumeClaim, + CodexRestartNotice, + HydrateWorkspaceSessionOptions, + ReconnectPersistedTerminalsOptions +} from './terminal-contracts' + +export type TerminalActions = { + setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void + claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void + seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void + markNativeChatLaunchPromptFailed: (tabId: string) => void + clearNativeChatLaunchPrompt: (tabId: string) => void + seedNativeChatLaunchDraft: (draft: NativeChatLaunchDraft) => void + markNativeChatLaunchDraftAdopted: (tabId: string) => void + resolveNativeChatLaunchDraft: ( + tabId: string, + resolution: Pick + ) => void + clearNativeChatLaunchDraft: (tabId: string) => void + markDefaultTerminalTabsApplied: (worktreeId: string) => void + setHydrationSucceeded: (value: boolean) => void + consumePendingSnapshot: (ptyId: string) => { + snapshot: string + cols?: number + rows?: number + isAlternateScreen?: boolean + } | null + consumePendingColdRestore: (ptyId: string) => { + scrollback: string + cwd: string + } | null + /** Atomically publishes runtime and unified tab ownership, layout, ordering, and focus. */ + createTab: ( + worktreeId: string, + targetGroupId?: string, + shellOverride?: string, + options?: { + pendingActivationSpawn?: boolean + initialPtyId?: string + activate?: boolean + recordInteraction?: boolean + id?: string + launchAgent?: TuiAgent + quickCommandLabel?: string | null + viewMode?: Tab['viewMode'] + startupCwd?: string + forceHostRuntime?: boolean + } + ) => TerminalTab + openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise + /** Synchronous retirement: provider teardown starts before state removal but is never awaited. */ + closeTab: ( + tabId: string, + opts?: { + recordInteraction?: boolean + reason?: TerminalTabCloseReason + captureRecentlyClosed?: boolean + remoteCloseOwnedByHost?: boolean + localPtyTeardownOwnedExternally?: boolean + precomputedRetirementPlan?: TerminalTabRetirementPlan + } + ) => void + reorderTabs: (worktreeId: string, tabIds: string[]) => void + setTabBarOrder: (worktreeId: string, order: string[]) => void + setActiveTab: (tabId: string) => void + setActiveTabForWorktree: (worktreeId: string, tabId: string) => void + /** Resolve the canonical legacy terminal-tab owner key for renderer lifecycle guards. */ + getTerminalTabOwnerWorktreeId?: (tabId: string) => string | null + updateTabTitle: (tabId: string, title: string) => void + updateTabTitles: (updates: readonly TerminalTabTitleUpdate[]) => void + setAiVaultTabTitle: (tabId: string, aiVaultTitle: AiVaultSessionTitle | null) => void + setGeneratedTabTitleFromAgentPrompt: ( + paneKey: string, + prompt: string, + options?: { + replaceExistingGeneratedTitle?: boolean + } + ) => void + setGeneratedTabTitlesFromAgentPrompts: (updates: readonly GeneratedTabTitleUpdate[]) => void + clearTabLaunchAgent: (tabId: string) => void + setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void + clearRuntimePaneTitle: (tabId: string, paneId: number) => void + markTerminalTabUnread: (tabId: string) => void + markTerminalPaneUnread: (paneKey: string) => void + markAgentCompletionPaneUnread: (paneKey: string) => void + clearTerminalTabUnread: (tabId: string) => void + clearTerminalPaneUnread: (paneKey: string) => void + setTabCustomTitle: ( + tabId: string, + title: string | null, + opts?: { + recordInteraction?: boolean + } + ) => void + setTabColor: (tabId: string, color: string | null) => void + /** Binds only live tabs and migrates replacement identity state before publishing ownership. */ + updateTabPtyId: ( + tabId: string, + ptyId: string, + replacedPtyId?: string, + directSshRetryAttemptId?: DirectSshPaneRetryAttemptId + ) => void + /** Reconciles exact exits; bulk clear intentionally retains relay-grace identity. */ + clearTabPtyId: (tabId: string, ptyId?: string) => void + clearDirectSshTargetPtyBindings: (targetId: string) => number + invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number + retryDirectSshTargetPanes: (authority: DirectSshAuthority, now?: number) => number + settleDirectSshPaneRetry: (result: DirectSshPaneRetryResult, now?: number) => void + /** Awaited verified shutdown; unlike closeTab, physical teardown gates the state commit. */ + shutdownWorktreeTerminals: ( + worktreeId: string, + opts?: { + keepIdentifiers?: boolean + shutdownReason?: AgentStatusWorktreeShutdownReason + sleepingPaneKeys?: string[] + expectedRuntimePtyIds?: string[] + backendOwnsPtyTeardown?: boolean + } + ) => Promise + /** Captures resumable pane evidence and publishes suppression before exact physical teardown. */ + shutdownCompletedAgentPaneForHibernation: ( + worktreeId: string, + opts: { + paneKey: string + tabId: string + leafId: string + ptyId: string + expectedRuntimePtyId?: string + } + ) => Promise + suppressPtyExit: (ptyId: string) => void + consumeSuppressedPtyExit: (ptyId: string) => boolean + isPtyShutdownPending: (ptyId: string) => boolean + queueCodexPaneRestarts: (ptyIds: string[]) => void + consumePendingCodexPaneRestart: (ptyId: string) => boolean + markCodexRestartNotices: ( + notices: (Pick & + Partial> & { + homeRouteChanged?: boolean + ptyId: string + })[] + ) => string[] + clearCodexRestartNotice: (ptyId: string) => void + dismissCodexRestartNotices: (ptyIds: string[]) => void + reopenCodexRestartPrompt: (ptyId: string) => void + replaceTerminalLayoutPanePtyId: (tabId: string, leafId: string, ptyId: string) => void + setTabPaneExpanded: (tabId: string, expanded: boolean) => void + setTabCanExpandPane: (tabId: string, canExpand: boolean) => void + setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void + syncPaneDetachPtyOwnership: (args: { + detachedLeafId: string + detachedPtyId: string | null + sourceLayout: TerminalLayoutSnapshot + sourceTabId: string + targetTabId: string + }) => void + queueTabStartupCommand: ( + tabId: string, + startup: { + command: string + delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery + env?: Record + envToDelete?: string[] + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + agentArgsOverride?: string | null + draftPrompt?: string + sessionOptions?: Record + initialAgentStatus?: { + agent: TuiAgent + prompt: string + } + showSessionRestoredBanner?: boolean + telemetry?: AgentStartedTelemetry + } + ) => void + queueTabInitialCwd: (tabId: string, cwd: string) => void + consumeTabInitialCwd: (tabId: string) => string | null + consumeTabStartupCommand: (tabId: string) => { + command: string + delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery + env?: Record + envToDelete?: string[] + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + agentArgsOverride?: string | null + draftPrompt?: string + sessionOptions?: Record + initialAgentStatus?: { + agent: TuiAgent + prompt: string + } + showSessionRestoredBanner?: boolean + telemetry?: AgentStartedTelemetry + } | null + queueTabSetupSplit: ( + tabId: string, + startup: { + command: string + env?: Record + direction: SetupSplitDirection + } + ) => void + consumeTabSetupSplit: (tabId: string) => { + command: string + env?: Record + direction: SetupSplitDirection + } | null + queueTabIssueCommandSplit: ( + tabId: string, + issueCommand: { + command: string + env?: Record + } + ) => void + consumeTabIssueCommandSplit: (tabId: string) => { + command: string + env?: Record + } | null + setCacheTimerStartedAt: (key: string, ts: number | null) => void + recordTerminalInput: (paneKey: string, timestamp?: number) => void + seedCacheTimersForIdleTabs: () => void + setDeferredSshReconnectTargets: (targetIds: string[]) => void + removeDeferredSshReconnectTarget: (targetId: string) => void + removeDeferredSshSessionId: (tabId: string) => void + /** Hydrates canonical rows first, then transfers normalized pane authority post-publication. */ + hydrateWorkspaceSession: ( + session: WorkspaceSessionState, + options?: HydrateWorkspaceSessionOptions + ) => void + /** Publishes wake hints without spawning; global completion alone opens the readiness gate. */ + reconnectPersistedTerminals: ( + signal?: AbortSignal, + options?: ReconnectPersistedTerminalsOptions + ) => Promise +} diff --git a/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts b/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts new file mode 100644 index 00000000000..f227b914641 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-active-workspace-creation.ts @@ -0,0 +1,69 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' +import { isWebClientLocation } from '@/lib/web-client-location' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createActiveWorkspaceTerminalActions( + _set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + openNewTerminalTabInActiveWorkspace: async (groupId) => { + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return + } + const workspaceScope = parseWorkspaceKey(worktreeId) + const worktreeRoute = + worktreeId === FLOATING_TERMINAL_WORKTREE_ID || workspaceScope?.type === 'folder' + ? null + : resolveWorktreeOperationRouteResult(state, worktreeId) + if (worktreeRoute && worktreeRoute.kind !== 'resolved') { + return + } + const runtimeEnvironmentId = worktreeRoute + ? worktreeRoute.route.runtimeEnvironmentId + : getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (runtimeEnvironmentId) { + const { createWebRuntimeSessionTerminal } = await import('@/runtime/web-runtime-session') + await createWebRuntimeSessionTerminal({ + worktreeId, + environmentId: runtimeEnvironmentId, + targetGroupId: groupId, + activate: true + }) + return + } + if (isWebClientLocation() && worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + return + } + const terminal = get().createTab(worktreeId, groupId) + get().setActiveTab(terminal.id) + get().setActiveTabType('terminal') + const latest = get() + const currentTerminals = latest.tabsByWorktree[worktreeId] ?? [] + const currentEditors = latest.openFiles.filter((file) => file.worktreeId === worktreeId) + const currentBrowsers = latest.browserTabsByWorktree[worktreeId] ?? [] + const stored = latest.tabBarOrderByWorktree[worktreeId] + const validIds = new Set([ + ...currentTerminals.map((tab) => tab.id), + ...currentEditors.map((file) => file.id), + ...currentBrowsers.map((tab) => tab.id) + ]) + const base = (stored ?? []).filter((id) => validIds.has(id)) + const inBase = new Set(base) + for (const id of validIds) { + if (!inBase.has(id)) { + base.push(id) + } + } + // Why: Cmd+J shares the titlebar-button creation path, so append the new terminal after mixed editor/browser tabs, not first. + get().setTabBarOrder(worktreeId, [...base.filter((id) => id !== terminal.id), terminal.id]) + focusTerminalTabSurface(terminal.id) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-contracts.ts b/src/renderer/src/store/terminals/terminal-contracts.ts new file mode 100644 index 00000000000..8ed44aa4899 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-contracts.ts @@ -0,0 +1,38 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' + +/** In-memory recovery claim consumed only after the resumed terminal hook becomes live. */ +export type AutomaticAgentResumeClaim = { + worktreeId: string + launchAgent: TuiAgent + providerSession: AgentProviderSessionMetadata +} + +export type CodexRestartNotice = { + previousAccountLabel: string + nextAccountLabel: string + /** Labels are display-only; account ids disambiguate equal labels and A→B→A collapse. */ + previousAccountId?: string | null + nextAccountId?: string | null + /** Persists a home-route mismatch after ephemeral launch-account memory has expired. */ + homeRouteChanged?: true + /** Accepted restart remains tracked so a failed execution can reopen instead of input-blocking invisibly. */ + restartRequested?: true + /** Dismissal outlives the prompt while explicitly preventing that notice from blocking pane input. */ + dismissed?: true +} + +/** Scoped direct-SSH hydration replaces only named workspace keys and authority. */ +export type HydrateWorkspaceSessionOptions = { + directSshAuthority?: DirectSshAuthority + runtimeHostIdByWorkspaceSessionKey?: Record +} & WorkspaceSessionHydrationOptions + +/** Scoped reconnect must still match this exact provider epoch and connection generation. */ +export type ReconnectPersistedTerminalsOptions = { + directSshAuthority: DirectSshAuthority + workspaceKeys: readonly string[] +} diff --git a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts new file mode 100644 index 00000000000..89a67bd72ae --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts @@ -0,0 +1,237 @@ +import { isClaudeAgent } from '@/lib/agent-status' +import { recordTerminalInputActivity } from '@/lib/terminal-input-activity-coalescing' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalEphemeralActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'markDefaultTerminalTabsApplied' + | 'setHydrationSucceeded' + | 'setRecentQuickCommandForGroup' + | 'claimAutomaticAgentResume' + | 'seedNativeChatLaunchPrompt' + | 'markNativeChatLaunchPromptFailed' + | 'clearNativeChatLaunchPrompt' + | 'seedNativeChatLaunchDraft' + | 'markNativeChatLaunchDraftAdopted' + | 'resolveNativeChatLaunchDraft' + | 'clearNativeChatLaunchDraft' + | 'recordTerminalInput' + | 'setCacheTimerStartedAt' + | 'seedCacheTimersForIdleTabs' + | 'setDeferredSshReconnectTargets' + | 'removeDeferredSshReconnectTarget' + | 'removeDeferredSshSessionId' +> { + return { + markDefaultTerminalTabsApplied: (worktreeId) => + set((s) => { + if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { + return {} + } + return { + defaultTerminalTabsAppliedByWorktreeId: { + ...s.defaultTerminalTabsAppliedByWorktreeId, + [worktreeId]: true + } + } + }), + setHydrationSucceeded: (value) => { + set({ hydrationSucceeded: value }) + }, + setRecentQuickCommandForGroup: (groupId, quickCommandId) => { + set((s) => ({ + recentQuickCommandIdByGroup: { + ...s.recentQuickCommandIdByGroup, + [groupId]: quickCommandId + } + })) + }, + claimAutomaticAgentResume: (tabId, claim) => { + set((s) => ({ + automaticAgentResumeClaimsByTabId: { + ...s.automaticAgentResumeClaimsByTabId, + [tabId]: claim + } + })) + }, + seedNativeChatLaunchPrompt: (prompt) => { + set((s) => ({ + nativeChatLaunchPromptByTabId: { + ...s.nativeChatLaunchPromptByTabId, + [prompt.tabId]: prompt + } + })) + }, + markNativeChatLaunchPromptFailed: (tabId) => { + set((s) => { + const current = s.nativeChatLaunchPromptByTabId[tabId] + if (!current || current.failed) { + return {} + } + return { + nativeChatLaunchPromptByTabId: { + ...s.nativeChatLaunchPromptByTabId, + [tabId]: { ...current, failed: true } + } + } + }) + }, + clearNativeChatLaunchPrompt: (tabId) => { + set((s) => { + if (!s.nativeChatLaunchPromptByTabId[tabId]) { + return {} + } + const next = { ...s.nativeChatLaunchPromptByTabId } + delete next[tabId] + return { nativeChatLaunchPromptByTabId: next } + }) + }, + seedNativeChatLaunchDraft: (draft) => { + set((s) => ({ + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [draft.tabId]: draft + } + })) + }, + markNativeChatLaunchDraftAdopted: (tabId) => { + set((s) => { + const current = s.nativeChatLaunchDraftByTabId[tabId] + if (!current || current.adopted) { + return {} + } + return { + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [tabId]: { ...current, adopted: true } + } + } + }) + }, + resolveNativeChatLaunchDraft: (tabId, resolution) => { + set((s) => { + const current = s.nativeChatLaunchDraftByTabId[tabId] + if ( + !current || + current.resolved || + current.createdAt !== resolution.createdAt || + current.text !== resolution.text + ) { + return {} + } + return { + nativeChatLaunchDraftByTabId: { + ...s.nativeChatLaunchDraftByTabId, + [tabId]: { ...current, resolved: true } + } + } + }) + }, + clearNativeChatLaunchDraft: (tabId) => { + set((s) => { + if (!s.nativeChatLaunchDraftByTabId[tabId]) { + return {} + } + const next = { ...s.nativeChatLaunchDraftByTabId } + delete next[tabId] + return { nativeChatLaunchDraftByTabId: next } + }) + }, + recordTerminalInput: (paneKey, timestamp = Date.now()) => { + if (!paneKey || !Number.isFinite(timestamp)) { + return + } + recordTerminalInputActivity({ + paneKey, + timestamp, + // Why: the first stamp for a pane must land synchronously; automation take-over + // detection subscribes and compares undefined→value across a launch. + forceWrite: get().lastTerminalInputAtByPaneKey[paneKey] === undefined, + commit: { + insert: (key, at) => + set((s) => ({ + lastTerminalInputAtByPaneKey: { ...s.lastTerminalInputAtByPaneKey, [key]: at } + })), + refreshExisting: (entries) => + set((s) => { + let next: Record | null = null + for (const [key, at] of entries) { + // Why: teardown (close pane/tab/worktree purge) deletes keys; a late flush must not resurrect them. + const current = s.lastTerminalInputAtByPaneKey[key] + if (current === undefined || current >= at) { + continue + } + next ??= { ...s.lastTerminalInputAtByPaneKey } + next[key] = at + } + return next ? { lastTerminalInputAtByPaneKey: next } : {} + }) + } + }) + }, + setCacheTimerStartedAt: (key, ts) => { + set((s) => { + // Why: a real pane write clears any ':seed' sentinel from seedCacheTimersForIdleTabs, avoiding phantom timers when the seed key doesn't match the real pane. + const colonIdx = key.indexOf(':') + const suffix = colonIdx === -1 ? null : key.slice(colonIdx + 1) + const seedKey = + colonIdx !== -1 && suffix !== 'seed' ? `${key.slice(0, colonIdx)}:seed` : null + const hasStaleSeed = seedKey !== null && seedKey in s.cacheTimerByKey + // Why: parked-pane watchers replay null-over-null on every working/exit transition; each redundant write runs every subscriber's selector. + if (s.cacheTimerByKey[key] === ts && !hasStaleSeed) { + return s + } + const next = { ...s.cacheTimerByKey, [key]: ts } + if (seedKey !== null) { + delete next[seedKey] + } + return { cacheTimerByKey: next } + }) + }, + seedCacheTimersForIdleTabs: () => { + // Why: tabs already idle when the feature is enabled mid-session missed their working→idle transition, so seed timers for them. + const s = get() + const now = Date.now() + const updates: Record = {} + for (const tabs of Object.values(s.tabsByWorktree)) { + for (const tab of tabs) { + if (!tab.title || !isClaudeAgent(tab.title)) { + continue + } + const status = classifyTitleActivity(tab.title) + if (status === null || status === 'working') { + continue + } + // Why: the store doesn't know which pane holds the idle session, so use a ':seed' sentinel; setCacheTimerStartedAt clears it on any real pane write. + const key = `${tab.id}:seed` + if (s.cacheTimerByKey[key] == null) { + updates[key] = now + } + } + } + if (Object.keys(updates).length > 0) { + set((s) => ({ + cacheTimerByKey: { ...s.cacheTimerByKey, ...updates } + })) + } + }, + setDeferredSshReconnectTargets: (targetIds) => set({ deferredSshReconnectTargets: targetIds }), + removeDeferredSshReconnectTarget: (targetId) => + set((s) => ({ + deferredSshReconnectTargets: s.deferredSshReconnectTargets.filter((id) => id !== targetId) + })), + removeDeferredSshSessionId: (tabId) => + set((s) => { + if (!s.deferredSshSessionIdsByTabId[tabId]) { + return {} + } + const next = { ...s.deferredSshSessionIdsByTabId } + delete next[tabId] + return { deferredSshSessionIdsByTabId: next } + }) + } +} diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts new file mode 100644 index 00000000000..d95439cf5a8 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -0,0 +1,154 @@ +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' +import { + normalizeTerminalLayoutPtyOwnership, + resolveTerminalLayoutPtyOwnershipTransfers +} from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { transferDirectSshPaneDetachLedger } from '../slices/direct-ssh-terminal-authority-ledger' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + isCurrentDirectSshAuthority, + resolvePrimaryLayoutPtyId, + uniquePtyIds, + withTerminalTabPtyId +} from './terminal-pty-identities' +import { transferNormalizedTerminalLayoutPtyOwnership } from './workspace-terminal-hydration-patch' + +export function createTerminalLayoutActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'replaceTerminalLayoutPanePtyId' + | 'setTabPaneExpanded' + | 'setTabCanExpandPane' + | 'setTabLayout' + | 'syncPaneDetachPtyOwnership' +> { + return { + replaceTerminalLayoutPanePtyId: (tabId, leafId, ptyId) => { + set((s) => { + const layout = s.terminalLayoutsByTabId[tabId] + if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { + return {} + } + return { + terminalLayoutsByTabId: { + ...s.terminalLayoutsByTabId, + [tabId]: { + ...layout, + ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId } + } + } + } + }) + }, + setTabPaneExpanded: (tabId, expanded) => { + set((s) => ({ + expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded } + })) + }, + setTabCanExpandPane: (tabId, canExpand) => { + set((s) => ({ + canExpandPaneByTabId: { ...s.canExpandPaneByTabId, [tabId]: canExpand } + })) + }, + setTabLayout: (tabId, layout) => { + let ownershipTransfers: ReturnType = [] + set((s) => { + if (!layout) { + if (!(tabId in s.terminalLayoutsByTabId)) { + return s + } + const next = { ...s.terminalLayoutsByTabId } + delete next[tabId] + return { terminalLayoutsByTabId: next } + } + const normalized = normalizeTerminalLayoutPtyOwnership(layout) + // Resolved before the bailout: normalization can transfer pane ownership even when the stored snapshot is untouched. + if (normalized.changed) { + ownershipTransfers = resolveTerminalLayoutPtyOwnershipTransfers( + layout, + normalized.snapshot + ) + } + // Why: pane-title churn re-persists structurally identical snapshots; bailing keeps every pane selector asleep. + const existing = s.terminalLayoutsByTabId[tabId] + if (existing && terminalLayoutEqual(existing, normalized.snapshot)) { + return s + } + return { + terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tabId]: normalized.snapshot } + } + }) + transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, ownershipTransfers) + }, + syncPaneDetachPtyOwnership: ({ + detachedLeafId, + detachedPtyId, + sourceLayout, + sourceTabId, + targetTabId + }) => { + const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId) + const targetPaneKey = makePaneKey(targetTabId, detachedLeafId) + set((s) => { + const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) + const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( + (ptyId) => ptyId !== detachedPtyId + ) + const sourcePtyIds = + layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds + const sourcePrimaryPtyId = + resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null + const nextPtyIdsByTabId = { + ...s.ptyIdsByTabId, + [sourceTabId]: sourcePtyIds + } + if (detachedPtyId) { + nextPtyIdsByTabId[targetTabId] = uniquePtyIds([ + ...(nextPtyIdsByTabId[targetTabId] ?? []), + detachedPtyId + ]) + } + const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId } + if (sourcePrimaryPtyId) { + nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId + } else { + delete nextLastKnownRelayPtyIdByTabId[sourceTabId] + } + if (detachedPtyId) { + nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId + } + // Why: pane-to-tab detach moves a live PTY without spawning or exiting, so transfer identity without activity bumps. + const sourceTabsByWorktree = withTerminalTabPtyId( + s.tabsByWorktree, + sourceTabId, + sourcePrimaryPtyId + ) + const nextTabsByWorktree = detachedPtyId + ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) + : sourceTabsByWorktree + const directSshLedger = transferDirectSshPaneDetachLedger(s, { + detachedPtyId, + sourcePtyId: sourcePrimaryPtyId, + sourceTabId, + targetTabId, + isAuthorityCurrent: (authority) => isCurrentDirectSshAuthority(s, authority) + }) + return { + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + ...directSshLedger, + ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) + } + }) + // Why: detach keeps the process and its pane key alive, so move resume/status authority to the new surface before the source closes. + get().transferAgentPaneAuthority({ + fromPaneKey: sourcePaneKey, + toPaneKey: targetPaneKey, + ptyId: detachedPtyId + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts new file mode 100644 index 00000000000..edade70fcf4 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -0,0 +1,243 @@ +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { + restorePtyDataHandlersAfterFailedShutdown, + unregisterPtyDataHandlers +} from '@/components/terminal-pane/pty-transport' +import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { + collectHibernatedCompletionEvidenceForWorktree, + collectSleepingAgentSessionRecordsForWorktree +} from '../slices/agent-status' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' +import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing' + +export function createTerminalPaneHibernationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + shutdownCompletedAgentPaneForHibernation: async (worktreeId, opts) => { + const paneKeys = [opts.paneKey] + const expectedRuntimePtyIds = sortedUniquePtyIds( + opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : [] + ) + const rendererShutdownPtyIds = [opts.ptyId] + const state = get() + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(state, worktreeId) + // Why: pane transports emit renderer PTY ids, not raw exact-stop handles; guard only the identity that can deliver an exit callback. + const exitGuardPtyIds = [opts.ptyId] + const tab = (state.tabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === opts.tabId + ) + const parsed = parsePaneKey(opts.paneKey) + const layout = state.terminalLayoutsByTabId[opts.tabId] + const liveTabPtyIds = state.ptyIdsByTabId[opts.tabId] ?? [] + if ( + !tab || + !parsed || + parsed.tabId !== opts.tabId || + parsed.leafId !== opts.leafId || + layout?.ptyIdsByLeafId?.[opts.leafId] !== opts.ptyId || + (expectedRuntimePtyIds.length === 0 && !liveTabPtyIds.includes(opts.ptyId)) + ) { + throw new Error('agent_hibernation_pane_binding_mismatch') + } + const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree( + state, + worktreeId, + { + paneKeys, + captureMode: 'completed-agent-hibernation' + } + ) + const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree( + state, + worktreeId, + paneKeys + ) + if (!sleepingAgentSessionRecords[opts.paneKey]) { + // Why: killing the PTY with no persisted resume record strands the pane unwakeable; abort instead of hibernating unrecoverably. + throw new Error('agent_hibernation_capture_missing') + } + const capture = shutdownBufferCaptures.get(opts.tabId) + if (capture) { + try { + capture({ includeLocalBuffers: false }) + } catch { + // Don't let one tab's capture failure block the pane hibernation. + } + } + // Why: store sleeping records before kill, since pty:exit can arrive first. + const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords) + const replacedSleepingRecords: Record = + {} + for (const key of sleepingRecordKeys) { + const existing = state.sleepingAgentSessionsByPaneKey[key] + if (existing) { + replacedSleepingRecords[key] = existing + } + } + const rollbackTargetShutdownState = (): void => { + set((s) => { + const next = { ...s.suppressedPtyExitIds } + for (const ptyId of exitGuardPtyIds) { + delete next[ptyId] + } + const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } + for (const key of sleepingRecordKeys) { + const replaced = replacedSleepingRecords[key] + if (replaced) { + nextSleeping[key] = replaced + } else { + delete nextSleeping[key] + } + } + return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping } + }) + } + set((s) => ({ + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + ...sleepingAgentSessionRecords + } + })) + if (expectedRuntimePtyIds.length > 0) { + if (!runtimeEnvironmentId) { + rollbackTargetShutdownState() + throw new Error('missing_runtime_for_exact_terminal_stop') + } + let stopResult: { + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string + } + try { + stopResult = await callRuntimeRpc<{ + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string + }>( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.stopExact', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + expectedPtyIds: expectedRuntimePtyIds, + keepHistory: true, + targetOnly: true + }, + { timeoutMs: 15000 } + ) + } catch (err) { + rollbackTargetShutdownState() + throw err + } + const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) + const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) + const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId)) + if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) { + rollbackTargetShutdownState() + throw new Error('exact_terminal_stop_mismatch') + } + if (stopResult.postStopVerified !== true) { + rollbackTargetShutdownState() + throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') + } + for (const snapshot of unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? []) { + snapshot.commit?.() + } + } else if (!opts.ptyId.startsWith('remote:')) { + // Why: pty.kill can flush final data before exit; unregister first so stale handlers can't fire phantom notifications during hibernation. + const handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? [] + try { + await window.api.pty.kill(opts.ptyId, { keepHistory: true }) + } catch (err) { + restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) + rollbackTargetShutdownState() + throw err + } + for (const snapshot of handlerSnapshots) { + snapshot.commit?.() + } + } + set((s) => { + const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? [] + const shutdownPtyIdSet = new Set(rendererShutdownPtyIds) + const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId)) + const nextTabsByWorktree = { ...s.tabsByWorktree } + const tabs = nextTabsByWorktree[worktreeId] ?? [] + const tabIndex = tabs.findIndex((candidate) => candidate.id === opts.tabId) + if (tabIndex !== -1) { + const nextTabs = [...tabs] + nextTabs[tabIndex] = { + ...nextTabs[tabIndex], + ptyId: remainingPtyIds.at(-1) ?? null + } + nextTabsByWorktree[worktreeId] = nextTabs + } + const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } + for (const ptyId of exitGuardPtyIds) { + delete nextCodexRestartNoticeByPtyId[ptyId] + } + const nextLastKnownRelay = + remainingPtyIds.length === 0 + ? { ...s.lastKnownRelayPtyIdByTabId } + : s.lastKnownRelayPtyIdByTabId + if (remainingPtyIds.length === 0) { + delete nextLastKnownRelay[opts.tabId] + } + let nextRuntimePaneTitlesByTabId = s.runtimePaneTitlesByTabId + const numericPaneId = Number(opts.leafId) + if ( + Number.isInteger(numericPaneId) && + s.runtimePaneTitlesByTabId[opts.tabId]?.[numericPaneId] + ) { + const nextByPane = { ...s.runtimePaneTitlesByTabId[opts.tabId] } + delete nextByPane[numericPaneId] + nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } + if (Object.keys(nextByPane).length > 0) { + nextRuntimePaneTitlesByTabId[opts.tabId] = nextByPane + } else { + delete nextRuntimePaneTitlesByTabId[opts.tabId] + } + } + const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } + const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } + delete nextUnreadTerminalPanes[opts.paneKey] + delete nextUnreadAgentCompletionPanes[opts.paneKey] + delete nextLastTerminalInputAtByPaneKey[opts.paneKey] + return { + tabsByWorktree: nextTabsByWorktree, + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [opts.tabId]: remainingPtyIds + }, + lastKnownRelayPtyIdByTabId: nextLastKnownRelay, + suppressedPtyExitIds: { + ...s.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + ...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId + ? { runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId } + : {}), + unreadTerminalPanes: nextUnreadTerminalPanes, + unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes, + lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey + } + }) + get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, { + retainedCompletionEvidence + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts new file mode 100644 index 00000000000..c157e504abe --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -0,0 +1,240 @@ +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { isTerminalTabPresent } from '../slices/terminal-tab-retirement' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + consumePendingActivationSpawn, + getPendingActivationSpawnCount, + isCurrentDirectSshAuthority, + isRemoteRuntimePtyId +} from './terminal-pty-identities' + +export function createTerminalPtyBindingActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + updateTabPtyId: (tabId, ptyId, replacedPtyId, directSshRetryAttemptId) => { + // Why: final guard preventing a late caller from recreating retired tab maps (async spawn owners still do their own provider teardown). + if (!isTerminalTabPresent(get(), tabId)) { + return + } + let worktreeId: string | null = null + let wasActivationSpawn = false + const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) + set((s) => { + if (directSshRetryAttemptId) { + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + const liveRetry = s.directSshLivePtyBindingByTabId[tabId] + const retryLease = + pendingRetry?.attemptId === directSshRetryAttemptId + ? pendingRetry + : liveRetry?.attemptId === directSshRetryAttemptId + ? liveRetry + : undefined + const boundTab = Object.values(s.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + if ( + !retryLease || + !boundTab || + parseAppSshPtyId(ptyId)?.connectionId !== retryLease.authority.targetId || + !isCurrentDirectSshAuthority(s, retryLease.authority) || + (boundTab.generation ?? 0) !== retryLease.tabGeneration + ) { + return s + } + } + const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] + const remote = parseRemoteRuntimePtyId(ptyId) + const legacyRemotePtyId = remote?.environmentId ? toRemoteRuntimePtyId(remote.handle) : null + const hasLegacyPtyBinding = legacyRemotePtyId + ? existingPtyIds.includes(legacyRemotePtyId) + : false + const explicitReplacementPtyId = replacedPtyId !== ptyId ? replacedPtyId : undefined + const replacementPtyId = + explicitReplacementPtyId ?? (hasLegacyPtyBinding ? legacyRemotePtyId : null) + const boundReplacementPtyId = + replacementPtyId && existingPtyIds.includes(replacementPtyId) ? replacementPtyId : null + const nextPtyIds = boundReplacementPtyId + ? [...new Set(existingPtyIds.map((id) => (id === boundReplacementPtyId ? ptyId : id)))] + : existingPtyIds.includes(ptyId) + ? existingPtyIds + : [...existingPtyIds, ptyId] + let nextTabsByWorktree = s.tabsByWorktree + for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { + const index = tabs.findIndex((t) => t.id === tabId) + if (index === -1) { + continue + } + worktreeId = wId + const tab = tabs[index] + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + wasActivationSpawn = true + } + // Why: consume one suppression per split-pane activation callback. + const { pendingActivationSpawn: _unused, ...rest } = tab + void _unused + // Why: tab.ptyId is the single-pane fallback for legacy attach; later split-pane spawns must not steal it or remount/close reattaches the tab to the wrong PTY. + const currentTabPtyId = tab.ptyId === replacementPtyId ? ptyId : tab.ptyId + const nextTabPtyId = currentTabPtyId ?? nextPtyIds[0] ?? null + const nextPendingActivationSpawn = consumePendingActivationSpawn( + tab.pendingActivationSpawn + ) + if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) { + const nextTabs = [...tabs] + nextTabs[index] = { + ...rest, + ...(nextPendingActivationSpawn + ? { pendingActivationSpawn: nextPendingActivationSpawn } + : {}), + ptyId: nextTabPtyId + } + nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs } + } + break + } + // Why: the first active PTY changes sorting, except activation-spawn side effects. + const isFirstPty = existingPtyIds.length === 0 + const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId + const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn + const shouldRetainSuppressedExit = Boolean( + explicitReplacementPtyId && + (s.suppressedPtyExitIds[ptyId] || + (replacementPtyId && s.suppressedPtyExitIds[replacementPtyId])) + ) + const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds } + delete nextSuppressedPtyExitIds[ptyId] + if (replacementPtyId) { + delete nextSuppressedPtyExitIds[replacementPtyId] + } + if (shouldRetainSuppressedExit) { + // Why: handle rotation keeps the same terminal lifecycle; an intentional exit racing the rotation must stay suppressed once. + nextSuppressedPtyExitIds[ptyId] = true + } + const hasReplacementPendingRestart = replacementPtyId + ? replacementPtyId in s.pendingCodexPaneRestartIds + : false + const hasReplacementRestartNotice = replacementPtyId + ? replacementPtyId in s.codexRestartNoticeByPtyId + : false + const hasReplacementMigrationUnsupported = replacementPtyId + ? replacementPtyId in s.migrationUnsupportedByPtyId + : false + const nextPendingCodexPaneRestartIds = hasReplacementPendingRestart + ? { ...s.pendingCodexPaneRestartIds } + : s.pendingCodexPaneRestartIds + const nextCodexRestartNoticeByPtyId = hasReplacementRestartNotice + ? { ...s.codexRestartNoticeByPtyId } + : s.codexRestartNoticeByPtyId + const nextMigrationUnsupportedByPtyId = hasReplacementMigrationUnsupported + ? { ...s.migrationUnsupportedByPtyId } + : s.migrationUnsupportedByPtyId + if (replacementPtyId) { + if (hasReplacementPendingRestart) { + nextPendingCodexPaneRestartIds[ptyId] = true + delete nextPendingCodexPaneRestartIds[replacementPtyId] + } + if (hasReplacementRestartNotice) { + const replacedNotice = nextCodexRestartNoticeByPtyId[replacementPtyId] + nextCodexRestartNoticeByPtyId[ptyId] ??= replacedNotice + delete nextCodexRestartNoticeByPtyId[replacementPtyId] + } + if (hasReplacementMigrationUnsupported) { + const replacedMigrationUnsupported = nextMigrationUnsupportedByPtyId[replacementPtyId] + nextMigrationUnsupportedByPtyId[ptyId] ??= { + ...replacedMigrationUnsupported, + ptyId + } + delete nextMigrationUnsupportedByPtyId[replacementPtyId] + } + } + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + const liveRetry = s.directSshLivePtyBindingByTabId[tabId] + const retryLease = + pendingRetry?.attemptId === directSshRetryAttemptId + ? pendingRetry + : liveRetry?.attemptId === directSshRetryAttemptId + ? liveRetry + : undefined + const boundTab = worktreeId + ? nextTabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + : undefined + const parsedSshPty = parseAppSshPtyId(ptyId) + const acknowledgesDirectSshRetry = Boolean( + retryLease && + boundTab && + parsedSshPty?.connectionId === retryLease.authority.targetId && + isCurrentDirectSshAuthority(s, retryLease.authority) && + (boundTab.generation ?? 0) === retryLease.tabGeneration && + nextPtyIds.includes(ptyId) + ) + let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId + let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId + if (acknowledgesDirectSshRetry && retryLease && boundTab?.ptyId) { + if (pendingRetry?.attemptId === directSshRetryAttemptId) { + nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + } + if (!liveRetry || liveRetry.attemptId !== directSshRetryAttemptId) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { + attemptId: retryLease.attemptId, + authority: retryLease.authority, + tabGeneration: retryLease.tabGeneration, + ptyId: boundTab.ptyId + } + } + } else if ( + (replacementPtyId === liveRetry.ptyId && boundTab.ptyId === ptyId) || + !nextPtyIds.includes(liveRetry.ptyId) + ) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { ...liveRetry, ptyId: boundTab.ptyId } + } + } + } else { + const liveBinding = s.directSshLivePtyBindingByTabId[tabId] + if (liveBinding) { + if ( + replacementPtyId === liveBinding.ptyId && + boundTab?.ptyId === ptyId && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId, + [tabId]: { ...liveBinding, ptyId } + } + } + } + } + return { + ...(nextTabsByWorktree !== s.tabsByWorktree + ? { tabsByWorktree: nextTabsByWorktree } + : {}), + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [tabId]: nextPtyIds + }, + lastKnownRelayPtyIdByTabId: { + ...s.lastKnownRelayPtyIdByTabId, + [tabId]: ptyId + }, + suppressedPtyExitIds: nextSuppressedPtyExitIds, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + migrationUnsupportedByPtyId: nextMigrationUnsupportedByPtyId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, + ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) + } + }) + // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. + if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { + get().bumpWorktreeActivity(worktreeId) + } + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-pty-identities.ts b/src/renderer/src/store/terminals/terminal-pty-identities.ts new file mode 100644 index 00000000000..52b29a29876 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pty-identities.ts @@ -0,0 +1,102 @@ +import type { AppState } from '../types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { resolveDirectSshTerminalWorkspaceKeys } from '../slices/direct-ssh-terminal-workspace-scope' + +export function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null +} + +export function isCurrentDirectSshAuthority( + state: AppState, + authority: DirectSshAuthority +): boolean { + const current = state.sshConnectionStates.get(authority.targetId) + return Boolean( + current?.status === 'connected' && + current.providerEpoch === authority.providerEpoch && + current.connectionGeneration === authority.connectionGeneration + ) +} + +export function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set { + return resolveDirectSshTerminalWorkspaceKeys( + { + targetId, + catalogRevision: 0, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey + }, + state.tabsByWorktree, + state.lastKnownRelayPtyIdByTabId + ) +} + +export function getPendingActivationSpawnCount(value: boolean | number | undefined): number { + if (value === true) { + return 1 + } + return typeof value === 'number' && value > 0 ? value : 0 +} + +export function consumePendingActivationSpawn( + value: boolean | number | undefined +): boolean | number | undefined { + const count = getPendingActivationSpawnCount(value) + if (count <= 1) { + return undefined + } + return count === 2 ? true : count - 1 +} + +export function getTabIdFromPaneKey(paneKey: string): string | null { + return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null +} + +export function sortedUniquePtyIds(ptyIds: readonly string[] | undefined): string[] { + return [...new Set((ptyIds ?? []).filter((ptyId) => ptyId.length > 0))].sort() +} + +export function equalStringSets(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false + } + const bSet = new Set(b) + return a.every((value) => bSet.has(value)) +} + +export function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] { + return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] +} + +export function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { + const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} + const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined + return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null +} + +export function withTerminalTabPtyId( + tabsByWorktree: Record, + tabId: string, + ptyId: string | null +): Record { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index === -1) { + continue + } + if (tabs[index]?.ptyId === ptyId) { + return tabsByWorktree + } + const nextTabs = [...tabs] + nextTabs[index] = { ...nextTabs[index]!, ptyId } + return { ...tabsByWorktree, [worktreeId]: nextTabs } + } + return tabsByWorktree +} diff --git a/src/renderer/src/store/terminals/terminal-pty-release.ts b/src/renderer/src/store/terminals/terminal-pty-release.ts new file mode 100644 index 00000000000..6a88cc0e85d --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-pty-release.ts @@ -0,0 +1,161 @@ +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + consumePendingActivationSpawn, + getPendingActivationSpawnCount, + isCurrentDirectSshAuthority, + isRemoteRuntimePtyId +} from './terminal-pty-identities' + +export function createTerminalPtyReleaseActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + clearTabPtyId: (tabId, ptyId) => { + if (ptyId && get().pendingPtyShutdownIds[ptyId]) { + // Why: an owner exit can arrive before its post-stop inventory; keep the renderer binding retryable until verification commits. + return + } + let worktreeId: string | null = null + let wasActivationSpawn = false + let preservesDirectSshContinuationGap = false + let isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) + set((s) => { + const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] + const remainingPtyIds = ptyId ? existingPtyIds.filter((id) => id !== ptyId) : [] + const liveBinding = s.directSshLivePtyBindingByTabId[tabId] + let nextTabsByWorktree = s.tabsByWorktree + for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) { + const index = tabs.findIndex((t) => t.id === tabId) + if (index === -1) { + continue + } + worktreeId = wId + const tab = tabs[index] + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + wasActivationSpawn = true + } + if (!ptyId) { + isRemoteRuntimeMirror = + existingPtyIds.length > 0 && existingPtyIds.every((id) => isRemoteRuntimePtyId(id)) + } + // Why: consume pendingActivationSpawn on real activation clears, but keep it when clearing a stale wake-hint id — its fallback spawn still needs the suppression. + const { pendingActivationSpawn: _unused, ...rest } = tab + void _unused + const nextTabPtyId = remainingPtyIds.at(-1) ?? null + preservesDirectSshContinuationGap = Boolean( + ptyId && + remainingPtyIds.length === 0 && + liveBinding?.ptyId === ptyId && + getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0 && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) + const shouldRetainActivationSpawn = + preservesDirectSshContinuationGap || + (wasActivationSpawn && ptyId != null && !existingPtyIds.includes(ptyId)) + const nextPendingActivationSpawn = shouldRetainActivationSpawn + ? tab.pendingActivationSpawn + : consumePendingActivationSpawn(tab.pendingActivationSpawn) + if (tab.pendingActivationSpawn || tab.ptyId !== nextTabPtyId) { + const nextTabs = [...tabs] + nextTabs[index] = { + ...rest, + ...(nextPendingActivationSpawn + ? { pendingActivationSpawn: nextPendingActivationSpawn } + : {}), + ptyId: nextTabPtyId + } + nextTabsByWorktree = { ...s.tabsByWorktree, [wId]: nextTabs } + } + break + } + const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } + if (worktreeId) { + nextPtyIdsByTabId[tabId] = remainingPtyIds + } else { + // Why: repo purge can retire the owning tab before its async exit arrives; don't resurrect an orphan PTY index. + delete nextPtyIdsByTabId[tabId] + } + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } + if (ptyId) { + delete nextPendingCodexPaneRestartIds[ptyId] + delete nextCodexRestartNoticeByPtyId[ptyId] + } else { + for (const currentPtyId of s.ptyIdsByTabId[tabId] ?? []) { + delete nextPendingCodexPaneRestartIds[currentPtyId] + delete nextCodexRestartNoticeByPtyId[currentPtyId] + } + } + // Why: an explicit exit drops the dead relay ID; bulk clears retain it for relay grace. + const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } + if (ptyId && nextLastKnownRelay[tabId] === ptyId) { + // Why: the relay slot holds ONE id per tab (the last pane to bind). If + // that pane exits, promote a surviving pane instead of clearing — else the + // survivor is left visible only in the layout leaf map, and a later + // relay-drop bulk-clear lets the orphan sweep delete the still-live tab + // (the orphan predicate reads this map but not layout leaves) (#9911). + const survivingPtyId = remainingPtyIds.at(-1) + if (survivingPtyId) { + nextLastKnownRelay[tabId] = survivingPtyId + } else { + delete nextLastKnownRelay[tabId] + } + } + let nextDirectSshPaneRetryByTabId = s.directSshPaneRetryByTabId + const pendingRetry = s.directSshPaneRetryByTabId[tabId] + if ( + pendingRetry && + (!ptyId || + (existingPtyIds.includes(ptyId) && + parseAppSshPtyId(ptyId)?.connectionId === pendingRetry.authority.targetId)) + ) { + nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + } + let nextDirectSshLivePtyBindingByTabId = s.directSshLivePtyBindingByTabId + if (liveBinding && (!ptyId || liveBinding.ptyId === ptyId)) { + nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId + } + const promotedPtyId = ptyId ? remainingPtyIds.at(-1) : undefined + if ( + promotedPtyId && + parseAppSshPtyId(promotedPtyId)?.connectionId === liveBinding.authority.targetId && + isCurrentDirectSshAuthority(s, liveBinding.authority) + ) { + nextDirectSshLivePtyBindingByTabId[tabId] = { + ...liveBinding, + ptyId: promotedPtyId + } + } else if (!preservesDirectSshContinuationGap) { + delete nextDirectSshLivePtyBindingByTabId[tabId] + } + } + return { + ...(nextTabsByWorktree !== s.tabsByWorktree + ? { tabsByWorktree: nextTabsByWorktree } + : {}), + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelay, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId + } + }) + // Bump activity on PTY exit, but skip intentional shutdowns (suppressed exits) and click-driven pane unmounts (pendingActivationSpawn). + if ( + worktreeId && + !wasActivationSpawn && + !isRemoteRuntimeMirror && + !hasWorktreeSleepIntent(worktreeId) && + !(ptyId && get().suppressedPtyExitIds[ptyId]) + ) { + get().bumpWorktreeActivity(worktreeId) + } + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-restart-state.ts b/src/renderer/src/store/terminals/terminal-restart-state.ts new file mode 100644 index 00000000000..f1075bdb698 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-restart-state.ts @@ -0,0 +1,202 @@ +import { isSameCodexRestartNoticeAccount } from '../slices/codex-restart-notice-account-identity' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalRestartActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'consumeSuppressedPtyExit' + | 'isPtyShutdownPending' + | 'suppressPtyExit' + | 'queueCodexPaneRestarts' + | 'consumePendingCodexPaneRestart' + | 'markCodexRestartNotices' + | 'clearCodexRestartNotice' + | 'dismissCodexRestartNotices' + | 'reopenCodexRestartPrompt' +> { + return { + consumeSuppressedPtyExit: (ptyId) => { + let wasSuppressed = false + set((s) => { + if (!s.suppressedPtyExitIds[ptyId]) { + return {} + } + wasSuppressed = true + const next = { ...s.suppressedPtyExitIds } + delete next[ptyId] + return { suppressedPtyExitIds: next } + }) + return wasSuppressed + }, + isPtyShutdownPending: (ptyId) => (get().pendingPtyShutdownIds[ptyId] ?? 0) > 0, + suppressPtyExit: (ptyId) => { + set((s) => ({ + suppressedPtyExitIds: { ...s.suppressedPtyExitIds, [ptyId]: true } + })) + }, + queueCodexPaneRestarts: (ptyIds) => { + if (ptyIds.length === 0) { + return + } + set((s) => { + // Why: the prompt is answered the moment the user asks for a restart. A + // pane whose tab isn't mounted can only restart when it next mounts, and + // leaving the prompt up re-showed a button that now does nothing. + const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId } + for (const ptyId of ptyIds) { + const notice = nextCodexRestartNoticeByPtyId[ptyId] + if (notice) { + // Why: mirror of the strip in dismissCodexRestartNotices. A queued + // restart leaves the pane on the old account until it relaunches, so + // it must re-block input that an earlier dismissal had freed. + const { dismissed: _dismissed, ...kept } = notice + nextCodexRestartNoticeByPtyId[ptyId] = { ...kept, restartRequested: true } + } + } + return { + pendingCodexPaneRestartIds: { + ...s.pendingCodexPaneRestartIds, + ...Object.fromEntries(ptyIds.map((ptyId) => [ptyId, true] as const)) + }, + codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId + } + }) + }, + consumePendingCodexPaneRestart: (ptyId) => { + let wasQueued = false + set((s) => { + if (!s.pendingCodexPaneRestartIds[ptyId]) { + return {} + } + wasQueued = true + const next = { ...s.pendingCodexPaneRestartIds } + delete next[ptyId] + return { pendingCodexPaneRestartIds: next } + }) + return wasQueued + }, + markCodexRestartNotices: (notices) => { + if (notices.length === 0) { + return [] + } + const noticedPtyIds: string[] = [] + set((s) => { + const next = { ...s.codexRestartNoticeByPtyId } + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + for (const notice of notices) { + const existing = next[notice.ptyId] + // Why one record rather than two lookups: the label and the id of the + // launch account have to come from the same source, or they can end up + // describing two different accounts. + const launch = existing ?? notice + const target = { id: notice.nextAccountId, label: notice.nextAccountLabel } + const homeRouteChanged = + notice.homeRouteChanged === undefined + ? existing?.homeRouteChanged === true + : notice.homeRouteChanged + // Why: a live Codex pane keeps its original launch account until it actually restarts, so A -> B -> A must not leave a stale restart notice. + if ( + !homeRouteChanged && + isSameCodexRestartNoticeAccount( + { id: launch.previousAccountId, label: launch.previousAccountLabel }, + target + ) + ) { + delete next[notice.ptyId] + delete nextPendingCodexPaneRestartIds[notice.ptyId] + continue + } + next[notice.ptyId] = { + previousAccountLabel: launch.previousAccountLabel, + nextAccountLabel: notice.nextAccountLabel, + ...(launch.previousAccountId === undefined + ? {} + : { previousAccountId: launch.previousAccountId }), + ...(notice.nextAccountId === undefined ? {} : { nextAccountId: notice.nextAccountId }), + ...(homeRouteChanged ? { homeRouteChanged: true as const } : {}), + // Why: a queued restart relaunches under whatever account is selected + // when it runs, so a later switch does not reopen an answered prompt. + ...(existing?.restartRequested ? { restartRequested: true as const } : {}), + // Why: a dismissal answered one question — "keep the launch account + // instead of this one". Only a genuinely different target re-asks it, + // so a later C reopens the prompt while adding an account or + // reauthenticating the active one (both re-mark live panes with the + // selection unchanged) must not resurrect it and re-mute the pane. + ...(existing?.dismissed && + isSameCodexRestartNoticeAccount( + { id: existing.nextAccountId, label: existing.nextAccountLabel }, + target + ) + ? { dismissed: true as const } + : {}) + } + noticedPtyIds.push(notice.ptyId) + } + return { + codexRestartNoticeByPtyId: next, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds + } + }) + return noticedPtyIds + }, + clearCodexRestartNotice: (ptyId) => { + set((s) => { + if (!s.codexRestartNoticeByPtyId[ptyId]) { + return {} + } + const next = { ...s.codexRestartNoticeByPtyId } + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + delete next[ptyId] + delete nextPendingCodexPaneRestartIds[ptyId] + return { + codexRestartNoticeByPtyId: next, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds + } + }) + }, + dismissCodexRestartNotices: (ptyIds) => { + set((s) => { + // Why: keeping the old account is an answer, not a restart — the record + // stays so `previousAccountLabel` still names the pane's launch account, + // but every consumer treats it as answered (prompt hidden, input freed). + const next = { ...s.codexRestartNoticeByPtyId } + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + let changed = false + for (const ptyId of ptyIds) { + const notice = next[ptyId] + if (!notice || notice.dismissed) { + continue + } + const { restartRequested: _restartRequested, ...kept } = notice + next[ptyId] = { ...kept, dismissed: true } + delete nextPendingCodexPaneRestartIds[ptyId] + changed = true + } + if (!changed) { + return {} + } + return { + codexRestartNoticeByPtyId: next, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds + } + }) + }, + reopenCodexRestartPrompt: (ptyId) => { + set((s) => { + const notice = s.codexRestartNoticeByPtyId[ptyId] + if (!notice?.restartRequested) { + return {} + } + const { restartRequested: _restartRequested, ...kept } = notice + const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } + delete nextPendingCodexPaneRestartIds[ptyId] + return { + codexRestartNoticeByPtyId: { ...s.codexRestartNoticeByPtyId, [ptyId]: kept }, + pendingCodexPaneRestartIds: nextPendingCodexPaneRestartIds + } + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-shutdown-guards.ts b/src/renderer/src/store/terminals/terminal-shutdown-guards.ts new file mode 100644 index 00000000000..83342bd5041 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-shutdown-guards.ts @@ -0,0 +1,185 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { + restorePtyDataHandlersAfterFailedShutdown, + unregisterPtyDataHandlers +} from '@/components/terminal-pane/pty-transport' +import type { PtyDataHandlerShutdownSnapshot } from '@/components/terminal-pane/pty-shutdown-data-suspension' +import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { + clearCommittedPtyShutdownSettlements, + hasCommittedPtyShutdownSettlement, + markCommittedPtyShutdowns, + noteCommittedPtyShutdownSettlements, + settleDeferredPtyShutdownExits +} from '@/components/terminal-pane/pty-shutdown-exit-deferral' +import type { TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export type TerminalShutdownGuardController = { + commitHandlerSnapshots: () => void + markShutdownPending: () => void + rollbackShutdown: () => void + settlePartialRendererStop: (stoppedPtyIds: readonly string[]) => void + stopRendererPtys: () => Promise<{ + stoppedPtyIds: string[] + failure?: PromiseRejectedResult + }> + unregisterHandlers: () => void + wasPartialRendererStopSettled: () => boolean +} + +export function createTerminalShutdownGuardController({ + exitGuardPtyIds, + get, + keepIdentifiers, + rendererShutdownPtyIds, + runtimeEnvironmentId, + set, + tabs +}: { + exitGuardPtyIds: readonly string[] + get: TerminalStoreGet + keepIdentifiers: boolean + rendererShutdownPtyIds: readonly string[] + runtimeEnvironmentId: string | null + set: TerminalStoreSet + tabs: readonly TerminalTab[] +}): TerminalShutdownGuardController { + let handlerSnapshots: PtyDataHandlerShutdownSnapshot[] = [] + let partialRendererStopSettled = false + + const markShutdownPending = (): void => { + set((state) => { + const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds } + for (const ptyId of exitGuardPtyIds) { + pendingPtyShutdownIds[ptyId] = (pendingPtyShutdownIds[ptyId] ?? 0) + 1 + } + return { + suppressedPtyExitIds: { + ...state.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + pendingPtyShutdownIds + } + }) + } + + const settleGuards = ( + stoppedPtyIds: ReadonlySet, + clearStoppedTabBindings = false + ): void => { + set((state) => { + const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds } + const suppressedPtyExitIds = { ...state.suppressedPtyExitIds } + for (const ptyId of exitGuardPtyIds) { + const remainingOwners = (pendingPtyShutdownIds[ptyId] ?? 0) - 1 + if (remainingOwners > 0) { + pendingPtyShutdownIds[ptyId] = remainingOwners + } else { + delete pendingPtyShutdownIds[ptyId] + if (!stoppedPtyIds.has(ptyId)) { + delete suppressedPtyExitIds[ptyId] + } + } + } + if (!clearStoppedTabBindings) { + return { pendingPtyShutdownIds, suppressedPtyExitIds } + } + const ptyIdsByTabId = { ...state.ptyIdsByTabId } + for (const tab of tabs) { + ptyIdsByTabId[tab.id] = (state.ptyIdsByTabId[tab.id] ?? []).filter( + (ptyId) => !stoppedPtyIds.has(ptyId) + ) + } + return { ptyIdsByTabId, pendingPtyShutdownIds, suppressedPtyExitIds } + }) + } + + const rollbackShutdown = (): void => { + if (handlerSnapshots.length > 0) { + restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) + } + set((state) => { + const suppressedPtyExitIds = { ...state.suppressedPtyExitIds } + const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds } + for (const ptyId of exitGuardPtyIds) { + const remainingOwners = (pendingPtyShutdownIds[ptyId] ?? 0) - 1 + if (remainingOwners > 0) { + pendingPtyShutdownIds[ptyId] = remainingOwners + } else { + delete pendingPtyShutdownIds[ptyId] + if (!hasCommittedPtyShutdownSettlement(ptyId)) { + delete suppressedPtyExitIds[ptyId] + } + } + } + return { suppressedPtyExitIds, pendingPtyShutdownIds } + }) + const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId)) + const committedPtyIds = settledPtyIds.filter(hasCommittedPtyShutdownSettlement) + const rolledBackPtyIds = settledPtyIds.filter( + (ptyId) => !hasCommittedPtyShutdownSettlement(ptyId) + ) + markCommittedPtyShutdowns(committedPtyIds) + settleDeferredPtyShutdownExits(committedPtyIds, 'committed') + settleDeferredPtyShutdownExits(rolledBackPtyIds, 'rolled-back') + clearCommittedPtyShutdownSettlements(settledPtyIds) + } + + const stopRendererPtys = async (): Promise<{ + stoppedPtyIds: string[] + failure?: PromiseRejectedResult + }> => { + const localPtyIds = rendererShutdownPtyIds.filter((ptyId) => !ptyId.startsWith('remote:')) + const results = await Promise.allSettled( + localPtyIds.map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) + ) + const stoppedPtyIds = [ + ...(runtimeEnvironmentId + ? rendererShutdownPtyIds.filter((ptyId) => ptyId.startsWith('remote:')) + : []), + ...localPtyIds.filter((_, index) => results[index]?.status === 'fulfilled') + ] + disposeParkedTerminalWatchersForPtyIds(stoppedPtyIds) + return { + stoppedPtyIds, + failure: results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + } + } + + const settlePartialRendererStop = (stoppedPtyIds: readonly string[]): void => { + partialRendererStopSettled = true + const stopped = new Set(stoppedPtyIds) + const stoppedSnapshots = handlerSnapshots.filter((snapshot) => stopped.has(snapshot.ptyId)) + const failedSnapshots = handlerSnapshots.filter((snapshot) => !stopped.has(snapshot.ptyId)) + for (const snapshot of stoppedSnapshots) { + snapshot.commit?.() + } + restorePtyDataHandlersAfterFailedShutdown(failedSnapshots) + noteCommittedPtyShutdownSettlements(stoppedPtyIds) + settleGuards(stopped, true) + const failedPtyIds = exitGuardPtyIds.filter((ptyId) => !stopped.has(ptyId)) + markCommittedPtyShutdowns(stoppedPtyIds) + settleDeferredPtyShutdownExits(stoppedPtyIds, 'committed') + settleDeferredPtyShutdownExits(failedPtyIds, 'rolled-back') + clearCommittedPtyShutdownSettlements(exitGuardPtyIds) + } + + return { + commitHandlerSnapshots: () => { + for (const snapshot of handlerSnapshots) { + snapshot.commit?.() + } + noteCommittedPtyShutdownSettlements(exitGuardPtyIds) + }, + markShutdownPending, + rollbackShutdown, + settlePartialRendererStop, + stopRendererPtys, + unregisterHandlers: () => { + handlerSnapshots = unregisterPtyDataHandlers([...rendererShutdownPtyIds]) ?? [] + }, + wasPartialRendererStopSettled: () => partialRendererStopSettled + } +} diff --git a/src/renderer/src/store/terminals/terminal-shutdown-state.ts b/src/renderer/src/store/terminals/terminal-shutdown-state.ts new file mode 100644 index 00000000000..a4cb7979978 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-shutdown-state.ts @@ -0,0 +1,194 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { clearTransientTerminalState } from '../slices/terminal-helpers' +import { + clearCommittedPtyShutdownSettlements, + markCommittedPtyShutdowns, + settleDeferredPtyShutdownExits +} from '@/components/terminal-pane/pty-shutdown-exit-deferral' +import { + removeSleepingRecordsReplacedByManualWorktreeSleep, + type AgentStatusWorktreeShutdownReason, + type RetainedAgentEntry +} from '../slices/agent-status' +import type { TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function commitTerminalShutdownState({ + exitGuardPtyIds, + get, + keepIdentifiers, + retainedCompletionEvidence, + set, + shutdownReason, + sleepingAgentSessionRecords, + sleepingPaneKeys, + tabs, + worktreeId +}: { + exitGuardPtyIds: readonly string[] + get: TerminalStoreGet + keepIdentifiers: boolean + retainedCompletionEvidence: readonly RetainedAgentEntry[] + set: TerminalStoreSet + shutdownReason: AgentStatusWorktreeShutdownReason + sleepingAgentSessionRecords: Record + sleepingPaneKeys?: readonly string[] + tabs: readonly TerminalTab[] + worktreeId: string +}): void { + set((state) => { + const tabsByWorktree = keepIdentifiers + ? state.tabsByWorktree + : { + ...state.tabsByWorktree, + [worktreeId]: (state.tabsByWorktree[worktreeId] ?? []).map((tab, index) => + clearTransientTerminalState(tab, index) + ) + } + const ptyIdsByTabId = { + ...state.ptyIdsByTabId, + ...Object.fromEntries(tabs.map((tab) => [tab.id, [] as string[]] as const)) + } + const runtimePaneTitlesByTabId = keepIdentifiers + ? state.runtimePaneTitlesByTabId + : { ...state.runtimePaneTitlesByTabId } + const suppressedPtyExitIds = { + ...state.suppressedPtyExitIds, + ...Object.fromEntries(exitGuardPtyIds.map((ptyId) => [ptyId, true] as const)) + } + const pendingPtyShutdownIds = { ...state.pendingPtyShutdownIds } + for (const ptyId of exitGuardPtyIds) { + const remainingOwners = (pendingPtyShutdownIds[ptyId] ?? 0) - 1 + if (remainingOwners > 0) { + pendingPtyShutdownIds[ptyId] = remainingOwners + } else { + delete pendingPtyShutdownIds[ptyId] + } + } + + // Sleeping terminals retain restart intent, but a wake can receive a different live PTY id. + const pendingCodexPaneRestartIds = keepIdentifiers + ? state.pendingCodexPaneRestartIds + : { ...state.pendingCodexPaneRestartIds } + const codexRestartNoticeByPtyId = { ...state.codexRestartNoticeByPtyId } + for (const ptyId of exitGuardPtyIds) { + if (!keepIdentifiers) { + delete pendingCodexPaneRestartIds[ptyId] + } + delete codexRestartNoticeByPtyId[ptyId] + } + + const pendingSetupSplitByTabId = { ...state.pendingSetupSplitByTabId } + const pendingIssueCommandSplitByTabId = { ...state.pendingIssueCommandSplitByTabId } + const terminalLayoutsByTabId = { ...state.terminalLayoutsByTabId } + let unreadTerminalTabs = state.unreadTerminalTabs + let unreadTerminalPanes = state.unreadTerminalPanes + let unreadAgentCompletionPanes = state.unreadAgentCompletionPanes + let lastTerminalInputAtByPaneKey = state.lastTerminalInputAtByPaneKey + + for (const tab of tabs) { + if (!keepIdentifiers) { + delete runtimePaneTitlesByTabId[tab.id] + } + delete pendingSetupSplitByTabId[tab.id] + delete pendingIssueCommandSplitByTabId[tab.id] + if (unreadTerminalTabs[tab.id]) { + if (unreadTerminalTabs === state.unreadTerminalTabs) { + unreadTerminalTabs = { ...state.unreadTerminalTabs } + } + delete unreadTerminalTabs[tab.id] + } + for (const paneKey of Object.keys(unreadTerminalPanes)) { + if (paneKey.startsWith(`${tab.id}:`)) { + if (unreadTerminalPanes === state.unreadTerminalPanes) { + unreadTerminalPanes = { ...unreadTerminalPanes } + } + delete unreadTerminalPanes[paneKey] + } + } + for (const paneKey of Object.keys(unreadAgentCompletionPanes)) { + if (paneKey.startsWith(`${tab.id}:`)) { + if (unreadAgentCompletionPanes === state.unreadAgentCompletionPanes) { + unreadAgentCompletionPanes = { ...unreadAgentCompletionPanes } + } + delete unreadAgentCompletionPanes[paneKey] + } + } + for (const paneKey of Object.keys(lastTerminalInputAtByPaneKey)) { + if (paneKey.startsWith(`${tab.id}:`)) { + if (lastTerminalInputAtByPaneKey === state.lastTerminalInputAtByPaneKey) { + lastTerminalInputAtByPaneKey = { ...lastTerminalInputAtByPaneKey } + } + delete lastTerminalInputAtByPaneKey[paneKey] + } + } + if (!keepIdentifiers) { + const layout = terminalLayoutsByTabId[tab.id] + if (layout?.ptyIdsByLeafId) { + terminalLayoutsByTabId[tab.id] = { ...layout, ptyIdsByLeafId: {} } + } + } + } + + const lastKnownRelayPtyIdByTabId = keepIdentifiers + ? state.lastKnownRelayPtyIdByTabId + : { ...state.lastKnownRelayPtyIdByTabId } + if (!keepIdentifiers) { + for (const tab of tabs) { + delete lastKnownRelayPtyIdByTabId[tab.id] + } + } + + return { + tabsByWorktree, + ptyIdsByTabId, + lastKnownRelayPtyIdByTabId, + runtimePaneTitlesByTabId, + suppressedPtyExitIds, + pendingPtyShutdownIds, + pendingCodexPaneRestartIds, + codexRestartNoticeByPtyId, + pendingSetupSplitByTabId, + pendingIssueCommandSplitByTabId, + terminalLayoutsByTabId, + ...(unreadTerminalTabs !== state.unreadTerminalTabs ? { unreadTerminalTabs } : {}), + ...(unreadTerminalPanes !== state.unreadTerminalPanes ? { unreadTerminalPanes } : {}), + ...(unreadAgentCompletionPanes !== state.unreadAgentCompletionPanes + ? { unreadAgentCompletionPanes } + : {}), + ...(lastTerminalInputAtByPaneKey !== state.lastTerminalInputAtByPaneKey + ? { lastTerminalInputAtByPaneKey } + : {}) + } + }) + + if (keepIdentifiers) { + set((state) => { + const base = + shutdownReason === 'manual-sleep' + ? removeSleepingRecordsReplacedByManualWorktreeSleep( + state.sleepingAgentSessionsByPaneKey, + worktreeId, + sleepingPaneKeys, + sleepingAgentSessionRecords + ).records + : state.sleepingAgentSessionsByPaneKey + return { + sleepingAgentSessionsByPaneKey: { ...base, ...sleepingAgentSessionRecords } + } + }) + } else { + get().clearSleepingAgentSessionsByWorktree(worktreeId) + } + + get().dropAgentStatusByWorktree(worktreeId, { + shutdownReason, + sleepingPaneKeys, + retainedCompletionEvidence + }) + get().clearPaneForegroundAgentByWorktree(worktreeId) + const settledPtyIds = exitGuardPtyIds.filter((ptyId) => !get().isPtyShutdownPending(ptyId)) + markCommittedPtyShutdowns(settledPtyIds) + settleDeferredPtyShutdownExits(settledPtyIds, 'committed') + clearCommittedPtyShutdownSettlements(settledPtyIds) +} diff --git a/src/renderer/src/store/terminals/terminal-shutdown.ts b/src/renderer/src/store/terminals/terminal-shutdown.ts new file mode 100644 index 00000000000..3c4da6b3b0a --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-shutdown.ts @@ -0,0 +1,172 @@ +import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { requestRemoteWorktreeSleep } from '@/runtime/remote-worktree-sleep' +import { + collectHibernatedCompletionEvidenceForWorktree, + collectSleepingAgentSessionRecordsForWorktree, + type AgentStatusWorktreeShutdownReason +} from '../slices/agent-status' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' +import { resolveTerminalStopRuntimeEnvironmentId } from './terminal-workspace-routing' +import { createTerminalShutdownGuardController } from './terminal-shutdown-guards' +import { commitTerminalShutdownState } from './terminal-shutdown-state' + +type ExactTerminalStopResult = { + stoppedPtyIds?: string[] + livePtyIds?: string[] + postStopVerified?: boolean + postStopFailure?: string +} + +export function createTerminalShutdownActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + shutdownWorktreeTerminals: async (worktreeId, opts) => { + const keepIdentifiers = opts?.keepIdentifiers ?? false + const shutdownReason: AgentStatusWorktreeShutdownReason = + opts?.shutdownReason ?? (keepIdentifiers ? 'manual-sleep' : 'remove-worktree') + const tabs = get().tabsByWorktree[worktreeId] ?? [] + const rendererShutdownPtyIds = sortedUniquePtyIds( + tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? []) + ) + const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) + const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) + // Only renderer-bound ids emit pane exit callbacks; raw RPC handles never enter this guard. + const exitGuardPtyIds = rendererShutdownPtyIds + const sleepingAgentSessionRecords = keepIdentifiers + ? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, { + paneKeys: opts?.sleepingPaneKeys, + ...(shutdownReason === 'manual-sleep' ? { captureMode: 'manual-worktree-sleep' } : {}), + ...(shutdownReason === 'auto-hibernate-completed-agent' + ? { captureMode: 'completed-agent-hibernation' } + : {}) + }) + : {} + const retainedCompletionEvidence = + shutdownReason === 'auto-hibernate-completed-agent' + ? collectHibernatedCompletionEvidenceForWorktree( + get(), + worktreeId, + opts?.sleepingPaneKeys + ) + : [] + const guards = createTerminalShutdownGuardController({ + exitGuardPtyIds, + get, + keepIdentifiers, + rendererShutdownPtyIds, + runtimeEnvironmentId, + set, + tabs + }) + + // Capture before kill; capture() can publish layout changes consumed by the final commit. + if (keepIdentifiers) { + for (const tab of tabs) { + const capture = shutdownBufferCaptures.get(tab.id) + if (capture) { + try { + capture({ includeLocalBuffers: false }) + } catch { + // One unavailable pane must not block capture of the rest of the worktree. + } + } + } + } + + if (expectedRuntimePtyIds.length === 0) { + guards.markShutdownPending() + guards.unregisterHandlers() + try { + // Backend ownership is opt-in; the default owner RPC prevents remote PTY leaks. + if (runtimeEnvironmentId && opts?.backendOwnsPtyTeardown !== true) { + await (shutdownReason === 'manual-sleep' + ? requestRemoteWorktreeSleep({ environmentId: runtimeEnvironmentId, worktreeId }) + : callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.stop', + { worktree: toRuntimeWorktreeSelector(worktreeId) }, + { timeoutMs: 15_000 } + )) + } + // Renderer teardown waits for the owner RPC so failures leave bindings retryable. + const rendererStop = await guards.stopRendererPtys() + if (rendererStop.failure) { + guards.settlePartialRendererStop(rendererStop.stoppedPtyIds) + throw rendererStop.failure.reason + } + } catch (error) { + if (!guards.wasPartialRendererStopSettled()) { + guards.rollbackShutdown() + } + throw error + } + } else { + if (!runtimeEnvironmentId) { + throw new Error('missing_runtime_for_exact_terminal_stop') + } + guards.markShutdownPending() + guards.unregisterHandlers() + let stopResult: ExactTerminalStopResult + try { + stopResult = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'terminal.stopExact', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + expectedPtyIds: expectedRuntimePtyIds, + keepHistory: keepIdentifiers + }, + { timeoutMs: 15_000 } + ) + } catch (error) { + guards.rollbackShutdown() + throw error + } + const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) + const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) + if ( + !equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || + !equalStringSets(livePtyIds, expectedRuntimePtyIds) + ) { + guards.rollbackShutdown() + throw new Error('exact_terminal_stop_mismatch') + } + if (stopResult.postStopVerified !== true) { + guards.rollbackShutdown() + throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') + } + try { + const rendererStop = await guards.stopRendererPtys() + if (rendererStop.failure) { + guards.settlePartialRendererStop(rendererStop.stoppedPtyIds) + throw rendererStop.failure.reason + } + } catch (error) { + if (!guards.wasPartialRendererStopSettled()) { + guards.rollbackShutdown() + } + throw error + } + } + + guards.commitHandlerSnapshots() + commitTerminalShutdownState({ + exitGuardPtyIds, + get, + keepIdentifiers, + retainedCompletionEvidence, + set, + shutdownReason, + sleepingAgentSessionRecords, + sleepingPaneKeys: opts?.sleepingPaneKeys, + tabs, + worktreeId + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-startup-queues.ts b/src/renderer/src/store/terminals/terminal-startup-queues.ts new file mode 100644 index 00000000000..fb7ebdabbe2 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-startup-queues.ts @@ -0,0 +1,133 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalStartupQueueActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'queueTabStartupCommand' + | 'queueTabInitialCwd' + | 'consumeTabInitialCwd' + | 'consumeTabStartupCommand' + | 'queueTabSetupSplit' + | 'consumeTabSetupSplit' + | 'queueTabIssueCommandSplit' + | 'consumeTabIssueCommandSplit' + | 'consumePendingSnapshot' + | 'consumePendingColdRestore' +> { + return { + queueTabStartupCommand: (tabId, startup) => { + // Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token. + const launchToken = startup.launchConfig + ? (startup.launchToken ?? createBrowserUuid()) + : undefined + set((s) => ({ + pendingStartupByTabId: { + ...s.pendingStartupByTabId, + [tabId]: { + ...startup, + ...(launchToken ? { launchToken } : {}) + } + } + })) + }, + queueTabInitialCwd: (tabId, cwd) => { + set((s) => ({ + pendingInitialCwdByTabId: { + ...s.pendingInitialCwdByTabId, + [tabId]: cwd + } + })) + }, + consumeTabInitialCwd: (tabId) => { + const pending = get().pendingInitialCwdByTabId[tabId] + if (!pending) { + return null + } + set((s) => { + const next = { ...s.pendingInitialCwdByTabId } + delete next[tabId] + return { pendingInitialCwdByTabId: next } + }) + return pending + }, + consumeTabStartupCommand: (tabId) => { + const pending = get().pendingStartupByTabId[tabId] + if (!pending) { + return null + } + set((s) => { + const next = { ...s.pendingStartupByTabId } + delete next[tabId] + return { pendingStartupByTabId: next } + }) + return pending + }, + queueTabSetupSplit: (tabId, startup) => { + set((s) => ({ + pendingSetupSplitByTabId: { + ...s.pendingSetupSplitByTabId, + [tabId]: startup + } + })) + }, + consumeTabSetupSplit: (tabId) => { + const pending = get().pendingSetupSplitByTabId[tabId] + if (!pending) { + return null + } + set((s) => { + const next = { ...s.pendingSetupSplitByTabId } + delete next[tabId] + return { pendingSetupSplitByTabId: next } + }) + return pending + }, + queueTabIssueCommandSplit: (tabId, issueCommand) => { + set((s) => ({ + pendingIssueCommandSplitByTabId: { + ...s.pendingIssueCommandSplitByTabId, + [tabId]: issueCommand + } + })) + }, + consumeTabIssueCommandSplit: (tabId) => { + const pending = get().pendingIssueCommandSplitByTabId[tabId] + if (!pending) { + return null + } + set((s) => { + const next = { ...s.pendingIssueCommandSplitByTabId } + delete next[tabId] + return { pendingIssueCommandSplitByTabId: next } + }) + return pending + }, + consumePendingSnapshot: (ptyId) => { + const snapshot = get().pendingSnapshotByPtyId[ptyId] + if (!snapshot) { + return null + } + set((s) => { + const next = { ...s.pendingSnapshotByPtyId } + delete next[ptyId] + return { pendingSnapshotByPtyId: next } + }) + return snapshot + }, + consumePendingColdRestore: (ptyId) => { + const data = get().pendingColdRestoreByPtyId[ptyId] + if (!data) { + return null + } + set((s) => { + const next = { ...s.pendingColdRestoreByPtyId } + delete next[ptyId] + return { pendingColdRestoreByPtyId: next } + }) + return data + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts new file mode 100644 index 00000000000..5f754cc6463 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -0,0 +1,125 @@ +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' +import type { + DirectSshLivePtyBinding, + DirectSshPaneRetryAttempt, + DirectSshPaneRetryHistory +} from '../slices/direct-ssh-terminal-recovery' +import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-contracts' +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { TerminalActions } from './terminal-actions' + +export type TerminalState = { + tabsByWorktree: Record + activeTabId: string | null + /** Per-worktree focus survives workspace switches independently of the global active tab. */ + activeTabIdByWorktree: Record + ptyIdsByTabId: Record + runtimePaneTitlesByTabId: Record> + unreadTerminalTabs: Record + unreadTerminalPanes: Record + unreadAgentCompletionPanes: Record + /** Scoped exit suppression and reference-counted shutdown ownership prevent teardown races. */ + suppressedPtyExitIds: Record + pendingPtyShutdownIds: Record + pendingCodexPaneRestartIds: Record + codexRestartNoticeByPtyId: Record + directSshPaneRetryByTabId: Record + directSshLivePtyBindingByTabId: Record + directSshPaneRetryHistoryByTabId: Record + expandedPaneByTabId: Record + canExpandPaneByTabId: Record + terminalLayoutsByTabId: Record + recentQuickCommandIdByGroup: Record + /** Runtime-only claim bridging startup payload consumption until terminal hooks mount. */ + automaticAgentResumeClaimsByTabId: Record + nativeChatLaunchPromptByTabId: Record + nativeChatLaunchDraftByTabId: Record + pendingStartupByTabId: Record< + string, + { + command: string + delivery?: 'terminal-paste' + startupCommandDelivery?: StartupCommandDelivery + env?: Record + envToDelete?: string[] + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + agentArgsOverride?: string | null + draftPrompt?: string + sessionOptions?: Record + initialAgentStatus?: { + agent: TuiAgent + prompt: string + } + showSessionRestoredBanner?: boolean + telemetry?: AgentStartedTelemetry + } + > + pendingInitialCwdByTabId: Record + pendingSetupSplitByTabId: Record< + string, + { + command: string + env?: Record + direction: SetupSplitDirection + } + > + pendingIssueCommandSplitByTabId: Record< + string, + { + command: string + env?: Record + } + > + tabBarOrderByWorktree: Record + /** False until global reconnect publishes every deferred wake hint. */ + workspaceSessionReady: boolean + restoredRuntimeHostIdByWorkspaceSessionKey: Record + defaultTerminalTabsAppliedByWorktreeId: Record + hydrationSucceeded: boolean + pendingReconnectWorktreeIds: string[] + pendingReconnectTabByWorktree: Record + /** Prior daemon/relay identities are wake hints; they are not proof of current liveness. */ + pendingReconnectPtyIdByTabId: Record + /** Retained across relay disconnect after tab.ptyId is cleared so persistence can reattach. */ + lastKnownRelayPtyIdByTabId: Record + /** Reattach snapshots are consumed once by the pane that receives the replacement PTY. */ + pendingSnapshotByPtyId: Record< + string, + { + snapshot: string + cols?: number + rows?: number + isAlternateScreen?: boolean + } + > + pendingColdRestoreByPtyId: Record< + string, + { + scrollback: string + cwd: string + } + > + cacheTimerByKey: Record + lastTerminalInputAtByPaneKey: Record + deferredSshReconnectTargets: string[] + deferredSshSessionIdsByTabId: Record +} +export type TerminalSlice = TerminalState & TerminalActions + +export type TerminalStoreSet = Parameters>[0] +export type TerminalStoreGet = Parameters>[1] diff --git a/src/renderer/src/store/terminals/terminal-tab-attention.ts b/src/renderer/src/store/terminals/terminal-tab-attention.ts new file mode 100644 index 00000000000..062043b546e --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-attention.ts @@ -0,0 +1,126 @@ +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalTabAttentionActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'markTerminalTabUnread' + | 'markTerminalPaneUnread' + | 'markAgentCompletionPaneUnread' + | 'clearTerminalTabUnread' + | 'clearTerminalPaneUnread' + | 'setTabCustomTitle' + | 'setTabColor' +> { + return { + markTerminalTabUnread: (tabId) => { + const state = get() + const ownerTab = Object.values(state.tabsByWorktree ?? {}) + .flat() + .find((t) => t.id === tabId) + if (!ownerTab) { + return + } + // Why: terminal attention persists until real interaction. + set((s) => { + if (s.unreadTerminalTabs[tabId]) { + return s + } + return { unreadTerminalTabs: { ...s.unreadTerminalTabs, [tabId]: true as const } } + }) + }, + markTerminalPaneUnread: (paneKey) => { + set((s) => { + if (s.unreadTerminalPanes[paneKey]) { + return s + } + return { unreadTerminalPanes: { ...s.unreadTerminalPanes, [paneKey]: true as const } } + }) + }, + markAgentCompletionPaneUnread: (paneKey) => { + set((s) => { + if (s.unreadAgentCompletionPanes[paneKey]) { + return s + } + return { + unreadAgentCompletionPanes: { + ...s.unreadAgentCompletionPanes, + [paneKey]: true as const + } + } + }) + }, + clearTerminalTabUnread: (tabId) => { + set((s) => { + if (!s.unreadTerminalTabs[tabId]) { + return s + } + const copy = { ...s.unreadTerminalTabs } + delete copy[tabId] + return { unreadTerminalTabs: copy } + }) + }, + clearTerminalPaneUnread: (paneKey) => { + set((s) => { + if (!s.unreadTerminalPanes[paneKey] && !s.unreadAgentCompletionPanes[paneKey]) { + return s + } + const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } + delete nextUnreadTerminalPanes[paneKey] + delete nextUnreadAgentCompletionPanes[paneKey] + return { + unreadTerminalPanes: nextUnreadTerminalPanes, + unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes + } + }) + }, + setTabCustomTitle: (tabId, title, opts) => { + set((s) => { + const next = { ...s.tabsByWorktree } + for (const wId of Object.keys(next)) { + next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, customTitle: title } : t)) + } + scheduleRuntimeGraphSync() + return { tabsByWorktree: next } + }) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) + if (item) { + get().setTabCustomLabel(item.id, title, opts) + } + }, + setTabColor: (tabId, color) => { + set((s) => { + const next = { ...s.tabsByWorktree } + for (const wId of Object.keys(next)) { + next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, color } : t)) + } + return { tabsByWorktree: next } + }) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) + if (item) { + get().setUnifiedTabColor(item.id, color) + // Why: tab color is host-authoritative for remote-server tabs; mirror it so it persists instead of reverting on the next snapshot. + const state = get() + const owningWorktreeId = Object.keys(state.unifiedTabsByWorktree).find((wId) => + (state.unifiedTabsByWorktree[wId] ?? []).some((entry) => entry.id === item.id) + ) + if ( + owningWorktreeId && + resolveTerminalWorktreeRoute(state, owningWorktreeId)?.runtimeEnvironmentId + ) { + void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => + setWebRuntimeTabProps({ worktreeId: owningWorktreeId, tabId: item.id, color }) + ) + } + } + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts new file mode 100644 index 00000000000..322d4106c7e --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts @@ -0,0 +1,68 @@ +import type { AppState } from '../types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import { + classifyTerminalRetirementWorktree, + type TerminalTabRetirementPlan +} from '../slices/terminal-tab-retirement' + +export function startTerminalTabProviderRetirement({ + localPtyTeardownOwnedExternally, + remoteCloseOwnedByHost, + retirementPlan, + state, + tabId +}: { + localPtyTeardownOwnedExternally: boolean + remoteCloseOwnedByHost: boolean + retirementPlan: TerminalTabRetirementPlan + state: AppState + tabId: string +}): void { + const fallbackWorktreeRoute = retirementPlan.worktreeId + ? resolveTerminalWorktreeRoute(state, retirementPlan.worktreeId) + : { runtimeEnvironmentId: null } + const retirementTasks: Promise[] = localPtyTeardownOwnedExternally + ? [] + : retirementPlan.localOrSshPtyIds.map(async (ptyId) => window.api.pty.kill(ptyId)) + const localOrSshTaskCount = retirementTasks.length + if (!remoteCloseOwnedByHost) { + for (const terminal of retirementPlan.runtimeTerminals) { + if (!terminal.environmentId && !fallbackWorktreeRoute) { + continue + } + const environmentId = terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId + retirementTasks.push( + callRuntimeRpc( + environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, + 'terminal.close', + { terminal: terminal.handle } + ) + ) + } + } + if (retirementPlan.unroutablePtyIds.length > 0) { + // Log the worktree shape, never its id, because worktree ids embed absolute paths. + console.warn('[terminal-retirement] skipped PTYs with no resolvable owner', { + tabId, + worktreeKind: classifyTerminalRetirementWorktree(retirementPlan.worktreeId), + count: retirementPlan.unroutablePtyIds.length + }) + } + // Close remains synchronous; provider failures cannot block ownership revocation. + void Promise.allSettled(retirementTasks).then((results) => { + const localOrSshFailures = results + .slice(0, localOrSshTaskCount) + .filter((result) => result.status === 'rejected').length + const runtimeFailures = results + .slice(localOrSshTaskCount) + .filter((result) => result.status === 'rejected').length + if (localOrSshFailures > 0 || runtimeFailures > 0) { + console.warn('[terminal-retirement] provider teardown failed', { + tabId, + localOrSshFailures, + runtimeFailures + }) + } + }) +} diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts new file mode 100644 index 00000000000..cea5f0bc474 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -0,0 +1,266 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { sweepRetiredTerminalTabState } from '../slices/retired-terminal-tab-state-sweep' +import { + getRecentlyClosedTabPosition, + pushClosedTerminalTabSnapshot, + pushRecentlyClosedTabKind +} from '../slices/recently-closed-tabs' +// Why: use the store-free registry (not terminal-parked-tab-watchers, which imports @/store) to avoid re-entering store creation during this slice's eval. +import { retireParkedTerminalTab } from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { + buildTerminalTabRetirementPlan, + removeSleepingAgentSessionsForTab +} from '../slices/terminal-tab-retirement' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { startTerminalTabProviderRetirement } from './terminal-tab-close-providers' + +export function createTerminalTabCloseActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + closeTab: (tabId, opts) => { + const closeReason = opts?.reason ?? 'user' + const retiresSession = closeReason === 'user' || closeReason === 'cleanup' + const retirementPlan = + opts?.precomputedRetirementPlan?.tabId === tabId + ? opts.precomputedRetirementPlan + : buildTerminalTabRetirementPlan(get(), tabId) + let closingWorktreeId: string | null = null + // Why: a parked tab has no mounted TerminalPane cleanup, so revoke its observer/candidate state before provider exit races. + retireParkedTerminalTab(tabId) + if (retiresSession) { + startTerminalTabProviderRetirement({ + localPtyTeardownOwnedExternally: opts?.localPtyTeardownOwnedExternally === true, + remoteCloseOwnedByHost: opts?.remoteCloseOwnedByHost === true, + retirementPlan, + state: get(), + tabId + }) + } + set((s) => { + const next = { ...s.tabsByWorktree } + let closedTab: TerminalTab | null = null + let closedWorktreeId: string | null = null + for (const wId of Object.keys(next)) { + const before = next[wId] + const closing = before.find((t) => t.id === tabId) + if (closing) { + closingWorktreeId = wId + // Why: capture the first-matched tab's snapshot for the Cmd+Shift+T reopen stack (see capturedSnapshot below). + if (!closedTab) { + closedTab = closing + closedWorktreeId = wId + } + } + const after = before.filter((t) => t.id !== tabId) + if (after.length !== before.length) { + next[wId] = after + } + } + // Why: only explicit user closes feed the Cmd+Shift+T reopen stack; cleanup/PTY-exit closes must not pollute undo history. + const closedPosition = + closedWorktreeId && closedTab + ? getRecentlyClosedTabPosition(s, closedWorktreeId, closedTab.id) + : undefined + const capturedSnapshot = + closeReason === 'user' && + opts?.captureRecentlyClosed !== false && + closedTab && + closedWorktreeId + ? { + ...(closedTab.startupCwd ? { startupCwd: closedTab.startupCwd } : {}), + ...(closedTab.shellOverride ? { shellOverride: closedTab.shellOverride } : {}), + ...(closedTab.customTitle ? { customTitle: closedTab.customTitle } : {}), + ...(closedTab.color ? { color: closedTab.color } : {}), + ...(closedPosition ? { position: closedPosition } : {}) + } + : null + const nextExpanded = { ...s.expandedPaneByTabId } + delete nextExpanded[tabId] + const nextCanExpand = { ...s.canExpandPaneByTabId } + delete nextCanExpand[tabId] + const nextLayouts = { ...s.terminalLayoutsByTabId } + delete nextLayouts[tabId] + const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } + delete nextPtyIdsByTabId[tabId] + const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } + delete nextLastKnownRelay[tabId] + const nextDeferredSshSessionIdsByTabId = { ...s.deferredSshSessionIdsByTabId } + delete nextDeferredSshSessionIdsByTabId[tabId] + const nextPendingReconnectPtyIdByTabId = { ...s.pendingReconnectPtyIdByTabId } + delete nextPendingReconnectPtyIdByTabId[tabId] + const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } + delete nextRuntimePaneTitlesByTabId[tabId] + const nextDirectSshPaneRetryByTabId = { ...s.directSshPaneRetryByTabId } + delete nextDirectSshPaneRetryByTabId[tabId] + const nextDirectSshLivePtyBindingByTabId = { + ...s.directSshLivePtyBindingByTabId + } + delete nextDirectSshLivePtyBindingByTabId[tabId] + const nextDirectSshPaneRetryHistoryByTabId = { + ...s.directSshPaneRetryHistoryByTabId + } + delete nextDirectSshPaneRetryHistoryByTabId[tabId] + // Why: keep the same reference when the closing tab had no unread flag, so unrelated closes don't force full-state selector re-eval. + let nextUnreadTerminalTabs = s.unreadTerminalTabs + if (s.unreadTerminalTabs[tabId]) { + nextUnreadTerminalTabs = { ...s.unreadTerminalTabs } + delete nextUnreadTerminalTabs[tabId] + } + let nextUnreadTerminalPanes = s.unreadTerminalPanes + for (const paneKey of Object.keys(s.unreadTerminalPanes)) { + if (paneKey.startsWith(`${tabId}:`)) { + if (nextUnreadTerminalPanes === s.unreadTerminalPanes) { + nextUnreadTerminalPanes = { ...s.unreadTerminalPanes } + } + delete nextUnreadTerminalPanes[paneKey] + } + } + let nextUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes + for (const paneKey of Object.keys(s.unreadAgentCompletionPanes)) { + if (paneKey.startsWith(`${tabId}:`)) { + if (nextUnreadAgentCompletionPanes === s.unreadAgentCompletionPanes) { + nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes } + } + delete nextUnreadAgentCompletionPanes[paneKey] + } + } + const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey } + for (const paneKey of Object.keys(nextLastTerminalInputAtByPaneKey)) { + if (paneKey.startsWith(`${tabId}:`)) { + delete nextLastTerminalInputAtByPaneKey[paneKey] + } + } + const nextSleepingAgentSessionsByPaneKey = retiresSession + ? removeSleepingAgentSessionsForTab(s.sleepingAgentSessionsByPaneKey, tabId) + : s.sleepingAgentSessionsByPaneKey + const nextPendingStartupByTabId = { ...s.pendingStartupByTabId } + delete nextPendingStartupByTabId[tabId] + const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId } + delete nextAutomaticAgentResumeClaimsByTabId[tabId] + const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } + delete nextNativeChatLaunchPromptByTabId[tabId] + const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } + delete nextNativeChatLaunchDraftByTabId[tabId] + const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId } + delete nextPendingInitialCwdByTabId[tabId] + const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId } + delete nextPendingSetupSplitByTabId[tabId] + const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId } + delete nextPendingIssueCommandSplitByTabId[tabId] + const nextCacheTimer = { ...s.cacheTimerByKey } + // Why: cache timer keys are `${tabId}:${leafId}` composites; remove all entries for the closing tab. + for (const key of Object.keys(nextCacheTimer)) { + if (key.startsWith(`${tabId}:`)) { + delete nextCacheTimer[key] + } + } + // Why: keep activeTabIdByWorktree in sync when closing a background-worktree tab, else the stale remembered tab falls back to tabs[0] on switch. + const nextActiveTabIdByWorktree = { ...s.activeTabIdByWorktree } + for (const [wId, tabs] of Object.entries(next)) { + if (nextActiveTabIdByWorktree[wId] === tabId) { + nextActiveTabIdByWorktree[wId] = tabs[0]?.id ?? null + } + } + // Why: keep tabBarOrderByWorktree in sync so stale terminal IDs don't linger and shift positions on later tab operations. + const nextTabBarOrderByWorktree: Record = { + ...s.tabBarOrderByWorktree + } + for (const wId of Object.keys(nextTabBarOrderByWorktree)) { + const order = nextTabBarOrderByWorktree[wId] + if (order?.includes(tabId)) { + nextTabBarOrderByWorktree[wId] = order.filter((entryId) => entryId !== tabId) + } + } + // Why: clean up unconsumed snapshot/cold-restore data (e.g. tab closed before TerminalPane mounted) to prevent unbounded store growth across restarts. + let nextSnapshots = s.pendingSnapshotByPtyId + let nextColdRestores = s.pendingColdRestoreByPtyId + const closingPtyIds = new Set([ + ...retirementPlan.localOrSshPtyIds, + ...retirementPlan.runtimeTerminals.map((terminal) => terminal.ptyId), + ...retirementPlan.cleanupOnlyPtyIds, + ...retirementPlan.unroutablePtyIds + ]) + for (const closingId of closingPtyIds) { + if (closingId in nextSnapshots) { + nextSnapshots = { ...nextSnapshots } + delete nextSnapshots[closingId] + } + if (closingId in nextColdRestores) { + nextColdRestores = { ...nextColdRestores } + delete nextColdRestores[closingId] + } + } + return { + tabsByWorktree: next, + activeTabId: s.activeTabId === tabId ? null : s.activeTabId, + activeTabIdByWorktree: nextActiveTabIdByWorktree, + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelay, + deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId, + pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId, + runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, + directSshPaneRetryByTabId: nextDirectSshPaneRetryByTabId, + directSshLivePtyBindingByTabId: nextDirectSshLivePtyBindingByTabId, + directSshPaneRetryHistoryByTabId: nextDirectSshPaneRetryHistoryByTabId, + ...(nextSleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey + ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessionsByPaneKey } + : {}), + // Why: skip writing unreadTerminalTabs when unchanged to avoid a no-op state allocation that re-evaluates full-state selectors. Mirrors tabs.ts. + ...(nextUnreadTerminalTabs !== s.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextUnreadTerminalPanes !== s.unreadTerminalPanes + ? { unreadTerminalPanes: nextUnreadTerminalPanes } + : {}), + ...(nextUnreadAgentCompletionPanes !== s.unreadAgentCompletionPanes + ? { unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes } + : {}), + lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey, + expandedPaneByTabId: nextExpanded, + canExpandPaneByTabId: nextCanExpand, + terminalLayoutsByTabId: nextLayouts, + pendingStartupByTabId: nextPendingStartupByTabId, + automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, + pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, + pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, + pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, + cacheTimerByKey: nextCacheTimer, + tabBarOrderByWorktree: nextTabBarOrderByWorktree, + pendingSnapshotByPtyId: nextSnapshots, + pendingColdRestoreByPtyId: nextColdRestores, + ...(capturedSnapshot && closedWorktreeId + ? { + recentlyClosedTerminalTabsByWorktree: pushClosedTerminalTabSnapshot( + s.recentlyClosedTerminalTabsByWorktree, + closedWorktreeId, + capturedSnapshot + ), + recentlyClosedTabKindsByWorktree: pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + closedWorktreeId, + 'terminal' + ) + } + : {}) + } + }) + // Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry. + sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId) + for (const tabs of Object.values(get().unifiedTabsByWorktree)) { + const workspaceItem = tabs.find( + (entry) => entry.contentType === 'terminal' && entry.entityId === tabId + ) + if (workspaceItem) { + get().closeUnifiedTab(workspaceItem.id, { + recordInteraction: opts?.recordInteraction, + terminalRetirementHandled: true + }) + } + } + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts new file mode 100644 index 00000000000..5f69aa19e74 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -0,0 +1,245 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' +import { emptyLayoutSnapshot } from '../slices/terminal-helpers' +import { + buildOrphanTerminalCleanupPatch, + getOrphanTerminalIds +} from '../slices/terminal-orphan-helpers' +import { + dedupeTabOrder, + ensureGroup, + findTabByEntityInGroup, + pushRecentTabId, + sanitizeRecentTabIds, + updateGroup +} from '../slices/tab-group-state' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { + getRemoteConnectionIdForWorktree, + resolveCreatedTabShellOverride, + worktreeUsesWslPath +} from './terminal-workspace-routing' +export function getNextTerminalOrdinal(tabs: TerminalTab[]): number { + const usedOrdinals = new Set() + for (const tab of tabs) { + const match = /^Terminal (\d+)$/.exec(tab.defaultTitle ?? tab.title) + if (!match) { + continue + } + usedOrdinals.add(Number(match[1])) + } + let nextOrdinal = 1 + while (usedOrdinals.has(nextOrdinal)) { + nextOrdinal += 1 + } + return nextOrdinal +} + +export function createTerminalTabCreationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + createTab: (worktreeId, targetGroupId, shellOverride, options) => { + let tab!: TerminalTab + set((s) => { + const orphanTerminalIds = getOrphanTerminalIds(s, worktreeId) + const orphanCleanupPatch = buildOrphanTerminalCleanupPatch(s, worktreeId, orphanTerminalIds) + const existing = (s.tabsByWorktree[worktreeId] ?? []).filter( + (entry) => !orphanTerminalIds.has(entry.id) + ) + // Why: honor a caller-supplied tab id but mint on collision — aliasing two PTYs to one id corrupts agent-status routing. See docs/cli-terminal-hook-pane-key.md. + // Why: only honor a non-empty trimmed hint; useIpcEvents spreads `id` whenever tabId !== undefined, so a stray '' would break paneKey routing. + const trimmedHint = typeof options?.id === 'string' ? options.id.trim() : '' + const hintedId = + trimmedHint.length > 0 && isValidHostTerminalTabId(trimmedHint) ? trimmedHint : undefined + const idCollides = + hintedId !== undefined && + Object.values(s.tabsByWorktree).some((tabs) => + tabs.some((entry) => entry.id === hintedId) + ) + if (idCollides) { + console.warn( + `[createTab] tabId hint ${hintedId} already exists; minting a fresh id (hook attribution will degrade for this terminal)` + ) + } + const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid() + const shouldActivate = options?.activate !== false + const nextOrdinal = getNextTerminalOrdinal(existing) + const defaultTitle = `Terminal ${nextOrdinal}` + const quickCommandLabel = options?.quickCommandLabel?.trim() + const startupCwd = options?.startupCwd + const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId) + const isRemoteWorktree = Boolean(remoteConnectionId) + const isWslWorktree = worktreeUsesWslPath(s, worktreeId) + const createdShellOverride = resolveCreatedTabShellOverride( + shellOverride, + s.settings?.terminalWindowsShell, + // Why: SSH PTYs ignore local Windows shell selection; a local shell icon would mislabel a remote terminal. + isRemoteWorktree, + remoteConnectionId + ? ((s.sshConnectionStates.get(remoteConnectionId) + ?.remotePlatform as NodeJS.Platform | null) ?? null) + : null, + // Why: new terminals enter the worktree's repo-scoped WSL distro even when the global Windows shell is PowerShell/cmd.exe. + isWslWorktree, + isRemoteWorktree || options?.forceHostRuntime + ? undefined + : getLocalProjectExecutionRuntimeContext(s, worktreeId) + ) + tab = { + id, + // Why: CLI-created background sessions already own a PTY, so reveal attaches instead of spawning a duplicate. + ptyId: options?.initialPtyId ?? null, + worktreeId, + // Why: reuse the lowest free ordinal so a fresh terminal stays "Terminal 1" after older tabs close, not a monotonic counter. + title: defaultTitle, + defaultTitle, + ...(quickCommandLabel ? { quickCommandLabel } : {}), + customTitle: null, + color: null, + sortOrder: existing.length, + createdAt: Date.now(), + ...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}), + ...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}), + ...(options?.forceHostRuntime ? { forceHostRuntime: true } : {}), + ...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}), + // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. + ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) + } + const validTargetGroupId = + targetGroupId && + s.groupsByWorktree[worktreeId]?.some((group) => group.id === targetGroupId) + ? targetGroupId + : undefined + const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( + s.groupsByWorktree, + s.activeGroupIdByWorktree, + worktreeId, + validTargetGroupId ?? s.activeGroupIdByWorktree[worktreeId] + ) + const nextActiveGroupIdByWorktree = + shouldActivate && validTargetGroupId + ? { ...activeGroupIdByWorktree, [worktreeId]: validTargetGroupId } + : activeGroupIdByWorktree + const existingUnifiedTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] + const existingTerminalTab = findTabByEntityInGroup( + s.unifiedTabsByWorktree, + worktreeId, + group.id, + id, + 'terminal' + ) + const groupsForWorktree = groupsByWorktree[worktreeId] ?? [] + const cleanedGroups = + orphanTerminalIds.size === 0 + ? groupsForWorktree + : groupsForWorktree.map((entry) => { + // Why: repair every group before adding the new tab, or inactive/background creation can revive stale focus. + const tabOrder = dedupeTabOrder(entry.tabOrder).filter( + (tabId) => !orphanTerminalIds.has(tabId) + ) + const recentTabIds = sanitizeRecentTabIds(entry.recentTabIds, tabOrder) + const replacedActiveTabId = Boolean( + entry.activeTabId && orphanTerminalIds.has(entry.activeTabId) + ) + const fallbackActiveTabId = recentTabIds.at(-1) ?? tabOrder[0] ?? null + const activeTabId = replacedActiveTabId ? fallbackActiveTabId : entry.activeTabId + return { + ...entry, + activeTabId, + tabOrder, + recentTabIds: + replacedActiveTabId && activeTabId + ? pushRecentTabId(recentTabIds, activeTabId) + : recentTabIds + } + }) + const cleanedTargetGroup = cleanedGroups.find((entry) => entry.id === group.id) ?? group + const cleanedGroupOrder = dedupeTabOrder(cleanedTargetGroup.tabOrder).filter( + (tabId) => !orphanTerminalIds.has(tabId) + ) + const unifiedTab = existingTerminalTab ?? { + id, + entityId: id, + groupId: group.id, + worktreeId, + contentType: 'terminal' as const, + label: tab.title, + ...(tab.quickCommandLabel?.trim() + ? { quickCommandLabel: tab.quickCommandLabel.trim() } + : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder: cleanedGroupOrder.length, + createdAt: tab.createdAt, + // Why: omit for non-agent tabs so they keep the implicit 'terminal' view mode. + ...(options?.viewMode ? { viewMode: options.viewMode } : {}) + } + const nextGroupOrder = dedupeTabOrder([...cleanedGroupOrder, unifiedTab.id]) + const nextRecent = shouldActivate + ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, nextGroupOrder), unifiedTab.id) + : sanitizeRecentTabIds(cleanedTargetGroup.recentTabIds, nextGroupOrder) + const cleanedActiveTabIdForWorktree = orphanCleanupPatch.activeTabIdByWorktree[worktreeId] + const cleanedGroupActiveTabId = + cleanedTargetGroup.activeTabId && !orphanTerminalIds.has(cleanedTargetGroup.activeTabId) + ? cleanedTargetGroup.activeTabId + : null + const nextActiveTabIdForWorktree = shouldActivate + ? tab.id + : (cleanedActiveTabIdForWorktree ?? cleanedGroupActiveTabId ?? tab.id) + return { + ...orphanCleanupPatch, + tabsByWorktree: { + ...orphanCleanupPatch.tabsByWorktree, + [worktreeId]: [...existing, tab] + }, + // Why: publish the unified tab atomically with the runtime tab so a transient legacy mount can't race the split host. + unifiedTabsByWorktree: { + ...s.unifiedTabsByWorktree, + [worktreeId]: existingTerminalTab + ? existingUnifiedTabs + : [...existingUnifiedTabs, unifiedTab] + }, + groupsByWorktree: { + ...groupsByWorktree, + [worktreeId]: updateGroup(cleanedGroups, { + ...cleanedTargetGroup, + activeTabId: shouldActivate + ? unifiedTab.id + : (cleanedGroupActiveTabId ?? unifiedTab.id), + tabOrder: nextGroupOrder, + recentTabIds: nextRecent + }) + }, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + layoutByWorktree: { + ...s.layoutByWorktree, + [worktreeId]: s.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } + }, + activeTabId: shouldActivate ? tab.id : orphanCleanupPatch.activeTabId, + activeTabIdByWorktree: { + ...orphanCleanupPatch.activeTabIdByWorktree, + [worktreeId]: nextActiveTabIdForWorktree + }, + ptyIdsByTabId: { + ...orphanCleanupPatch.ptyIdsByTabId, + [tab.id]: options?.initialPtyId ? [options.initialPtyId] : [] + }, + terminalLayoutsByTabId: { + ...orphanCleanupPatch.terminalLayoutsByTabId, + [tab.id]: emptyLayoutSnapshot() + } + } + }) + const shouldRecordInteraction = + options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) + if (shouldRecordInteraction) { + get().recordFeatureInteraction?.('terminal-tabs') + } + return tab + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-navigation.ts b/src/renderer/src/store/terminals/terminal-tab-navigation.ts new file mode 100644 index 00000000000..e8f33d17e79 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-navigation.ts @@ -0,0 +1,121 @@ +import { resolveActiveTabOwnerWorktreeId } from '../slices/active-tab-owner-worktree' +import { getTerminalTabOwnerWorktreeId } from '../slices/terminal-tab-owner-index' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalTabNavigationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'reorderTabs' + | 'setTabBarOrder' + | 'setActiveTab' + | 'setActiveTabForWorktree' + | 'getTerminalTabOwnerWorktreeId' +> { + return { + reorderTabs: (worktreeId, tabIds) => { + set((s) => { + const tabs = s.tabsByWorktree[worktreeId] ?? [] + const tabMap = new Map(tabs.map((t) => [t.id, t])) + const orderedSet = new Set(tabIds) + const missingTabs = tabs.filter((t) => !orderedSet.has(t.id)) + const reordered = [ + ...tabIds.map((id) => tabMap.get(id)!).filter(Boolean), + ...missingTabs + ].map((tab, i) => ({ ...tab, sortOrder: i })) + return { + tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: reordered } + } + }) + }, + setTabBarOrder: (worktreeId, order) => { + set((s) => { + // Update unified visual order + const newTabBarOrder = { ...s.tabBarOrderByWorktree, [worktreeId]: order } + // Keep terminal tab sortOrder in sync for persistence + const tabs = s.tabsByWorktree[worktreeId] + if (!tabs) { + return { tabBarOrderByWorktree: newTabBarOrder } + } + const tabMap = new Map(tabs.map((t) => [t.id, t])) + // Extract terminal IDs in their new relative order + const terminalIdsInOrder = order.filter((id) => tabMap.has(id)) + const orderedSet = new Set(terminalIdsInOrder) + const missingTabs = tabs.filter((t) => !orderedSet.has(t.id)) + const updatedTabs = [ + ...terminalIdsInOrder.map((id) => tabMap.get(id)!).filter(Boolean), + ...missingTabs + ].map((tab, i) => ({ ...tab, sortOrder: i })) + return { + tabBarOrderByWorktree: newTabBarOrder, + tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: updatedTabs } + } + }) + }, + setActiveTab: (tabId) => { + let tabOwnerWorktreeId: string | null = null + set((s) => { + // Why: clear bells only for visible tabs; background bells must persist. + tabOwnerWorktreeId = resolveActiveTabOwnerWorktreeId( + s.tabsByWorktree, + s.activeWorktreeId, + tabId + ) + const isActiveWorktreeTab = + tabOwnerWorktreeId !== null && tabOwnerWorktreeId === s.activeWorktreeId + const nextUnreadTerminalTabs = + isActiveWorktreeTab && s.unreadTerminalTabs[tabId] + ? (() => { + const copy = { ...s.unreadTerminalTabs } + delete copy[tabId] + return copy + })() + : s.unreadTerminalTabs + // Why: activeTabId marks the visible tab, so background bells remain unread. + return { + activeTabId: isActiveWorktreeTab ? tabId : s.activeTabId, + // Why: a redundant activation must not reallocate this map — Terminal's + // active-terminal repair effect depends on it, so a re-activation that + // can't converge activeTabId (tab id reused by an earlier-scanned + // worktree) would otherwise re-trigger itself into React error #185. + activeTabIdByWorktree: + tabOwnerWorktreeId !== null && s.activeTabIdByWorktree[tabOwnerWorktreeId] !== tabId + ? { ...s.activeTabIdByWorktree, [tabOwnerWorktreeId]: tabId } + : s.activeTabIdByWorktree, + unreadTerminalTabs: nextUnreadTerminalTabs + } + }) + const state = get() + const ownerUnifiedTabs = + tabOwnerWorktreeId !== null && + Object.hasOwn(state.unifiedTabsByWorktree, tabOwnerWorktreeId) + ? state.unifiedTabsByWorktree[tabOwnerWorktreeId] + : [] + // Why: a duplicated entity id must activate the same owner chosen above. + const item = + ownerUnifiedTabs.find( + (entry) => entry.contentType === 'terminal' && entry.entityId === tabId + ) ?? + Object.values(state.unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) + if (item) { + state.activateTab( + item.id, + tabOwnerWorktreeId !== null ? { worktreeId: tabOwnerWorktreeId } : undefined + ) + } + }, + getTerminalTabOwnerWorktreeId: (tabId) => + getTerminalTabOwnerWorktreeId(get().tabsByWorktree, tabId), + setActiveTabForWorktree: (worktreeId, tabId) => { + set((s) => ({ + activeTabIdByWorktree: { + ...s.activeTabIdByWorktree, + [worktreeId]: tabId + } + })) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-presentation.ts b/src/renderer/src/store/terminals/terminal-tab-presentation.ts new file mode 100644 index 00000000000..7e1d2f64442 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-presentation.ts @@ -0,0 +1,203 @@ +import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' +import { + applyGeneratedTabTitleUpdates, + applyTerminalTabTitleUpdates +} from '../slices/terminal-tab-title-batch' +import { + adoptTerminalTabOwnerMetadataOnlyBuckets, + getTerminalTabOwnerWorktreeId +} from '../slices/terminal-tab-owner-index' +import { getTabIdFromPaneKey } from './terminal-pty-identities' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' + +export function createTerminalTabPresentationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick< + TerminalSlice, + | 'updateTabTitle' + | 'updateTabTitles' + | 'setAiVaultTabTitle' + | 'setGeneratedTabTitleFromAgentPrompt' + | 'setGeneratedTabTitlesFromAgentPrompts' + | 'clearTabLaunchAgent' + | 'setRuntimePaneTitle' + | 'clearRuntimePaneTitle' +> { + return { + updateTabTitle: (tabId, title) => { + set((state) => { + const result = applyTerminalTabTitleUpdates(state, [{ tabId, title }]) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() + } + return result.patch ?? state + }) + }, + updateTabTitles: (updates) => { + if (updates.length === 0) { + return + } + set((state) => { + const result = applyTerminalTabTitleUpdates(state, updates) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() + } + return result.patch ?? state + }) + }, + setAiVaultTabTitle: (tabId, aiVaultTitle) => { + set((s) => { + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return s + } + const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] + const current = tabs.find((tab) => tab.id === tabId) + const sameTitle = + current?.aiVaultTitle?.agent === aiVaultTitle?.agent && + current?.aiVaultTitle?.sessionId === aiVaultTitle?.sessionId && + current?.aiVaultTitle?.title === aiVaultTitle?.title + if (!current || sameTitle) { + return s + } + const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab)) + const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs } + const unifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] + const nextUnifiedTabs = unifiedTabs.map((tab) => + tab.contentType === 'terminal' && tab.entityId === tabId ? { ...tab, aiVaultTitle } : tab + ) + adoptTerminalTabOwnerMetadataOnlyBuckets(s.tabsByWorktree, nextTabsByWorktree, [ + ownerWorktreeId + ]) + scheduleRuntimeGraphSync() + return { + tabsByWorktree: nextTabsByWorktree, + unifiedTabsByWorktree: { + ...s.unifiedTabsByWorktree, + [ownerWorktreeId]: nextUnifiedTabs + } + } + }) + }, + setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => { + // Why: setAgentStatus is high-frequency; skip derive/set unless the feature is on and this tab still needs a (re)generated title. + const state = get() + const tabId = getTabIdFromPaneKey(paneKey) + if (!tabId || prompt.length === 0 || state.settings?.tabAutoGenerateTitle !== true) { + return + } + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return + } + const tabs = state.tabsByWorktree[ownerWorktreeId] ?? [] + const currentTab = tabs.find((tab) => tab.id === tabId) + if (!currentTab || currentTab.customTitle?.trim() || currentTab.quickCommandLabel?.trim()) { + return + } + const existingGeneratedTitle = currentTab.generatedTitle?.trim() + if (existingGeneratedTitle && options?.replaceExistingGeneratedTitle !== true) { + return + } + set((latestState) => { + const result = applyGeneratedTabTitleUpdates(latestState, [{ paneKey, prompt, options }]) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() + } + return result.patch ?? latestState + }) + }, + setGeneratedTabTitlesFromAgentPrompts: (updates) => { + if (updates.length === 0) { + return + } + set((state) => { + const result = applyGeneratedTabTitleUpdates(state, updates) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() + } + return result.patch ?? state + }) + }, + clearTabLaunchAgent: (tabId) => { + set((s) => { + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return s + } + const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] + const tabIndex = tabs.findIndex((t) => t.id === tabId) + const currentTab = tabs[tabIndex] + if (!currentTab?.launchAgent) { + return s + } + const { launchAgent: _launchAgent, ...tabWithoutLaunchAgent } = currentTab + void _launchAgent + const nextTabs = [...tabs] + nextTabs[tabIndex] = tabWithoutLaunchAgent + scheduleRuntimeGraphSync() + return { tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: nextTabs } } + }) + }, + setRuntimePaneTitle: (tabId, paneId, title) => { + set((s) => { + const currentByPane = s.runtimePaneTitlesByTabId[tabId] ?? {} + const prevTitle = currentByPane[paneId] + if (prevTitle === title) { + return s + } + if (prevTitle && isDecorativeAgentTitleFrameChange(prevTitle, title)) { + return s + } + // Why: re-sort hookless title changes only when their activity classification changes. + const classificationChanged = + classifyTitleActivity(prevTitle ?? '') !== classifyTitleActivity(title) + // Why: skip active-worktree remount side effects and orphaned panes. + const ownerWorktreeId = classificationChanged + ? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + : null + const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId + const shouldBump = classificationChanged && ownerWorktreeId !== null && !isActive + return { + runtimePaneTitlesByTabId: { + ...s.runtimePaneTitlesByTabId, + [tabId]: { ...currentByPane, [paneId]: title } + }, + ...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {}) + } + }) + }, + clearRuntimePaneTitle: (tabId, paneId) => { + set((s) => { + const currentByPane = s.runtimePaneTitlesByTabId[tabId] + if (!currentByPane || !(paneId in currentByPane)) { + return s + } + const prevTitle = currentByPane[paneId] + const nextByPane = { ...currentByPane } + delete nextByPane[paneId] + const next = { ...s.runtimePaneTitlesByTabId } + if (Object.keys(nextByPane).length > 0) { + next[tabId] = nextByPane + } else { + delete next[tabId] + } + // Why: clearing a classified title changes the smart-sort title-heuristic verdict, so it needs a re-sort. See setRuntimePaneTitle. + const hadClassification = classifyTitleActivity(prevTitle ?? '') !== null + // Why: same active-worktree gate as setRuntimePaneTitle — click-driven teardown clears must not re-rank the sidebar; skip when owner is missing (orphaned). + const ownerWorktreeId = hadClassification + ? getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + : null + const isActive = ownerWorktreeId !== null && ownerWorktreeId === s.activeWorktreeId + const shouldBump = hadClassification && ownerWorktreeId !== null && !isActive + return { + runtimePaneTitlesByTabId: next, + ...(shouldBump ? { sortEpoch: s.sortEpoch + 1 } : {}) + } + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-workspace-routing.ts b/src/renderer/src/store/terminals/terminal-workspace-routing.ts new file mode 100644 index 00000000000..9148b9a5f7c --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-workspace-routing.ts @@ -0,0 +1,115 @@ +import type { AppState } from '../types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { isWslUncPath } from '../../../../shared/wsl-paths' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' +import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function isWindowsRendererRuntime(): boolean { + return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') +} + +export function isAllowedRemoteWindowsTerminalShell(shell: string | undefined): boolean { + return ( + shell === 'powershell.exe' || + shell === 'pwsh.exe' || + shell === 'cmd.exe' || + shell === 'wsl.exe' || + shell === WINDOWS_GIT_BASH_SHELL + ) +} + +export function resolveCreatedTabShellOverride( + explicitShellOverride: string | undefined, + defaultWindowsShell: string | undefined, + isRemoteWorktree: boolean, + remotePlatform: NodeJS.Platform | null, + isWslWorktree: boolean, + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): string | undefined { + if (isRemoteWorktree) { + if (remotePlatform === 'win32' && isAllowedRemoteWindowsTerminalShell(explicitShellOverride)) { + return explicitShellOverride + } + return undefined + } + if (isWindowsRendererRuntime()) { + return resolveLocalWindowsTerminalShellOverrideForTab({ + explicitShellOverride, + defaultWindowsShell, + isWslWorktree, + projectRuntime + }) + } + if (explicitShellOverride !== undefined) { + return explicitShellOverride + } + return undefined +} + +export function worktreeUsesWslPath( + state: Pick, + worktreeId: string +): boolean { + const parsed = parseWorkspaceKey(worktreeId) + if (parsed?.type === 'folder') { + const folderWorkspace = state.folderWorkspaces.find( + (workspace) => workspace.id === parsed.folderWorkspaceId + ) + return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false + } + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + return worktree ? isWslUncPath(worktree.path) : false +} + +export function worktreeUsesRemoteConnection( + state: Pick, + worktreeId: string +): boolean { + const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) + if (parsedWorkspaceKey?.type === 'folder') { + return Boolean(getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId)) + } + const directRepoId = getRepoIdFromWorktreeId(worktreeId) + const directRepo = state.repos.find((repo) => repo.id === directRepoId) + if (directRepo) { + return Boolean(directRepo.connectionId) + } + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + return Boolean(repo?.connectionId) +} + +export function getRemoteConnectionIdForWorktree( + state: Pick, + worktreeId: string +): string | null { + const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) + if (parsedWorkspaceKey?.type === 'folder') { + return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) ?? null + } + const directRepoId = getRepoIdFromWorktreeId(worktreeId) + const directRepo = state.repos.find((repo) => repo.id === directRepoId) + if (directRepo) { + return directRepo.connectionId?.trim() || null + } + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + return repo?.connectionId?.trim() || null +} + +export function resolveTerminalStopRuntimeEnvironmentId( + state: Pick, + worktreeId: string +): string | null { + return getRuntimeEnvironmentIdForWorktree(state, worktreeId) +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts new file mode 100644 index 00000000000..46fb906a9b9 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -0,0 +1,222 @@ +import type { AppState } from '../types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { resolveAgentPaneAuthorityKey } from '../slices/agent-pane-authority' +import type { HydrateWorkspaceSessionOptions } from './terminal-contracts' + +export type WorkspaceHydrationPatch = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'activeWorkspaceKey' + | 'activeWorkspaceExecutionHostId' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' + | 'repos' + | 'tabsByWorktree' + | 'worktreesByRepo' + | 'lastVisitedAtByWorktreeId' + | 'defaultTerminalTabsAppliedByWorktreeId' + | 'automaticAgentResumeClaimsByTabId' + | 'sleepingAgentSessionsByPaneKey' + | 'pendingReconnectWorktreeIds' + | 'pendingReconnectTabByWorktree' + | 'pendingReconnectPtyIdByTabId' + | 'everActivatedWorktreeIds' + | 'worktreeNavHistory' + | 'worktreeNavHistoryIndex' + | 'ptyIdsByTabId' + | 'terminalLayoutsByTabId' +> + +export function replaceHydratedRecordKeys( + current: Record, + hydrated: Record, + replaceKeys: ReadonlySet +): Record { + return { + ...Object.fromEntries(Object.entries(current).filter(([key]) => !replaceKeys.has(key))), + ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => replaceKeys.has(key))) + } +} + +export type TerminalLayoutPtyOwnershipTransfer = { + removedLeafId: string + retainedLeafId: string + ptyId: string +} + +export function transferNormalizedTerminalLayoutPtyOwnership( + state: Pick, + tabId: string, + transfers: readonly TerminalLayoutPtyOwnershipTransfer[] +): void { + if (transfers.length === 0) { + return + } + const ptyIdsOwnedByOtherTabs = new Set() + for (const [candidateTabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { + if (candidateTabId === tabId) { + continue + } + for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { + ptyIdsOwnedByOtherTabs.add(ptyId) + } + } + for (const { removedLeafId, retainedLeafId, ptyId } of transfers) { + if ( + !isTerminalLeafId(removedLeafId) || + !isTerminalLeafId(retainedLeafId) || + ptyIdsOwnedByOtherTabs.has(ptyId) + ) { + continue + } + const fromPaneKey = makePaneKey(tabId, removedLeafId) + const currentOwner = parsePaneKey(resolveAgentPaneAuthorityKey(fromPaneKey)) + if (currentOwner && currentOwner.tabId !== tabId) { + continue + } + state.transferAgentPaneAuthority({ + fromPaneKey, + toPaneKey: makePaneKey(tabId, retainedLeafId), + ptyId + }) + } +} + +export function targetScopedWorkspaceHydrationPatch( + state: AppState, + hydrated: WorkspaceHydrationPatch, + session: WorkspaceSessionState, + options: HydrateWorkspaceSessionOptions +): Partial { + const workspaceKeys = new Set(options.replaceWorkspaceKeys) + const targetTabIds = new Set( + [...workspaceKeys].flatMap((workspaceKey) => [ + ...(state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id), + ...(session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ]) + ) + const retainedTargetTabIds = new Set( + [...workspaceKeys].flatMap((workspaceKey) => + (hydrated.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + const deletedTargetTabIds = new Set( + [...workspaceKeys] + .flatMap((workspaceKey) => (state.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)) + .filter((tabId) => !retainedTargetTabIds.has(tabId)) + ) + const pendingReconnectPtyIdByTabId = replaceHydratedRecordKeys( + state.pendingReconnectPtyIdByTabId, + {}, + targetTabIds + ) + const authority = options.directSshAuthority + if (authority) { + for (const workspaceKey of hydrated.pendingReconnectWorktreeIds) { + if (!workspaceKeys.has(workspaceKey)) { + continue + } + for (const tab of session.tabsByWorktree[workspaceKey] ?? []) { + // Why: rows hydration dropped (invalid id, canonical duplicate) would leak reconnect keys nothing owns. + if (!retainedTargetTabIds.has(tab.id)) { + continue + } + const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId + if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) { + pendingReconnectPtyIdByTabId[tab.id] = ptyId + } + } + } + } + const activeOutsideScope = + state.activeWorktreeId != null && !workspaceKeys.has(state.activeWorktreeId) + const sleepingAgentSessionsByPaneKey = Object.fromEntries([ + ...Object.entries(state.sleepingAgentSessionsByPaneKey).filter( + ([, record]) => !workspaceKeys.has(record.worktreeId) + ), + ...Object.entries(hydrated.sleepingAgentSessionsByPaneKey).filter(([, record]) => + workspaceKeys.has(record.worktreeId) + ) + ]) + const everActivatedWorktreeIds = new Set(state.everActivatedWorktreeIds) + for (const workspaceKey of hydrated.everActivatedWorktreeIds) { + if (workspaceKeys.has(workspaceKey)) { + everActivatedWorktreeIds.add(workspaceKey) + } + } + return { + activeRepoId: activeOutsideScope ? state.activeRepoId : hydrated.activeRepoId, + activeWorktreeId: activeOutsideScope ? state.activeWorktreeId : hydrated.activeWorktreeId, + activeWorkspaceKey: activeOutsideScope ? state.activeWorkspaceKey : hydrated.activeWorkspaceKey, + activeWorkspaceExecutionHostId: activeOutsideScope + ? state.activeWorkspaceExecutionHostId + : hydrated.activeWorkspaceExecutionHostId, + activeTabId: activeOutsideScope ? state.activeTabId : hydrated.activeTabId, + activeTabIdByWorktree: replaceHydratedRecordKeys( + state.activeTabIdByWorktree, + hydrated.activeTabIdByWorktree, + workspaceKeys + ), + tabsByWorktree: replaceHydratedRecordKeys( + state.tabsByWorktree, + hydrated.tabsByWorktree, + workspaceKeys + ), + lastVisitedAtByWorktreeId: replaceHydratedRecordKeys( + state.lastVisitedAtByWorktreeId, + hydrated.lastVisitedAtByWorktreeId, + workspaceKeys + ), + defaultTerminalTabsAppliedByWorktreeId: replaceHydratedRecordKeys( + state.defaultTerminalTabsAppliedByWorktreeId, + hydrated.defaultTerminalTabsAppliedByWorktreeId, + workspaceKeys + ), + automaticAgentResumeClaimsByTabId: replaceHydratedRecordKeys( + state.automaticAgentResumeClaimsByTabId, + hydrated.automaticAgentResumeClaimsByTabId, + targetTabIds + ), + sleepingAgentSessionsByPaneKey, + pendingReconnectWorktreeIds: [ + ...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)), + ...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key)) + ], + pendingReconnectTabByWorktree: replaceHydratedRecordKeys( + state.pendingReconnectTabByWorktree, + hydrated.pendingReconnectTabByWorktree, + workspaceKeys + ), + pendingReconnectPtyIdByTabId, + everActivatedWorktreeIds, + directSshPaneRetryByTabId: replaceHydratedRecordKeys( + state.directSshPaneRetryByTabId, + {}, + deletedTargetTabIds + ), + directSshLivePtyBindingByTabId: replaceHydratedRecordKeys( + state.directSshLivePtyBindingByTabId, + {}, + deletedTargetTabIds + ), + directSshPaneRetryHistoryByTabId: replaceHydratedRecordKeys( + state.directSshPaneRetryHistoryByTabId, + {}, + deletedTargetTabIds + ), + ptyIdsByTabId: replaceHydratedRecordKeys( + state.ptyIdsByTabId, + hydrated.ptyIdsByTabId, + targetTabIds + ), + terminalLayoutsByTabId: replaceHydratedRecordKeys( + state.terminalLayoutsByTabId, + hydrated.terminalLayoutsByTabId, + targetTabIds + ) + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts new file mode 100644 index 00000000000..6d6169a22f4 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -0,0 +1,209 @@ +import type { WorkspaceKey } from '../../../../shared/folder-workspace-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + folderWorkspaceKey, + parseWorkspaceKey, + worktreeWorkspaceKey +} from '../../../../shared/workspace-scope' +import { buildByIdIndex } from '../slices/worktree-by-id-index' +import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys' +import { + buildValidWorktreeIdsForSessionHydration, + collectPersistedWorktreeIdsForSessionHydration +} from '../slices/degraded-repo-worktree-validity' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { buildRuntimeSessionPlaceholders } from './workspace-terminal-placeholders' +import { + targetScopedWorkspaceHydrationPatch, + transferNormalizedTerminalLayoutPtyOwnership, + type TerminalLayoutPtyOwnershipTransfer, + type WorkspaceHydrationPatch +} from './workspace-terminal-hydration-patch' +import { buildWorkspaceTerminalRowPlan } from './workspace-terminal-row-plan' +import { buildWorkspaceTerminalReconnectPlan } from './workspace-terminal-reconnect-plan' +import { buildWorkspaceTerminalLayoutPlan } from './workspace-terminal-layout-plan' +import { addHydratedSshWorktreePlaceholders } from './workspace-terminal-ssh-placeholders' + +export function createWorkspaceTerminalHydrationActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + hydrateWorkspaceSession: (session, options) => { + const ownershipTransferTabIds = options?.replaceWorkspaceKeys + ? new Set( + options.replaceWorkspaceKeys.flatMap((workspaceKey) => + (session.tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + : null + const ownershipTransfersByTabId = new Map() + set((s) => { + const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({ + repos: s.repos, + runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {}, + worktreesByRepo: s.worktreesByRepo + }) + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( + { + repos: runtimeSessionPlaceholders.repos, + worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo, + detectedWorktreesByRepo: s.detectedWorktreesByRepo + }, + collectPersistedWorktreeIdsForSessionHydration(session) + ) + const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id)) + // Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart. + validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of s.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } + addAdditionalValidWorkspaceKeys(validWorktreeIds, options) + const { + canonicalTabIdBySubsumedTabId, + reconnectPtyIdByRetainedTabId, + releasedPtyIdsByTabId, + sleepingAgentSessionsByPaneKey, + tabsByWorktree, + validTabIds + } = buildWorkspaceTerminalRowPlan(session, validWorktreeIds, options) + const fallbackActiveWorktreeId = + !session.activeWorktreeId && + session.activeRepoId && + knownRepoIds.has(session.activeRepoId) + ? (runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.find( + (worktree) => worktree.isMainWorktree + )?.id ?? + runtimeSessionPlaceholders.worktreesByRepo[session.activeRepoId]?.[0]?.id ?? + null) + : null + const activeWorktreeId = (() => { + if (session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)) { + return session.activeWorktreeId + } + // Why: a workspace with no tabs is still valid; fall back from the active repo to avoid a blank landing screen when tabs were pruned or never created. + return fallbackActiveWorktreeId + })() + const activeWorkspaceKey: WorkspaceKey | null = + session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey) + ? session.activeWorkspaceKey + : activeWorktreeId + ? parseWorkspaceKey(activeWorktreeId) + ? (activeWorktreeId as WorkspaceKey) + : worktreeWorkspaceKey(activeWorktreeId) + : null + const activeWorkspaceExecutionHostId = + activeWorkspaceKey && session.activeWorkspaceExecutionHostId + ? session.activeWorkspaceExecutionHostId + : null + // Why: follow a subsumed row to the canonical twin that inherited its PTY, else the app + // restarts with no active terminal even though the same session is still mounted. + const restoredActiveTabId = session.activeTabId + ? (canonicalTabIdBySubsumedTabId.get(session.activeTabId) ?? session.activeTabId) + : null + const activeTabId = + restoredActiveTabId && validTabIds.has(restoredActiveTabId) ? restoredActiveTabId : null + const activeRepoId = + session.activeRepoId && + runtimeSessionPlaceholders.repos.some((repo) => repo.id === session.activeRepoId) + ? session.activeRepoId + : null + const { + pendingReconnectPtyIdByTabId, + pendingReconnectTabByWorktree, + pendingReconnectWorktreeIds + } = buildWorkspaceTerminalReconnectPlan({ + reconnectPtyIdByRetainedTabId, + releasedPtyIdsByTabId, + repos: runtimeSessionPlaceholders.repos, + session, + validTabIds, + validWorktreeIds, + worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo + }) + // Restore per-worktree active tab; validate ids when the map exists, else derive for legacy sessions. + let activeTabIdByWorktree: Record = {} + if (session.activeTabIdByWorktree) { + for (const [wId, tabId] of Object.entries(session.activeTabIdByWorktree)) { + if (!validWorktreeIds.has(wId) || !tabId) { + continue + } + // Why: a subsumed row's canonical twin holds the same PTY, so follow the pointer there + // instead of forgetting which terminal the workspace last focused. + const restored = validTabIds.has(tabId) + ? tabId + : canonicalTabIdBySubsumedTabId.get(tabId) + if (restored && validTabIds.has(restored)) { + activeTabIdByWorktree[wId] = restored + } + } + } else { + // Legacy sessions: best-effort derivation + if (activeWorktreeId && activeTabId) { + activeTabIdByWorktree[activeWorktreeId] = activeTabId + } + for (const [wId, tabs] of Object.entries(tabsByWorktree)) { + if (!activeTabIdByWorktree[wId] && tabs.length > 0) { + activeTabIdByWorktree[wId] = tabs[0].id + } + } + } + const worktreesByRepo = addHydratedSshWorktreePlaceholders( + runtimeSessionPlaceholders.repos, + runtimeSessionPlaceholders.worktreesByRepo, + tabsByWorktree + ) + // Why: record restored active worktrees to avoid suppressing later real activity. + const nextEverActivated = new Set(s.everActivatedWorktreeIds) + if (activeWorktreeId) { + nextEverActivated.add(activeWorktreeId) + } + // Why indexed: the layout map below looks up a tab per persisted layout, and + // re-flattening tabsByWorktree per entry is O(tabs x layouts). + const allTabs = Object.values(tabsByWorktree).flat() + const tabById = buildByIdIndex(allTabs) + const hydrated: WorkspaceHydrationPatch = { + activeRepoId, + activeWorktreeId, + activeWorkspaceKey, + activeWorkspaceExecutionHostId, + activeTabId, + activeTabIdByWorktree, + restoredRuntimeHostIdByWorkspaceSessionKey: + options?.runtimeHostIdByWorkspaceSessionKey ?? {}, + repos: runtimeSessionPlaceholders.repos, + tabsByWorktree, + worktreesByRepo, + // Why: restore the focus-recency map; pruning is deferred to App.tsx (post-hydration) because SSH worktrees may still be appearing in worktreesByRepo. + lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {}, + defaultTerminalTabsAppliedByWorktreeId: + session.defaultTerminalTabsAppliedByWorktreeId ?? {}, + automaticAgentResumeClaimsByTabId: {}, + sleepingAgentSessionsByPaneKey, + pendingReconnectWorktreeIds, + pendingReconnectTabByWorktree, + pendingReconnectPtyIdByTabId, + everActivatedWorktreeIds: nextEverActivated, + // Why: seed hydrated active worktrees so the first activation has a Back target. + worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [], + worktreeNavHistoryIndex: activeWorktreeId ? 0 : -1, + ptyIdsByTabId: Object.fromEntries(allTabs.map((tab) => [tab.id, []] as const)), + terminalLayoutsByTabId: buildWorkspaceTerminalLayoutPlan({ + ownershipTransfersByTabId, + ownershipTransferTabIds, + releasedPtyIdsByTabId, + session, + tabById, + validTabIds + }) + } + return options?.replaceWorkspaceKeys + ? targetScopedWorkspaceHydrationPatch(s, hydrated, session, options) + : hydrated + }) + for (const [tabId, transfers] of ownershipTransfersByTabId) { + transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, transfers) + } + } + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts new file mode 100644 index 00000000000..a97af259580 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-layout-plan.ts @@ -0,0 +1,58 @@ +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { releaseTerminalLayoutPtyIds } from '../slices/terminal-session-row-hydration' +import { + normalizeTerminalLayoutSnapshot, + resolvePtyBoundActiveLeafId +} from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { resolveTerminalLayoutPtyOwnershipTransfers } from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' +import type { TerminalLayoutPtyOwnershipTransfer } from './workspace-terminal-hydration-patch' + +export function buildWorkspaceTerminalLayoutPlan({ + ownershipTransfersByTabId, + ownershipTransferTabIds, + releasedPtyIdsByTabId, + session, + tabById, + validTabIds +}: { + ownershipTransfersByTabId: Map + ownershipTransferTabIds: ReadonlySet | null + releasedPtyIdsByTabId: ReadonlyMap> + session: WorkspaceSessionState + tabById: ReadonlyMap + validTabIds: ReadonlySet +}): Record { + return Object.fromEntries( + Object.entries(session.terminalLayoutsByTabId) + .filter(([tabId]) => validTabIds.has(tabId)) + .map(([tabId, persisted]) => { + const releasedPtyIds = releasedPtyIdsByTabId.get(tabId) + const layout = releasedPtyIds + ? releaseTerminalLayoutPtyIds(persisted, releasedPtyIds) + : persisted + const normalization = normalizeTerminalLayoutSnapshot(layout) + const normalized = normalization.snapshot + if ( + normalization.changed && + (!ownershipTransferTabIds || ownershipTransferTabIds.has(tabId)) + ) { + ownershipTransfersByTabId.set( + tabId, + resolveTerminalLayoutPtyOwnershipTransfers(layout, normalized) + ) + } + const tab = tabById.get(tabId) + const sanitized = tab ? sanitizeTerminalLayoutPaneTitles(normalized, tab) : normalized + const activeLeafId = sanitized.root + ? resolvePtyBoundActiveLeafId({ + root: sanitized.root, + activeLeafId: sanitized.activeLeafId, + ptyIdsByLeafId: sanitized.ptyIdsByLeafId + }) + : sanitized.activeLeafId + return [tabId, { ...sanitized, activeLeafId }] + }) + ) +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts b/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts new file mode 100644 index 00000000000..e83adf8b937 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts @@ -0,0 +1,91 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { DEFAULT_REPO_BADGE_COLOR } from '../../../../shared/constants' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' + +export function getPathDisplayName(path: string, fallback: string): string { + const normalized = path.trim().replace(/[\\/]+$/g, '') + const basename = normalized.split(/[\\/]/).findLast(Boolean)?.trim() + return basename || fallback +} + +export function buildRuntimeSessionPlaceholders({ + repos, + runtimeHostIdByWorkspaceSessionKey, + worktreesByRepo +}: { + repos: readonly Repo[] + runtimeHostIdByWorkspaceSessionKey: Record + worktreesByRepo: Record +}): { + repos: Repo[] + worktreesByRepo: Record +} { + let nextRepos = repos.slice() + let nextWorktreesByRepo = worktreesByRepo + for (const workspaceSessionKey of Object.keys(runtimeHostIdByWorkspaceSessionKey)) { + const hostId = runtimeHostIdByWorkspaceSessionKey[workspaceSessionKey] + if (parseExecutionHostId(hostId)?.kind !== 'runtime') { + continue + } + const workspaceScope = parseWorkspaceKey(workspaceSessionKey) + if (workspaceScope?.type === 'folder') { + continue + } + const worktreeId = + workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : workspaceSessionKey + // Why: strip the synthetic `::workspace:` suffix so path is the real folder — Git callers must not spawn against a nonexistent cwd. + const parsed = splitWorktreeIdForFilesystem(worktreeId) + if (!parsed) { + continue + } + const existingRepo = nextRepos.some((repo) => repo.id === parsed.repoId) + if (!existingRepo) { + // Why: remote catalogs load after hydration but host-split session writes need owner metadata; skip if the repo id already exists to avoid duplicates. + nextRepos = [ + ...nextRepos, + { + id: parsed.repoId, + path: parsed.worktreePath, + displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), + badgeColor: DEFAULT_REPO_BADGE_COLOR, + addedAt: 0, + connectionId: null, + executionHostId: hostId + } + ] + } + const current = nextWorktreesByRepo[parsed.repoId] ?? [] + if (current.some((worktree) => worktree.id === worktreeId)) { + continue + } + const placeholder: Worktree = { + id: worktreeId, + repoId: parsed.repoId, + hostId, + displayName: getPathDisplayName(parsed.worktreePath, parsed.repoId), + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + path: parsed.worktreePath, + head: '', + branch: '', + isBare: false, + isMainWorktree: false + } + nextWorktreesByRepo = + nextWorktreesByRepo === worktreesByRepo ? { ...worktreesByRepo } : nextWorktreesByRepo + nextWorktreesByRepo[parsed.repoId] = [...current, placeholder] + } + return { repos: nextRepos, worktreesByRepo: nextWorktreesByRepo } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts new file mode 100644 index 00000000000..86a7d9b6b31 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts @@ -0,0 +1,88 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index' + +export type WorkspaceTerminalReconnectPlan = { + pendingReconnectPtyIdByTabId: Record + pendingReconnectTabByWorktree: Record + pendingReconnectWorktreeIds: string[] +} + +export function buildWorkspaceTerminalReconnectPlan({ + reconnectPtyIdByRetainedTabId, + releasedPtyIdsByTabId, + repos, + session, + validTabIds, + validWorktreeIds, + worktreesByRepo +}: { + reconnectPtyIdByRetainedTabId: ReadonlyMap + releasedPtyIdsByTabId: ReadonlyMap> + repos: readonly Repo[] + session: WorkspaceSessionState + validTabIds: ReadonlySet + validWorktreeIds: ReadonlySet + worktreesByRepo: Record +}): WorkspaceTerminalReconnectPlan { + // The shutdown list is authoritative when present; PTY ids are wake hints, not activity state. + const shutdownIds = + session.activeWorktreeIdsOnShutdown ?? + Object.entries(session.tabsByWorktree) + .filter(([, tabs]) => tabs.some((tab) => tab.ptyId)) + .map(([worktreeId]) => worktreeId) + const pendingReconnectWorktreeIds = shutdownIds.filter((id) => validWorktreeIds.has(id)) + const remoteSessionIds = session.remoteSessionIdsByTabId ?? {} + const pendingReconnectTabByWorktree: Record = {} + for (const worktreeId of pendingReconnectWorktreeIds) { + const liveTabIds = (session.tabsByWorktree[worktreeId] ?? []) + .filter( + (tab) => + (tab.ptyId || remoteSessionIds[tab.id] || reconnectPtyIdByRetainedTabId.has(tab.id)) && + validTabIds.has(tab.id) + ) + .map((tab) => tab.id) + if (liveTabIds.length > 0) { + pendingReconnectTabByWorktree[worktreeId] = liveTabIds + } + } + + const pendingReconnectPtyIdByTabId: Record = {} + const worktreeById = buildWorktreeByIdIndex(worktreesByRepo) + const repoById = buildByIdIndex(repos) + for (const worktreeId of pendingReconnectWorktreeIds) { + const worktree = worktreeById.get(worktreeId) + const repo = worktree ? repoById.get(worktree.repoId) : null + // SSH sessions reconnect through their relay rather than the local daemon. + if (repo?.connectionId) { + continue + } + for (const tab of session.tabsByWorktree[worktreeId] ?? []) { + if ( + tab.ptyId && + validTabIds.has(tab.id) && + !releasedPtyIdsByTabId.get(tab.id)?.has(tab.ptyId) + ) { + pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId + } + } + } + for (const [tabId, sessionId] of Object.entries(remoteSessionIds)) { + if (validTabIds.has(tabId) && !releasedPtyIdsByTabId.get(tabId)?.has(sessionId)) { + pendingReconnectPtyIdByTabId[tabId] = sessionId + } + } + // Retained split rows need an owned leaf PTY anchor until their pane remounts. + for (const [tabId, ptyId] of reconnectPtyIdByRetainedTabId) { + if (validTabIds.has(tabId) && !pendingReconnectPtyIdByTabId[tabId]) { + pendingReconnectPtyIdByTabId[tabId] = ptyId + } + } + + return { + pendingReconnectPtyIdByTabId, + pendingReconnectTabByWorktree, + pendingReconnectWorktreeIds + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts new file mode 100644 index 00000000000..5b1e0d39f93 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -0,0 +1,195 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { isCurrentDirectSshAuthority } from './terminal-pty-identities' + +export function createWorkspaceTerminalReconnectActions( + set: TerminalStoreSet, + get: TerminalStoreGet +): Pick { + return { + reconnectPersistedTerminals: async (signal, options) => { + if ( + signal?.aborted || + (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) + ) { + return + } + const { + pendingReconnectWorktreeIds, + pendingReconnectTabByWorktree, + pendingReconnectPtyIdByTabId, + terminalLayoutsByTabId, + tabsByWorktree, + ptyIdsByTabId + } = get() + const scopedWorkspaceKeys = options ? new Set(options.workspaceKeys) : null + const ids = (pendingReconnectWorktreeIds ?? []).filter( + (id) => !scopedWorkspaceKeys || scopedWorkspaceKeys.has(id) + ) + if (ids.length === 0) { + if (options) { + return + } + set({ + workspaceSessionReady: true, + pendingReconnectWorktreeIds: [], + pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {} + }) + return + } + // Why: defer daemon attachment for real dimensions; eager 80×24 flushes garble output. + let reconnectedTabsByWorktree: Record | null = null + let reconnectedPtyIdsByTabId: Record | null = null + // Why indexed: the loop neither sets state nor awaits, so one index over the + // whole store snapshot serves every iteration. + const worktreeById = buildWorktreeByIdIndex(get().worktreesByRepo) + const repoById = buildByIdIndex(get().repos) + for (const worktreeId of ids) { + const tabs = tabsByWorktree[worktreeId] ?? [] + const worktree = worktreeById.get(worktreeId) + const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null + // Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail. + const sshTargetId = options?.directSshAuthority.targetId ?? repo?.connectionId ?? null + const sshState = sshTargetId ? get().sshConnectionStates.get(sshTargetId) : null + const sshConnected = sshTargetId != null && sshState?.status === 'connected' + const supportsDeferredReattach = options + ? sshConnected + : !repo?.connectionId || sshConnected + console.debug( + `[reconnect-terminals] worktree=${worktreeId} connectionId=${repo?.connectionId} sshStatus=${sshState?.status} supportsDeferredReattach=${supportsDeferredReattach}` + ) + const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? [] + const tabsToReconnect: TerminalTab[] = + targetTabIds.length > 0 + ? targetTabIds + .map((id) => tabs.find((t) => t.id === id)) + .filter((t): t is TerminalTab => t != null) + : tabs.slice(0, 1) + if (tabsToReconnect.length === 0) { + continue + } + for (const tab of tabsToReconnect) { + const tabId = tab.id + const layout = terminalLayoutsByTabId[tabId] + const leafPtyMap = layout?.ptyIdsByLeafId ?? {} + const pendingPtyId = pendingReconnectPtyIdByTabId[tabId] + const tabLevelPtyId = + options && + parseAppSshPtyId(pendingPtyId ?? '')?.connectionId !== + options.directSshAuthority.targetId + ? undefined + : pendingPtyId + const hasLeafMappings = Object.keys(leafPtyMap).length > 0 + // Why: publish live PTY hints before mount; pty-connection reattaches later. + console.debug( + `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` + ) + // Why: populate ptyIdsByTabId so the sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. + // A row whose tab.ptyId went to the canonical row has no tab-level id left, but its own leaf PTYs still need advertising. + const allPtyIds = hasLeafMappings + ? (Object.values(leafPtyMap).filter(Boolean) as string[]) + : tabLevelPtyId + ? [tabLevelPtyId] + : [] + if (allPtyIds.length > 0) { + // Why: hide-sleeping reads ptyIdsByTabId for liveness; restored daemon sessions run before their pane remounts, so advertise them. + reconnectedPtyIdsByTabId ??= { ...ptyIdsByTabId } + reconnectedPtyIdsByTabId[tabId] = allPtyIds + } + if (tabLevelPtyId) { + reconnectedTabsByWorktree ??= { ...tabsByWorktree } + const nextTabs = reconnectedTabsByWorktree[worktreeId] + if (!nextTabs) { + continue + } + reconnectedTabsByWorktree[worktreeId] = nextTabs.map((t) => + t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t + ) + } + } + } + // Why: keep deferred SSH session IDs for post-cleanup reconnect. + const scopedTabIds = new Set( + [...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) => + (tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) + ) + ) + const deferredSshSessionIdsByTabId: Record = options + ? Object.fromEntries( + Object.entries(get().deferredSshSessionIdsByTabId).filter( + ([tabId]) => !scopedTabIds.has(tabId) + ) + ) + : {} + for (const worktreeId of ids) { + const worktree = worktreeById.get(worktreeId) + // Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map. + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) + const repo = repoId ? (repoById.get(repoId) ?? null) : null + const connectionId = options?.directSshAuthority.targetId ?? repo?.connectionId + if (!connectionId) { + continue + } + // Why: a repo can outlive its SSH target when the target was removed out of + // band (a crash between removal and cleanup, or edited out of the config). + // Once the authoritative target list has loaded, don't re-defer sessions for + // a target it no longer lists — a stranded deferred id reads as liveness and + // the orphan sweep could never remove the dead tab. Defer while the list is + // still unknown so a normal cold-start reconnect isn't dropped (#9911). + if (get().sshTargetsHydrated && !get().sshTargetLabels.has(connectionId)) { + continue + } + const sshConnected = get().sshConnectionStates.get(connectionId)?.status === 'connected' + if (sshConnected) { + continue + } + const tabs = tabsByWorktree[worktreeId] ?? [] + for (const tab of tabs) { + const sessionId = pendingReconnectPtyIdByTabId[tab.id] + if ( + sessionId && + (!options || parseAppSshPtyId(sessionId)?.connectionId === connectionId) + ) { + deferredSshSessionIdsByTabId[tab.id] = sessionId + } + } + } + if ( + signal?.aborted || + (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) + ) { + return + } + const remainingReconnectWorktreeIds = options + ? pendingReconnectWorktreeIds.filter((id) => !scopedWorkspaceKeys?.has(id)) + : [] + const remainingReconnectTabByWorktree = options + ? Object.fromEntries( + Object.entries(pendingReconnectTabByWorktree).filter( + ([workspaceKey]) => !scopedWorkspaceKeys?.has(workspaceKey) + ) + ) + : {} + const remainingReconnectPtyIdByTabId = options + ? Object.fromEntries( + Object.entries(pendingReconnectPtyIdByTabId).filter( + ([tabId]) => !scopedTabIds.has(tabId) + ) + ) + : {} + set({ + ...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}), + ...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}), + ...(options ? {} : { workspaceSessionReady: true }), + pendingReconnectWorktreeIds: remainingReconnectWorktreeIds, + pendingReconnectTabByWorktree: remainingReconnectTabByWorktree, + pendingReconnectPtyIdByTabId: remainingReconnectPtyIdByTabId, + deferredSshSessionIdsByTabId + }) + } + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts new file mode 100644 index 00000000000..fc565f3a3b7 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts @@ -0,0 +1,101 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { + collectReleasedLeafIds, + hydrateWorkspaceTerminalRows +} from '../slices/terminal-session-row-hydration' +import { removeSleepingAgentSessionsForTab } from '../slices/terminal-tab-retirement' +import type { HydrateWorkspaceSessionOptions } from './terminal-contracts' + +export type WorkspaceTerminalRowPlan = { + canonicalTabIdBySubsumedTabId: Map + reconnectPtyIdByRetainedTabId: Map + releasedPtyIdsByTabId: Map> + sleepingAgentSessionsByPaneKey: Record + tabsByWorktree: Record + validTabIds: Set +} + +export function buildWorkspaceTerminalRowPlan( + session: WorkspaceSessionState, + validWorktreeIds: ReadonlySet, + options?: HydrateWorkspaceSessionOptions +): WorkspaceTerminalRowPlan { + // Remote wire rows are authoritative independently of locally persisted unified tabs. + const remoteSnapshotWorkspaceKeys = new Set( + options?.directSshAuthority ? (options.replaceWorkspaceKeys ?? []) : [] + ) + const rowHydrationByWorktree = Object.entries(session.tabsByWorktree) + .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) + .map( + ([worktreeId, tabs]) => + [ + worktreeId, + hydrateWorkspaceTerminalRows(session, worktreeId, tabs, { + rowsFromRemoteSnapshot: remoteSnapshotWorkspaceKeys.has(worktreeId) + }) + ] as const + ) + const tabsByWorktree: Record = Object.fromEntries( + rowHydrationByWorktree + .map(([worktreeId, hydration]) => [worktreeId, hydration.rows] as const) + .filter( + ([worktreeId, tabs]) => tabs.length > 0 || session.tabsByWorktree[worktreeId]?.length === 0 + ) + ) + const releasedPtyIdsByTabId = new Map>( + rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.releasedPtyIdsByTabId]) + ) + const reconnectPtyIdByRetainedTabId = new Map( + rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.reconnectPtyIdByRetainedTabId]) + ) + const canonicalTabIdBySubsumedTabId = new Map( + rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.canonicalTabIdBySubsumedTabId]) + ) + const validTabIds = new Set( + Object.values(tabsByWorktree) + .flat() + .map((tab) => tab.id) + ) + let sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter(([, record]) => + validWorktreeIds.has(record.worktreeId) + ) + ) + + // Dropped rows never reach retirement, so remove their otherwise-stranded sleeping records. + for (const [, hydration] of rowHydrationByWorktree) { + for (const tabId of [...hydration.subsumedTabIds, ...hydration.invalidTabIds]) { + sleepingAgentSessionsByPaneKey = removeSleepingAgentSessionsForTab( + sleepingAgentSessionsByPaneKey, + tabId + ) + } + } + // Released leaves transferred to canonical rows and must not retain a duplicate sleeping owner. + const releasedPaneKeys = new Set( + [...releasedPtyIdsByTabId].flatMap(([tabId, releasedPtyIds]) => + collectReleasedLeafIds(session.terminalLayoutsByTabId[tabId], releasedPtyIds) + .filter(isTerminalLeafId) + .map((leafId) => makePaneKey(tabId, leafId)) + ) + ) + if (releasedPaneKeys.size > 0) { + sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(sleepingAgentSessionsByPaneKey).filter( + ([paneKey]) => !releasedPaneKeys.has(paneKey) + ) + ) + } + + return { + canonicalTabIdBySubsumedTabId, + reconnectPtyIdByRetainedTabId, + releasedPtyIdsByTabId, + sleepingAgentSessionsByPaneKey, + tabsByWorktree, + validTabIds + } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts b/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts new file mode 100644 index 00000000000..3012b017e15 --- /dev/null +++ b/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts @@ -0,0 +1,51 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getRepoIdFromWorktreeId, + splitWorktreeIdForFilesystem +} from '../../../../shared/worktree/id' + +export function addHydratedSshWorktreePlaceholders( + repos: readonly Repo[], + sourceWorktreesByRepo: Record, + tabsByWorktree: Record +): Record { + const sshRepoIds = new Set(repos.filter((repo) => repo.connectionId).map((repo) => repo.id)) + const worktreesByRepo = { ...sourceWorktreesByRepo } + for (const worktreeId of Object.keys(tabsByWorktree)) { + const repoId = getRepoIdFromWorktreeId(worktreeId) + if (!sshRepoIds.has(repoId)) { + continue + } + if ((worktreesByRepo[repoId] ?? []).some((worktree) => worktree.id === worktreeId)) { + continue + } + // Strip synthetic folder suffixes so the placeholder path remains a valid cwd. + const path = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? '' + const displayName = path.split(/[/\\]/).pop() || path + const placeholder: Worktree = { + id: worktreeId, + repoId, + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + path, + head: '', + branch: '', + isBare: false, + isMainWorktree: false + } + worktreesByRepo[repoId] = [...(worktreesByRepo[repoId] ?? []), placeholder] + } + return worktreesByRepo +} diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index a8d38c67342..214c6017eda 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -1,7 +1,7 @@ import type { RepoSlice } from './slices/repos' import type { SparsePresetsSlice } from './slices/sparse-presets' import type { WorktreeSlice } from './slices/worktrees' -import type { TerminalSlice } from './slices/terminals' +import type { TerminalSlice } from './terminals/terminal-state' import type { TabsSlice } from './slices/tabs' import type { UISlice } from './slices/ui' import type { SettingsSlice } from './slices/settings' From 2c77f71c4f2ce2bb00a8c61cf65e7f9442213680 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:23 -0700 Subject: [PATCH 04/10] refactor(renderer): split IPC event bridges (#16185) * refactor(renderer): split IPC event bridges * test(renderer): follow extracted IPC shortcut bridge --- config/max-lines-baseline.txt | 1 - .../app-shell/use-app-session-persistence.ts | 4 +- .../ipc-events/agent-dashboard-command.ts | 30 + .../ipc-events/agent-status-bridge-types.ts | 36 + .../agent-status-event-applicator.ts | 295 ++ .../ipc-events/agent-status-ipc-bridge.ts | 284 ++ .../ipc-events/agent-status-listeners.ts | 129 + .../agent-status-pane-routing-index.ts | 153 + .../hooks/ipc-events/agent-status-routing.ts | 217 + .../ipc-events/app-lifetime-ipc-bridge.ts | 122 + ...browser-automation-bootstrap-lease.test.ts | 63 + .../browser-automation-bootstrap-lease.ts | 75 + .../ipc-events/browser-request-ipc-bridge.ts | 242 + .../ipc-events/browser-session-tab-target.ts | 25 + .../ipc-events/browser-state-ipc-bridge.ts | 83 + .../ipc-events/content-creation-ipc-bridge.ts | 133 + .../ipc-events/direct-ssh-bridge-runtime.ts | 199 + .../ipc-events/direct-ssh-state-ipc-bridge.ts | 296 ++ .../ipc-events/mobile-driver-ipc-bridge.ts | 139 + .../mobile-terminal-close-ipc-bridge.ts | 116 + .../hooks/ipc-events/new-workspace-command.ts | 36 + .../normalize-agent-status-event.ts | 25 + .../ipc-events/project-catalog-ipc-bridge.ts | 101 + .../hooks/ipc-events/rate-limit-ipc-bridge.ts | 30 + .../ipc-events/remote-workspace-ipc-bridge.ts | 50 + .../ipc-events/runtime-client-ipc-bridge.ts | 199 + ...time-environment-subscription-selection.ts | 203 + .../ipc-events/session-tab-ipc-bridge.ts | 108 + .../ipc-events/settings-sidebar-ipc-bridge.ts | 190 + .../ipc-events/tab-lifecycle-ipc-bridge.ts | 180 + .../ipc-events/terminal-command-state.ts | 132 + .../terminal-presentation-ipc-bridge.ts | 272 + .../ipc-events/terminal-request-ipc-bridge.ts | 152 + .../terminal-ui-routing-ipc-bridge.ts | 95 + .../updater-status-ipc-bridge.test.ts | 62 + .../ipc-events/updater-status-ipc-bridge.ts | 21 + .../workspace-shortcut-ipc-bridge.ts | 136 + .../ipc-events/worktree-event-runtime.ts | 165 + .../src/hooks/ipc-events/zoom-ipc-bridge.ts | 48 + .../src/hooks/remote-workspace-target-sync.ts | 1 - ...IpcEvents-agent-dashboard-shortcut.test.ts | 2 +- .../useIpcEvents-browser-navigation.test.ts | 2 +- .../src/hooks/useIpcEvents-lifecycle.test.ts | 502 ++ ...seIpcEvents-new-workspace-shortcut.test.ts | 5 +- ...ents-runtime-environment-selectors.test.ts | 4 +- .../hooks/useIpcEvents-zoom-routing.test.ts | 12 +- src/renderer/src/hooks/useIpcEvents.ts | 4455 +---------------- .../workspace-activation-path-gate.test.ts | 5 +- 48 files changed, 5372 insertions(+), 4463 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-listeners.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-routing.ts create mode 100644 src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts create mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/new-workspace-command.ts create mode 100644 src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts create mode 100644 src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts create mode 100644 src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-command-state.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts create mode 100644 src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 67f8212e06d..43aa99cf788 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -124,7 +124,6 @@ inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts inline src/renderer/src/hooks/useAutomationDispatchEvents.ts inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts -inline src/renderer/src/hooks/useIpcEvents.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index aed0685dbb7..5047feac381 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' -import { isRemoteWorkspaceSnapshotApplyInProgress } from '../hooks/useIpcEvents' +import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -76,7 +76,7 @@ export function useAppSessionPersistence(): void { useEffect(() => { return createSessionWriteSubscriber({ store: useAppStore, - shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), + shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), persist: ({ patch }) => { const state = useAppStore.getState() // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. diff --git a/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts new file mode 100644 index 00000000000..32812f74d71 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts @@ -0,0 +1,30 @@ +import type { AppState } from '../../store/types' + +export function toggleAgentDashboardFromShortcut( + state: Pick< + AppState, + | 'activeView' + | 'settings' + | 'agentDashboardDrawerOpen' + | 'setSidebarOpen' + | 'setAgentDashboardDrawerOpen' + >, + openPopout: () => void +): void { + if ( + state.activeView === 'settings' || + state.settings?.experimentalAgentDashboardPopout !== true + ) { + return + } + if (state.settings.experimentalAgentDashboardMode === 'popout') { + openPopout() + return + } + const nextOpen = !state.agentDashboardDrawerOpen + // The drawer self-closes with the sidebar: reveal only when opening, never while closing. + if (nextOpen) { + state.setSidebarOpen(true) + } + state.setAgentDashboardDrawerOpen(nextOpen) +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts b/src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts new file mode 100644 index 00000000000..c5d827eea73 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts @@ -0,0 +1,36 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { AgentStatusBatchTransaction } from '@/store/slices/agent-status' +import type { AgentStatusPaneRoutingIndex } from './agent-status-pane-routing-index' + +export type PendingAgentStatusEvent = { + data: AgentStatusIpcPayload + firstSeenAt: number + replay: boolean +} + +export type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped' + +type ProjectedAgentTabTitles = { + title: string | undefined + identityTitle: string | undefined +} + +export type AgentStatusBatchContext = { + transaction: AgentStatusBatchTransaction + routingIndex: AgentStatusPaneRoutingIndex + projectedTitlesByTabId: Map + tabTitlesByTabId: Map + notificationEffects: (() => void)[] +} + +export type AgentStatusBatchEvent = { + data: AgentStatusIpcPayload + replay?: boolean + retry?: boolean +} + +export type AgentStatusApplyOptions = { + replay?: boolean + retry?: boolean + batch?: AgentStatusBatchContext +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts new file mode 100644 index 00000000000..4e24bf97168 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -0,0 +1,295 @@ +import { isWslHookRelayConnectionId } from '../../../../shared/wsl-hook-relay-contract' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../../../shared/agent-status-identity' +import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' +import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' +import { track } from '@/lib/telemetry' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import type { AgentStatusBatchUpdate, AgentStatusUpdate } from '@/store/slices/agent-status' +import { observeAgentHookCompletionForNotification } from '../agent-hook-completion-notifications' +import { useAppStore } from '../../store' +import { + applyResolvedAgentTerminalTitleToTab, + hasRuntimeBackedWorktreeAttribution, + isAgentStatusForRecentlyClosedTab, + resolveHookPayloadAgentType, + resolvePaneKey, + resolveWorktreeConnection, + shouldApplyResolvedAgentTerminalTitleToTab +} from './agent-status-routing' +import { + resolvePaneKeyFromRoutingIndex, + resolveWorktreeConnectionFromRoutingIndex +} from './agent-status-pane-routing-index' +import type { + AgentStatusApplyOptions, + AgentStatusApplyResult, + PendingAgentStatusEvent +} from './agent-status-bridge-types' +import { normalizeAgentStatusEvent } from './normalize-agent-status-event' + +export function createAgentStatusEventApplicator(args: { + pendingAgentStatusEvents: PendingAgentStatusEvent[] + transientClearWatermarkByConnectionId: Map + enqueuePendingAgentStatus: (data: AgentStatusIpcPayload, options?: { replay?: boolean }) => void +}): (data: AgentStatusIpcPayload, options?: AgentStatusApplyOptions) => AgentStatusApplyResult { + const { + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + enqueuePendingAgentStatus + } = args + const applyAgentStatus = ( + data: AgentStatusIpcPayload, + options?: AgentStatusApplyOptions + ): AgentStatusApplyResult => { + const store = options?.batch?.transaction.getState() ?? useAppStore.getState() + if (!store.workspaceSessionReady) { + return 'dropped' + } + if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) { + return 'dropped' + } + const paneKey = resolveAgentPaneAuthorityKey(data.paneKey) + const ownerTabId = parsePaneKey(paneKey)?.tabId ?? data.tabId + const payload = normalizeAgentStatusEvent(data) + if (!payload) { + return 'dropped' + } + let { + exists, + title, + identityTitle, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle + } = options?.batch + ? resolvePaneKeyFromRoutingIndex(options.batch.routingIndex, paneKey) + : resolvePaneKey(store, paneKey) + const projectedTitles = + titleUsesTabTitle && ownerTabId + ? options?.batch?.projectedTitlesByTabId.get(ownerTabId) + : undefined + if (projectedTitles) { + title = projectedTitles.title + identityTitle = projectedTitles.identityTitle + } + if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { + const fallbackOwnership = options?.batch + ? resolveWorktreeConnectionFromRoutingIndex(options.batch.routingIndex, data.worktreeId) + : resolveWorktreeConnection(store, data.worktreeId) + if (fallbackOwnership.worktreeExists) { + owningWorktreeId = data.worktreeId + repoConnectionId = fallbackOwnership.repoConnectionId + repoConnectionResolved = fallbackOwnership.repoConnectionResolved + exists = true + } + } + if (!exists) { + if (options?.replay === true) { + if (data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { + if (options?.retry !== true) { + enqueuePendingAgentStatus(data, { replay: true }) + } + return 'pending' + } + return 'dropped' + } + if (options?.retry !== true) { + track('agent_hook_unattributed', { reason: 'unknown_tab_id' }) + enqueuePendingAgentStatus(data) + } + return 'pending' + } + if (options?.replay !== true && options?.retry !== true) { + for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { + if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { + pendingAgentStatusEvents.splice(index, 1) + } + } + } + const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId) + ? null + : data.connectionId + const transientClearWatermark = + typeof data.connectionId === 'string' + ? transientClearWatermarkByConnectionId.get(data.connectionId) + : undefined + if (transientClearWatermark !== undefined && data.receivedAt <= transientClearWatermark) { + return 'dropped' + } + const canAcceptPendingRemoteOwnership = + ownershipConnectionId !== undefined && + ownershipConnectionId !== null && + !repoConnectionResolved && + data.worktreeId !== undefined && + data.worktreeId === owningWorktreeId + if ( + ownershipConnectionId !== undefined && + ownershipConnectionId !== repoConnectionId && + !canAcceptPendingRemoteOwnership + ) { + return 'dropped' + } + const existingStatus = store.agentStatusByPaneKey[paneKey] + if (existingStatus && data.receivedAt < existingStatus.updatedAt) { + return 'dropped' + } + if (data.providerSessionOnly) { + if (!data.providerSession || data.agentType !== 'pi') { + return 'dropped' + } + const providerSessionUpdate: AgentStatusBatchUpdate = { + kind: 'providerSession', + paneKey, + agent: 'pi', + providerSession: data.providerSession, + timing: { updatedAt: data.receivedAt }, + routing: { + tabId: ownerTabId, + worktreeId: data.worktreeId ?? owningWorktreeId, + ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) + }, + metadata: data.launchToken ? { launchToken: data.launchToken } : undefined + } + if (options?.batch) { + return options.batch.transaction.apply(providerSessionUpdate) ? 'applied' : 'dropped' + } + store.recordAgentProviderSession( + providerSessionUpdate.paneKey, + providerSessionUpdate.agent, + providerSessionUpdate.providerSession, + providerSessionUpdate.timing, + providerSessionUpdate.routing, + providerSessionUpdate.metadata + ) + return 'applied' + } + const resolvedPayload = resolveHookPayloadAgentType(payload, identityTitle ?? title) + const statusPayload = data.orchestration + ? { ...resolvedPayload, orchestration: data.orchestration } + : resolvedPayload + const statusPayloadWithTurnBoundary = data.promptInteractionKey + ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } + : statusPayload + const statusPayloadWithProvenance = + data.restoredUnconfirmed === true + ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } + : statusPayloadWithTurnBoundary + const statusPayloadWithObservation = data.observation + ? { ...statusPayloadWithProvenance, observation: data.observation } + : statusPayloadWithProvenance + const identity = resolveAgentStatusIdentity({ + existing: existingStatus + ? { + agentType: existingStatus.agentType, + state: existingStatus.state, + updatedAt: existingStatus.updatedAt, + restoredUnconfirmed: existingStatus.restoredUnconfirmed + } + : undefined, + incoming: statusPayload.agentType, + now: data.receivedAt + }) + if ( + existingStatus && + shouldSuppressInheritedTerminalStatus({ + inheritedFromActivePane: identity.inheritedFromActivePane, + incomingState: statusPayload.state + }) + ) { + return 'dropped' + } + if ( + shouldSuppressCodexAutoApprovalStatus(statusPayload, { + paneKey, + tabId: ownerTabId, + terminalHandle: data.terminalHandle, + launchToken: data.launchToken, + providerSession: data.providerSession, + existingProviderSession: existingStatus?.providerSession + }) + ) { + return 'dropped' + } + const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title) + const statusWorktreeId = data.worktreeId ?? owningWorktreeId + const update: AgentStatusUpdate = { + paneKey, + payload: statusPayloadWithObservation, + terminalTitle, + timing: { + updatedAt: data.receivedAt, + stateStartedAt: data.stateStartedAt + }, + routing: { + tabId: ownerTabId, + worktreeId: statusWorktreeId, + terminalHandle: data.terminalHandle, + ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) + }, + metadata: + data.providerSession || data.launchToken + ? { + ...(data.providerSession ? { providerSession: data.providerSession } : {}), + ...(data.launchToken ? { launchToken: data.launchToken } : {}) + } + : undefined + } + const applyPostCommitNotification = (): void => { + if (statusWorktreeId && (options?.replay !== true || resolvedPayload.state === 'working')) { + const notificationPayload = + typeof data.stateStartedAt === 'number' + ? { ...resolvedPayload, stateStartedAt: data.stateStartedAt } + : resolvedPayload + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: statusWorktreeId, + payload: notificationPayload, + ...(options?.replay === true ? { seedOnly: true } : {}) + }) + } + } + if (options?.batch) { + if (!options.batch.transaction.apply(update)) { + return 'dropped' + } + options.batch.notificationEffects.push(applyPostCommitNotification) + if ( + terminalTitle && + shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle) + ) { + const tabId = parsePaneKey(paneKey)?.tabId + if (tabId) { + options.batch.tabTitlesByTabId.set(tabId, terminalTitle) + if (titleUsesTabTitle) { + const titleChanges = !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle) + options.batch.projectedTitlesByTabId.set(tabId, { + title: titleChanges ? terminalTitle : title, + identityTitle: titleChanges ? terminalTitle : identityTitle + }) + } + } + } + } else { + store.setAgentStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, title, terminalTitle) + applyPostCommitNotification() + } + return 'applied' + } + + return applyAgentStatus +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts new file mode 100644 index 00000000000..3a99527b1d3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts @@ -0,0 +1,284 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { syncAgentHookCompletionNotificationsForStoreUpdate } from '../agent-hook-completion-notifications' +import { registerAgentStatusListeners } from './agent-status-listeners' +import { useAppStore } from '../../store' +import { + createAgentStatusPaneRoutingIndex, + resolvePaneKeyFromRoutingIndex +} from './agent-status-pane-routing-index' +import { createAgentStatusEventApplicator } from './agent-status-event-applicator' +import type { + AgentStatusApplyResult, + AgentStatusBatchContext, + AgentStatusBatchEvent, + PendingAgentStatusEvent +} from './agent-status-bridge-types' + +const PENDING_AGENT_STATUS_RETRY_MS = 100 +const PENDING_AGENT_STATUS_TTL_MS = 15_000 +const MAX_PENDING_AGENT_STATUS_EVENTS = 100 +const LIVE_AGENT_STATUS_BURST_WINDOW_MS = 33 + +export type AgentStatusIpcBridge = { + disposeAsyncState: () => void + unsubscribeStore: () => void +} + +export function registerAgentStatusIpcBridge(unsubs: (() => void)[]): AgentStatusIpcBridge { + const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] + const transientClearWatermarkByConnectionId = new Map() + let disposed = false + let pendingAgentStatusRetryTimer: ReturnType | null = null + let isFlushingAgentStatuses = false + const liveAgentStatusBurstQueue: AgentStatusIpcPayload[] = [] + let liveAgentStatusBurstTimer: ReturnType | null = null + let lastLiveAgentStatusApplyAt = 0 + function schedulePendingAgentStatusFlush(): void { + if (pendingAgentStatusRetryTimer !== null || pendingAgentStatusEvents.length === 0) { + return + } + pendingAgentStatusRetryTimer = globalThis.setTimeout(() => { + pendingAgentStatusRetryTimer = null + flushPendingAgentStatuses() + }, PENDING_AGENT_STATUS_RETRY_MS) + } + + function enqueuePendingAgentStatus( + data: AgentStatusIpcPayload, + options?: { replay?: boolean } + ): void { + pendingAgentStatusEvents.push({ + data, + firstSeenAt: Date.now(), + replay: options?.replay === true + }) + while (pendingAgentStatusEvents.length > MAX_PENDING_AGENT_STATUS_EVENTS) { + pendingAgentStatusEvents.shift() + } + schedulePendingAgentStatusFlush() + } + + function flushPendingAgentStatuses(): void { + // Why: guard re-entrancy — a subscriber firing mid-loop must not reprocess queued events the outer flush already owns. + if (isFlushingAgentStatuses) { + return + } + if (pendingAgentStatusEvents.length === 0) { + return + } + isFlushingAgentStatuses = true + try { + const now = Date.now() + const candidates = pendingAgentStatusEvents + .splice(0) + .filter((event) => now - event.firstSeenAt <= PENDING_AGENT_STATUS_TTL_MS) + let results: AgentStatusApplyResult[] + try { + results = applyAgentStatusBatch( + candidates.map((event) => ({ data: event.data, replay: event.replay, retry: true })) + ) + } catch (err) { + // Why: the queue was already spliced, so a throwing fold would drop the whole + // burst and strand every pane in it. Requeue ahead of newer arrivals and retry. + pendingAgentStatusEvents.unshift(...candidates) + throw err + } + for (let index = 0; index < candidates.length; index += 1) { + if (results[index] === 'pending') { + pendingAgentStatusEvents.push(candidates[index]) + } + } + if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) { + globalThis.clearTimeout(pendingAgentStatusRetryTimer) + pendingAgentStatusRetryTimer = null + } + } finally { + isFlushingAgentStatuses = false + } + schedulePendingAgentStatusFlush() + } + + const applyAgentStatus = createAgentStatusEventApplicator({ + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + enqueuePendingAgentStatus + }) + let snapshotRequestedForReadyWindow = false + let snapshotRequestId = 0 + const requestAgentStatusSnapshotIfReady = (): void => { + const store = useAppStore.getState() + if (!store.workspaceSessionReady) { + snapshotRequestedForReadyWindow = false + return + } + if (snapshotRequestedForReadyWindow) { + return + } + const getSnapshot = window.api.agentStatus.getSnapshot + if (typeof getSnapshot !== 'function') { + return + } + snapshotRequestedForReadyWindow = true + const requestId = ++snapshotRequestId + void getSnapshot() + .then((entries) => { + if (disposed || requestId !== snapshotRequestId) { + return + } + const current = useAppStore.getState() + if (!current.workspaceSessionReady) { + return + } + applyAgentStatusBatch(entries.map((data) => ({ data, replay: true }))) + const getMigrationUnsupportedSnapshot = + window.api.agentStatus.getMigrationUnsupportedSnapshot + if (typeof getMigrationUnsupportedSnapshot !== 'function') { + return + } + void getMigrationUnsupportedSnapshot().then((unsupportedEntries) => { + if (disposed || requestId !== snapshotRequestId) { + return + } + const unsupportedStore = useAppStore.getState() + if (!unsupportedStore.workspaceSessionReady) { + return + } + const unsupportedRoutingIndex = createAgentStatusPaneRoutingIndex(unsupportedStore) + for (const entry of unsupportedEntries) { + if ( + entry.paneKey && + resolvePaneKeyFromRoutingIndex(unsupportedRoutingIndex, entry.paneKey).exists + ) { + unsupportedStore.setMigrationUnsupportedPty(entry) + } + } + }) + }) + .catch((err) => { + // Why: stay latched on failure; the store subscriber fires on every update, so resetting here would turn a persistent IPC failure into a retry storm (flag clears on workspaceSessionReady toggle). + console.warn('[agent-status] failed to load startup snapshot:', err) + }) + } + + function applyAgentStatusBatch( + events: readonly AgentStatusBatchEvent[] + ): AgentStatusApplyResult[] { + if (events.length === 0) { + return [] + } + return useAppStore.getState().transactAgentStatuses((transaction) => { + const batch: AgentStatusBatchContext = { + transaction, + routingIndex: createAgentStatusPaneRoutingIndex(transaction.getState()), + projectedTitlesByTabId: new Map(), + tabTitlesByTabId: new Map(), + notificationEffects: [] + } + const results = events.map(({ data, replay, retry }) => + applyAgentStatus(data, { batch, replay, retry }) + ) + if (batch.tabTitlesByTabId.size > 0) { + transaction.afterCommit(() => { + useAppStore + .getState() + .updateTabTitles( + [...batch.tabTitlesByTabId].map(([tabId, title]) => ({ tabId, title })) + ) + }) + } + for (const effect of batch.notificationEffects) { + transaction.afterCommit(effect) + } + return results + }) + } + + function applyLiveAgentStatusBatch(batch: readonly AgentStatusIpcPayload[]): boolean { + return applyAgentStatusBatch(batch.map((data) => ({ data }))).some( + (result) => result === 'applied' + ) + } + + function flushLiveAgentStatusBurst(): void { + liveAgentStatusBurstTimer = null + lastLiveAgentStatusApplyAt = Date.now() + // Why: splice before publishing — synchronous Zustand subscribers can enqueue the next burst. + const batch = liveAgentStatusBurstQueue.splice(0) + if (!applyLiveAgentStatusBatch(batch)) { + lastLiveAgentStatusApplyAt = 0 + } + } + + function drainQueuedLiveAgentStatusesForPane(paneKey: string): void { + const queuedForPane: AgentStatusIpcPayload[] = [] + const remaining: AgentStatusIpcPayload[] = [] + for (const queued of liveAgentStatusBurstQueue) { + if (queued.paneKey === paneKey) { + queuedForPane.push(queued) + } else { + remaining.push(queued) + } + } + liveAgentStatusBurstQueue.length = 0 + liveAgentStatusBurstQueue.push(...remaining) + applyLiveAgentStatusBatch(queuedForPane) + } + + function enqueueLiveAgentStatus(data: AgentStatusIpcPayload): void { + const now = Date.now() + if ( + liveAgentStatusBurstTimer === null && + now - lastLiveAgentStatusApplyAt >= LIVE_AGENT_STATUS_BURST_WINDOW_MS + ) { + lastLiveAgentStatusApplyAt = now + // Why: only an applied event commits state and costs a render pass — + // a dropped/pending leading edge must not make its successor pay + // burst latency (startup replay and unmounted panes stay immediate). + if (applyAgentStatus(data) !== 'applied') { + lastLiveAgentStatusApplyAt = 0 + } + return + } + liveAgentStatusBurstQueue.push(data) + if (liveAgentStatusBurstTimer === null) { + liveAgentStatusBurstTimer = globalThis.setTimeout( + flushLiveAgentStatusBurst, + LIVE_AGENT_STATUS_BURST_WINDOW_MS + ) + } + } + + registerAgentStatusListeners({ + unsubs, + enqueueLiveAgentStatus, + drainQueuedLiveAgentStatusesForPane, + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + liveAgentStatusBurstQueue + }) + + // Why: main hook server is the durable source of truth; pull the snapshot only after tabs are ready so early startup pushes can be ignored, not buffered. + requestAgentStatusSnapshotIfReady() + const unsubscribeAgentStatusStore = useAppStore.subscribe((state, previousState) => { + requestAgentStatusSnapshotIfReady() + flushPendingAgentStatuses() + syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState) + }) + + return { + disposeAsyncState: () => { + disposed = true + snapshotRequestId += 1 + if (pendingAgentStatusRetryTimer !== null) { + globalThis.clearTimeout(pendingAgentStatusRetryTimer) + } + pendingAgentStatusEvents.length = 0 + if (liveAgentStatusBurstTimer !== null) { + globalThis.clearTimeout(liveAgentStatusBurstTimer) + liveAgentStatusBurstTimer = null + } + liveAgentStatusBurstQueue.length = 0 + }, + unsubscribeStore: unsubscribeAgentStatusStore + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts new file mode 100644 index 00000000000..70b13e22a41 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -0,0 +1,129 @@ +import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../../shared/agent-status-types' +import { + resolveLegacyWorkerTerminalRecoveryAction, + rollbackLegacyWorkerTerminalSurfaceInStore +} from '../legacy-worker-terminal-recovery-event' +import { useAppStore } from '../../store' +import { resolvePaneKey } from './agent-status-routing' +import type { PendingAgentStatusEvent } from './agent-status-bridge-types' + +export function registerAgentStatusListeners(args: { + unsubs: (() => void)[] + enqueueLiveAgentStatus: (data: AgentStatusIpcPayload) => void + drainQueuedLiveAgentStatusesForPane: (paneKey: string) => void + pendingAgentStatusEvents: PendingAgentStatusEvent[] + transientClearWatermarkByConnectionId: Map + liveAgentStatusBurstQueue: AgentStatusIpcPayload[] +}): void { + const { + unsubs, + enqueueLiveAgentStatus, + drainQueuedLiveAgentStatusesForPane, + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + liveAgentStatusBurstQueue + } = args + unsubs.push( + window.api.agentStatus.onSet((data) => { + enqueueLiveAgentStatus(data) + }) + ) + const unsubscribeAgentStatusClear = window.api.agentStatus.onClear?.( + (data: AgentStatusClearIpcPayload) => { + if (typeof data !== 'object' || data === null) { + return + } + if ('transient' in data && data.transient === true) { + if ( + typeof data.connectionId !== 'string' || + data.connectionId.length === 0 || + !Number.isFinite(data.clearedAt) + ) { + return + } + const previousWatermark = transientClearWatermarkByConnectionId.get(data.connectionId) ?? -1 + const effectiveWatermark = Math.max(previousWatermark, data.clearedAt) + transientClearWatermarkByConnectionId.set(data.connectionId, effectiveWatermark) + for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { + const pending = pendingAgentStatusEvents[index].data + if ( + pending.connectionId === data.connectionId && + pending.receivedAt <= effectiveWatermark + ) { + pendingAgentStatusEvents.splice(index, 1) + } + } + for (let index = liveAgentStatusBurstQueue.length - 1; index >= 0; index -= 1) { + const queued = liveAgentStatusBurstQueue[index] + if ( + queued.connectionId === data.connectionId && + queued.receivedAt <= effectiveWatermark + ) { + liveAgentStatusBurstQueue.splice(index, 1) + } + } + useAppStore.getState().clearTransientAgentStatuses(data.connectionId, effectiveWatermark) + return + } + if (!('paneKey' in data) || typeof data.paneKey !== 'string') { + return + } + // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. + if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { + drainQueuedLiveAgentStatusesForPane(data.paneKey) + } + for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { + if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { + pendingAgentStatusEvents.splice(index, 1) + } + } + const store = useAppStore.getState() + if (store.agentStatusByPaneKey[data.paneKey]?.state === 'done') { + return + } + store.removeAgentStatus(data.paneKey) + } + ) + if (unsubscribeAgentStatusClear) { + unsubs.push(unsubscribeAgentStatusClear) + } + const unsubscribeMigrationUnsupported = window.api.agentStatus.onMigrationUnsupported?.( + (entry) => { + const store = useAppStore.getState() + if (!store.workspaceSessionReady) { + return + } + if (entry.paneKey && resolvePaneKey(store, entry.paneKey).exists) { + store.setMigrationUnsupportedPty(entry) + } + } + ) + if (unsubscribeMigrationUnsupported) { + unsubs.push(unsubscribeMigrationUnsupported) + } + const unsubscribeMigrationUnsupportedClear = window.api.agentStatus.onMigrationUnsupportedClear?.( + ({ ptyId }) => { + useAppStore.getState().clearMigrationUnsupportedPty(ptyId) + } + ) + if (unsubscribeMigrationUnsupportedClear) { + unsubs.push(unsubscribeMigrationUnsupportedClear) + } + const unsubscribeLegacyWorkerTerminalRecovery = + window.api.agentStatus.onLegacyWorkerTerminalRecovery?.((event) => { + const action = resolveLegacyWorkerTerminalRecoveryAction(event) + if (action.kind === 'rollback-surface') { + window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail: action.detail })) + rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), action.detail) + } else if (action.kind === 'clear-sleeping') { + useAppStore.getState().clearSleepingAgentSession(action.paneKey) + } + }) + if (unsubscribeLegacyWorkerTerminalRecovery) { + unsubs.push(unsubscribeLegacyWorkerTerminalRecovery) + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts new file mode 100644 index 00000000000..b7f9467bfd2 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts @@ -0,0 +1,153 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { AppState } from '../../store/types' + +type AgentStatusPaneResolution = { + exists: boolean + title: string | undefined + identityTitle: string | undefined + repoConnectionId: string | null + repoConnectionResolved: boolean + owningWorktreeId: string | undefined + titleUsesTabTitle: boolean +} + +type AgentStatusWorktreeConnectionResolution = { + worktreeExists: boolean + repoConnectionId: string | null + repoConnectionResolved: boolean +} + +type IndexedAgentStatusTab = { + title: string | undefined + unifiedLabel: string | undefined + owningWorktreeId: string +} + +export type AgentStatusPaneRoutingIndex = { + tabsById: Map + layoutsByTabId: AppState['terminalLayoutsByTabId'] + leafIdsByRoot: WeakMap> + worktreesById: ReturnType + reposById: ReturnType +} + +function createUnifiedTerminalLabelIndex( + entries: AppState['unifiedTabsByWorktree'][string] | undefined +): Map { + const labelsByTabId = new Map() + for (const entry of entries ?? []) { + if (entry.contentType !== 'terminal' || labelsByTabId.has(entry.entityId)) { + continue + } + const rawLabel = entry.label?.trim() + labelsByTabId.set(entry.entityId, rawLabel && rawLabel.length > 0 ? rawLabel : undefined) + } + return labelsByTabId +} + +export function createAgentStatusPaneRoutingIndex(store: AppState): AgentStatusPaneRoutingIndex { + const tabsById = new Map() + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { + const unifiedLabelsByTabId = createUnifiedTerminalLabelIndex( + store.unifiedTabsByWorktree?.[worktreeId] + ) + for (const tab of tabs) { + const tabId = tab.id + if (!tabsById.has(tabId)) { + tabsById.set(tabId, { + title: tab.title, + unifiedLabel: unifiedLabelsByTabId.get(tabId), + owningWorktreeId: worktreeId + }) + } + } + } + return { + tabsById, + layoutsByTabId: store.terminalLayoutsByTabId, + leafIdsByRoot: new WeakMap(), + worktreesById: getWorktreeMapFromState(store), + reposById: getRepoMapFromState(store) + } +} + +export function resolveWorktreeConnectionFromRoutingIndex( + index: AgentStatusPaneRoutingIndex, + worktreeId: string +): AgentStatusWorktreeConnectionResolution { + const worktree = index.worktreesById.get(worktreeId) + if (!worktree) { + return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } + } + const repo = index.reposById.get(worktree.repoId) + return { + worktreeExists: true, + repoConnectionId: repo?.connectionId ?? null, + repoConnectionResolved: repo !== undefined + } +} + +export function resolvePaneKeyFromRoutingIndex( + index: AgentStatusPaneRoutingIndex, + paneKey: string +): AgentStatusPaneResolution { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const { tabId, leafId } = parsed + const tab = index.tabsById.get(tabId) + if (!tab) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const connection = resolveWorktreeConnectionFromRoutingIndex(index, tab.owningWorktreeId) + const layout = index.layoutsByTabId?.[tabId] + if (layout?.root) { + let leafIds = index.leafIdsByRoot.get(layout.root) + if (!leafIds) { + leafIds = new Set(collectLeafIdsInOrder(layout.root)) + index.leafIdsByRoot.set(layout.root, leafIds) + } + if (!leafIds.has(leafId)) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: connection.repoConnectionId, + repoConnectionResolved: connection.repoConnectionResolved, + owningWorktreeId: tab.owningWorktreeId, + titleUsesTabTitle: false + } + } + } + const rawPaneTitle = layout?.titlesByLeafId?.[leafId] + const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined + return { + exists: true, + title: paneTitle ?? tab.title, + identityTitle: paneTitle ?? tab.unifiedLabel ?? tab.title, + repoConnectionId: connection.repoConnectionId, + repoConnectionResolved: connection.repoConnectionResolved, + owningWorktreeId: tab.owningWorktreeId, + titleUsesTabTitle: paneTitle === undefined + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts new file mode 100644 index 00000000000..1aa80a282ef --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts @@ -0,0 +1,217 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import type { AppState } from '../../store/types' +import { titleHasAgentName } from '../../../../shared/agent-detection' +import type { + AgentStatusIpcPayload, + ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import type { useAppStore } from '../../store' + +export function isAgentStatusForRecentlyClosedTab( + store: Pick, + paneKey: string +): boolean { + const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) + if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) { + return true + } + const tabId = parsePaneKey(ownerPaneKey)?.tabId + return tabId ? store.recentlyClosedAgentStatusTabIds[tabId] === true : false +} + +export function hasRuntimeBackedWorktreeAttribution(data: AgentStatusIpcPayload): boolean { + return ( + (typeof data.terminalHandle === 'string' && data.terminalHandle.length > 0) || + data.orchestration !== undefined + ) +} + +export function tryMakePaneKey(tabId: string, leafId: string): string | null { + try { + return makePaneKey(tabId, leafId) + } catch { + return null + } +} + +export function applyResolvedAgentTerminalTitleToTab( + store: ReturnType, + paneKey: string, + previousTitle: string | undefined, + nextTitle: string | undefined +): void { + if ( + !nextTitle || + !shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, previousTitle, nextTitle) + ) { + return + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return + } + // Why: hook completion can arrive while the pane transport is unmounted; keep the tab label synced to the resolved state title. + store.updateTabTitle(parsed.tabId, nextTitle) +} + +export function shouldApplyResolvedAgentTerminalTitleToTab( + store: ReturnType, + paneKey: string, + previousTitle: string | undefined, + nextTitle: string | undefined +): boolean { + if (!nextTitle || nextTitle === previousTitle) { + return false + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return false + } + const layout = store.terminalLayoutsByTabId?.[parsed.tabId] + if (layout?.root && layout.activeLeafId && layout.activeLeafId !== parsed.leafId) { + return false + } + return true +} + +/** Resolve a paneKey (tabId:leafId) to liveness, current title, owning worktree, + * and the owning repo's connectionId. Used for agent-type inference and to drop + * status updates for torn-down tabs or dead connections (an SSH reconnect retires the + * old connectionId, so events still in flight under it must not land). */ +export function resolvePaneKey( + store: ReturnType, + paneKey: string +): { + exists: boolean + title: string | undefined + identityTitle: string | undefined + repoConnectionId: string | null + repoConnectionResolved: boolean + owningWorktreeId: string | undefined + titleUsesTabTitle: boolean +} { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const { tabId, leafId } = parsed + const layout = store.terminalLayoutsByTabId?.[tabId] + let exists = false + let tabTitle: string | undefined + let unifiedTabLabel: string | undefined + let owningWorktreeId: string | undefined + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { + for (const tab of tabs) { + if (tab.id === tabId) { + exists = true + tabTitle = tab.title + owningWorktreeId = worktreeId + const visibleTab = (store.unifiedTabsByWorktree?.[worktreeId] ?? []).find( + (entry) => entry.contentType === 'terminal' && entry.entityId === tabId + ) + const rawVisibleLabel = visibleTab?.label?.trim() + unifiedTabLabel = + rawVisibleLabel && rawVisibleLabel.length > 0 ? rawVisibleLabel : undefined + break + } + } + if (exists) { + break + } + } + // Why: keep "resolved to a local repo" distinct from "not hydrated yet" so callers filter strictly post-hydration but still accept SSH snapshots during the startup ownership gap. + let repoConnectionId: string | null = null + let repoConnectionResolved = false + if (owningWorktreeId !== undefined) { + const worktree = getWorktreeMapFromState(store).get(owningWorktreeId) + if (worktree) { + const repo = getRepoMapFromState(store).get(worktree.repoId) + repoConnectionResolved = repo !== undefined + repoConnectionId = repo?.connectionId ?? null + } + } + if (!exists) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle: false + } + } + // Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed. + const leafExists = layout?.root ? collectLeafIdsInOrder(layout.root).includes(leafId) : true + if (!leafExists) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle: false + } + } + // Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there. + const rawPaneTitle = layout?.titlesByLeafId?.[leafId] + // Why: treat empty-string paneTitle as "no title" so the tab-level fallback fires; nullish-coalescing on '' would short-circuit and erase cached terminalTitle. + const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined + return { + exists, + title: paneTitle ?? tabTitle, + // Why: some agents (OpenClaude) keep the terminal title generic while the tab label carries the agent identity; use only the non-custom label for attribution. + identityTitle: paneTitle ?? unifiedTabLabel ?? tabTitle, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle: paneTitle === undefined + } +} + +export function resolveWorktreeConnection( + store: ReturnType, + worktreeId: string +): { + worktreeExists: boolean + repoConnectionId: string | null + repoConnectionResolved: boolean +} { + const worktree = getWorktreeMapFromState(store).get(worktreeId) + if (!worktree) { + return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } + } + const repo = getRepoMapFromState(store).get(worktree.repoId) + return { + worktreeExists: true, + repoConnectionId: repo?.connectionId ?? null, + repoConnectionResolved: repo !== undefined + } +} + +export function resolveHookPayloadAgentType( + payload: ParsedAgentStatusPayload, + terminalTitle: string | undefined +): ParsedAgentStatusPayload { + if ( + payload.agentType !== 'claude' || + !terminalTitle || + !titleHasAgentName(terminalTitle, 'openclaude') + ) { + return payload + } + // Why: OpenClaude emits Claude-compatible hooks; the title is the last renderer signal to keep it out of Claude-only status paths. + return { ...payload, agentType: 'openclaude' } +} diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts new file mode 100644 index 00000000000..f4bc7c6c007 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -0,0 +1,122 @@ +import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' +import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' +import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' +import { resetAgentHookCompletionNotificationCoordinators } from '../agent-hook-completion-notifications' +import { useAppStore } from '../../store' +import { registerAgentStatusIpcBridge } from './agent-status-ipc-bridge' +import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge' +import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' +import { registerContentCreationIpcBridge } from './content-creation-ipc-bridge' +import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' +import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' +import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' +import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge' +import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge' +import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge' +import { registerRemoteWorkspaceIpcBridge } from './remote-workspace-ipc-bridge' +import { registerRuntimeClientIpcBridge } from './runtime-client-ipc-bridge' +import { registerSessionTabIpcBridge } from './session-tab-ipc-bridge' +import { registerSettingsAndSidebarIpcBridge } from './settings-sidebar-ipc-bridge' +import { registerTabLifecycleIpcBridge } from './tab-lifecycle-ipc-bridge' +import { registerTerminalPresentationIpcBridge } from './terminal-presentation-ipc-bridge' +import { registerTerminalRequestIpcBridge } from './terminal-request-ipc-bridge' +import { registerTerminalUiRoutingIpcBridge } from './terminal-ui-routing-ipc-bridge' +import { registerUpdaterStatusIpcBridge } from './updater-status-ipc-bridge' +import { createWorktreeEventRuntime } from './worktree-event-runtime' +import { registerWorkspaceShortcutIpcBridge } from './workspace-shortcut-ipc-bridge' +import { registerZoomIpcBridge } from './zoom-ipc-bridge' + +function isRuntimeEnvironmentActive(): boolean { + return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) +} + +function remountTerminalTabsAwaitingHostHydration(): void { + const store = useAppStore.getState() + for (const tabId of getTabIdsAwaitingHostHydrationRemount(store)) { + store.remountTerminalTabForRecovery(tabId) + } +} + +export type IpcEventsCleanupPhase = + | 'agent.disposeAsyncState' + | 'mobile.disposeHydration' + | 'runtimeStore.unsubscribe' + | 'agentStore.unsubscribe' + | 'ipc.dispose' + | 'directSsh.stop' + | 'notifications.reset' + +export function installAppLifetimeIpcEvents( + onCleanupPhase?: (phase: IpcEventsCleanupPhase) => void +): () => void { + const unsubs: (() => void)[] = [] + const directSshRuntime = createDirectSshBridgeRuntime() + const backgroundWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() + unsubs.push(backgroundWakeDispatcher.dispose) + unsubs.push(attachMobileMarkdownBridge()) + + const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive) + const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime) + registerProjectCatalogIpcBridge( + unsubs, + worktreeRuntime.worktreeChangeRefreshQueue, + isRuntimeEnvironmentActive, + remountTerminalTabsAwaitingHostHydration + ) + registerSettingsAndSidebarIpcBridge(unsubs) + registerWorkspaceShortcutIpcBridge(unsubs) + unsubs.push( + window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { + void worktreeRuntime + .activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId, + worktreeId, + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}) + }, + { allowRuntimeEnvironment: false } + ) + .catch((error) => console.error('Failed to activate CLI-created worktree:', error)) + }) + ) + + registerTerminalPresentationIpcBridge(unsubs) + registerTerminalRequestIpcBridge(unsubs) + registerTerminalUiRoutingIpcBridge(unsubs) + registerSessionTabIpcBridge(unsubs) + registerMobileAndTerminalCloseIpcBridge(unsubs, backgroundWakeDispatcher.request) + registerUpdaterStatusIpcBridge(unsubs) + registerBrowserStateIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerContentCreationIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerBrowserRequestIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerTabLifecycleIpcBridge(unsubs) + registerRateLimitIpcBridge(unsubs) + registerDirectSshStateIpcBridge(unsubs, directSshRuntime) + registerRemoteWorkspaceIpcBridge(unsubs, directSshRuntime) + registerZoomIpcBridge(unsubs) + const agentStatusBridge = registerAgentStatusIpcBridge(unsubs) + const disposeMobileDriverHydration = registerMobileDriverIpcBridge( + unsubs, + isRuntimeEnvironmentActive + ) + + return () => { + agentStatusBridge.disposeAsyncState() + onCleanupPhase?.('agent.disposeAsyncState') + disposeMobileDriverHydration() + onCleanupPhase?.('mobile.disposeHydration') + unsubscribeRuntimeEnvironmentStore() + onCleanupPhase?.('runtimeStore.unsubscribe') + agentStatusBridge.unsubscribeStore() + onCleanupPhase?.('agentStore.unsubscribe') + unsubs.forEach((unsubscribe) => unsubscribe()) + onCleanupPhase?.('ipc.dispose') + directSshRuntime.stop() + onCleanupPhase?.('directSsh.stop') + resetAgentHookCompletionNotificationCoordinators() + onCleanupPhase?.('notifications.reset') + } +} diff --git a/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts new file mode 100644 index 00000000000..5bf85bca8bb --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' + +const mocks = vi.hoisted(() => ({ + acquireVisibility: vi.fn(), + releaseVisibility: vi.fn(), + requestBackgroundMount: vi.fn() +})) + +vi.mock('@/components/browser-pane/host-guest/browser-automation-visibility', () => ({ + acquireBrowserAutomationVisibility: mocks.acquireVisibility, + releaseBrowserAutomationVisibility: mocks.releaseVisibility +})) +vi.mock('@/components/terminal/background-terminal-worktree-mount', () => ({ + requestBackgroundTerminalWorktreeMount: mocks.requestBackgroundMount +})) +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => ({ + activeWorktreeId: 'wt-active', + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeBrowserTabIdByWorktree: {} + }) + } +})) + +describe('browser automation bootstrap lease ownership', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mocks.acquireVisibility + .mockReturnValueOnce('lease-1') + .mockReturnValueOnce('lease-2') + .mockReturnValueOnce('lease-3') + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('replaces a same-page lease and expires different page leases independently after 10s', () => { + acquireBrowserAutomationBootstrapLease('wt-1', 'page-1') + acquireBrowserAutomationBootstrapLease('wt-1', 'page-1') + acquireBrowserAutomationBootstrapLease('wt-2', 'page-2') + + expect(mocks.releaseVisibility).toHaveBeenCalledTimes(1) + expect(mocks.releaseVisibility).toHaveBeenLastCalledWith('lease-1') + expect(mocks.requestBackgroundMount.mock.calls).toEqual([ + [{ worktreeId: 'wt-1' }], + [{ worktreeId: 'wt-1' }], + [{ worktreeId: 'wt-2' }] + ]) + + vi.advanceTimersByTime(9_999) + expect(mocks.releaseVisibility).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(1) + expect(mocks.releaseVisibility.mock.calls).toEqual([['lease-1'], ['lease-2'], ['lease-3']]) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts new file mode 100644 index 00000000000..2bf48de78e8 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts @@ -0,0 +1,75 @@ +import { + acquireBrowserAutomationVisibility, + releaseBrowserAutomationVisibility +} from '@/components/browser-pane/host-guest/browser-automation-visibility' +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { useAppStore } from '../../store' +import type { AppState } from '../../store/types' + +const BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS = 10_000 +const browserAutomationBootstrapLeaseByPageId = new Map() + +function releaseBrowserAutomationBootstrapLease(browserPageId: string): void { + const existing = browserAutomationBootstrapLeaseByPageId.get(browserPageId) + if (!existing) { + return + } + window.clearTimeout(existing.timer) + releaseBrowserAutomationVisibility(existing.token) + browserAutomationBootstrapLeaseByPageId.delete(browserPageId) +} + +function findBrowserPageWorktreeId(store: AppState, browserPageId: string): string | null { + for (const [worktreeId, browserTabs] of Object.entries(store.browserTabsByWorktree)) { + for (const workspace of browserTabs) { + if ( + workspace.id === browserPageId || + workspace.activePageId === browserPageId || + workspace.pageIds?.includes(browserPageId) + ) { + return worktreeId + } + } + } + for (const pages of Object.values(store.browserPagesByWorkspace)) { + const page = pages.find((candidate) => candidate.id === browserPageId) + if (page) { + return page.worktreeId + } + } + return null +} + +export function acquireBrowserAutomationBootstrapLease( + worktreeId: string | null | undefined, + browserPageId?: string | null +): void { + const store = useAppStore.getState() + const targetWorktreeId = + worktreeId ?? + (browserPageId ? findBrowserPageWorktreeId(store, browserPageId) : null) ?? + store.activeWorktreeId + if (!targetWorktreeId) { + return + } + requestBackgroundTerminalWorktreeMount({ worktreeId: targetWorktreeId }) + let targetBrowserPageId = browserPageId ?? null + if (!targetBrowserPageId) { + const browserTabs = store.browserTabsByWorktree[targetWorktreeId] ?? [] + const activeWorkspaceId = store.activeBrowserTabIdByWorktree[targetWorktreeId] ?? null + const workspace = + browserTabs.find((tab) => tab.id === activeWorkspaceId) ?? browserTabs[0] ?? null + targetBrowserPageId = + workspace?.activePageId ?? workspace?.pageIds?.[0] ?? workspace?.id ?? null + } + if (!targetBrowserPageId) { + return + } + + releaseBrowserAutomationBootstrapLease(targetBrowserPageId) + const token = acquireBrowserAutomationVisibility(targetBrowserPageId) + const timer = window.setTimeout(() => { + releaseBrowserAutomationBootstrapLease(targetBrowserPageId) + }, BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS) + browserAutomationBootstrapLeaseByPageId.set(targetBrowserPageId, { token, timer }) +} diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts new file mode 100644 index 00000000000..056b1857021 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts @@ -0,0 +1,242 @@ +import { destroyPersistentWebview } from '@/components/browser-pane/host-guest/webview-registry' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel +} from '../../store/pinned-tab-close-guard' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' + +export function registerBrowserRequestIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): void { + unsubs.push( + window.api.ui.onRequestTabCreate((data) => { + try { + if (isRuntimeEnvironmentActive()) { + // Why: browser automation targets client-local Electron webviews that runtime agents can't see or control. + window.api.ui.replyTabCreate({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.291c8ed902', + 'Browser tabs are unavailable while a remote runtime is active' + ) + }) + return + } + const store = useAppStore.getState() + const worktreeId = data.worktreeId ?? store.activeWorktreeId + if (!worktreeId) { + window.api.ui.replyTabCreate({ + requestId: data.requestId, + error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') + }) + return + } + // Why: CLI-created tabs should land in the active browser tab's group, not the terminal's UI-active group. + const activeBrowserTabId = store.activeBrowserTabIdByWorktree[worktreeId] + const activeBrowserUnifiedTab = activeBrowserTabId + ? (store.unifiedTabsByWorktree[worktreeId] ?? []).find( + (t) => t.contentType === 'browser' && t.entityId === activeBrowserTabId + ) + : undefined + + // Why: a user-initiated open (data.activate, e.g. mobile tapping an HTML path) foregrounds the tab so it lands in active-group order and publishes to mobile. + // Agent/automation opens stay in the background (activate:false) in the active browser group. + const workspace = store.createBrowserTab(worktreeId, data.url, { + title: data.url, + browserPageId: data.browserPageId, + targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId, + sessionProfileId: data.sessionProfileId, + sessionPartition: data.sessionPartition, + activate: data.activate === true + }) + // Why: registerGuest fires with the page ID, not the workspace ID; return it so waitForTabRegistration can correlate. + const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? [] + const browserPageId = pages[0]?.id ?? workspace.id + acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) + window.api.ui.replyTabCreate({ requestId: data.requestId, browserPageId }) + } catch (err) { + window.api.ui.replyTabCreate({ + requestId: data.requestId, + error: err instanceof Error ? err.message : 'Tab creation failed' + }) + } + }) + ) + + unsubs.push( + window.api.ui.onRequestTabSetProfile((data) => { + try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTabSetProfile({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.f45fa2b03c', + 'Browser profiles are unavailable while a remote runtime is active' + ) + }) + return + } + const store = useAppStore.getState() + const owningWorkspace = Object.values(store.browserTabsByWorktree) + .flat() + .find((workspace) => { + if (workspace.id === data.browserPageId) { + return true + } + const pages = store.browserPagesByWorkspace[workspace.id] ?? [] + return pages.some((page) => page.id === data.browserPageId) + }) + if (!owningWorkspace) { + window.api.ui.replyTabSetProfile({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.0e3cf53060', + 'Browser tab {{value0}} not found', + { value0: data.browserPageId } + ) + }) + return + } + // Why: a workspace may host several browser pages; profile switch must tear down all sibling webviews, not just the IPC's. + const workspacePages = store.browserPagesByWorkspace[owningWorkspace.id] ?? [] + if (workspacePages.length > 0) { + for (const page of workspacePages) { + destroyPersistentWebview(page.id) + } + } else { + destroyPersistentWebview(data.browserPageId) + } + store.switchBrowserTabProfile(owningWorkspace.id, data.profileId, data.sessionPartition) + window.api.ui.replyTabSetProfile({ requestId: data.requestId }) + } catch (err) { + window.api.ui.replyTabSetProfile({ + requestId: data.requestId, + error: err instanceof Error ? err.message : 'Tab profile update failed' + }) + } + }) + ) + + unsubs.push( + window.api.ui.onRequestTabClose((data) => { + try { + if (isRuntimeEnvironmentActive()) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.291c8ed902', + 'Browser tabs are unavailable while a remote runtime is active' + ) + }) + return + } + const store = useAppStore.getState() + const explicitTargetId = data.tabId ?? null + const replyBrowserTabNotFound = (tabId: string): void => { + window.api.ui.replyTabClose({ + requestId: data.requestId, + code: 'browser_tab_not_found', + error: translate( + 'auto.hooks.useIpcEvents.0e3cf53060', + 'Browser tab {{value0}} not found', + { value0: tabId } + ) + }) + } + const replyPinnedBrowserCloseCanceled = (tabId: string): void => { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.2f6637fe6c', + 'Browser tab {{value0}} is pinned', + { value0: tabId } + ) + }) + } + const closeBrowserWorkspaceWithReply = (worktreeId: string, workspaceId: string): void => { + const currentStore = useAppStore.getState() + guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(currentStore, worktreeId, workspaceId), + tabLabel: resolvePinnedTabLabel(currentStore, worktreeId, workspaceId), + onClose: () => { + useAppStore.getState().closeBrowserTab(workspaceId) + window.api.ui.replyTabClose({ requestId: data.requestId }) + }, + onCancel: () => replyPinnedBrowserCloseCanceled(workspaceId) + }) + } + const tabToClose = + explicitTargetId ?? + (data.worktreeId + ? (store.activeBrowserTabIdByWorktree?.[data.worktreeId] ?? null) + : store.activeBrowserTabId) + if (!tabToClose) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: translate('auto.hooks.useIpcEvents.a8d2bf8e9e', 'No active browser tab to close') + }) + return + } + // Why: the bridge keys tabs by browserPageId, but closeBrowserTab expects a workspace id. + // Per the CLI's `tab close --page` contract, close only that page unless it is the last in its workspace. + const isWorkspaceId = Object.values(store.browserTabsByWorktree) + .flat() + .some((ws) => ws.id === tabToClose) + if (!isWorkspaceId) { + const owningWorkspace = Object.entries(store.browserPagesByWorkspace).find(([, pages]) => + pages.some((p) => p.id === tabToClose) + ) + if (owningWorkspace) { + const [workspaceId, pages] = owningWorkspace + const owningWorktreeId = + Object.entries(store.browserTabsByWorktree).find(([, tabs]) => + tabs.some((tab) => tab.id === workspaceId) + )?.[0] ?? null + if (data.worktreeId && owningWorktreeId !== data.worktreeId) { + replyBrowserTabNotFound(tabToClose) + return + } + if (pages.length <= 1) { + if (owningWorktreeId) { + closeBrowserWorkspaceWithReply(owningWorktreeId, workspaceId) + return + } + store.closeBrowserTab(workspaceId) + } else { + store.closeBrowserPage(tabToClose) + } + window.api.ui.replyTabClose({ requestId: data.requestId }) + return + } + } + const owningWorktreeId = + Object.entries(store.browserTabsByWorktree).find(([, tabs]) => + tabs.some((tab) => tab.id === tabToClose) + )?.[0] ?? null + if (owningWorktreeId) { + if (data.worktreeId && owningWorktreeId !== data.worktreeId) { + replyBrowserTabNotFound(tabToClose) + return + } + closeBrowserWorkspaceWithReply(owningWorktreeId, tabToClose) + return + } + if (explicitTargetId) { + replyBrowserTabNotFound(explicitTargetId) + return + } + store.closeBrowserTab(tabToClose) + window.api.ui.replyTabClose({ requestId: data.requestId }) + } catch (err) { + window.api.ui.replyTabClose({ + requestId: data.requestId, + error: err instanceof Error ? err.message : 'Tab close failed' + }) + } + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts b/src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts new file mode 100644 index 00000000000..193b0e22d92 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts @@ -0,0 +1,25 @@ +import type { AppState } from '../../store/types' + +export type BrowserSessionTabTarget = + | { kind: 'unified-browser'; unifiedTabId: string; workspaceId: string; groupId: string } + | { kind: 'fallback-browser'; workspaceId: string } + +export function resolveBrowserSessionTabTarget( + state: Pick, + worktreeId: string, + tabId: string +): BrowserSessionTabTarget | null { + const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId) + if (tab?.contentType === 'browser') { + return { + kind: 'unified-browser', + unifiedTabId: tab.id, + workspaceId: tab.entityId, + groupId: tab.groupId + } + } + const fallbackBrowser = (state.browserTabsByWorktree[worktreeId] ?? []).find( + (workspace) => workspace.id === tabId + ) + return fallbackBrowser ? { kind: 'fallback-browser', workspaceId: fallbackBrowser.id } : null +} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts new file mode 100644 index 00000000000..0920bf9ecec --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -0,0 +1,83 @@ +import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { redactKagiSessionToken } from '../../../../shared/browser-url' +import { useAppStore } from '../../store' +import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' + +export function registerBrowserStateIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): void { + unsubs.push( + window.api.ui.onFullscreenChanged((isFullScreen) => { + useAppStore.getState().setIsFullScreen(isFullScreen) + }) + ) + unsubs.push( + window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().updateBrowserPageState(browserPageId, { + loading: false, + loadError, + canGoBack: false, + canGoForward: false + }) + }) + ) + const unsubscribeCertificateFailure = window.api.browser.onCertificateFailureChanged?.( + ({ browserPageId, failure }) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().setBrowserPageCertificateFailure(browserPageId, failure) + } + ) + if (unsubscribeCertificateFailure) { + unsubs.push(unsubscribeCertificateFailure) + } + unsubs.push( + window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => { + if (isRuntimeEnvironmentActive()) { + return + } + const store = useAppStore.getState() + // The redacted live registry must precede the raw persisted store update. + rememberLiveBrowserUrl(browserPageId, redactKagiSessionToken(url)) + store.setBrowserPageUrl(browserPageId, url) + store.updateBrowserPageState(browserPageId, { title, loading: false }) + }) + ) + unsubs.push( + window.api.browser.onActivateView(({ worktreeId, browserPageId }) => { + if (!isRuntimeEnvironmentActive()) { + acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) + } + }) + ) + unsubs.push( + window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => { + if (isRuntimeEnvironmentActive()) { + return + } + const store = useAppStore.getState() + const targetWorktreeId = worktreeId ?? store.activeWorktreeId + if (targetWorktreeId) { + store.focusBrowserTabInWorktree(targetWorktreeId, browserPageId) + } + }) + ) + unsubs.push( + window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => { + const store = useAppStore.getState() + const sourcePage = Object.values(store.browserPagesByWorkspace) + .flat() + .find((page) => page.id === browserPageId) + if (!sourcePage || getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { + return + } + store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts new file mode 100644 index 00000000000..973a87b08ef --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts @@ -0,0 +1,133 @@ +import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab' +import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' +import { + isManualSimulatorLaunchPending, + rememberPrelaunchedSimulatorSession +} from '@/lib/simulator-launch-coordination' +import { + createFloatingWorkspaceBrowserTab, + createFloatingWorkspaceMarkdownTab, + isFloatingWorkspacePanelFocused +} from '@/lib/floating-workspace-terminal-actions' +import { translate } from '@/i18n/i18n' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { toast } from 'sonner' +import { useAppStore } from '../../store' + +export function registerContentCreationIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): void { + unsubs.push( + window.api.ui.onNewBrowserTab(() => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + void createFloatingWorkspaceBrowserTab(store).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + return + } + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + const targetGroupId = + store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id + if (!targetGroupId) { + return + } + void store.openNewBrowserTabInActiveWorkspace(targetGroupId).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + }) + ) + + unsubs.push( + window.api.ui.onNewMarkdownTab(() => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + void createFloatingWorkspaceMarkdownTab(store).catch((err) => { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.hooks.useIpcEvents.56d3ec4203', + 'Failed to create untitled markdown file.' + ) + ) + }) + return + } + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + const targetGroupId = + store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id + if (targetGroupId) { + void store.openNewMarkdownInActiveWorkspace(targetGroupId) + } + }) + ) + + // Why: emulator IPC is additive; guard so older clients or partial preload mocks don't crash the hook when it's absent. + const unsubscribeNewSimulatorTab = window.api.ui.onNewSimulatorTab?.(() => { + if (isRuntimeEnvironmentActive()) { + return + } + const store = useAppStore.getState() + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + void openMobileEmulatorTab(worktreeId, { placement: 'rightSplit' }).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + }) + if (unsubscribeNewSimulatorTab) { + unsubs.push(unsubscribeNewSimulatorTab) + } + + const unsubscribeEmulatorAutoAttach = window.api.emulator?.onAutoAttach( + ({ worktreeId, info }) => { + if (isRuntimeEnvironmentActive()) { + return + } + if (isManualSimulatorLaunchPending(worktreeId)) { + // Why: manual launches pre-attach so the ready pane opens in the right split, not as a hidden tab in this group. + rememberPrelaunchedSimulatorSession(worktreeId, info) + return + } + ensureSimulatorTab(worktreeId, { + surfacePane: false, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + // Why: watcher may detect a helper while a simulator tab is already mounted; push stream info so the pane updates without re-attach. + window.setTimeout(() => { + window.dispatchEvent( + new CustomEvent('orca:emulator-auto-attach', { + detail: { worktreeId, info } + }) + ) + }, 0) + } + ) + if (unsubscribeEmulatorAutoAttach) { + unsubs.push(unsubscribeEmulatorAutoAttach) + } + + const unsubscribeEmulatorPaneFocus = window.api.emulator?.onPaneFocus(({ worktreeId }) => { + if (isRuntimeEnvironmentActive()) { + return + } + ensureSimulatorTab(worktreeId, { + surfacePane: true, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + }) + if (unsubscribeEmulatorPaneFocus) { + unsubs.push(unsubscribeEmulatorPaneFocus) + } + + // Why: reply with the page ID so main can await registerGuest before returning to the CLI. +} diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts new file mode 100644 index 00000000000..3844d32ec0c --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts @@ -0,0 +1,199 @@ +import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry' +import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { useAppStore } from '../../store' +import { + createDirectSshHostHydration, + type DirectSshHostHydration +} from '../direct-ssh-host-hydration' +import { + createDirectSshReconnectCoordinator, + type DirectSshPreparationInput, + type DirectSshPreparationReason, + type DirectSshReconnectCoordinator +} from '../direct-ssh-reconnect-coordinator' +import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' +import { createDirectSshWorktreeRefreshScheduler } from '../direct-ssh-worktree-refresh-scheduler' +import { + createRemoteWorkspaceTargetSync, + type RemoteWorkspaceTargetSync +} from '../remote-workspace-target-sync' +import type { AppState } from '../../store/types' + +type DirectSshTerminalActions = Partial< + Pick +> +type AuthorityDeadline = { timer: ReturnType; settle: () => void } + +export type DirectSshBridgeRuntime = { + reconnectAuthorityByTarget: Map + reconnectCoordinator: DirectSshReconnectCoordinator + hostHydration: DirectSshHostHydration + remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null + currentAuthority: (targetId: string) => DirectSshAuthority | null + terminalActions: () => DirectSshTerminalActions + prepareAndSync: ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason, + options?: { authorityAlreadyReplaced?: boolean } + ) => Promise + isStopped: () => boolean + addDeadline: (deadline: AuthorityDeadline) => void + removeDeadline: (deadline: AuthorityDeadline) => void + stop: () => void +} + +export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime { + const reconnectAuthorityByTarget = new Map() + const deadlines = new Set() + let stopped = false + const currentAuthority = (targetId: string): DirectSshAuthority | null => { + const state = useAppStore.getState().sshConnectionStates?.get(targetId) + if ( + state?.status !== 'connected' || + state.targetId !== targetId || + !state.providerEpoch || + state.connectionGeneration === undefined + ) { + return null + } + return { + targetId, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } + } + const scheduler = createDirectSshWorktreeRefreshScheduler({ + startAttempt: (key) => { + const acquired = acquireDirectSshDetectedWorktreeRefresh(useAppStore, { + repoId: key.repoId, + executionHostId: key.executionHostId, + authority: { + targetId: key.targetId, + providerEpoch: key.providerEpoch, + connectionGeneration: key.connectionGeneration + }, + requireAuthoritative: key.authorityRequirement === 'required' + }) + return { + providerRequestId: acquired.providerRequestId, + result: acquired.result.then((result) => acquired.merge(result)), + cancel: acquired.release + } + } + }) + const hostHydration = createDirectSshHostHydration({ + store: useAppStore, + isCurrentAuthority: (authority) => + directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority), + listRepos: (authority) => { + const executionHostId = toSshExecutionHostId(authority.targetId) + return ( + window.api.repos.listForExecutionHost?.({ + executionHostId, + expectedAuthority: authority + }) ?? + Promise.resolve({ authoritative: false, executionHostId, reason: 'unavailable' as const }) + ) + }, + listLineage: (authority) => { + const executionHostId = toSshExecutionHostId(authority.targetId) + return ( + window.api.worktrees.listLineageForHost?.({ + executionHostId, + expectedAuthority: authority + }) ?? + Promise.resolve({ authoritative: false, executionHostId, reason: 'unavailable' as const }) + ) + } + }) + const terminalActions = (): DirectSshTerminalActions => + useAppStore.getState() as DirectSshTerminalActions + let remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null = null + const reconnectCoordinator = createDirectSshReconnectCoordinator({ + scheduler, + isCurrentConnectedAuthority: (authority) => + directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority), + capturePreparationInput: hostHydration.capturePreparationInput, + readHostScopedLineage: hostHydration.readHostScopedLineage, + invalidateStaleTerminalBindings: (authority) => + terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, + retryTargetPanes: (authority) => terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + finalizeHydratedTerminalPanes: (authority) => + terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + correctUnboundTerminalPanes: (authority) => + terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + syncRemoteWorkspaceAfterConnect: (token) => remoteWorkspaceTargetSync?.syncAfterConnect(token), + onTelemetry: createDirectSshReconnectProductTelemetryAdapter() + }) + const remoteWorkspaceApi = window.api.remoteWorkspace + if (remoteWorkspaceApi) { + remoteWorkspaceTargetSync = createRemoteWorkspaceTargetSync({ + store: useAppStore, + remoteWorkspace: remoteWorkspaceApi, + getCurrentAuthority: currentAuthority, + isPreparationTokenCurrent: hostHydration.isPreparationTokenCurrent, + capturePreparationInput: (authority, reason, revision) => + hostHydration.capturePreparationInput(authority, reason, revision), + prepareOnly: reconnectCoordinator.prepareOnly, + finalizeHydratedTerminals: (authority) => + directSshAuthoritiesEqual(reconnectAuthorityByTarget.get(authority.targetId), authority) + ? reconnectCoordinator.finalizeHydratedTerminals(authority) + : 0 + }) + } + const prepareAndSync: DirectSshBridgeRuntime['prepareAndSync'] = async ( + authority, + reason, + options + ) => { + try { + if (!options?.authorityAlreadyReplaced) { + reconnectCoordinator.replaceAuthority(authority) + } + const input: DirectSshPreparationInput | null = await hostHydration.capturePreparationInput( + authority, + reason + ) + if (!input) { + return + } + const prepared = await reconnectCoordinator.prepareOnly(input) + if (prepared.token && hostHydration.isPreparationTokenCurrent(prepared.token)) { + await remoteWorkspaceTargetSync?.syncAfterConnect(prepared.token) + } + } catch (error) { + if (directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority)) { + useAppStore.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + message: error instanceof Error ? error.message : 'Workspace sync failed' + }) + } + } + } + return { + reconnectAuthorityByTarget, + reconnectCoordinator, + hostHydration, + remoteWorkspaceTargetSync, + currentAuthority, + terminalActions, + prepareAndSync, + isStopped: () => stopped, + addDeadline: (deadline) => deadlines.add(deadline), + removeDeadline: (deadline) => deadlines.delete(deadline), + stop: () => { + stopped = true + for (const deadline of deadlines) { + clearTimeout(deadline.timer) + deadline.settle() + } + deadlines.clear() + remoteWorkspaceTargetSync?.stop() + hostHydration.stop() + reconnectCoordinator.stop() + reconnectAuthorityByTarget.clear() + } + } +} diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts new file mode 100644 index 00000000000..c29cb8ec19e --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -0,0 +1,296 @@ +import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' +import type { DirectSshAuthority, SshConnectionState } from '../../../../shared/ssh-types' +import { useAppStore } from '../../store' +import { isDirectSshReconnectCoordinatorRoutingEnabled } from '../direct-ssh-reconnect-rollout' +import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' +import { + registerDirectSshWakeRouting, + routeDirectSshConnectedState, + type DirectSshConnectedStateOrigin +} from '../direct-ssh-state-routing' +import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' +export function registerDirectSshStateIpcBridge( + unsubs: (() => void)[], + runtime: DirectSshBridgeRuntime +): void { + const { + reconnectAuthorityByTarget, + reconnectCoordinator, + currentAuthority, + terminalActions, + prepareAndSync + } = runtime + const sshStateWatermarkByTargetId = new Map() + const pendingPortHydrationByTargetId = new Map< + string, + { receivedForwardPush: boolean; receivedDetectedPush: boolean } + >() + const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => { + const pendingPortHydration = { + receivedForwardPush: false, + receivedDetectedPush: false + } + pendingPortHydrationByTargetId.set(targetId, pendingPortHydration) + const isHydrationAuthorityCurrent = (): boolean => + !runtime.isStopped() && directSshAuthoritiesEqual(currentAuthority(targetId), authority) + const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => { + if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) { + useAppStore.getState().setPortForwards(targetId, forwards) + } + }) + const detectedHydration = window.api.ssh.listDetectedPorts({ targetId }).then((detected) => { + if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedDetectedPush) { + useAppStore.getState().setDetectedPorts(targetId, detected) + } + }) + void Promise.allSettled([forwardHydration, detectedHydration]).then(() => { + if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) { + pendingPortHydrationByTargetId.delete(targetId) + } + }) + } + let applySshConnectionStateChange!: ( + targetId: string, + state: SshConnectionState, + origin: DirectSshConnectedStateOrigin + ) => void + void (async () => { + try { + const targets = await window.api.ssh.listTargets() + if (runtime.isStopped()) { + return + } + useAppStore.getState().setSshTargetsMetadata(targets) + try { + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + if (runtime.isStopped()) { + return + } + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + } catch {} + for (const target of targets) { + const hydrationWatermark = sshStateWatermarkByTargetId.get(target.id) ?? 0 + const state = await window.api.ssh.getState({ targetId: target.id }) + if ( + !runtime.isStopped() && + state && + (sshStateWatermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark + ) { + applySshConnectionStateChange(target.id, state as SshConnectionState, 'initial-hydration') + } + } + } catch {} + })() + unsubs.push( + window.api.ssh.onCredentialRequest((data) => { + useAppStore.getState().enqueueSshCredentialRequest(data) + }) + ) + unsubs.push( + window.api.ssh.onCredentialResolved(({ requestId }) => { + useAppStore.getState().removeSshCredentialRequest(requestId) + }) + ) + + unsubs.push( + window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => { + const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) + if (pendingPortHydration) { + pendingPortHydration.receivedForwardPush = true + } + useAppStore.getState().setPortForwards(targetId, forwards) + }) + ) + + unsubs.push( + window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => { + const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) + if (pendingPortHydration) { + pendingPortHydration.receivedDetectedPush = true + } + useAppStore.getState().setDetectedPorts(targetId, ports) + }) + ) + + const reconcileSshAuthority = ( + targetId: string, + initiatingState: SshConnectionState, + origin: DirectSshConnectedStateOrigin, + watermark: number + ): void => { + let pendingDeadline: { timer: ReturnType; settle: () => void } | undefined + const deadline = new Promise((resolve) => { + const settle = (): void => resolve(null) + const timer = setTimeout(settle, 5_000) + pendingDeadline = { timer, settle } + runtime.addDeadline(pendingDeadline) + }) + void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline]) + .then((latest) => { + if ( + runtime.isStopped() || + latest?.targetId !== targetId || + !latest?.providerEpoch || + latest.connectionGeneration === undefined || + (sshStateWatermarkByTargetId.get(targetId) ?? 0) !== watermark + ) { + return + } + const current = useAppStore.getState().sshConnectionStates?.get(targetId) + if ( + current?.status !== initiatingState.status || + latest.status !== initiatingState.status || + current.providerEpoch !== initiatingState.providerEpoch || + current.connectionGeneration !== initiatingState.connectionGeneration || + (current.providerEpoch !== undefined && + current.providerEpoch !== null && + current.providerEpoch !== latest.providerEpoch) || + (current.connectionGeneration !== undefined && + current.connectionGeneration !== latest.connectionGeneration) + ) { + return + } + applySshConnectionStateChange( + targetId, + { + ...current, + providerEpoch: latest.providerEpoch, + connectionGeneration: latest.connectionGeneration + }, + origin + ) + }) + .catch(() => undefined) + .finally(() => { + if (pendingDeadline) { + clearTimeout(pendingDeadline.timer) + runtime.removeDeadline(pendingDeadline) + } + }) + } + + applySshConnectionStateChange = ( + targetId: string, + state: SshConnectionState, + origin: DirectSshConnectedStateOrigin + ): void => { + const store = useAppStore.getState() + const previous = store.sshConnectionStates?.get(targetId) + store.setSshConnectionState(targetId, state) + + if (canConnectSshStatus(state.status)) { + reconnectAuthorityByTarget.delete(targetId) + reconnectCoordinator.invalidate(targetId) + store.clearRemoteDetectedAgents(targetId) + + store.clearPortForwards(targetId) + store.setDetectedPorts(targetId, []) + + store.clearDirectSshTargetPtyBindings(targetId) + return + } + + if (state.status !== 'connected') { + return + } + const authority = currentAuthority(targetId) + if (!authority) { + reconcileSshAuthority(targetId, state, origin, sshStateWatermarkByTargetId.get(targetId) ?? 0) + return + } + const previousAuthority = + previous?.status === 'connected' && + previous.providerEpoch && + previous.connectionGeneration !== undefined + ? { + targetId, + providerEpoch: previous.providerEpoch, + connectionGeneration: previous.connectionGeneration + } + : null + routeDirectSshConnectedState( + { + coordinator: reconnectCoordinator, + coordinatorRoutingEnabled: isDirectSshReconnectCoordinatorRoutingEnabled(), + invalidateStaleTerminalBindings: (nextAuthority) => + terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? 0, + retryTargetPanes: (nextAuthority) => + terminalActions().retryDirectSshTargetPanes?.(nextAuthority) ?? 0, + prepareAndSync: prepareAndSync, + rememberReconnectAuthority: (nextAuthority) => { + if (nextAuthority) { + reconnectAuthorityByTarget.set(targetId, nextAuthority) + } else { + reconnectAuthorityByTarget.delete(targetId) + } + } + }, + { authority, previousAuthority, origin } + ) + if (origin === 'initial-hydration') { + hydrateSshPorts(targetId, authority) + } + } + + let sshTargetStateEventId = 0 + const latestSshTargetStateEventByTargetId = new Map() + + const handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => { + const store = useAppStore.getState() + const state = data.state as SshConnectionState + const stateEventId = ++sshTargetStateEventId + sshStateWatermarkByTargetId.set( + data.targetId, + (sshStateWatermarkByTargetId.get(data.targetId) ?? 0) + 1 + ) + latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) + if (!store.sshTargetLabels.has(data.targetId)) { + window.api.ssh + .listTargets() + .catch(() => window.api.ssh.listTargets()) + .then((targets) => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { + return + } + latestSshTargetStateEventByTargetId.delete(data.targetId) + if (runtime.isStopped()) { + return + } + const latestStore = useAppStore.getState() + if (!targets.some((target) => target.id === data.targetId)) { + latestStore.clearRemovedSshTargetState(data.targetId) + return + } + latestStore.setSshTargetsMetadata(targets) + applySshConnectionStateChange(data.targetId, state, 'push') + }) + .catch(() => { + if ( + !runtime.isStopped() && + latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId + ) { + latestSshTargetStateEventByTargetId.delete(data.targetId) + applySshConnectionStateChange(data.targetId, state, 'push') + } + }) + return + } + + latestSshTargetStateEventByTargetId.delete(data.targetId) + applySshConnectionStateChange(data.targetId, state, 'push') + } + + unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent)) + unsubs.push( + registerDirectSshWakeRouting({ + getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], + wakeAuthority: (authority) => { + reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh') + void prepareAndSync(authority, 'wake-refresh') + }, + ...(typeof window.api.ui.onSystemResumed === 'function' + ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } + : {}) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts new file mode 100644 index 00000000000..1796afa8a89 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts @@ -0,0 +1,139 @@ +import { + hydrateBrowserDrivers, + setDriverForBrowserPage +} from '@/lib/pane-manager/browser-mobile-driver-state' +import { setDriverForPty, hydrateDrivers } from '@/lib/pane-manager/mobile-driver-state' +import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides' +import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' +import type { + RuntimeBrowserDriverState, + RuntimeTerminalDriverState +} from '../../../../shared/runtime-types' +import { useAppStore } from '../../store' + +const MAX_PENDING_MOBILE_STATE_EVENTS = 300 + +type PendingMobileStateEvent = + | { + kind: 'fit' + event: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + } + } + | { kind: 'driver'; event: { ptyId: string; driver: RuntimeTerminalDriverState } } + | { + kind: 'browser-driver' + event: { browserPageId: string; driver: RuntimeBrowserDriverState } + } + +export function registerMobileDriverIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): () => void { + let mobileStateHydrated = isRuntimeEnvironmentActive() + const pendingMobileStateEvents: PendingMobileStateEvent[] = [] + let disposed = false + + const applyPendingMobileStateEvents = (): void => { + for (const pending of pendingMobileStateEvents) { + if (pending.kind === 'fit') { + const { ptyId, mode, cols, rows } = pending.event + setFitOverride(ptyId, mode, cols, rows) + } else if (pending.kind === 'driver') { + setDriverForPty(pending.event.ptyId, pending.event.driver) + } else { + setDriverForBrowserPage(pending.event.browserPageId, pending.event.driver) + } + } + pendingMobileStateEvents.length = 0 + } + const enqueue = (event: PendingMobileStateEvent): void => { + pendingMobileStateEvents.push(event) + while (pendingMobileStateEvents.length > MAX_PENDING_MOBILE_STATE_EVENTS) { + pendingMobileStateEvents.shift() + } + } + + unsubs.push( + window.api.runtime.onTerminalFitOverrideChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + if (!mobileStateHydrated) { + enqueue({ kind: 'fit', event }) + return + } + setFitOverride(event.ptyId, event.mode, event.cols, event.rows) + }) + ) + unsubs.push( + window.api.runtime.onTerminalDriverChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + if (!mobileStateHydrated) { + enqueue({ kind: 'driver', event }) + return + } + setDriverForPty(event.ptyId, event.driver) + }) + ) + const unsubscribeLaunchDraftResolution = window.api.runtime.onNativeChatLaunchDraftResolved?.( + (event) => { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), { + type: 'nativeChatLaunchDraftResolved', + ...event + }) + } + ) + if (unsubscribeLaunchDraftResolution) { + unsubs.push(unsubscribeLaunchDraftResolution) + } + unsubs.push( + window.api.runtime.onBrowserDriverChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + if (!mobileStateHydrated) { + enqueue({ kind: 'browser-driver', event }) + return + } + setDriverForBrowserPage(event.browserPageId, event.driver) + }) + ) + + // Subscribe before snapshots; queued pushes replay in arrival order after all three hydrate. + if (!isRuntimeEnvironmentActive()) { + void Promise.all([ + window.api.runtime.getTerminalFitOverrides(), + window.api.runtime.getTerminalDrivers(), + window.api.runtime.getBrowserDrivers() + ]) + .then(([overrides, drivers, browserDrivers]) => { + if (disposed) { + return + } + hydrateOverrides(overrides) + hydrateDrivers(drivers) + hydrateBrowserDrivers(browserDrivers) + mobileStateHydrated = true + applyPendingMobileStateEvents() + }) + .catch((error: unknown) => { + if (disposed) { + return + } + console.error('Failed to hydrate mobile terminal state:', error) + mobileStateHydrated = true + applyPendingMobileStateEvents() + }) + } + + return () => { + disposed = true + pendingMobileStateEvents.length = 0 + } +} diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts new file mode 100644 index 00000000000..8d58bcad3ea --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -0,0 +1,116 @@ +import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { CloseTerminalPaneDetail } from '@/constants/terminal' +import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { detectLanguage } from '@/lib/language-detect' +import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' +import { useAppStore } from '../../store' + +export function registerMobileAndTerminalCloseIpcBridge( + unsubs: (() => void)[], + requestSleepingAgentWake: (worktreeId: string) => void +): void { + unsubs.push( + window.api.ui.onOpenFileFromMobile( + ({ worktreeId, filePath, relativePath, runtimeEnvironmentId }) => { + const store = useAppStore.getState() + const basename = relativePath.split(/[\\/]/).pop() || relativePath + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + // Why: renderer owns tab creation so grouped order and markdown bridges share the desktop File Explorer's store path. + store.openFile({ + filePath, + relativePath, + worktreeId, + language: detectLanguage(basename), + runtimeEnvironmentId, + mode: 'edit' + }) + store.setActiveTabType('editor') + store.revealWorktreeInSidebar(worktreeId) + } + ) + ) + + unsubs.push( + window.api.ui.onOpenDiffFromMobile( + ({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId }) => { + const store = useAppStore.getState() + const language = detectLanguage(relativePath) + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + // Why: mobile renders diffs from metadata; the editor-local Changes shortcut would send plain markdown back to mobile. + store.openDiff(worktreeId, filePath, relativePath, language, staged, { + runtimeEnvironmentId + }) + store.setActiveTabType('editor') + store.revealWorktreeInSidebar(worktreeId) + } + ) + ) + + unsubs.push( + window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => { + if (paneRuntimeId != null) { + // Why: route pane closes via the lifecycle hook for sibling promotion (falls through to closeTab on the last pane). + const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId } + window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) + } else { + // Why: the CLI/RPC caller is answered immediately, so it cannot wait on a modal. + closeTerminalTab(tabId, { skipRunningProcessConfirm: true }) + } + }) + ) + + // Why: during an in-place renderer reload an older preload can linger; keep this listener additive at that seam. + if (window.api.ui.onTerminalTabCloseRequest) { + unsubs.push( + window.api.ui.onTerminalTabCloseRequest( + ({ requestId, tabId, localPtyTeardownOwnedExternally }) => { + let responded = false + const respond = (error?: string): void => { + if (responded) { + return + } + responded = true + window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) + } + closeTerminalTab(tabId, { + rejectPinned: true, + ...(localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}), + onCancel: () => respond('terminal_tab_pinned'), + onClosed: () => { + void (async () => { + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + respond() + })().catch((error: unknown) => { + respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') + }) + } + }) + } + ) + ) + } + + unsubs.push( + window.api.ui.onSleepWorktree(({ worktreeId }) => { + void runSleepWorktree(worktreeId) + }) + ) + + unsubs.push( + window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { + // Why: a phone opened this worktree; wake its slept agents without changing the desktop's worktree/tab/view. + requestSleepingAgentWake(worktreeId) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/new-workspace-command.ts b/src/renderer/src/hooks/ipc-events/new-workspace-command.ts new file mode 100644 index 00000000000..98dd2b045c2 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/new-workspace-command.ts @@ -0,0 +1,36 @@ +import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' +import { getLinearIssueWorkspaceName } from '../../../../shared/workspace-name' +import type { AppState } from '../../store/types' + +type NewWorkspaceShortcutModalData = { + telemetrySource: 'shortcut' + prefilledName?: string + linkedWorkItem?: LinkedWorkItemSummary +} + +export function buildNewWorkspaceShortcutModalData( + state: Pick +): NewWorkspaceShortcutModalData { + const linearIssue = + state.activeView === 'tasks' ? (state.taskPageData.openLinearIssue ?? null) : null + if (!linearIssue) { + return { telemetrySource: 'shortcut' } + } + + return { + telemetrySource: 'shortcut', + prefilledName: getLinearIssueWorkspaceName(linearIssue), + // Cmd+N from a Linear issue mirrors its Start-workspace action with source context. + linkedWorkItem: buildLinearIssueLinkedWorkItem(linearIssue) + } +} + +export function openNewWorkspaceFromShortcut( + state: Pick +): void { + if (state.activeModal === 'new-workspace-composer') { + return + } + state.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(state)) +} diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts new file mode 100644 index 00000000000..d9c92610bbd --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -0,0 +1,25 @@ +import { + normalizeAgentStatusPayload, + type AgentStatusIpcPayload, + type ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' + +export function normalizeAgentStatusEvent( + data: AgentStatusIpcPayload +): ParsedAgentStatusPayload | null { + return normalizeAgentStatusPayload({ + state: data.state, + workingMode: data.workingMode, + prompt: data.prompt, + agentType: data.agentType, + model: data.model, + toolName: data.toolName, + toolInput: data.toolInput, + interactivePrompt: data.interactivePrompt, + lastAssistantMessage: data.lastAssistantMessage, + interrupted: data.interrupted, + sessionBoundary: data.sessionBoundary, + turnCompletedAt: data.turnCompletedAt, + subagents: data.subagents + }) +} diff --git a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts new file mode 100644 index 00000000000..6c75e473725 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts @@ -0,0 +1,101 @@ +import { applyWorktreeHeadIdentities } from '../worktree-head-identity-apply' +import type { WorktreeChangeRefreshQueue } from '../worktree-change-refresh-queue' +import { useAppStore } from '../../store' + +export function registerProjectCatalogIpcBridge( + unsubs: (() => void)[], + worktreeChangeRefreshQueue: WorktreeChangeRefreshQueue, + isRuntimeEnvironmentActive: () => boolean, + remountTerminalTabsAwaitingHostHydration: () => void +): void { + unsubs.push( + window.api.repos.onChanged(() => { + const state = useAppStore.getState() + if (isRuntimeEnvironmentActive()) { + // Why: the all-host sidebar shows local repos even under a runtime; refresh the local slice, keep runtime slices. + void (async () => { + const localOwner = { runtimeEnvironmentId: null } + await state.fetchRepos(localOwner) + await state.fetchProjectGroups(localOwner) + await state.fetchFolderWorkspaces(localOwner) + remountTerminalTabsAwaitingHostHydration() + })() + return + } + void state.fetchProjectGroups() + void state.fetchFolderWorkspaces() + void state.fetchRepos().then(remountTerminalTabsAwaitingHostHydration) + }) + ) + + unsubs.push( + window.api.worktrees.onChanged( + async (data: { + repoId: string + renamed?: { oldWorktreeId: string; newWorktreeId: string } + }) => { + // Why: preserve this event's local origin across queue delays and runtime + // focus changes; otherwise an unbound repo can refresh from the wrong host. + // A folder rename changes the worktree id; handleWorktreesChanged re-keys + // state and shields it from the deletion diff. + worktreeChangeRefreshQueue.enqueue({ + ...data, + forceLocalOwner: true + }) + } + ) + ) + + if (window.api.worktrees.onHeadIdentitiesChanged) { + unsubs.push( + window.api.worktrees.onHeadIdentitiesChanged((data) => { + if (isRuntimeEnvironmentActive()) { + // Why: local worktree events carry local repo ids; the local-pinned list + // refresh (onChanged) covers local rows while a runtime is active. + return + } + const state = useAppStore.getState() + applyWorktreeHeadIdentities(data, { + getWorktreesForRepo: (repoId) => state.worktreesByRepo[repoId], + updateWorktreeGitIdentity: state.updateWorktreeGitIdentity + }) + }) + ) + } + + unsubs.push( + window.api.worktrees.onBaseStatus((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().updateWorktreeBaseStatus(event) + }) + ) + + unsubs.push( + window.api.worktrees.onRemoteBranchConflict((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().updateWorktreeRemoteBranchConflict(event) + }) + ) + + // Why: route main's two-phase creation progress to each pending entry by correlation id (?. guards stale preload). + unsubs.push( + window.api.worktrees.onCreateProgress?.((data) => { + if (!data.creationId) { + return + } + useAppStore.getState().updatePendingWorktreeCreation(data.creationId, { phase: data.phase }) + }) ?? (() => {}) + ) + + if (window.api.gh?.onPRRefreshEvent) { + unsubs.push( + window.api.gh.onPRRefreshEvent((event) => { + useAppStore.getState().applyGitHubPRRefreshEvent(event) + }) + ) + } +} diff --git a/src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts new file mode 100644 index 00000000000..48fb94c8304 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts @@ -0,0 +1,30 @@ +import type { RateLimitState } from '../../../../shared/rate-limit-types' +import { useAppStore } from '../../store' + +export function registerRateLimitIpcBridge(unsubs: (() => void)[]): void { + let initialSnapshotPending = true + let receivedPushBeforeInitialSnapshot = false + unsubs.push( + window.api.rateLimits.onUpdate((state) => { + if (initialSnapshotPending) { + receivedPushBeforeInitialSnapshot = true + } + useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) + }) + ) + // The startup get is a fallback: a push before resolution permanently wins. + window.api.rateLimits.get().then((state) => { + initialSnapshotPending = false + if (receivedPushBeforeInitialSnapshot) { + return + } + useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) + }) + + const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.((progress) => { + useAppStore.getState().applyWorkspaceSpaceProgress(progress) + }) + if (unsubscribeWorkspaceSpaceProgress) { + unsubs.push(unsubscribeWorkspaceSpaceProgress) + } +} diff --git a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts new file mode 100644 index 00000000000..161d6eaf576 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts @@ -0,0 +1,50 @@ +import { useAppStore } from '../../store' +import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' + +export function registerRemoteWorkspaceIpcBridge( + unsubs: (() => void)[], + runtime: DirectSshBridgeRuntime +): void { + let clientId: string | null = null + let clientIdPromise: Promise | null = null + const getClientId = (): Promise => { + const remoteWorkspace = window.api.remoteWorkspace + if (!remoteWorkspace) { + return Promise.resolve(null) + } + if (clientId) { + return Promise.resolve(clientId) + } + clientIdPromise ??= remoteWorkspace + .clientId() + .then((id) => { + clientId = id + return id + }) + .catch(() => null) + return clientIdPromise + } + if (!window.api.remoteWorkspace) { + return + } + void getClientId() + unsubs.push( + window.api.remoteWorkspace.onChanged((event) => { + void (async () => { + const currentClientId = await getClientId() + if (event.sourceClientId && currentClientId && event.sourceClientId === currentClientId) { + return + } + await runtime.remoteWorkspaceTargetSync + ?.applyUnsolicitedSnapshot(event.targetId, event.snapshot) + .catch((error) => { + useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { + phase: 'error', + revision: event.snapshot.revision, + message: error instanceof Error ? error.message : 'Failed to apply remote workspace' + }) + }) + })() + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts new file mode 100644 index 00000000000..a4218c65763 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -0,0 +1,199 @@ +import { applyHostWorktreeTerminalSleepState } from '@/components/terminal-pane/pty-shutdown-exit-deferral' +import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' +import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { + applyRuntimeEnvironmentSshStateChanged, + hydrateRuntimeEnvironmentSshState, + refreshRuntimeEnvironmentSshTargetMetadata +} from '@/runtime/runtime-environment-ssh-state' +import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import { useAppStore } from '../../store' +import { createRuntimeClientEventsSync } from '../runtime-client-events-sync' +import { + createRuntimeProjectRefreshScheduler, + refreshRuntimeProjectWorktreesAndLineage +} from '../runtime-project-refresh-scheduler' +import { + buildRuntimeClientEventEnvironmentKey, + createRuntimeEnvironmentStoreSyncSubscriber, + getReachableRuntimeEnvironmentIds, + getRuntimeClientEventEnvironmentIds, + invalidateRuntimeClientEventReplay +} from './runtime-environment-subscription-selection' +import type { WorktreeEventRuntime } from './worktree-event-runtime' + +export function registerRuntimeClientIpcBridge( + unsubs: (() => void)[], + worktreeRuntime: WorktreeEventRuntime +): () => void { + const { worktreeChangeRefreshQueue, activateNotifiedWorktree } = worktreeRuntime + const ensureRuntimeEventRepoKnown = async ( + environmentId: string, + repoId: string + ): Promise => { + if ((useAppStore.getState().repos ?? []).some((repo) => repo.id === repoId)) { + return + } + await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) + } + + const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({ + refresh: async (environmentId) => { + // Why: project events can reveal target CRUD, but known target states already arrive by push. + void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {}) + const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) + // Why: the host emits one reposChanged for group/folder-workspace edits too, so those + // catalogs go stale without this; groups first because folder workspaces resolve owners from them. + const runtimeOwner = { runtimeEnvironmentId: environmentId } + // Why: catalogs and worktrees are independent; serializing them put two 15s RPC + // timeouts ahead of worktree/lineage convergence on a wedged host. + await Promise.all([ + (async () => { + await useAppStore.getState().fetchProjectGroups(runtimeOwner) + await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner) + })(), + refreshRuntimeProjectWorktreesAndLineage( + environmentId, + repos, + (repoId, options) => useAppStore.getState().fetchWorktrees(repoId, options), + (options) => useAppStore.getState().fetchWorktreeLineage(options) + ) + ]) + }, + onError: (error) => { + console.error('Failed to refresh runtime projects:', error) + } + }) + + const handleRuntimeClientEvent = ( + environmentId: string, + event: RuntimeClientEvent, + generation = getEnvironmentSshStateGeneration(environmentId) + ): void => { + if (event.type === 'worktreeTerminalSleepState') { + applyHostWorktreeTerminalSleepState(environmentId, event) + return + } + if (event.type === 'terminalSideEffects') { + dispatchTerminalSideEffectBatch({ + ...event.batch, + ptyId: toRemoteRuntimePtyId(event.batch.ptyId, environmentId) + }) + return + } + if (event.type === 'nativeChatLaunchDraftResolved') { + applyNativeChatLaunchDraftResolved(useAppStore.getState(), event) + return + } + if (event.type === 'reposChanged') { + runtimeProjectRefreshScheduler.request(environmentId) + return + } + if (event.type === 'sshStateChanged') { + applyRuntimeEnvironmentSshStateChanged(environmentId, event.targetId, event.state, generation) + return + } + if (event.type === 'worktreesChanged') { + void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => + worktreeChangeRefreshQueue.enqueue({ + repoId: event.repoId, + executionHostId: toRuntimeExecutionHostId(environmentId) + }) + ) + return + } + if (event.type === 'linearLinkedIssueUpdated') { + void useAppStore + .getState() + .refreshLinearIssue(event.identifier, event.workspaceId) + .catch((error) => { + console.error('Failed to refresh updated Linear issue:', error) + }) + return + } + void ensureRuntimeEventRepoKnown(environmentId, event.repoId) + .then(() => activateNotifiedWorktree(event, { allowRuntimeEnvironment: true })) + .catch((error) => { + console.error('Failed to activate runtime-created worktree:', error) + }) + } + + const runtimeClientEventsSync = createRuntimeClientEventsSync({ + getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()), + getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]), + subscribe: (environmentId, onEvent, onError) => { + const sshGeneration = getEnvironmentSshStateGeneration(environmentId) + const runtimeGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) + const runtimeRevision = getRuntimeEnvironmentRevision(environmentId) + return subscribeRuntimeClientEvents( + environmentId, + (event) => { + if ( + sshGeneration === getEnvironmentSshStateGeneration(environmentId) && + runtimeGeneration === getRuntimeEnvironmentConnectionGeneration(environmentId) && + runtimeRevision === getRuntimeEnvironmentRevision(environmentId) + ) { + onEvent(event) + } + }, + onError, + () => { + invalidateRuntimeClientEventReplay({ + getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, + requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId), + markEnvironmentSshStateStale: () => + useAppStore.getState().markEnvironmentSshStateStale(environmentId), + hydrateEnvironmentSshState: () => + hydrateRuntimeEnvironmentSshState(environmentId, { force: true }), + sync: runtimeClientEventsSync.sync + }) + } + ) + }, + onEvent: handleRuntimeClientEvent + }) + + // Why: no on-connect repo fetch (PR #2); seed discovery for connected runtimes or remote projects hide until Add-Project. + const initialRuntimeEnvironmentState = useAppStore.getState() + const runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds( + initialRuntimeEnvironmentState + ) + for (const environmentId of runtimeClientEventEnvironmentIds) { + runtimeProjectRefreshScheduler.request(environmentId) + } + const reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds( + initialRuntimeEnvironmentState + ) + const handleRuntimeEnvironmentStoreWrite = createRuntimeEnvironmentStoreSyncSubscriber({ + initialDesiredEnvironmentIds: runtimeClientEventEnvironmentIds, + initialReachableEnvironmentIds: reachableRuntimeEnvironmentIds, + buildEnvironmentKey: buildRuntimeClientEventEnvironmentKey, + getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, + getReachableEnvironmentIds: getReachableRuntimeEnvironmentIds, + requestProjectRefresh: (environmentId) => + // The scheduler coalesces bursts per environment. + runtimeProjectRefreshScheduler.request(environmentId), + markEnvironmentSshStateStale: (environmentId) => { + // No-op when the environment has no SSH bucket (e.g. web client). + useAppStore.getState().markEnvironmentSshStateStale(environmentId) + }, + sync: runtimeClientEventsSync.sync + }) + const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe( + handleRuntimeEnvironmentStoreWrite + ) + // Subscribe before the first runtime stream starts: replay invalidation may + // synchronously publish a tracked SSH bucket and relies on this listener to + // replace that subscription exactly once. + runtimeClientEventsSync.sync() + unsubs.push(runtimeClientEventsSync.stop) + unsubs.push(runtimeProjectRefreshScheduler.stop) + + return unsubscribeRuntimeEnvironmentStore +} diff --git a/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts new file mode 100644 index 00000000000..b2fee2d646e --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts @@ -0,0 +1,203 @@ +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import type { AppState } from '../../store/types' + +export type RuntimeEnvironmentStoreSyncState = Pick< + AppState, + 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId' | 'settings' | 'sshStateByEnvironment' +> + +function getActiveRuntimeEnvironmentId(state: RuntimeEnvironmentStoreSyncState): string | null { + return state.settings?.activeRuntimeEnvironmentId?.trim() || null +} + +export function getRuntimeClientEventEnvironmentIds( + state: RuntimeEnvironmentStoreSyncState +): string[] { + const ids = new Set() + const activeEnvironmentId = getActiveRuntimeEnvironmentId(state) + if (activeEnvironmentId) { + ids.add(activeEnvironmentId) + } + for (const environment of state.runtimeEnvironments ?? []) { + if (state.runtimeStatusByEnvironmentId?.get(environment.id)?.status) { + ids.add(environment.id) + } + } + return [...ids] +} + +export function getReachableRuntimeEnvironmentIds( + state: RuntimeEnvironmentStoreSyncState +): string[] { + const ids: string[] = [] + for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) { + if (status?.status) { + ids.push(environmentId) + } + } + return ids +} + +export function canSkipRuntimeEnvironmentStoreSync( + state: RuntimeEnvironmentStoreSyncState, + previousState: RuntimeEnvironmentStoreSyncState +): boolean { + return ( + state.runtimeEnvironments === previousState.runtimeEnvironments && + state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId && + state.sshStateByEnvironment === previousState.sshStateByEnvironment && + getActiveRuntimeEnvironmentId(state) === getActiveRuntimeEnvironmentId(previousState) + ) +} + +export function buildRuntimeClientEventEnvironmentKey(environmentIds: string[]): string { + return [...new Set(environmentIds)] + .sort() + .map( + (environmentId) => + `${environmentId}:${getRuntimeEnvironmentConnectionGeneration(environmentId)}:${getEnvironmentSshStateGeneration(environmentId)}:${getRuntimeEnvironmentRevision(environmentId) ?? 'unknown'}` + ) + .join('\u0000') +} + +/** Ids in `next` not in `previous` — environments that just became connected. */ +export function getNewlyConnectedRuntimeEnvironmentIds( + previous: readonly string[], + next: readonly string[] +): string[] { + const known = new Set(previous) + return [...new Set(next)].filter((environmentId) => !known.has(environmentId)) +} + +/** Ids in `previous` not in `next` — environments whose transport was just observed down. */ +export function getNewlyDisconnectedRuntimeEnvironmentIds( + previous: readonly string[], + next: readonly string[] +): string[] { + return getNewlyConnectedRuntimeEnvironmentIds(next, previous) +} + +export function getRuntimeProjectRefreshEnvironmentIds(args: { + previousDesired: readonly string[] + nextDesired: readonly string[] + previousReachable: readonly string[] + nextReachable: readonly string[] +}): string[] { + return [ + ...new Set([ + ...getNewlyConnectedRuntimeEnvironmentIds(args.previousDesired, args.nextDesired), + ...getNewlyConnectedRuntimeEnvironmentIds(args.previousReachable, args.nextReachable) + ]) + ] +} + +type RuntimeEnvironmentStoreSyncSubscriberDeps = { + initialDesiredEnvironmentIds: string[] + initialReachableEnvironmentIds: string[] + buildEnvironmentKey: (environmentIds: string[]) => string + getDesiredEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] + getReachableEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] + requestProjectRefresh: (environmentId: string) => void + markEnvironmentSshStateStale: (environmentId: string) => void + sync: () => void +} + +export type RuntimeEnvironmentStoreSyncSubscriber = ( + state: RuntimeEnvironmentStoreSyncState, + previousState: RuntimeEnvironmentStoreSyncState +) => void + +/** + * Builds the one renderer-wide runtime subscriber. The reference gate runs + * before either host collection is enumerated; key generation remains the + * second gate for relevant-reference writes whose effective subscription set + * did not change. + */ +export function createRuntimeEnvironmentStoreSyncSubscriber( + deps: RuntimeEnvironmentStoreSyncSubscriberDeps +): RuntimeEnvironmentStoreSyncSubscriber { + let desiredEnvironmentIds = deps.initialDesiredEnvironmentIds + let desiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) + let reachableEnvironmentIds = deps.initialReachableEnvironmentIds + let reachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) + let handlingStoreWrite = false + + return (state, previousState) => { + // markEnvironmentSshStateStale can synchronously publish its nested SSH + // bucket. The outer pass incorporates that generation before syncing, so a + // re-entrant pass would only enumerate and sync the same transition twice. + if (handlingStoreWrite || canSkipRuntimeEnvironmentStoreSync(state, previousState)) { + return + } + + handlingStoreWrite = true + try { + const nextDesiredEnvironmentIds = deps.getDesiredEnvironmentIds(state) + const nextReachableEnvironmentIds = deps.getReachableEnvironmentIds(state) + const refreshEnvironmentIds = getRuntimeProjectRefreshEnvironmentIds({ + previousDesired: desiredEnvironmentIds, + nextDesired: nextDesiredEnvironmentIds, + previousReachable: reachableEnvironmentIds, + nextReachable: nextReachableEnvironmentIds + }) + const disconnectedEnvironmentIds = getNewlyDisconnectedRuntimeEnvironmentIds( + reachableEnvironmentIds, + nextReachableEnvironmentIds + ) + + desiredEnvironmentIds = nextDesiredEnvironmentIds + reachableEnvironmentIds = nextReachableEnvironmentIds + for (const environmentId of refreshEnvironmentIds) { + deps.requestProjectRefresh(environmentId) + } + for (const environmentId of disconnectedEnvironmentIds) { + deps.markEnvironmentSshStateStale(environmentId) + } + + // Build after disconnect invalidation: marking a mirrored SSH bucket stale + // advances its generation, and the replacement subscription must capture + // that final generation in this same (single) sync. + const nextDesiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) + const nextReachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) + if ( + nextDesiredEnvironmentKey === desiredEnvironmentKey && + nextReachableEnvironmentKey === reachableEnvironmentKey + ) { + return + } + desiredEnvironmentKey = nextDesiredEnvironmentKey + reachableEnvironmentKey = nextReachableEnvironmentKey + deps.sync() + } finally { + handlingStoreWrite = false + } + } +} + +type RuntimeClientEventReplayInvalidationDeps = { + getSshStateReference: () => RuntimeEnvironmentStoreSyncState['sshStateByEnvironment'] + requestProjectRefresh: () => void + markEnvironmentSshStateStale: () => void + hydrateEnvironmentSshState: () => Promise + sync: () => void +} + +/** + * Invalidates a replay after the runtime event stream reports a transport gap. + * A tracked SSH bucket publishes synchronously and lets the store subscriber + * sync it; an empty/already-stale bucket has no reference publication, so this + * path must explicitly sync the advanced module-level SSH generation. + */ +export function invalidateRuntimeClientEventReplay( + deps: RuntimeClientEventReplayInvalidationDeps +): void { + deps.requestProjectRefresh() + const previousSshStateReference = deps.getSshStateReference() + deps.markEnvironmentSshStateStale() + if (deps.getSshStateReference() === previousSshStateReference) { + deps.sync() + } + void deps.hydrateEnvironmentSshState().catch(() => {}) +} diff --git a/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts new file mode 100644 index 00000000000..b0b284fbd79 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts @@ -0,0 +1,108 @@ +import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' +import { + SESSION_TAB_CLOSE_CANCELED_ERROR, + SESSION_TAB_CLOSE_FAILED_ERROR, + SESSION_TAB_NOT_FOUND_ERROR, + SESSION_TAB_CLOSE_TIMEOUT_ERROR +} from '../../../../shared/session-tab-close' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel +} from '../../store/pinned-tab-close-guard' +import { useAppStore } from '../../store' +import { resolveBrowserSessionTabTarget } from './browser-session-tab-target' + +export function registerSessionTabIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onCloseSessionTab(({ tabId, worktreeId }) => { + const store = useAppStore.getState() + const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + if (browserTarget) { + guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, browserTarget.workspaceId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId), + onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) + }) + return + } + guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, tabId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), + onClose: () => { + const currentStore = useAppStore.getState() + closeMobileSessionTabInStore(currentStore, worktreeId, tabId) + } + }) + }) + ) + + unsubs.push( + window.api.ui.onSessionTabCloseRequest(({ requestId, tabId, worktreeId, expiresAt }) => { + const store = useAppStore.getState() + const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + let cancelConfirmation: (() => void) | undefined + let timeout: ReturnType | undefined + let settled = false + const respond = (error?: string): void => { + if (settled) { + return + } + settled = true + if (timeout !== undefined) { + clearTimeout(timeout) + } + window.api.ui.respondSessionTabClose({ requestId, ...(error ? { error } : {}) }) + } + if (expiresAt !== undefined) { + timeout = setTimeout( + () => { + cancelConfirmation?.() + respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) + }, + Math.max(0, expiresAt - Date.now()) + ) + } + const closeAndRespond = (): void => { + if (expiresAt !== undefined && Date.now() >= expiresAt) { + respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) + return + } + try { + if (browserTarget) { + useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) + respond() + return + } + const closed = closeMobileSessionTabInStore(useAppStore.getState(), worktreeId, tabId) + respond(closed ? undefined : SESSION_TAB_NOT_FOUND_ERROR) + } catch (error) { + respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR) + } + } + const visibleId = browserTarget?.workspaceId ?? tabId + cancelConfirmation = guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, visibleId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, visibleId), + onClose: closeAndRespond, + onCancel: () => respond(SESSION_TAB_CLOSE_CANCELED_ERROR) + }) + }) + ) + + unsubs.push( + window.api.ui.onMoveSessionTab((move) => { + const { tabId, targetGroupId } = move + const store = useAppStore.getState() + if (move.kind === 'reorder') { + store.reorderUnifiedTabs(targetGroupId, move.tabOrder) + return + } + store.dropUnifiedTab(tabId, { + groupId: targetGroupId, + ...(move.kind === 'move-to-group' ? { index: move.index } : {}), + ...(move.kind === 'split' ? { splitDirection: move.splitDirection } : {}) + }) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts new file mode 100644 index 00000000000..bf3d74dd3a9 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts @@ -0,0 +1,190 @@ +import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' +import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { subscribeToUnpairedDeviceAuthNotification } from '../unpaired-device-auth-notification' +import { translate } from '@/i18n/i18n' +import { toast } from 'sonner' +import { useAppStore } from '../../store' + +function getShortcutPlatform(): NodeJS.Platform { + if (navigator.userAgent.includes('Mac')) { + return 'darwin' + } + if (navigator.userAgent.includes('Windows')) { + return 'win32' + } + return 'linux' +} + +export function registerSettingsAndSidebarIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onOpenSettings(() => { + useAppStore.getState().openSettingsPage() + }) + ) + + const unsubscribeOpenSkillShare = window.api.ui.onOpenSkillShare?.((shareId) => { + useAppStore.getState().openSkillShare(shareId) + }) + if (unsubscribeOpenSkillShare) { + unsubs.push(unsubscribeOpenSkillShare) + } + + // Why: a tray "Settings…" click can fire before this attaches; consume any queued intent (?. guards stale preload). + void window.api.ui + .consumePendingOpenSettings?.() + .then((open) => { + if (open) { + useAppStore.getState().openSettingsPage() + } + }) + .catch(() => {}) + + const pendingSkillShare = window.api.ui.consumePendingSkillShare?.() + if (pendingSkillShare && typeof pendingSkillShare.then === 'function') { + void pendingSkillShare + .then((shareId) => { + if (shareId) { + useAppStore.getState().openSkillShare(shareId) + } + }) + .catch(() => {}) + } + + unsubs.push( + window.api.ui.onOpenSetupGuide?.(() => { + useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' }) + }) ?? (() => {}) + ) + + // Why: a phone stuck in a silent 4001 auth loop (lost device registry) reads as + // "phone won't connect" with no clue on either end; main throttles to once per session. + unsubs.push( + subscribeToUnpairedDeviceAuthNotification(window.api.mobile, () => { + toast.warning( + translate( + 'auto.hooks.useIpcEvents.ef223fbb6b', + 'A device tried to connect but is not paired' + ), + { + id: 'unpaired-device-auth-failure', + description: translate( + 'auto.hooks.useIpcEvents.11992d0337', + 'If this was your phone or another Orca client, re-pair it from Settings → Mobile.' + ), + // Why: main emits this recovery path once per session, so it must remain visible until acted on or dismissed. + duration: Infinity, + action: { + label: translate('auto.hooks.useIpcEvents.6573cfe955', 'Open Mobile Settings'), + onClick: () => { + const store = useAppStore.getState() + store.openSettingsTarget({ pane: 'mobile', repoId: null }) + store.openSettingsPage() + } + } + } + ) + }) + ) + + unsubs.push( + window.api.ui.onOpenFeatureTour(() => { + useAppStore.getState().openModal('feature-wall', { source: 'help_menu' }) + }) + ) + + // Why: View > Appearance toggles settings in main and broadcasts; merge into the store for an immediate re-render. + unsubs.push( + window.api.settings.onChanged((updates) => { + const store = useAppStore.getState() + if (!store.settings) { + return + } + const { worktreeVisibilityDefaults, ...activeOwnerUpdates } = updates + const settingsUpdates = store.settings.activeRuntimeEnvironmentId + ? activeOwnerUpdates + : updates + useAppStore.setState({ + settings: { + ...store.settings, + ...settingsUpdates, + notifications: { + ...store.settings.notifications, + ...updates.notifications + } + }, + ...(worktreeVisibilityDefaults + ? { + worktreeVisibilityDefaultsByHost: { + ...store.worktreeVisibilityDefaultsByHost, + local: worktreeVisibilityDefaults + } + } + : {}) + }) + if ('worktreeVisibilityDefaults' in updates) { + void store.fetchAllWorktrees({ visibilityOwnerHostId: 'local' }) + } + }) + ) + + // Why: UI view-state is shared with mobile via ui.set; re-hydrate so mobile changes reflect live in the desktop sidebar. + unsubs.push( + window.api.ui.onStateChanged((ui) => { + useAppStore.getState().hydratePersistedUI(ui, 'sync') + }) + ) + + if (window.api.keybindings) { + unsubs.push( + window.api.keybindings.onChanged((snapshot) => { + useAppStore.getState().setKeybindingSnapshot(snapshot) + }) + ) + } + + unsubs.push( + window.api.ui.onToggleLeftSidebar(() => { + useAppStore.getState().toggleSidebar() + }) + ) + + unsubs.push( + window.api.ui.onToggleRightSidebar(() => { + const store = useAppStore.getState() + if (!canShowRightSidebarForView(store.activeView)) { + return + } + store.toggleRightSidebar() + }) + ) + + unsubs.push( + window.api.ui.onToggleWorktreePalette(() => { + const store = useAppStore.getState() + if (store.activeModal === 'worktree-palette') { + store.closeModal() + return + } + store.openModal('worktree-palette') + }) + ) + + unsubs.push( + window.api.ui.onToggleFloatingTerminal(() => { + window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT)) + }) + ) + + if (window.api.ui.onTerminalShortcutCaptured) { + unsubs.push( + window.api.ui.onTerminalShortcutCaptured(({ actionId }) => { + showTerminalShortcutCaptureNotification({ + actionId, + platform: getShortcutPlatform(), + keybindings: useAppStore.getState().keybindings + }) + }) + ) + } +} diff --git a/src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts new file mode 100644 index 00000000000..d585e721ff6 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts @@ -0,0 +1,180 @@ +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + closeWebRuntimeSessionTab, + createWebRuntimeSessionTerminal, + isWebRuntimeSessionActive +} from '@/runtime/web-runtime-session' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel +} from '../../store/pinned-tab-close-guard' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { + createFloatingWorkspaceTerminalTab, + isEmptyFloatingWorkspacePanelVisible, + isFloatingWorkspacePanelFocused, + resolveFloatingWorkspaceBrowserWorkspaceId, + switchFloatingWorkspaceTab +} from '@/lib/floating-workspace-terminal-actions' +import { + dispatchFloatingWorkspaceGuestClose, + dispatchFloatingWorkspaceGuestSelectIndex +} from '@/lib/floating-workspace-guest-bridge' + +import { useAppStore } from '../../store' +import { + handleSwitchRecentTab, + handleSwitchTab, + handleSwitchTabAcrossAllTypes, + handleSwitchTerminalTab +} from '../ipc-tab-switch' +function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null { + return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) +} + +export function registerTabLifecycleIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onNewTerminalTab(() => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + void createFloatingWorkspaceTerminalTab(store) + return + } + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + void (async () => { + const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) + const outcome = await createWebRuntimeSessionTerminal({ + worktreeId, + environmentId, + activate: true + }) + if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) { + return + } + const newTab = store.createTab(worktreeId) + store.setActiveTabType('terminal') + // Why: mirror Terminal.tsx handleNewTab so a new tab appends at the end, not index 0, when tabBarOrder is unset. + const freshStore = useAppStore.getState() + const currentTerminals = freshStore.tabsByWorktree[worktreeId] ?? [] + const currentEditors = freshStore.openFiles.filter((f) => f.worktreeId === worktreeId) + const currentBrowsers = freshStore.browserTabsByWorktree[worktreeId] ?? [] + const stored = freshStore.tabBarOrderByWorktree[worktreeId] + const termIds = currentTerminals.map((t) => t.id) + const editorIds = currentEditors.map((f) => f.id) + const browserIds = currentBrowsers.map((tab) => tab.id) + const validIds = new Set([...termIds, ...editorIds, ...browserIds]) + const base = (stored ?? []).filter((id) => validIds.has(id)) + const inBase = new Set(base) + for (const id of [...termIds, ...editorIds, ...browserIds]) { + if (!inBase.has(id)) { + base.push(id) + inBase.add(id) + } + } + const order = base.filter((id) => id !== newTab.id) + order.push(newTab.id) + freshStore.setTabBarOrder(worktreeId, order) + focusTerminalTabSurface(newTab.id) + })() + }) + ) + + unsubs.push( + window.api.ui.onCloseActiveTab(() => { + if (isEmptyFloatingWorkspacePanelVisible()) { + window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT)) + return + } + const store = useAppStore.getState() + if (store.activeTabType === 'browser' && store.activeBrowserTabId) { + const tabId = store.activeBrowserTabId + const worktreeId = store.activeWorktreeId + const closeActiveBrowserTab = (): void => { + const currentStore = useAppStore.getState() + const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) + if (environmentId && worktreeId) { + if (!isWebRuntimeSessionActive(environmentId)) { + currentStore.closeBrowserTab(tabId) + return + } + void closeWebRuntimeSessionTab({ + worktreeId, + tabId, + environmentId, + reason: 'user' + }) + return + } + currentStore.closeBrowserTab(tabId) + } + if (worktreeId && isUnifiedTabPinned(store, worktreeId, tabId)) { + guardPinnedTabClose({ + isPinned: true, + tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), + onClose: closeActiveBrowserTab + }) + return + } + closeActiveBrowserTab() + } + }) + ) + + unsubs.push( + window.api.ui.onCloseFloatingItem(({ sourceId }) => { + // Main forwards the guest's browser *page* id; resolve it to the owning live floating + // browser workspace (the id space the panel closes by), then hand off to the mounted + // panel's own close closure (pin guard + reclaim intent). Stale id = no-op. + const workspaceId = resolveFloatingWorkspaceBrowserWorkspaceId( + useAppStore.getState(), + sourceId + ) + if (!workspaceId) { + return + } + dispatchFloatingWorkspaceGuestClose({ sourceId: workspaceId }) + }) + ) + unsubs.push( + window.api.ui.onSelectFloatingIndex(({ index }) => { + dispatchFloatingWorkspaceGuestSelectIndex({ index }) + }) + ) + + unsubs.push( + window.api.ui.onSwitchTab((direction) => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + switchFloatingWorkspaceTab(store, direction, 'same-type') + return + } + handleSwitchTab(direction) + }) + ) + unsubs.push( + window.api.ui.onSwitchTabAcrossAllTypes((direction) => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + switchFloatingWorkspaceTab(store, direction, 'all-types') + return + } + handleSwitchTabAcrossAllTypes(direction) + }) + ) + unsubs.push(window.api.ui.onSwitchRecentTab(handleSwitchRecentTab)) + unsubs.push( + window.api.ui.onSwitchTerminalTab((direction) => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + switchFloatingWorkspaceTab(store, direction, 'terminal') + return + } + handleSwitchTerminalTab(direction) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-command-state.ts b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts new file mode 100644 index 00000000000..3e6c61b506f --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts @@ -0,0 +1,132 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph' +import type { RuntimeTerminalPresentation } from '../../../../shared/runtime-types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../../shared/terminal-tab-types' +import type { AppState } from '../../store/types' + +export function resolveTerminalPresentation(data: { + presentation?: RuntimeTerminalPresentation + activate?: boolean + focus?: boolean +}): RuntimeTerminalPresentation | undefined { + if (data.presentation) { + return data.presentation + } + if (data.focus !== undefined) { + return data.focus ? 'focused' : 'background' + } + if (data.activate === true) { + return 'focused' + } + return undefined +} + +export function focusTerminalInitiatedTab(tabId: string, leafId?: string | null): void { + if (!focusRuntimeTerminalSurface(tabId, leafId)) { + focusTerminalTabSurface(tabId, leafId) + } +} + +export function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void { + store.setActiveView('terminal') + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + if (!store.isNavigatingHistory) { + store.recordWorktreeVisit(worktreeId) + } +} + +type TerminalSplitDirection = 'horizontal' | 'vertical' + +function insertLeafAfterSource( + node: TerminalPaneLayoutNode, + sourceLeafId: string, + newLeafId: string, + direction: TerminalSplitDirection +): { node: TerminalPaneLayoutNode; inserted: boolean } { + if (node.type === 'leaf') { + if (node.leafId !== sourceLeafId) { + return { node, inserted: false } + } + return { + node: { + type: 'split', + direction, + first: node, + second: { type: 'leaf', leafId: newLeafId }, + ratio: 0.5 + }, + inserted: true + } + } + const first = insertLeafAfterSource(node.first, sourceLeafId, newLeafId, direction) + if (first.inserted) { + return { node: { ...node, first: first.node }, inserted: true } + } + const second = insertLeafAfterSource(node.second, sourceLeafId, newLeafId, direction) + return second.inserted + ? { node: { ...node, second: second.node }, inserted: true } + : { node, inserted: false } +} + +export function addSplitLeafToLayout( + layout: TerminalLayoutSnapshot | null | undefined, + sourceLeafId: string, + newLeafId: string, + ptyId: string, + direction: TerminalSplitDirection, + title?: string | null, + activateNewLeaf = true +): TerminalLayoutSnapshot { + const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId } + const existingLeafIds = collectLeafIdsInOrder(root) + const nextActiveLeafId = + activateNewLeaf || !layout?.activeLeafId || !existingLeafIds.includes(layout.activeLeafId) + ? newLeafId + : layout.activeLeafId + const nextRoot = existingLeafIds.includes(newLeafId) + ? root + : (() => { + const inserted = insertLeafAfterSource(root, sourceLeafId, newLeafId, direction) + if (inserted.inserted) { + return inserted.node + } + return { + type: 'split' as const, + direction, + first: root, + second: { type: 'leaf' as const, leafId: newLeafId }, + ratio: 0.5 + } + })() + return { + ...(layout ?? { root: null, activeLeafId: null, expandedLeafId: null }), + root: nextRoot, + activeLeafId: nextActiveLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { ...layout?.ptyIdsByLeafId, [newLeafId]: ptyId }, + ...(title ? { titlesByLeafId: { ...layout?.titlesByLeafId, [newLeafId]: title } } : {}) + } +} + +export function activateExistingLeafInLayout( + layout: TerminalLayoutSnapshot | null | undefined, + leafId: string, + ptyId: string, + title?: string | null +): TerminalLayoutSnapshot | null { + if (!layout?.root || !collectLeafIdsInOrder(layout.root).includes(leafId)) { + return null + } + return { + ...layout, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leafId]: ptyId }, + ...(title ? { titlesByLeafId: { ...layout.titlesByLeafId, [leafId]: title } } : {}) + } +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts new file mode 100644 index 00000000000..0be417cf3e3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -0,0 +1,272 @@ +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' +import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { resolveTerminalTabPtyOwnership } from '@/lib/terminal-tab-for-pty-id' +import { SPLIT_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { SplitTerminalPaneDetail } from '@/constants/terminal' +import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' +import { verifyTerminalRevealIdentity } from '@/lib/terminal-reveal-identity' +import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { tryMakePaneKey } from './agent-status-routing' +import { useAppStore } from '../../store' +import { + activateExistingLeafInLayout, + activateTerminalInitiatedWorktree, + addSplitLeafToLayout, + focusTerminalInitiatedTab, + resolveTerminalPresentation +} from './terminal-command-state' + +export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onCreateTerminal( + ({ + requestId, + worktreeId, + command, + cwd, + env, + launchConfig, + resumeProviderSession, + launchToken, + launchAgent, + viewMode, + title, + ptyId, + activate, + focus, + presentation, + surfaceOwner, + tabId, + leafId, + splitFromLeafId, + splitDirection, + splitTelemetrySource + }) => { + try { + const store = useAppStore.getState() + const terminalPresentation = resolveTerminalPresentation({ + presentation, + activate, + focus + }) + const shouldActivate = terminalPresentation === 'focused' + const shouldSurfaceOwner = terminalPresentation !== 'background' && surfaceOwner !== false + if (shouldActivate) { + activateTerminalInitiatedWorktree(store, worktreeId) + } + const worktreeTabs = store.tabsByWorktree[worktreeId] ?? [] + // Why: a split pane revealed from mobile is only bound in the persisted + // layout until its pane mounts; missing it minted a duplicate tab (#10486). + const ownership = ptyId + ? resolveTerminalTabPtyOwnership( + store, + worktreeId, + ptyId, + tabId !== undefined ? { preferTabId: tabId } : {} + ) + : { kind: 'none' as const } + const existingTab = + ownership.kind === 'owned' + ? worktreeTabs.find((candidate) => candidate.id === ownership.tabId) + : undefined + const isSplitReveal = Boolean(ptyId && tabId && leafId && splitFromLeafId) + const splitTargetTab = isSplitReveal + ? worktreeTabs.find((candidate) => candidate.id === tabId) + : undefined + if (isSplitReveal && !splitTargetTab) { + throw new Error(`Terminal tab ${tabId} not found`) + } + const reusedTab = existingTab ?? splitTargetTab + const tab = + reusedTab ?? + (ptyId + ? store.createTab(worktreeId, undefined, undefined, { + initialPtyId: ptyId, + activate: shouldActivate, + ...(launchAgent + ? { + launchAgent, + // Why: a paired client resolved explicit mode before PTY materialization; only omitted mode uses host defaults. + ...(viewMode + ? { viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: launchAgent, + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })) + } + : {}), + ...(cwd ? { startupCwd: cwd } : {}), + // Why: CLI-spawned PTYs bake the pane key into env; adopt the same tab id so hook-event attribution keeps working. + ...(tabId !== undefined ? { id: tabId } : {}) + }) + : store.createTab( + worktreeId, + undefined, + undefined, + shouldActivate + ? cwd + ? { startupCwd: cwd } + : undefined + : { + activate: false, + recordInteraction: false, + ...(cwd ? { startupCwd: cwd } : {}) + } + )) + // Why: a reused tab whose id differs from the hint breaks the PTY's baked-in paneKey attribution; warn during dev. + if (tabId !== undefined && tab.id !== tabId) { + console.warn( + `[onCreateTerminal] tabId hint ${tabId} ignored for ptyId ${ptyId}; existing tab ${tab.id} adopted instead (hook attribution will degrade for this terminal)` + ) + } + if (shouldActivate) { + store.setActiveTabType('terminal') + store.setActiveTab(tab.id) + } + if (shouldSurfaceOwner) { + store.revealWorktreeInSidebar(worktreeId) + focusTerminalInitiatedTab(tab.id, leafId) + } + // Why: only stamp the runtime title on fresh tabs; reused tabs may have a user customTitle it would overwrite on focus. + if (title && !reusedTab) { + store.setTabCustomTitle(tab.id, title, { recordInteraction: false }) + } + if (leafId && ptyId) { + const launchPaneKey = tryMakePaneKey(tab.id, leafId) + if (launchConfig) { + if (launchPaneKey) { + store.registerAgentLaunchConfig(launchPaneKey, launchConfig, { + ...(launchAgent ? { agentType: launchAgent } : {}), + ...(launchToken ? { launchToken } : {}), + tabId: tab.id, + leafId + }) + } + } else if (!splitFromLeafId && launchPaneKey) { + store.clearAgentLaunchConfig(launchPaneKey) + } + if (splitFromLeafId) { + // Why: runtime split PTYs already carry the parent tab's paneKey, so reuse the tab instead of minting a collision tab. + store.updateTabPtyId(tab.id, ptyId) + const existingLayout = store.terminalLayoutsByTabId?.[tab.id] + const sourcePtyId = existingLayout?.ptyIdsByLeafId?.[splitFromLeafId] + store.setTabLayout( + tab.id, + addSplitLeafToLayout( + existingLayout, + splitFromLeafId, + leafId, + ptyId, + splitDirection ?? 'horizontal', + title, + shouldActivate + ) + ) + window.dispatchEvent( + new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { + detail: { + tabId: tab.id, + paneRuntimeId: -1, + direction: splitDirection ?? 'horizontal', + sourceLeafId: splitFromLeafId, + sourcePtyId, + telemetrySource: splitTelemetrySource, + newLeafId: leafId, + ptyId + } + }) + ) + } else { + // Why: CLI/runtime PTYs emit hook events before the tab mounts, so the leaf must exist in layout for paneKey validation. + const existingLayout = reusedTab + ? activateExistingLeafInLayout( + store.terminalLayoutsByTabId?.[tab.id], + leafId, + ptyId, + title + ) + : null + if (existingLayout) { + store.updateTabPtyId(tab.id, ptyId) + store.setTabLayout(tab.id, existingLayout) + } else { + store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title)) + } + } + } + if (command) { + store.queueTabStartupCommand(tab.id, { + command, + ...(env ? { env } : {}), + ...(launchConfig ? { launchConfig } : {}), + ...(resumeProviderSession ? { resumeProviderSession } : {}), + ...(launchToken ? { launchToken } : {}), + ...(launchAgent ? { launchAgent } : {}) + }) + } + if (ptyId && terminalPresentation === 'background') { + requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) + } + if (requestId) { + // Why: attest the actual binding; recovery callers compare it with their expected identity. + const identity = + ptyId && tabId && leafId + ? verifyTerminalRevealIdentity(useAppStore.getState(), { + worktreeId, + tabId: tab.id, + leafId, + ptyId + }) + : undefined + window.api.ui.replyTerminalCreate({ + requestId, + tabId: tab.id, + title: title ?? tab.title, + ...(identity ? { identity } : {}) + }) + } + } catch (err) { + if (!requestId) { + throw err + } + window.api.ui.replyTerminalCreate({ + requestId, + error: err instanceof Error ? err.message : 'Terminal reveal failed' + }) + } + } + ) + ) + + // Why: background-mount a mobile-subscribed tab's PTY without navigating the desktop (STA-1840). + unsubs.push( + window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { + if (!worktreeId) { + return + } + // Why: synthetic pty handles need persisted-tab resolution; a miss must not mount every saved tab in a hidden worktree. + const mount = planMobileTerminalTabMount( + useAppStore.getState(), + { + worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }, + { + isTabMounted: hasRegisteredRuntimeTerminalTab + } + ) + if (mount) { + requestBackgroundTerminalWorktreeMount(mount) + } + }) + ) + + // Why: CLI-driven terminal creation waits for the tabId reply so it can hand the caller a usable handle immediately. +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts new file mode 100644 index 00000000000..ef8540550cd --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts @@ -0,0 +1,152 @@ +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import { + activateTerminalInitiatedWorktree, + focusTerminalInitiatedTab, + resolveTerminalPresentation +} from './terminal-command-state' + +export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onRequestTerminalCreate((data) => { + try { + const store = useAppStore.getState() + const worktreeId = data.worktreeId ?? store.activeWorktreeId + if (!worktreeId) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') + }) + return + } + const worktreeRoute = resolveTerminalWorktreeRoute(store, worktreeId) + if (!worktreeRoute) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.unresolvedTerminalWorktreeOwner', + 'Terminal creation is unavailable because the worktree owner could not be resolved' + ) + }) + return + } + // Why: runtime-session requests are host-owned tabs materialized by this renderer, not ordinary local creates. + if (worktreeRoute.runtimeEnvironmentId && data.source !== 'runtime-session') { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: translate( + 'auto.hooks.useIpcEvents.7a64b31991', + 'Local terminal creation is unavailable while a remote runtime is active' + ) + }) + return + } + const terminalPresentation = resolveTerminalPresentation(data) + const shouldActivate = terminalPresentation === 'focused' + const shouldSurfaceOwner = + terminalPresentation !== 'background' && data.surfaceOwner !== false + if (shouldActivate) { + activateTerminalInitiatedWorktree(store, worktreeId) + } + // Why: the paired launch client already resolved the mode, so its choice wins over the host renderer's local default. + const tabOptions = data.launchAgent + ? { + ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), + launchAgent: data.launchAgent, + ...(data.viewMode + ? { viewMode: data.viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: data.launchAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })), + ...(data.cwd ? { startupCwd: data.cwd } : {}) + } + : shouldActivate + ? data.cwd + ? { startupCwd: data.cwd } + : undefined + : { + activate: false, + recordInteraction: false, + ...(data.cwd ? { startupCwd: data.cwd } : {}) + } + const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions) + if (!shouldActivate) { + // Why: renderer-backed Codex startup must mount its new TerminalPane without switching UI or connecting every saved tab. + requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) + } + if (data.afterTabId) { + const createdUnifiedTab = useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id) + const anchorUnifiedTab = useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((item) => item.id === data.afterTabId) + if ( + createdUnifiedTab && + anchorUnifiedTab && + createdUnifiedTab.groupId === anchorUnifiedTab.groupId + ) { + const group = useAppStore + .getState() + .groupsByWorktree[worktreeId]?.find((item) => item.id === createdUnifiedTab.groupId) + const order = (group?.tabOrder ?? []).filter((id) => id !== createdUnifiedTab.id) + const anchorIndex = order.indexOf(anchorUnifiedTab.id) + order.splice( + anchorIndex === -1 ? order.length : anchorIndex + 1, + 0, + createdUnifiedTab.id + ) + useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, { + recordInteraction: false + }) + } + } + if (shouldActivate) { + store.setActiveTabType('terminal') + store.setActiveTab(tab.id) + } + if (shouldSurfaceOwner) { + store.revealWorktreeInSidebar(worktreeId) + focusTerminalInitiatedTab(tab.id) + } + if (data.title) { + store.setTabCustomTitle(tab.id, data.title, { recordInteraction: false }) + } + if (data.command) { + store.queueTabStartupCommand(tab.id, { + command: data.command, + ...(data.env ? { env: data.env } : {}), + ...(data.envToDelete ? { envToDelete: data.envToDelete } : {}), + ...(data.launchConfig ? { launchConfig: data.launchConfig } : {}), + ...(data.resumeProviderSession + ? { resumeProviderSession: data.resumeProviderSession } + : {}), + ...(data.launchToken ? { launchToken: data.launchToken } : {}), + ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), + ...(data.startupCommandDelivery + ? { startupCommandDelivery: data.startupCommandDelivery } + : {}) + }) + } + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + tabId: tab.id, + title: data.title ?? tab.title + }) + } catch (err) { + window.api.ui.replyTerminalCreate({ + requestId: data.requestId, + error: err instanceof Error ? err.message : 'Terminal creation failed' + }) + } + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts new file mode 100644 index 00000000000..4a7269fe2e7 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts @@ -0,0 +1,95 @@ +import { SPLIT_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { SplitTerminalPaneDetail } from '@/constants/terminal' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { useAppStore } from '../../store' +import { resolveBrowserSessionTabTarget } from './browser-session-tab-target' +import { + activateTerminalInitiatedWorktree, + focusTerminalInitiatedTab +} from './terminal-command-state' + +export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onSplitTerminal( + ({ tabId, paneRuntimeId, direction, command, telemetrySource }) => { + const detail: SplitTerminalPaneDetail = { + tabId, + paneRuntimeId, + direction, + command, + telemetrySource + } + window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail })) + } + ) + ) + + unsubs.push( + window.api.ui.onRenameTerminal(({ tabId, title }) => { + useAppStore.getState().setTabCustomTitle(tabId, title) + }) + ) + + unsubs.push( + window.api.ui.onFocusTerminal( + ({ + tabId, + worktreeId, + leafId, + ackPaneKeyOnSuccess, + flashFocusedPane, + scrollToBottomIfOutputSinceLastView + }) => { + const store = useAppStore.getState() + activateTerminalInitiatedWorktree(store, worktreeId) + store.setActiveTab(tabId) + store.revealWorktreeInSidebar(worktreeId) + if (ackPaneKeyOnSuccess || flashFocusedPane || scrollToBottomIfOutputSinceLastView) { + activateTabAndFocusPane(tabId, leafId ?? null, { + ...(ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess } : {}), + ...(flashFocusedPane ? { flashFocusedPane: true } : {}), + ...(scrollToBottomIfOutputSinceLastView + ? { scrollToBottomIfOutputSinceLastView: true } + : {}) + }) + return + } + focusTerminalInitiatedTab(tabId, leafId) + } + ) + ) + + unsubs.push( + window.api.ui.onFocusEditorTab(({ tabId, worktreeId }) => { + const store = useAppStore.getState() + const tab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId) + const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + if (!tab) { + if (browserTarget) { + // Why: older/mobile fallback snapshots identify browser tabs by workspace id when no unified tab wrapper exists. + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + store.setActiveBrowserTab(browserTarget.workspaceId) + store.setActiveTabType('browser') + store.revealWorktreeInSidebar(worktreeId) + } + return + } + store.setActiveWorktree(worktreeId) + store.markWorktreeVisited(worktreeId) + store.setActiveView('terminal') + store.focusGroup(worktreeId, tab.groupId) + store.activateTab(tab.id) + if (browserTarget) { + // Why: browser tabs need their own active-page state, not the editor file activation path. + store.setActiveBrowserTab(browserTarget.workspaceId) + store.setActiveTabType('browser') + } else { + store.setActiveFile(tab.entityId) + store.setActiveTabType('editor') + } + store.revealWorktreeInSidebar(worktreeId) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts new file mode 100644 index 00000000000..761ad4ca6db --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { registerUpdaterStatusIpcBridge } from './updater-status-ipc-bridge' + +const mocks = vi.hoisted(() => ({ setUpdateStatus: vi.fn() })) + +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => ({ + setUpdateStatus: mocks.setUpdateStatus, + clearDismissedUpdateVersion: vi.fn() + }) + } +})) + +describe('registerUpdaterStatusIpcBridge', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.unstubAllGlobals() + }) + + it('registers after requesting the snapshot and preserves the current late snapshot overwrite', async () => { + const order: string[] = [] + let resolveSnapshot: ((status: { state: string }) => void) | undefined + let statusListener: ((status: { state: string }) => void) | undefined + const statusCleanup = vi.fn() + const dismissalCleanup = vi.fn() + vi.stubGlobal('window', { + api: { + updater: { + getStatus: () => { + order.push('snapshot') + return new Promise<{ state: string }>((resolve) => { + resolveSnapshot = resolve + }) + }, + onStatus: (listener: (status: { state: string }) => void) => { + order.push('listener') + statusListener = listener + return statusCleanup + }, + onClearDismissal: () => dismissalCleanup + } + } + }) + + const unsubs: (() => void)[] = [] + registerUpdaterStatusIpcBridge(unsubs) + + expect(order).toEqual(['snapshot', 'listener']) + statusListener?.({ state: 'available' }) + resolveSnapshot?.({ state: 'idle' }) + await Promise.resolve() + expect(mocks.setUpdateStatus.mock.calls).toEqual([ + [{ state: 'available' }], + [{ state: 'idle' }] + ]) + + unsubs.forEach((unsubscribe) => unsubscribe()) + expect(statusCleanup).toHaveBeenCalledOnce() + expect(dismissalCleanup).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts new file mode 100644 index 00000000000..6cbd6809123 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts @@ -0,0 +1,21 @@ +import type { UpdateStatus } from '../../../../shared/update-status-types' +import { useAppStore } from '../../store' + +/** Installs updater listeners in their historical snapshot-before-push order. */ +export function registerUpdaterStatusIpcBridge(unsubs: (() => void)[]): void { + // Current behavior intentionally permits the initial snapshot to overwrite an earlier push. + window.api.updater.getStatus().then((status) => { + useAppStore.getState().setUpdateStatus(status as UpdateStatus) + }) + + unsubs.push( + window.api.updater.onStatus((raw) => { + useAppStore.getState().setUpdateStatus(raw as UpdateStatus) + }) + ) + unsubs.push( + window.api.updater.onClearDismissal(() => { + useAppStore.getState().clearDismissedUpdateVersion() + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts new file mode 100644 index 00000000000..9e78916fef3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts @@ -0,0 +1,136 @@ +import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events' +import { TOGGLE_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' +import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' +import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' +import { getVisibleWorktreeShortcutTargets } from '@/components/sidebar/visible-worktrees' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { deleteHoveredWorkspaceImmediately } from '@/components/sidebar/hovered-workspace-delete' +import { isFloatingWorkspacePanelFocused } from '@/lib/floating-workspace-terminal-actions' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { useAppStore } from '../../store' +import { toggleAgentDashboardFromShortcut } from './agent-dashboard-command' +import { openNewWorkspaceFromShortcut } from './new-workspace-command' + +export function registerWorkspaceShortcutIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onOpenQuickOpen(() => { + const store = useAppStore.getState() + if (store.activeView === 'terminal' && store.activeWorktreeId !== null) { + store.openModal('quick-open') + } + }) + ) + + unsubs.push( + window.api.ui.onToggleQuickCommandsMenu(() => { + window.dispatchEvent(new CustomEvent(TOGGLE_QUICK_COMMANDS_MENU_EVENT)) + }) + ) + + unsubs.push( + window.api.ui.onOpenNewWorkspace(() => { + const store = useAppStore.getState() + openNewWorkspaceFromShortcut(store) + }) + ) + + if (window.api.ui.onDeleteCurrentWorkspace) { + unsubs.push( + window.api.ui.onDeleteCurrentWorkspace(() => { + if (isFloatingWorkspacePanelFocused()) { + return + } + deleteHoveredWorkspaceImmediately(useAppStore.getState()) + }) + ) + } + + if (window.api.ui.onOpenWorkspaceBoard) { + unsubs.push( + window.api.ui.onOpenWorkspaceBoard(() => { + const store = useAppStore.getState() + if (store.activeView === 'settings') { + return + } + store.setSidebarOpen(true) + window.dispatchEvent(new CustomEvent(TOGGLE_WORKSPACE_BOARD_EVENT)) + }) + ) + } + + if (window.api.ui.onToggleAgentDashboard) { + unsubs.push( + window.api.ui.onToggleAgentDashboard(() => { + toggleAgentDashboardFromShortcut(useAppStore.getState(), () => { + void window.api.dashboard.openPopout() + }) + }) + ) + } + + unsubs.push( + window.api.ui.onOpenTasks(() => { + const store = useAppStore.getState() + if (store.activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) { + return + } + store.openTaskPage() + }) + ) + + unsubs.push( + window.api.ui.onJumpToWorktreeIndex((index) => { + const store = useAppStore.getState() + // Why: while Cmd+J is open the digit chord means "activate recent row N" — main already + // preventDefault'd it, so routing it here keeps digits out of the palette's search input. + if (store.activeModal === 'worktree-palette') { + emitCmdJRowIndexJump(index) + return + } + if (store.activeView !== 'terminal') { + return + } + const visibleTargets = getVisibleWorktreeShortcutTargets() + const target = visibleTargets[index] + if (target) { + if (target.executionHostId) { + activateAndRevealWorkspace(target.id, { executionHostId: target.executionHostId }) + } else { + activateAndRevealWorkspace(target.id) + } + } + }) + ) + + unsubs.push( + window.api.ui.onJumpToTabIndex((index) => { + // Why: dropped while Cmd+J is open — never switch tabs behind the overlay. + if (useAppStore.getState().activeModal === 'worktree-palette') { + return + } + activateTabNumberShortcut(index) + }) + ) + + unsubs.push( + window.api.ui.onWorktreeHistoryNavigate((direction) => { + const store = useAppStore.getState() + // Why: mirror button visibility — worktree history nav is only meaningful in the terminal view, so no-op elsewhere. + if (store.activeView !== 'terminal') { + return + } + if (direction === 'back') { + store.goBackWorktree() + } else { + store.goForwardWorktree() + } + }) + ) + + unsubs.push( + window.api.ui.onToggleStatusBar(() => { + const store = useAppStore.getState() + store.setStatusBarVisible(!store.statusBarVisible) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts new file mode 100644 index 00000000000..d5846c28f69 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -0,0 +1,165 @@ +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import type { AppState } from '../../store/types' +import { useAppStore } from '../../store' +import { + createWorktreeChangeRefreshQueue, + type WorktreeChangeRefreshQueue +} from '../worktree-change-refresh-queue' + +const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 +const recentlyRenamedWorktreeIdExpiry = new Map() + +function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set | null { + const detected = state.detectedWorktreesByRepo[repoId] + return detected?.authoritative === true + ? new Set(detected.worktrees.map((worktree) => worktree.id)) + : null +} +function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set { + return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id)) +} + +export type WorktreeEventRuntime = { + worktreeChangeRefreshQueue: WorktreeChangeRefreshQueue + activateNotifiedWorktree: ( + event: Extract, + options: { allowRuntimeEnvironment: boolean } + ) => Promise +} + +export function createWorktreeEventRuntime( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): WorktreeEventRuntime { + const handleWorktreesChanged = async ( + repoId: string, + renamed?: { oldWorktreeId: string; newWorktreeId: string }, + options?: { forceLocalOwner?: boolean; executionHostId?: ExecutionHostId } + ): Promise => { + const localRefreshStartedWithRuntime = + options?.forceLocalOwner === true && isRuntimeEnvironmentActive() + // Why: capture active-ness before migration moves the pointer; re-key maps before the diff so a rename isn't a deletion. + const renamedWasActive = + renamed != null && useAppStore.getState().activeWorktreeId === renamed.oldWorktreeId + if (renamed) { + // Shield both ids from the deletion diff across the rename's event burst — the worktree list lags the on-disk move. + const expiry = Date.now() + WORKTREE_RENAME_PURGE_GRACE_MS + recentlyRenamedWorktreeIdExpiry.set(renamed.oldWorktreeId, expiry) + recentlyRenamedWorktreeIdExpiry.set(renamed.newWorktreeId, expiry) + useAppStore.getState().migrateWorktreeIdentity(renamed.oldWorktreeId, renamed.newWorktreeId) + } + // Why: diff before/after fetch to catch out-of-band deletions and purge worktree state, else zombie ptyId entries leak (design §2c, §4.4). + const state = useAppStore.getState() + const before = + getAuthoritativeDetectedWorktreeIds(state, repoId) ?? + getVisibleWorktreeIdsForRepo(state, repoId) + await state.fetchWorktrees( + repoId, + options?.forceLocalOwner + ? { forceLocalOwner: true } + : options?.executionHostId + ? { + executionHostId: options.executionHostId, + suppressRemoteLineageRefresh: true + } + : undefined + ) + await useAppStore + .getState() + .fetchWorktreeLineage( + options?.forceLocalOwner + ? { forceLocalOwner: true } + : options?.executionHostId + ? { executionHostId: options.executionHostId } + : undefined + ) + // Why: an id change unmounts the active pane; re-activate so the tab reconciles, else it vanishes until re-select. + if (renamedWasActive && renamed) { + useAppStore.getState().setActiveWorktree(renamed.newWorktreeId) + } + // Sweep expired rename-grace entries before any early return, else forced-local + // (or non-authoritative) events let the map grow for the session. + const now = Date.now() + for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) { + if (expiry <= now) { + recentlyRenamedWorktreeIdExpiry.delete(id) + } + } + // Why: the deletion diff below is repo-wide, but a forced-local scan overlapping + // a runtime cannot prove remote absence (legacy runtime rows may lack hostId). + // fetchWorktrees still purges removed local rows host-scoped; accepted gap: the + // workspace-space entry survives until the next local-only rescan. + if ( + options?.forceLocalOwner && + (localRefreshStartedWithRuntime || isRuntimeEnvironmentActive()) + ) { + return + } + const afterState = useAppStore.getState() + const after = getAuthoritativeDetectedWorktreeIds(afterState, repoId) + if (!after) { + return + } + const removed: string[] = [] + for (const id of before) { + if (after.has(id)) { + continue + } + // A recently renamed worktree's old/new id isn't a deletion — its state moved to the new id; the list just lags. + const graceExpiry = recentlyRenamedWorktreeIdExpiry.get(id) + if (graceExpiry != null && graceExpiry > now) { + continue + } + removed.push(id) + } + if (removed.length > 0) { + console.warn( + `[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`, + removed + ) + const purgeHostId = + options?.executionHostId ?? + // A forced-local refresh owns only the local host's panes. + (options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined) + afterState.purgeWorktreeTerminalState( + purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed + ) + afterState.removeWorkspaceSpaceWorktrees(removed) + } + } + const worktreeChangeRefreshQueue = createWorktreeChangeRefreshQueue(handleWorktreesChanged) + unsubs.push(worktreeChangeRefreshQueue.dispose) + + const activateNotifiedWorktree = async ( + { + repoId, + worktreeId, + setup, + startup, + defaultTabs + }: Extract, + options: { allowRuntimeEnvironment: boolean } + ): Promise => { + if (!options.allowRuntimeEnvironment && isRuntimeEnvironmentActive()) { + // Why: local CLI worktree events carry local ids; runtime activation comes via the remote stream, allowed separately. + return + } + const existedBeforeFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) + // Why: fetch first so activation can resolve the CLI-created worktree; it arrived from main, not yet in renderer state. + await useAppStore.getState().fetchWorktrees(repoId) + const existsAfterFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) + // Why: use the canonical activation path so the CLI switch records a back/forward visit, or the nav buttons ignore it. + activateAndRevealWorktree(worktreeId, { + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), + ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), + // Why: this activation came from the host runtime stream; echoing it back can create a selection loop. + notifyHostRuntime: false + }) + } + + return { worktreeChangeRefreshQueue, activateNotifiedWorktree } +} diff --git a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts new file mode 100644 index 00000000000..584045b8c12 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts @@ -0,0 +1,48 @@ +import { applyUIZoom } from '@/lib/ui-zoom' +import { computeEditorFontSize, nextEditorFontZoomLevel } from '@/lib/editor-font-zoom' +import { zoomLevelToPercent } from '@/components/settings/SettingsConstants' +import { dispatchZoomLevelChanged } from '@/lib/zoom-events' +import { stepUIZoomLevel } from '../../../../shared/ui-zoom-level' +import { useAppStore } from '../../store' +import { resolveZoomTarget } from '../resolve-zoom-target' + +export function registerZoomIpcBridge(unsubs: (() => void)[]): void { + // Zoom handling for menu accelerators and keyboard fallback paths. + unsubs.push( + window.api.ui.onTerminalZoom((direction) => { + const store = useAppStore.getState() + const { activeView, activeTabType, editorFontZoomLevel, setEditorFontZoomLevel, settings } = + store + const target = resolveZoomTarget({ + activeView, + activeTabType, + activeElement: document.activeElement + }) + if (target === 'terminal') { + return + } + if (target === 'editor') { + const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction) + setEditorFontZoomLevel(next) + void window.api.ui.set({ editorFontZoomLevel: next }) + + // Why: mirror the editor's base font (terminalFontSize) + clamping so the overlay percent matches the rendered size. + const baseFontSize = settings?.terminalFontSize ?? 13 + const actual = computeEditorFontSize(baseFontSize, next) + const percent = Math.round((actual / baseFontSize) * 100) + dispatchZoomLevelChanged('editor', percent) + return + } + + const current = window.api.ui.getZoomLevel() + const next = stepUIZoomLevel(current, direction) + + applyUIZoom(next) + void window.api.ui.set({ uiZoomLevel: next }) + + dispatchZoomLevelChanged('ui', zoomLevelToPercent(next)) + }) + ) + + // Why: re-parse main-process agent status here so the renderer applies the same normalization regardless of hook vs OSC source. +} diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 836fd763fc6..b60c26b2ddc 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -16,7 +16,6 @@ import type { import { buildDirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator' import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' -export { isDirectSshRemoteWorkspaceApplyInProgress } from './remote-workspace-snapshot-apply' const WORKSPACE_HYDRATION_TIMEOUT_MS = 10_000 diff --git a/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts index 56d9823d652..11c95866ed2 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { toggleAgentDashboardFromShortcut } from './useIpcEvents' +import { toggleAgentDashboardFromShortcut } from './ipc-events/agent-dashboard-command' function makeState( overrides: { diff --git a/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts index f4d274a3838..6854f93161f 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { resolveBrowserSessionTabTarget } from './useIpcEvents' +import { resolveBrowserSessionTabTarget } from './ipc-events/browser-session-tab-target' import { createHarnessStoreState, loadIpcEventsHarness } from './ipc-events-test-harness' describe('browser navigation updates', () => { diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts new file mode 100644 index 00000000000..bd80c6aa726 --- /dev/null +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -0,0 +1,502 @@ +import type * as ReactModule from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' +import { createHarnessStoreState } from './ipc-events-test-harness' +const EXPECTED_DIRECT_CALLBACK_METHODS = [ + 'agentStatus.onClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onSet', + 'browser.onActivateView', + 'browser.onCertificateFailureChanged', + 'browser.onGuestLoadFailed', + 'browser.onNavigationUpdate', + 'browser.onOpenLinkInOrcaTab', + 'browser.onPaneFocus', + 'emulator.onAutoAttach', + 'emulator.onPaneFocus', + 'gh.onPRRefreshEvent', + 'keybindings.onChanged', + 'rateLimits.onUpdate', + 'remoteWorkspace.onChanged', + 'repos.onChanged', + 'runtime.onBrowserDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onTerminalDriverChanged', + 'runtime.onTerminalFitOverrideChanged', + 'settings.onChanged', + 'ssh.onCredentialRequest', + 'ssh.onCredentialResolved', + 'ssh.onDetectedPortsChanged', + 'ssh.onPortForwardsChanged', + 'ssh.onStateChanged', + 'ui.onActivateWorktree', + 'ui.onCloseActiveTab', + 'ui.onCloseFloatingItem', + 'ui.onCloseSessionTab', + 'ui.onCloseTerminal', + 'ui.onCreateTerminal', + 'ui.onDeleteCurrentWorkspace', + 'ui.onFocusEditorTab', + 'ui.onFocusTerminal', + 'ui.onFullscreenChanged', + 'ui.onJumpToTabIndex', + 'ui.onJumpToWorktreeIndex', + 'ui.onMoveSessionTab', + 'ui.onNewBrowserTab', + 'ui.onNewMarkdownTab', + 'ui.onNewSimulatorTab', + 'ui.onNewTerminalTab', + 'ui.onOpenDiffFromMobile', + 'ui.onOpenFeatureTour', + 'ui.onOpenFileFromMobile', + 'ui.onOpenNewWorkspace', + 'ui.onOpenQuickOpen', + 'ui.onOpenSettings', + 'ui.onOpenSetupGuide', + 'ui.onOpenSkillShare', + 'ui.onOpenTasks', + 'ui.onOpenWorkspaceBoard', + 'ui.onRenameTerminal', + 'ui.onRequestTabClose', + 'ui.onRequestTabCreate', + 'ui.onRequestTabSetProfile', + 'ui.onRequestTerminalCreate', + 'ui.onRequestTerminalTabMount', + 'ui.onResumeSleepingAgents', + 'ui.onSelectFloatingIndex', + 'ui.onSessionTabCloseRequest', + 'ui.onSleepWorktree', + 'ui.onSplitTerminal', + 'ui.onStateChanged', + 'ui.onSwitchRecentTab', + 'ui.onSwitchTab', + 'ui.onSwitchTabAcrossAllTypes', + 'ui.onSwitchTerminalTab', + 'ui.onSystemResumed', + 'ui.onTerminalShortcutCaptured', + 'ui.onTerminalTabCloseRequest', + 'ui.onTerminalZoom', + 'ui.onToggleAgentDashboard', + 'ui.onToggleFloatingTerminal', + 'ui.onToggleLeftSidebar', + 'ui.onToggleQuickCommandsMenu', + 'ui.onToggleRightSidebar', + 'ui.onToggleStatusBar', + 'ui.onToggleWorktreePalette', + 'ui.onWorktreeHistoryNavigate', + 'updater.onClearDismissal', + 'updater.onStatus', + 'workspaceSpace.onProgress', + 'worktrees.onBaseStatus', + 'worktrees.onChanged', + 'worktrees.onCreateProgress', + 'worktrees.onHeadIdentitiesChanged', + 'worktrees.onRemoteBranchConflict' +] as const + +const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ + 'ui.onMobileMarkdownRequest', + 'repos.onChanged', + 'worktrees.onChanged', + 'worktrees.onHeadIdentitiesChanged', + 'worktrees.onBaseStatus', + 'worktrees.onRemoteBranchConflict', + 'worktrees.onCreateProgress', + 'gh.onPRRefreshEvent', + 'ui.onOpenSettings', + 'ui.onOpenSkillShare', + 'ui.onOpenSetupGuide', + 'mobile.onUnpairedDeviceAuthFailure', + 'ui.onOpenFeatureTour', + 'settings.onChanged', + 'ui.onStateChanged', + 'keybindings.onChanged', + 'ui.onToggleLeftSidebar', + 'ui.onToggleRightSidebar', + 'ui.onToggleWorktreePalette', + 'ui.onToggleFloatingTerminal', + 'ui.onTerminalShortcutCaptured', + 'ui.onOpenQuickOpen', + 'ui.onToggleQuickCommandsMenu', + 'ui.onOpenNewWorkspace', + 'ui.onDeleteCurrentWorkspace', + 'ui.onOpenWorkspaceBoard', + 'ui.onToggleAgentDashboard', + 'ui.onOpenTasks', + 'ui.onJumpToWorktreeIndex', + 'ui.onJumpToTabIndex', + 'ui.onWorktreeHistoryNavigate', + 'ui.onToggleStatusBar', + 'ui.onActivateWorktree', + 'ui.onCreateTerminal', + 'ui.onRequestTerminalTabMount', + 'ui.onRequestTerminalCreate', + 'ui.onSplitTerminal', + 'ui.onRenameTerminal', + 'ui.onFocusTerminal', + 'ui.onFocusEditorTab', + 'ui.onCloseSessionTab', + 'ui.onSessionTabCloseRequest', + 'ui.onMoveSessionTab', + 'ui.onOpenFileFromMobile', + 'ui.onOpenDiffFromMobile', + 'ui.onCloseTerminal', + 'ui.onTerminalTabCloseRequest', + 'ui.onSleepWorktree', + 'ui.onResumeSleepingAgents', + 'updater.onStatus', + 'updater.onClearDismissal', + 'ui.onFullscreenChanged', + 'browser.onGuestLoadFailed', + 'browser.onCertificateFailureChanged', + 'browser.onNavigationUpdate', + 'browser.onActivateView', + 'browser.onPaneFocus', + 'browser.onOpenLinkInOrcaTab', + 'ui.onNewBrowserTab', + 'ui.onNewMarkdownTab', + 'ui.onNewSimulatorTab', + 'emulator.onAutoAttach', + 'emulator.onPaneFocus', + 'ui.onRequestTabCreate', + 'ui.onRequestTabSetProfile', + 'ui.onRequestTabClose', + 'ui.onNewTerminalTab', + 'ui.onCloseActiveTab', + 'ui.onCloseFloatingItem', + 'ui.onSelectFloatingIndex', + 'ui.onSwitchTab', + 'ui.onSwitchTabAcrossAllTypes', + 'ui.onSwitchRecentTab', + 'ui.onSwitchTerminalTab', + 'rateLimits.onUpdate', + 'workspaceSpace.onProgress', + 'ssh.onCredentialRequest', + 'ssh.onCredentialResolved', + 'ssh.onPortForwardsChanged', + 'ssh.onDetectedPortsChanged', + 'ssh.onStateChanged', + 'ui.onSystemResumed', + 'remoteWorkspace.onChanged', + 'ui.onTerminalZoom', + 'agentStatus.onSet', + 'agentStatus.onClear', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'runtime.onTerminalFitOverrideChanged', + 'runtime.onTerminalDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onBrowserDriverChanged' +] as const + +type ListenerRecord = { + callback: (...args: unknown[]) => void + active: boolean + cleanup: Mock +} + +describe('useIpcEvents App-lifetime lifecycle', () => { + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + vi.doUnmock('./ipc-events/app-lifetime-ipc-bridge') + }) + + it('owns one empty-dependency React effect', async () => { + let dependencies: readonly unknown[] | undefined + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (_effect: () => void | (() => void), nextDependencies?: readonly unknown[]) => { + dependencies = nextDependencies + } + } + }) + vi.doMock('./ipc-events/app-lifetime-ipc-bridge', () => ({ + installAppLifetimeIpcEvents: vi.fn(() => vi.fn()) + })) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + expect(dependencies).toEqual([]) + }) + + it('leaves exactly one listener per channel across a StrictMode cleanup-remount cycle', async () => { + const registrationOrder: string[] = [] + const cleanupOrder: string[] = [] + const listeners = new Map() + const storeSubscriptions: { active: boolean; cleanup: Mock }[] = [] + const setUpdateStatus = vi.fn() + const storeState = new Proxy( + createHarnessStoreState({ + tabsByWorktree: { 'wt-1': [] }, + setUpdateStatus, + workspaceSessionReady: true, + runtimeEnvironments: [{ id: 'runtime-1' }], + runtimeStatusByEnvironmentId: new Map([['runtime-1', { status: 'connected' }]]) + }), + { + get: (target, property: string) => + property in target ? target[property] : property.startsWith('set') ? vi.fn() : undefined + } + ) + + vi.doMock('../store', () => ({ + useAppStore: { + getState: () => storeState, + subscribe: vi.fn(() => { + const record = { active: true, cleanup: vi.fn() } + const subscriptionIndex = storeSubscriptions.length + storeSubscriptions.push(record) + return () => { + cleanupOrder.push(`store.unsubscribe.${subscriptionIndex}`) + record.active = false + record.cleanup() + } + }) + } + })) + + const namespace = (name: string): Record => + new Proxy( + {}, + { + get: (_target, property: string) => { + if (name === 'runtimeEnvironments' && property === 'subscribe') { + return async () => { + registrationOrder.push('runtimeEnvironments.subscribe') + return { + unsubscribe: () => cleanupOrder.push('runtimeEnvironment.unsubscribe') + } + } + } + if (property.startsWith('on')) { + return (callback: (...args: unknown[]) => void) => { + registrationOrder.push(`${name}.${property}`) + const record: ListenerRecord = { callback, active: true, cleanup: vi.fn() } + const records = listeners.get(`${name}.${property}`) ?? [] + records.push(record) + listeners.set(`${name}.${property}`, records) + return () => { + cleanupOrder.push(`ipc.${name}.${property}`) + record.active = false + record.cleanup() + } + } + } + if (property === 'getStatus') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve({ state: 'idle' }) + } + } + if (property === 'get') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve({ limits: {}, lastUpdatedAt: 0 }) + } + } + if (property === 'getState') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve(null) + } + } + if (property === 'clientId') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve(null) + } + } + if (property.startsWith('get') || property.startsWith('list')) { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve([]) + } + } + if (property.startsWith('consumePending')) { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve(null) + } + } + return vi.fn() + } + } + ) + const api = new Proxy( + {}, + { get: (_target, property: string) => namespace(property) } + ) as unknown + vi.stubGlobal('window', { + api, + dispatchEvent: vi.fn(), + setTimeout, + clearTimeout + }) + + const { installAppLifetimeIpcEvents } = await import('./ipc-events/app-lifetime-ipc-bridge') + const recordCleanupPhase = (phase: string): void => { + cleanupOrder.push(phase) + } + const firstCleanup = installAppLifetimeIpcEvents(recordCleanupPhase) + await Promise.resolve() + await Promise.resolve() + const directCallbackMethods = [...listeners.keys()] + .filter( + (method) => + method !== 'mobile.onUnpairedDeviceAuthFailure' && method !== 'ui.onMobileMarkdownRequest' + ) + .sort() + expect(directCallbackMethods).toEqual(EXPECTED_DIRECT_CALLBACK_METHODS) + expect([...listeners.keys()].sort()).toEqual( + [...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE].sort() + ) + expect( + registrationOrder.filter( + (entry) => + entry === 'runtimeEnvironments.subscribe' || + EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.includes( + entry as (typeof EXPECTED_CALLBACK_REGISTRATION_SEQUENCE)[number] + ) + ) + ).toEqual([ + 'ui.onMobileMarkdownRequest', + 'runtimeEnvironments.subscribe', + ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(1) + ]) + const groupOrder = (names: readonly string[]): string[] => + registrationOrder.filter((entry) => names.includes(entry)) + expect( + groupOrder([ + 'ui.onOpenSettings', + 'ui.onOpenSkillShare', + 'ui.consumePendingOpenSettings', + 'ui.consumePendingSkillShare' + ]) + ).toEqual([ + 'ui.onOpenSettings', + 'ui.onOpenSkillShare', + 'ui.consumePendingOpenSettings', + 'ui.consumePendingSkillShare' + ]) + expect( + groupOrder([ + 'updater.getStatus', + 'updater.onStatus', + 'updater.onClearDismissal', + 'rateLimits.onUpdate', + 'rateLimits.get' + ]) + ).toEqual([ + 'updater.getStatus', + 'updater.onStatus', + 'updater.onClearDismissal', + 'rateLimits.onUpdate', + 'rateLimits.get' + ]) + expect( + groupOrder([ + 'agentStatus.onSet', + 'agentStatus.onClear', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'agentStatus.getSnapshot' + ]) + ).toEqual([ + 'agentStatus.onSet', + 'agentStatus.onClear', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'agentStatus.getSnapshot' + ]) + expect( + groupOrder([ + 'runtime.onTerminalFitOverrideChanged', + 'runtime.onTerminalDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onBrowserDriverChanged', + 'runtime.getTerminalFitOverrides', + 'runtime.getTerminalDrivers', + 'runtime.getBrowserDrivers' + ]) + ).toEqual([ + 'runtime.onTerminalFitOverrideChanged', + 'runtime.onTerminalDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onBrowserDriverChanged', + 'runtime.getTerminalFitOverrides', + 'runtime.getTerminalDrivers', + 'runtime.getBrowserDrivers' + ]) + expect( + [...listeners.values()].every((records) => records.filter((item) => item.active).length === 1) + ).toBe(true) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(2) + + firstCleanup() + const ipcCleanupOrder = cleanupOrder + .filter((entry) => entry.startsWith('ipc.') && entry !== 'ipc.dispose') + .map((entry) => entry.slice('ipc.'.length)) + expect(ipcCleanupOrder).toEqual(EXPECTED_CALLBACK_REGISTRATION_SEQUENCE) + expect(cleanupOrder.slice(0, 6)).toEqual([ + 'agent.disposeAsyncState', + 'mobile.disposeHydration', + 'store.unsubscribe.0', + 'runtimeStore.unsubscribe', + 'store.unsubscribe.1', + 'agentStore.unsubscribe' + ]) + expect(cleanupOrder.indexOf('runtimeEnvironment.unsubscribe')).toBeGreaterThan( + cleanupOrder.indexOf('ipc.ui.onMobileMarkdownRequest') + ) + expect(cleanupOrder.indexOf('runtimeEnvironment.unsubscribe')).toBeLessThan( + cleanupOrder.indexOf('ipc.repos.onChanged') + ) + expect(cleanupOrder.indexOf('directSsh.stop')).toBeGreaterThan( + cleanupOrder.lastIndexOf('ipc.runtime.onBrowserDriverChanged') + ) + expect(cleanupOrder.at(-1)).toBe('notifications.reset') + expect([...listeners.values()].every((records) => records.every((item) => !item.active))).toBe( + true + ) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(0) + expect( + [...listeners.values()].every((records) => + records.every((item) => item.cleanup.mock.calls.length === 1) + ) + ).toBe(true) + + const statusWritesBeforePostUnmountEvent = setUpdateStatus.mock.calls.length + for (const record of listeners.get('updater.onStatus') ?? []) { + if (record.active) { + record.callback({ state: 'available' }) + } + } + expect(setUpdateStatus).toHaveBeenCalledTimes(statusWritesBeforePostUnmountEvent) + + const secondCleanup = installAppLifetimeIpcEvents(recordCleanupPhase) + expect( + [...listeners.values()].every((records) => records.filter((item) => item.active).length === 1) + ).toBe(true) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(2) + + secondCleanup() + expect([...listeners.values()].every((records) => records.every((item) => !item.active))).toBe( + true + ) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(0) + expect( + [...listeners.values()].every((records) => + records.every((item) => item.cleanup.mock.calls.length === 1) + ) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts b/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts index 7ce571b73b9..a72216cc9b6 100644 --- a/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { buildNewWorkspaceShortcutModalData, openNewWorkspaceFromShortcut } from './useIpcEvents' +import { + buildNewWorkspaceShortcutModalData, + openNewWorkspaceFromShortcut +} from './ipc-events/new-workspace-command' describe('buildNewWorkspaceShortcutModalData', () => { it('carries the active Linear issue into the Cmd+N composer', () => { diff --git a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts index ad1d318f332..c47487dd57e 100644 --- a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts @@ -8,11 +8,11 @@ import { getRuntimeClientEventEnvironmentIds, getRuntimeProjectRefreshEnvironmentIds, invalidateRuntimeClientEventReplay -} from './useIpcEvents' +} from './ipc-events/runtime-environment-subscription-selection' import type { RuntimeEnvironmentStoreSyncState, RuntimeEnvironmentStoreSyncSubscriber -} from './useIpcEvents' +} from './ipc-events/runtime-environment-subscription-selection' describe('buildRuntimeClientEventEnvironmentKey', () => { it('treats runtime environment ids as a stable set', () => { diff --git a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts index 9a9ec54974f..be11678c1fd 100644 --- a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts @@ -1,6 +1,6 @@ import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { resolveZoomTarget } from './useIpcEvents' +import { resolveZoomTarget } from './resolve-zoom-target' function makeTarget(args: { hasXtermClass?: boolean; editorClosest?: boolean }): { classList: { contains: (token: string) => boolean } @@ -91,6 +91,16 @@ describe('useIpcEvents zoom routing', () => { beforeEach(() => { vi.resetModules() vi.unstubAllGlobals() + // Zoom routing never renders toast UI; keep Sonner's DOM style injector out of this synthetic-document harness. + vi.doMock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } + })) }) it('applies app zoom for an active browser tab', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 95c8278fc52..e9e6ce4929e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1,4456 +1,7 @@ -/* oxlint-disable max-lines -- Why: this App-level IPC bridge intentionally keeps the renderer's main-process event contract in one place so shortcut, runtime, updater, and agent-status wiring do not drift across files. */ import { useEffect } from 'react' -import { toast } from 'sonner' -import { useAppStore } from '../store' -import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' -import { applyWorktreeHeadIdentities } from './worktree-head-identity-apply' -import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors' -import { applyUIZoom } from '@/lib/ui-zoom' -import { activateAndRevealWorktree, activateAndRevealWorkspace } from '@/lib/worktree-activation' -import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' -import { deleteHoveredWorkspaceImmediately } from '@/components/sidebar/hovered-workspace-delete' -import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' -import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' -import { TOGGLE_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' -import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' -import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' -import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' -import { resolveTerminalTabPtyOwnership } from '@/lib/terminal-tab-for-pty-id' -import { - hasRegisteredRuntimeTerminalTab, - focusRuntimeTerminalSurface -} from '@/runtime/sync-runtime-graph' -import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' -import { getVisibleWorktreeShortcutTargets } from '@/components/sidebar/visible-worktrees' -import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' -import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' -import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom' -import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode -} from '../../../shared/terminal-tab-types' -import type { UpdateStatus } from '../../../shared/update-status-types' -import type { RateLimitState } from '../../../shared/rate-limit-types' -import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types' -import { - LOCAL_EXECUTION_HOST_ID, - toRuntimeExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' -import type { - RuntimeBrowserDriverState, - RuntimeTerminalPresentation, - RuntimeTerminalDriverState -} from '../../../shared/runtime-types' -import { zoomLevelToPercent } from '@/components/settings/SettingsConstants' -import { stepUIZoomLevel } from '../../../shared/ui-zoom-level' -import { dispatchZoomLevelChanged } from '@/lib/zoom-events' -import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' -import { resolveZoomTarget } from './resolve-zoom-target' -import { - handleSwitchRecentTab, - handleSwitchTab, - handleSwitchTabAcrossAllTypes, - handleSwitchTerminalTab -} from './ipc-tab-switch' -import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab' -import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' -import { - isManualSimulatorLaunchPending, - rememberPrelaunchedSimulatorSession -} from '@/lib/simulator-launch-coordination' -import { - normalizeAgentStatusPayload, - type AgentStatusClearIpcPayload, - type AgentStatusIpcPayload, - type ParsedAgentStatusPayload -} from '../../../shared/agent-status-types' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../../shared/agent-status-identity' -import { isGitRepoKind } from '../../../shared/repo-kind' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' -import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events' -import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' -import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' -import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' -import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides' -import { setDriverForPty, hydrateDrivers } from '@/lib/pane-manager/mobile-driver-state' -import { - hydrateBrowserDrivers, - setDriverForBrowserPage -} from '@/lib/pane-manager/browser-mobile-driver-state' -import { destroyPersistentWebview } from '@/components/browser-pane/host-guest/webview-registry' -import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry' -import { - acquireBrowserAutomationVisibility, - releaseBrowserAutomationVisibility -} from '@/components/browser-pane/host-guest/browser-automation-visibility' -import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' -import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' -import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' -import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' -import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' -import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' -import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' -import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' -import { - applyRuntimeEnvironmentSshStateChanged, - hydrateRuntimeEnvironmentSshState, - refreshRuntimeEnvironmentSshTargetMetadata -} from '@/runtime/runtime-environment-ssh-state' -import { - createRuntimeProjectRefreshScheduler, - refreshRuntimeProjectWorktreesAndLineage -} from './runtime-project-refresh-scheduler' -import { createRuntimeClientEventsSync } from './runtime-client-events-sync' -import { detectLanguage } from '@/lib/language-detect' -import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' -import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' -import { track } from '@/lib/telemetry' -import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' -import { verifyTerminalRevealIdentity } from '@/lib/terminal-reveal-identity' -import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' -import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' -import { applyHostWorktreeTerminalSleepState } from '@/components/terminal-pane/pty-shutdown-exit-deferral' -import { - resolveLegacyWorkerTerminalRecoveryAction, - rollbackLegacyWorkerTerminalSurfaceInStore -} from './legacy-worker-terminal-recovery-event' -import type { AppState } from '../store/types' -import { - guardPinnedTabClose, - isUnifiedTabPinned, - resolvePinnedTabLabel -} from '../store/pinned-tab-close-guard' -import { - closeWebRuntimeSessionTab, - createWebRuntimeSessionTerminal, - isWebRuntimeSessionActive -} from '@/runtime/web-runtime-session' -import { - createFloatingWorkspaceBrowserTab, - createFloatingWorkspaceMarkdownTab, - createFloatingWorkspaceTerminalTab, - isEmptyFloatingWorkspacePanelVisible, - isFloatingWorkspacePanelFocused, - resolveFloatingWorkspaceBrowserWorkspaceId, - switchFloatingWorkspaceTab -} from '@/lib/floating-workspace-terminal-actions' -import { - dispatchFloatingWorkspaceGuestClose, - dispatchFloatingWorkspaceGuestSelectIndex -} from '@/lib/floating-workspace-guest-bridge' -import { - observeAgentHookCompletionForNotification, - resetAgentHookCompletionNotificationCoordinators, - syncAgentHookCompletionNotificationsForStoreUpdate -} from './agent-hook-completion-notifications' -import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' -import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' -import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' -import { titleHasAgentName } from '../../../shared/agent-detection' -import { isDecorativeAgentTitleFrameChange } from '../../../shared/agent-decorative-title-signature' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' -import type { - AgentStatusBatchTransaction, - AgentStatusBatchUpdate, - AgentStatusUpdate -} from '@/store/slices/agent-status' -import { translate } from '@/i18n/i18n' -import { redactKagiSessionToken } from '../../../shared/browser-url' -import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' -import { - SESSION_TAB_CLOSE_CANCELED_ERROR, - SESSION_TAB_CLOSE_FAILED_ERROR, - SESSION_TAB_NOT_FOUND_ERROR, - SESSION_TAB_CLOSE_TIMEOUT_ERROR -} from '../../../shared/session-tab-close' -import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' -import { getConnectionIdFromState } from '@/lib/connection-context' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees' -import { createDirectSshWorktreeRefreshScheduler } from './direct-ssh-worktree-refresh-scheduler' -import { - createDirectSshReconnectCoordinator, - type DirectSshPreparationInput, - type DirectSshPreparationReason -} from './direct-ssh-reconnect-coordinator' -import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' -import { createDirectSshHostHydration } from './direct-ssh-host-hydration' -import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry' -import { - createRemoteWorkspaceTargetSync, - isDirectSshRemoteWorkspaceApplyInProgress, - type RemoteWorkspaceTargetSync -} from './remote-workspace-target-sync' -import { - registerDirectSshWakeRouting, - routeDirectSshConnectedState, - type DirectSshConnectedStateOrigin -} from './direct-ssh-state-routing' -import { isDirectSshReconnectCoordinatorRoutingEnabled } from './direct-ssh-reconnect-rollout' - -function getShortcutPlatform(): NodeJS.Platform { - if (navigator.userAgent.includes('Mac')) { - return 'darwin' - } - if (navigator.userAgent.includes('Windows')) { - return 'win32' - } - return 'linux' -} - -const BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS = 10_000 -const browserAutomationBootstrapLeaseByPageId = new Map() - -function resolveTerminalPresentation(data: { - presentation?: RuntimeTerminalPresentation - activate?: boolean - focus?: boolean -}): RuntimeTerminalPresentation | undefined { - if (data.presentation) { - return data.presentation - } - if (data.focus !== undefined) { - return data.focus ? 'focused' : 'background' - } - if (data.activate === true) { - return 'focused' - } - return undefined -} - -function releaseBrowserAutomationBootstrapLease(browserPageId: string): void { - const existing = browserAutomationBootstrapLeaseByPageId.get(browserPageId) - if (!existing) { - return - } - window.clearTimeout(existing.timer) - releaseBrowserAutomationVisibility(existing.token) - browserAutomationBootstrapLeaseByPageId.delete(browserPageId) -} - -function findBrowserPageWorktreeId(store: AppState, browserPageId: string): string | null { - for (const [worktreeId, browserTabs] of Object.entries(store.browserTabsByWorktree)) { - for (const workspace of browserTabs) { - if ( - workspace.id === browserPageId || - workspace.activePageId === browserPageId || - workspace.pageIds?.includes(browserPageId) - ) { - return worktreeId - } - } - } - - for (const pages of Object.values(store.browserPagesByWorkspace)) { - const page = pages.find((candidate) => candidate.id === browserPageId) - if (page) { - return page.worktreeId - } - } - - return null -} - -function acquireBrowserAutomationBootstrapLease( - worktreeId: string | null | undefined, - browserPageId?: string | null -): void { - const store = useAppStore.getState() - const targetWorktreeId = - worktreeId ?? - (browserPageId ? findBrowserPageWorktreeId(store, browserPageId) : null) ?? - store.activeWorktreeId - if (!targetWorktreeId) { - return - } - requestBackgroundTerminalWorktreeMount({ worktreeId: targetWorktreeId }) - let targetBrowserPageId = browserPageId ?? null - if (!targetBrowserPageId) { - const browserTabs = store.browserTabsByWorktree[targetWorktreeId] ?? [] - const activeWorkspaceId = store.activeBrowserTabIdByWorktree[targetWorktreeId] ?? null - const workspace = - browserTabs.find((tab) => tab.id === activeWorkspaceId) ?? browserTabs[0] ?? null - targetBrowserPageId = - workspace?.activePageId ?? workspace?.pageIds?.[0] ?? workspace?.id ?? null - } - if (!targetBrowserPageId) { - return - } - - releaseBrowserAutomationBootstrapLease(targetBrowserPageId) - const token = acquireBrowserAutomationVisibility(targetBrowserPageId) - const timer = window.setTimeout(() => { - releaseBrowserAutomationBootstrapLease(targetBrowserPageId) - }, BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS) - browserAutomationBootstrapLeaseByPageId.set(targetBrowserPageId, { token, timer }) -} - -export { resolveZoomTarget } from './resolve-zoom-target' - -const PENDING_AGENT_STATUS_RETRY_MS = 100 -const PENDING_AGENT_STATUS_TTL_MS = 15_000 -const MAX_PENDING_AGENT_STATUS_EVENTS = 100 -// Why: each live status event is its own IPC task, so a multi-agent burst pays -// one full render pass per event; same-task commits batch to ONE pass (React -// flushes external-store updates at the microtask boundary). One frame of -// buffering collapses a burst; the leading event still applies immediately. -const LIVE_AGENT_STATUS_BURST_WINDOW_MS = 33 -// Why: mobile driver hydration is async; cap replay so a stuck IPC snapshot can't retain an unbounded startup buffer. -const MAX_PENDING_MOBILE_STATE_EVENTS = 300 -// Why: a rename's event burst lags the on-disk move; shield both ids from the deletion diff for a grace window. -const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 -const recentlyRenamedWorktreeIdExpiry = new Map() - -function isAgentStatusForRecentlyClosedTab( - store: Pick, - paneKey: string -): boolean { - const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) - if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) { - return true - } - const tabId = parsePaneKey(ownerPaneKey)?.tabId - if (!tabId) { - return false - } - return store.recentlyClosedAgentStatusTabIds[tabId] === true -} - -function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set | null { - const detected = state.detectedWorktreesByRepo[repoId] - if (detected?.authoritative !== true) { - return null - } - return new Set(detected.worktrees.map((worktree) => worktree.id)) -} - -function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set { - return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id)) -} - -function focusTerminalInitiatedTab(tabId: string, leafId?: string | null): void { - if (!focusRuntimeTerminalSurface(tabId, leafId)) { - focusTerminalTabSurface(tabId, leafId) - } -} - -function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void { - store.setActiveView('terminal') - store.setActiveWorktree(worktreeId) - // Why: CLI/runtime terminal focus is user-visible navigation, so feed both Cmd+J recency and the back/forward stack. - store.markWorktreeVisited(worktreeId) - if (!store.isNavigatingHistory) { - store.recordWorktreeVisit(worktreeId) - } -} - -type TerminalSplitDirection = 'horizontal' | 'vertical' - -function insertLeafAfterSource( - node: TerminalPaneLayoutNode, - sourceLeafId: string, - newLeafId: string, - direction: TerminalSplitDirection -): { node: TerminalPaneLayoutNode; inserted: boolean } { - if (node.type === 'leaf') { - if (node.leafId !== sourceLeafId) { - return { node, inserted: false } - } - return { - node: { - type: 'split', - direction, - first: node, - second: { type: 'leaf', leafId: newLeafId }, - ratio: 0.5 - }, - inserted: true - } - } - - const first = insertLeafAfterSource(node.first, sourceLeafId, newLeafId, direction) - if (first.inserted) { - return { node: { ...node, first: first.node }, inserted: true } - } - const second = insertLeafAfterSource(node.second, sourceLeafId, newLeafId, direction) - if (second.inserted) { - return { node: { ...node, second: second.node }, inserted: true } - } - return { node, inserted: false } -} - -function addSplitLeafToLayout( - layout: TerminalLayoutSnapshot | null | undefined, - sourceLeafId: string, - newLeafId: string, - ptyId: string, - direction: TerminalSplitDirection, - title?: string | null, - activateNewLeaf = true -): TerminalLayoutSnapshot { - const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId } - const existingLeafIds = collectLeafIdsInOrder(root) - const nextActiveLeafId = - activateNewLeaf || !layout?.activeLeafId || !existingLeafIds.includes(layout.activeLeafId) - ? newLeafId - : layout.activeLeafId - const nextRoot = existingLeafIds.includes(newLeafId) - ? root - : (() => { - const inserted = insertLeafAfterSource(root, sourceLeafId, newLeafId, direction) - if (inserted.inserted) { - return inserted.node - } - return { - type: 'split' as const, - direction, - first: root, - second: { type: 'leaf' as const, leafId: newLeafId }, - ratio: 0.5 - } - })() - return { - ...(layout ?? { root: null, activeLeafId: null, expandedLeafId: null }), - root: nextRoot, - activeLeafId: nextActiveLeafId, - expandedLeafId: null, - ptyIdsByLeafId: { - ...layout?.ptyIdsByLeafId, - [newLeafId]: ptyId - }, - ...(title - ? { - titlesByLeafId: { - ...layout?.titlesByLeafId, - [newLeafId]: title - } - } - : {}) - } -} - -function activateExistingLeafInLayout( - layout: TerminalLayoutSnapshot | null | undefined, - leafId: string, - ptyId: string, - title?: string | null -): TerminalLayoutSnapshot | null { - if (!layout?.root || !collectLeafIdsInOrder(layout.root).includes(leafId)) { - return null - } - return { - ...layout, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { - ...layout.ptyIdsByLeafId, - [leafId]: ptyId - }, - ...(title - ? { - titlesByLeafId: { - ...layout.titlesByLeafId, - [leafId]: title - } - } - : {}) - } -} - -export function isRemoteWorkspaceSnapshotApplyInProgress(): boolean { - return isDirectSshRemoteWorkspaceApplyInProgress() -} - -type BrowserSessionTabTarget = - | { kind: 'unified-browser'; unifiedTabId: string; workspaceId: string; groupId: string } - | { kind: 'fallback-browser'; workspaceId: string } - -type NewWorkspaceShortcutModalData = { - telemetrySource: 'shortcut' - prefilledName?: string - linkedWorkItem?: ReturnType -} - -export function buildNewWorkspaceShortcutModalData( - state: Pick -): NewWorkspaceShortcutModalData { - const linearIssue = - state.activeView === 'tasks' ? (state.taskPageData.openLinearIssue ?? null) : null - if (!linearIssue) { - return { telemetrySource: 'shortcut' } - } - - return { - telemetrySource: 'shortcut', - prefilledName: getLinearIssueWorkspaceName(linearIssue), - // Why: Cmd+N from a Linear issue mirrors its Start-workspace action, else the agent launches without source context. - linkedWorkItem: buildLinearIssueLinkedWorkItem(linearIssue) - } -} - -export function openNewWorkspaceFromShortcut( - state: Pick -): void { - if (state.activeModal === 'new-workspace-composer') { - return - } - state.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(state)) -} - -export function toggleAgentDashboardFromShortcut( - state: Pick< - AppState, - | 'activeView' - | 'settings' - | 'agentDashboardDrawerOpen' - | 'setSidebarOpen' - | 'setAgentDashboardDrawerOpen' - >, - openPopout: () => void -): void { - // Why: mirror the sidebar entry's gate — the chord must stay inert while the - // experiment is off, so a stale binding cannot open a hidden surface. - if ( - state.activeView === 'settings' || - state.settings?.experimentalAgentDashboardPopout !== true - ) { - return - } - if (state.settings.experimentalAgentDashboardMode === 'popout') { - openPopout() - return - } - const nextOpen = !state.agentDashboardDrawerOpen - // Why: the drawer lives beside the sidebar and self-closes when the sidebar - // collapses, so opening it has to reveal the sidebar first. Closing must not, - // or dismissing the drawer would force the sidebar back open. - if (nextOpen) { - state.setSidebarOpen(true) - } - state.setAgentDashboardDrawerOpen(nextOpen) -} - -export function resolveBrowserSessionTabTarget( - state: Pick, - worktreeId: string, - tabId: string -): BrowserSessionTabTarget | null { - const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId) - if (tab?.contentType === 'browser') { - return { - kind: 'unified-browser', - unifiedTabId: tab.id, - workspaceId: tab.entityId, - groupId: tab.groupId - } - } - const fallbackBrowser = (state.browserTabsByWorktree[worktreeId] ?? []).find( - (workspace) => workspace.id === tabId - ) - return fallbackBrowser ? { kind: 'fallback-browser', workspaceId: fallbackBrowser.id } : null -} - -function isRuntimeEnvironmentActive(): boolean { - return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) -} - -/** Remount panes that parked with no PTY while their owning host was unknown. */ -function remountTerminalTabsAwaitingHostHydration(): void { - const store = useAppStore.getState() - for (const tabId of getTabIdsAwaitingHostHydrationRemount(store)) { - store.remountTerminalTabForRecovery(tabId) - } -} - -export type RuntimeEnvironmentStoreSyncState = Pick< - AppState, - 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId' | 'settings' | 'sshStateByEnvironment' -> - -function getActiveRuntimeEnvironmentId(state: RuntimeEnvironmentStoreSyncState): string | null { - return state.settings?.activeRuntimeEnvironmentId?.trim() || null -} - -export function getRuntimeClientEventEnvironmentIds( - state: RuntimeEnvironmentStoreSyncState -): string[] { - const ids = new Set() - const activeEnvironmentId = getActiveRuntimeEnvironmentId(state) - if (activeEnvironmentId) { - ids.add(activeEnvironmentId) - } - for (const environment of state.runtimeEnvironments ?? []) { - const status = state.runtimeStatusByEnvironmentId?.get(environment.id) - if (status?.status) { - ids.add(environment.id) - } - } - return [...ids] -} - -export function getReachableRuntimeEnvironmentIds( - state: RuntimeEnvironmentStoreSyncState -): string[] { - const ids: string[] = [] - for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) { - if (status?.status) { - ids.push(environmentId) - } - } - return ids -} - -export function canSkipRuntimeEnvironmentStoreSync( - state: RuntimeEnvironmentStoreSyncState, - previousState: RuntimeEnvironmentStoreSyncState -): boolean { - return ( - state.runtimeEnvironments === previousState.runtimeEnvironments && - state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId && - state.sshStateByEnvironment === previousState.sshStateByEnvironment && - getActiveRuntimeEnvironmentId(state) === getActiveRuntimeEnvironmentId(previousState) - ) -} - -export function buildRuntimeClientEventEnvironmentKey(environmentIds: string[]): string { - return [...new Set(environmentIds)] - .sort() - .map( - (environmentId) => - `${environmentId}:${getRuntimeEnvironmentConnectionGeneration(environmentId)}:${getEnvironmentSshStateGeneration(environmentId)}:${getRuntimeEnvironmentRevision(environmentId) ?? 'unknown'}` - ) - .join('\u0000') -} - -/** Ids in `next` not in `previous` — environments that just became connected (exported to unit-test on-connect discovery). */ -export function getNewlyConnectedRuntimeEnvironmentIds( - previous: readonly string[], - next: readonly string[] -): string[] { - const known = new Set(previous) - return [...new Set(next)].filter((environmentId) => !known.has(environmentId)) -} - -/** Ids in `previous` not in `next` — environments whose transport was just observed down. */ -export function getNewlyDisconnectedRuntimeEnvironmentIds( - previous: readonly string[], - next: readonly string[] -): string[] { - return getNewlyConnectedRuntimeEnvironmentIds(next, previous) -} - -export function getRuntimeProjectRefreshEnvironmentIds(args: { - previousDesired: readonly string[] - nextDesired: readonly string[] - previousReachable: readonly string[] - nextReachable: readonly string[] -}): string[] { - return [ - ...new Set([ - ...getNewlyConnectedRuntimeEnvironmentIds(args.previousDesired, args.nextDesired), - ...getNewlyConnectedRuntimeEnvironmentIds(args.previousReachable, args.nextReachable) - ]) - ] -} - -type RuntimeEnvironmentStoreSyncSubscriberDeps = { - initialDesiredEnvironmentIds: string[] - initialReachableEnvironmentIds: string[] - buildEnvironmentKey: (environmentIds: string[]) => string - getDesiredEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] - getReachableEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] - requestProjectRefresh: (environmentId: string) => void - markEnvironmentSshStateStale: (environmentId: string) => void - sync: () => void -} - -export type RuntimeEnvironmentStoreSyncSubscriber = ( - state: RuntimeEnvironmentStoreSyncState, - previousState: RuntimeEnvironmentStoreSyncState -) => void - -/** - * Builds the one renderer-wide runtime subscriber. The reference gate runs - * before either host collection is enumerated; key generation remains the - * second gate for relevant-reference writes whose effective subscription set - * did not change. - */ -export function createRuntimeEnvironmentStoreSyncSubscriber( - deps: RuntimeEnvironmentStoreSyncSubscriberDeps -): RuntimeEnvironmentStoreSyncSubscriber { - let desiredEnvironmentIds = deps.initialDesiredEnvironmentIds - let desiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) - let reachableEnvironmentIds = deps.initialReachableEnvironmentIds - let reachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) - let handlingStoreWrite = false - - return (state, previousState) => { - // markEnvironmentSshStateStale can synchronously publish its nested SSH - // bucket. The outer pass incorporates that generation before syncing, so a - // re-entrant pass would only enumerate and sync the same transition twice. - if (handlingStoreWrite || canSkipRuntimeEnvironmentStoreSync(state, previousState)) { - return - } - - handlingStoreWrite = true - try { - const nextDesiredEnvironmentIds = deps.getDesiredEnvironmentIds(state) - const nextReachableEnvironmentIds = deps.getReachableEnvironmentIds(state) - const refreshEnvironmentIds = getRuntimeProjectRefreshEnvironmentIds({ - previousDesired: desiredEnvironmentIds, - nextDesired: nextDesiredEnvironmentIds, - previousReachable: reachableEnvironmentIds, - nextReachable: nextReachableEnvironmentIds - }) - const disconnectedEnvironmentIds = getNewlyDisconnectedRuntimeEnvironmentIds( - reachableEnvironmentIds, - nextReachableEnvironmentIds - ) - - desiredEnvironmentIds = nextDesiredEnvironmentIds - reachableEnvironmentIds = nextReachableEnvironmentIds - for (const environmentId of refreshEnvironmentIds) { - deps.requestProjectRefresh(environmentId) - } - for (const environmentId of disconnectedEnvironmentIds) { - deps.markEnvironmentSshStateStale(environmentId) - } - - // Build after disconnect invalidation: marking a mirrored SSH bucket stale - // advances its generation, and the replacement subscription must capture - // that final generation in this same (single) sync. - const nextDesiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) - const nextReachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) - if ( - nextDesiredEnvironmentKey === desiredEnvironmentKey && - nextReachableEnvironmentKey === reachableEnvironmentKey - ) { - return - } - desiredEnvironmentKey = nextDesiredEnvironmentKey - reachableEnvironmentKey = nextReachableEnvironmentKey - deps.sync() - } finally { - handlingStoreWrite = false - } - } -} - -type RuntimeClientEventReplayInvalidationDeps = { - getSshStateReference: () => RuntimeEnvironmentStoreSyncState['sshStateByEnvironment'] - requestProjectRefresh: () => void - markEnvironmentSshStateStale: () => void - hydrateEnvironmentSshState: () => Promise - sync: () => void -} - -/** - * Invalidates a replay after the runtime event stream reports a transport gap. - * A tracked SSH bucket publishes synchronously and lets the store subscriber - * sync it; an empty/already-stale bucket has no reference publication, so this - * path must explicitly sync the advanced module-level SSH generation. - */ -export function invalidateRuntimeClientEventReplay( - deps: RuntimeClientEventReplayInvalidationDeps -): void { - deps.requestProjectRefresh() - const previousSshStateReference = deps.getSshStateReference() - deps.markEnvironmentSshStateStale() - if (deps.getSshStateReference() === previousSshStateReference) { - deps.sync() - } - void deps.hydrateEnvironmentSshState().catch(() => {}) -} - -function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null { - return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) -} +import { installAppLifetimeIpcEvents } from './ipc-events/app-lifetime-ipc-bridge' +/** Installs the renderer IPC bridge once for the App lifetime. */ export function useIpcEvents(): void { - useEffect(() => { - const unsubs: (() => void)[] = [] - const reconnectAuthorityByTarget = new Map() - const authorityReconciliationDeadlines = new Set<{ - timer: ReturnType - settle: () => void - }>() - let directSshEffectStopped = false - const currentDirectSshAuthority = (targetId: string): DirectSshAuthority | null => { - const state = useAppStore.getState().sshConnectionStates?.get(targetId) - if ( - state?.status !== 'connected' || - state.targetId !== targetId || - !state.providerEpoch || - state.connectionGeneration === undefined - ) { - return null - } - return { - targetId, - providerEpoch: state.providerEpoch, - connectionGeneration: state.connectionGeneration - } - } - const scheduler = createDirectSshWorktreeRefreshScheduler({ - startAttempt: (key) => { - const acquired = acquireDirectSshDetectedWorktreeRefresh(useAppStore, { - repoId: key.repoId, - executionHostId: key.executionHostId, - authority: { - targetId: key.targetId, - providerEpoch: key.providerEpoch, - connectionGeneration: key.connectionGeneration - }, - requireAuthoritative: key.authorityRequirement === 'required' - }) - return { - providerRequestId: acquired.providerRequestId, - result: acquired.result.then((result) => acquired.merge(result)), - cancel: acquired.release - } - } - }) - const hostHydration = createDirectSshHostHydration({ - store: useAppStore, - isCurrentAuthority: (authority) => - directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority), - listRepos: (authority) => { - const executionHostId = toSshExecutionHostId(authority.targetId) - return ( - window.api.repos.listForExecutionHost?.({ - executionHostId, - expectedAuthority: authority - }) ?? - Promise.resolve({ - authoritative: false, - executionHostId, - reason: 'unavailable' as const - }) - ) - }, - listLineage: (authority) => { - const executionHostId = toSshExecutionHostId(authority.targetId) - return ( - window.api.worktrees.listLineageForHost?.({ - executionHostId, - expectedAuthority: authority - }) ?? - Promise.resolve({ - authoritative: false, - executionHostId, - reason: 'unavailable' as const - }) - ) - } - }) - type DirectSshTerminalActions = Partial< - Pick - > - const directSshTerminalActions = (): DirectSshTerminalActions => - useAppStore.getState() as DirectSshTerminalActions - let remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null = null - const reconnectCoordinator = createDirectSshReconnectCoordinator({ - scheduler, - isCurrentConnectedAuthority: (authority) => - directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority), - capturePreparationInput: hostHydration.capturePreparationInput, - readHostScopedLineage: hostHydration.readHostScopedLineage, - invalidateStaleTerminalBindings: (authority) => - directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, - retryTargetPanes: (authority) => - directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, - finalizeHydratedTerminalPanes: (authority) => - directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, - correctUnboundTerminalPanes: (authority) => - directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, - syncRemoteWorkspaceAfterConnect: (token) => - remoteWorkspaceTargetSync?.syncAfterConnect(token), - onTelemetry: createDirectSshReconnectProductTelemetryAdapter() - }) - const remoteWorkspaceApi = window.api.remoteWorkspace - if (remoteWorkspaceApi) { - remoteWorkspaceTargetSync = createRemoteWorkspaceTargetSync({ - store: useAppStore, - remoteWorkspace: remoteWorkspaceApi, - getCurrentAuthority: currentDirectSshAuthority, - isPreparationTokenCurrent: hostHydration.isPreparationTokenCurrent, - capturePreparationInput: (authority, reason, snapshotRevision) => - hostHydration.capturePreparationInput(authority, reason, snapshotRevision), - prepareOnly: reconnectCoordinator.prepareOnly, - finalizeHydratedTerminals: (authority) => - directSshAuthoritiesEqual(reconnectAuthorityByTarget.get(authority.targetId), authority) - ? reconnectCoordinator.finalizeHydratedTerminals(authority) - : 0 - }) - } - const prepareAndSyncDirectSshTarget = async ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason, - options?: { authorityAlreadyReplaced?: boolean } - ): Promise => { - try { - if (!options?.authorityAlreadyReplaced) { - reconnectCoordinator.replaceAuthority(authority) - } - const input: DirectSshPreparationInput | null = await hostHydration.capturePreparationInput( - authority, - reason - ) - if (!input) { - return - } - const prepared = await reconnectCoordinator.prepareOnly(input) - if (prepared.token && hostHydration.isPreparationTokenCurrent(prepared.token)) { - await remoteWorkspaceTargetSync?.syncAfterConnect(prepared.token) - } - } catch (error) { - if (directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority)) { - useAppStore.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { - phase: 'error', - message: error instanceof Error ? error.message : 'Workspace sync failed' - }) - } - } - } - const backgroundSleepingAgentWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() - unsubs.push(backgroundSleepingAgentWakeDispatcher.dispose) - type PendingAgentStatusEvent = { - data: AgentStatusIpcPayload - firstSeenAt: number - replay: boolean - } - type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped' - type ProjectedAgentTabTitles = { - title: string | undefined - identityTitle: string | undefined - } - type AgentStatusBatchContext = { - transaction: AgentStatusBatchTransaction - routingIndex: AgentStatusPaneRoutingIndex - projectedTitlesByTabId: Map - tabTitlesByTabId: Map - notificationEffects: (() => void)[] - } - type AgentStatusBatchEvent = { - data: AgentStatusIpcPayload - replay?: boolean - retry?: boolean - } - type AgentStatusApplyOptions = { - replay?: boolean - retry?: boolean - batch?: AgentStatusBatchContext - } - const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] - const transientClearWatermarkByConnectionId = new Map() - let agentStatusEffectDisposed = false - let pendingAgentStatusRetryTimer: ReturnType | null = null - // Why: setAgentStatus notifies synchronously and re-enters this flush mid-drain; guard re-entrancy (crash 9fc89529). - let isFlushingAgentStatuses = false - const liveAgentStatusBurstQueue: AgentStatusIpcPayload[] = [] - let liveAgentStatusBurstTimer: ReturnType | null = null - let lastLiveAgentStatusApplyAt = 0 - - unsubs.push(attachMobileMarkdownBridge()) - - const handleWorktreesChanged = async ( - repoId: string, - renamed?: { oldWorktreeId: string; newWorktreeId: string }, - options?: { forceLocalOwner?: boolean; executionHostId?: ExecutionHostId } - ): Promise => { - const localRefreshStartedWithRuntime = - options?.forceLocalOwner === true && isRuntimeEnvironmentActive() - // Why: capture active-ness before migration moves the pointer; re-key maps before the diff so a rename isn't a deletion. - const renamedWasActive = - renamed != null && useAppStore.getState().activeWorktreeId === renamed.oldWorktreeId - if (renamed) { - // Shield both ids from the deletion diff across the rename's event burst — the worktree list lags the on-disk move. - const expiry = Date.now() + WORKTREE_RENAME_PURGE_GRACE_MS - recentlyRenamedWorktreeIdExpiry.set(renamed.oldWorktreeId, expiry) - recentlyRenamedWorktreeIdExpiry.set(renamed.newWorktreeId, expiry) - useAppStore.getState().migrateWorktreeIdentity(renamed.oldWorktreeId, renamed.newWorktreeId) - } - // Why: diff before/after fetch to catch out-of-band deletions and purge worktree state, else zombie ptyId entries leak (design §2c, §4.4). - const state = useAppStore.getState() - const before = - getAuthoritativeDetectedWorktreeIds(state, repoId) ?? - getVisibleWorktreeIdsForRepo(state, repoId) - await state.fetchWorktrees( - repoId, - options?.forceLocalOwner - ? { forceLocalOwner: true } - : options?.executionHostId - ? { - executionHostId: options.executionHostId, - suppressRemoteLineageRefresh: true - } - : undefined - ) - await useAppStore - .getState() - .fetchWorktreeLineage( - options?.forceLocalOwner - ? { forceLocalOwner: true } - : options?.executionHostId - ? { executionHostId: options.executionHostId } - : undefined - ) - // Why: an id change unmounts the active pane; re-activate so the tab reconciles, else it vanishes until re-select. - if (renamedWasActive && renamed) { - useAppStore.getState().setActiveWorktree(renamed.newWorktreeId) - } - // Sweep expired rename-grace entries before any early return, else forced-local - // (or non-authoritative) events let the map grow for the session. - const now = Date.now() - for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) { - if (expiry <= now) { - recentlyRenamedWorktreeIdExpiry.delete(id) - } - } - // Why: the deletion diff below is repo-wide, but a forced-local scan overlapping - // a runtime cannot prove remote absence (legacy runtime rows may lack hostId). - // fetchWorktrees still purges removed local rows host-scoped; accepted gap: the - // workspace-space entry survives until the next local-only rescan. - if ( - options?.forceLocalOwner && - (localRefreshStartedWithRuntime || isRuntimeEnvironmentActive()) - ) { - return - } - const afterState = useAppStore.getState() - const after = getAuthoritativeDetectedWorktreeIds(afterState, repoId) - if (!after) { - return - } - const removed: string[] = [] - for (const id of before) { - if (after.has(id)) { - continue - } - // A recently renamed worktree's old/new id isn't a deletion — its state moved to the new id; the list just lags. - const graceExpiry = recentlyRenamedWorktreeIdExpiry.get(id) - if (graceExpiry != null && graceExpiry > now) { - continue - } - removed.push(id) - } - if (removed.length > 0) { - console.warn( - `[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`, - removed - ) - const purgeHostId = - options?.executionHostId ?? - (options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined) - afterState.purgeWorktreeTerminalState( - purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed - ) - afterState.removeWorkspaceSpaceWorktrees(removed) - } - } - const worktreeChangeRefreshQueue = createWorktreeChangeRefreshQueue(handleWorktreesChanged) - unsubs.push(worktreeChangeRefreshQueue.dispose) - - const activateNotifiedWorktree = async ( - { - repoId, - worktreeId, - setup, - startup, - defaultTabs - }: Extract, - options: { allowRuntimeEnvironment: boolean } - ): Promise => { - if (!options.allowRuntimeEnvironment && isRuntimeEnvironmentActive()) { - // Why: local CLI worktree events carry local ids; runtime activation comes via the remote stream, allowed separately. - return - } - const existedBeforeFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) - // Why: fetch first so activation can resolve the CLI-created worktree; it arrived from main, not yet in renderer state. - await useAppStore.getState().fetchWorktrees(repoId) - const existsAfterFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId)) - // Why: use the canonical activation path so the CLI switch records a back/forward visit, or the nav buttons ignore it. - activateAndRevealWorktree(worktreeId, { - ...(setup ? { setup } : {}), - ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}), - ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), - // Why: this activation came from the host runtime stream; echoing it back can create a selection loop. - notifyHostRuntime: false - }) - } - - const ensureRuntimeEventRepoKnown = async ( - environmentId: string, - repoId: string - ): Promise => { - if ((useAppStore.getState().repos ?? []).some((repo) => repo.id === repoId)) { - return - } - await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) - } - - const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({ - refresh: async (environmentId) => { - // Why: project events can reveal target CRUD, but known target states already arrive by push. - void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {}) - const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) - // Why: the host emits one reposChanged for group/folder-workspace edits too, so those - // catalogs go stale without this; groups first because folder workspaces resolve owners from them. - const runtimeOwner = { runtimeEnvironmentId: environmentId } - // Why: catalogs and worktrees are independent; serializing them put two 15s RPC - // timeouts ahead of worktree/lineage convergence on a wedged host. - await Promise.all([ - (async () => { - await useAppStore.getState().fetchProjectGroups(runtimeOwner) - await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner) - })(), - refreshRuntimeProjectWorktreesAndLineage( - environmentId, - repos, - (repoId, options) => useAppStore.getState().fetchWorktrees(repoId, options), - (options) => useAppStore.getState().fetchWorktreeLineage(options) - ) - ]) - }, - onError: (error) => { - console.error('Failed to refresh runtime projects:', error) - } - }) - - // Assigned later (by the ssh.onStateChanged wiring); safe because subscriptions attach asynchronously. - let handleSshStateChangedEvent: ((data: { targetId: string; state: unknown }) => void) | null = - null - - const handleRuntimeClientEvent = ( - environmentId: string, - event: RuntimeClientEvent, - generation = getEnvironmentSshStateGeneration(environmentId) - ): void => { - if (event.type === 'worktreeTerminalSleepState') { - applyHostWorktreeTerminalSleepState(environmentId, event) - return - } - if (event.type === 'terminalSideEffects') { - dispatchTerminalSideEffectBatch({ - ...event.batch, - ptyId: toRemoteRuntimePtyId(event.batch.ptyId, environmentId) - }) - return - } - if (event.type === 'nativeChatLaunchDraftResolved') { - applyNativeChatLaunchDraftResolved(useAppStore.getState(), event) - return - } - if (event.type === 'reposChanged') { - runtimeProjectRefreshScheduler.request(environmentId) - return - } - if (event.type === 'sshStateChanged') { - applyRuntimeEnvironmentSshStateChanged( - environmentId, - event.targetId, - event.state, - generation - ) - return - } - if (event.type === 'worktreesChanged') { - void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() => - worktreeChangeRefreshQueue.enqueue({ - repoId: event.repoId, - executionHostId: toRuntimeExecutionHostId(environmentId) - }) - ) - return - } - if (event.type === 'linearLinkedIssueUpdated') { - void useAppStore - .getState() - .refreshLinearIssue(event.identifier, event.workspaceId) - .catch((error) => { - console.error('Failed to refresh updated Linear issue:', error) - }) - return - } - void ensureRuntimeEventRepoKnown(environmentId, event.repoId) - .then(() => activateNotifiedWorktree(event, { allowRuntimeEnvironment: true })) - .catch((error) => { - console.error('Failed to activate runtime-created worktree:', error) - }) - } - - const runtimeClientEventsSync = createRuntimeClientEventsSync({ - getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()), - getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]), - subscribe: (environmentId, onEvent, onError) => { - const sshGeneration = getEnvironmentSshStateGeneration(environmentId) - const runtimeGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) - const runtimeRevision = getRuntimeEnvironmentRevision(environmentId) - return subscribeRuntimeClientEvents( - environmentId, - (event) => { - if ( - sshGeneration === getEnvironmentSshStateGeneration(environmentId) && - runtimeGeneration === getRuntimeEnvironmentConnectionGeneration(environmentId) && - runtimeRevision === getRuntimeEnvironmentRevision(environmentId) - ) { - onEvent(event) - } - }, - onError, - () => { - invalidateRuntimeClientEventReplay({ - getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, - requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId), - markEnvironmentSshStateStale: () => - useAppStore.getState().markEnvironmentSshStateStale(environmentId), - hydrateEnvironmentSshState: () => - hydrateRuntimeEnvironmentSshState(environmentId, { force: true }), - sync: runtimeClientEventsSync.sync - }) - } - ) - }, - onEvent: handleRuntimeClientEvent - }) - - // Why: no on-connect repo fetch (PR #2); seed discovery for connected runtimes or remote projects hide until Add-Project. - const initialRuntimeEnvironmentState = useAppStore.getState() - const runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds( - initialRuntimeEnvironmentState - ) - for (const environmentId of runtimeClientEventEnvironmentIds) { - runtimeProjectRefreshScheduler.request(environmentId) - } - const reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds( - initialRuntimeEnvironmentState - ) - const handleRuntimeEnvironmentStoreWrite = createRuntimeEnvironmentStoreSyncSubscriber({ - initialDesiredEnvironmentIds: runtimeClientEventEnvironmentIds, - initialReachableEnvironmentIds: reachableRuntimeEnvironmentIds, - buildEnvironmentKey: buildRuntimeClientEventEnvironmentKey, - getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, - getReachableEnvironmentIds: getReachableRuntimeEnvironmentIds, - requestProjectRefresh: (environmentId) => - runtimeProjectRefreshScheduler.request(environmentId), - markEnvironmentSshStateStale: (environmentId) => { - // No-op when the environment has no SSH bucket (e.g. web client). - useAppStore.getState().markEnvironmentSshStateStale(environmentId) - }, - sync: runtimeClientEventsSync.sync - }) - const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe( - handleRuntimeEnvironmentStoreWrite - ) - // Subscribe before the first runtime stream starts: replay invalidation may - // synchronously publish a tracked SSH bucket and relies on this listener to - // replace that subscription exactly once. - runtimeClientEventsSync.sync() - unsubs.push(runtimeClientEventsSync.stop) - unsubs.push(runtimeProjectRefreshScheduler.stop) - - unsubs.push( - window.api.repos.onChanged(() => { - const state = useAppStore.getState() - if (isRuntimeEnvironmentActive()) { - // Why: the all-host sidebar shows local repos even under a runtime; refresh the local slice, keep runtime slices. - void (async () => { - const localOwner = { runtimeEnvironmentId: null } - await state.fetchRepos(localOwner) - await state.fetchProjectGroups(localOwner) - await state.fetchFolderWorkspaces(localOwner) - remountTerminalTabsAwaitingHostHydration() - })() - return - } - void state.fetchProjectGroups() - void state.fetchFolderWorkspaces() - void state.fetchRepos().then(remountTerminalTabsAwaitingHostHydration) - }) - ) - - unsubs.push( - window.api.worktrees.onChanged( - async (data: { - repoId: string - renamed?: { oldWorktreeId: string; newWorktreeId: string } - }) => { - // Why: preserve this event's local origin across queue delays and runtime - // focus changes; otherwise an unbound repo can refresh from the wrong host. - // A folder rename changes the worktree id; handleWorktreesChanged re-keys - // state and shields it from the deletion diff. - worktreeChangeRefreshQueue.enqueue({ - ...data, - forceLocalOwner: true - }) - } - ) - ) - - if (window.api.worktrees.onHeadIdentitiesChanged) { - unsubs.push( - window.api.worktrees.onHeadIdentitiesChanged((data) => { - if (isRuntimeEnvironmentActive()) { - // Why: local worktree events carry local repo ids; the local-pinned list - // refresh (onChanged) covers local rows while a runtime is active. - return - } - const state = useAppStore.getState() - applyWorktreeHeadIdentities(data, { - getWorktreesForRepo: (repoId) => state.worktreesByRepo[repoId], - updateWorktreeGitIdentity: state.updateWorktreeGitIdentity - }) - }) - ) - } - - unsubs.push( - window.api.worktrees.onBaseStatus((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - useAppStore.getState().updateWorktreeBaseStatus(event) - }) - ) - - unsubs.push( - window.api.worktrees.onRemoteBranchConflict((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - useAppStore.getState().updateWorktreeRemoteBranchConflict(event) - }) - ) - - // Why: route main's two-phase creation progress to each pending entry by correlation id (?. guards stale preload). - unsubs.push( - window.api.worktrees.onCreateProgress?.((data) => { - if (!data.creationId) { - return - } - useAppStore.getState().updatePendingWorktreeCreation(data.creationId, { phase: data.phase }) - }) ?? (() => {}) - ) - - if (window.api.gh?.onPRRefreshEvent) { - unsubs.push( - window.api.gh.onPRRefreshEvent((event) => { - useAppStore.getState().applyGitHubPRRefreshEvent(event) - }) - ) - } - - unsubs.push( - window.api.ui.onOpenSettings(() => { - useAppStore.getState().openSettingsPage() - }) - ) - - const unsubscribeOpenSkillShare = window.api.ui.onOpenSkillShare?.((shareId) => { - useAppStore.getState().openSkillShare(shareId) - }) - if (unsubscribeOpenSkillShare) { - unsubs.push(unsubscribeOpenSkillShare) - } - - // Why: a tray "Settings…" click can fire before this attaches; consume any queued intent (?. guards stale preload). - void window.api.ui - .consumePendingOpenSettings?.() - .then((open) => { - if (open) { - useAppStore.getState().openSettingsPage() - } - }) - .catch(() => {}) - - const pendingSkillShare = window.api.ui.consumePendingSkillShare?.() - if (pendingSkillShare && typeof pendingSkillShare.then === 'function') { - void pendingSkillShare - .then((shareId) => { - if (shareId) { - useAppStore.getState().openSkillShare(shareId) - } - }) - .catch(() => {}) - } - - unsubs.push( - window.api.ui.onOpenSetupGuide?.(() => { - useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' }) - }) ?? (() => {}) - ) - - // Why: a phone stuck in a silent 4001 auth loop (lost device registry) reads as - // "phone won't connect" with no clue on either end; main throttles to once per session. - unsubs.push( - subscribeToUnpairedDeviceAuthNotification(window.api.mobile, () => { - toast.warning( - translate( - 'auto.hooks.useIpcEvents.ef223fbb6b', - 'A device tried to connect but is not paired' - ), - { - id: 'unpaired-device-auth-failure', - description: translate( - 'auto.hooks.useIpcEvents.11992d0337', - 'If this was your phone or another Orca client, re-pair it from Settings → Mobile.' - ), - // Why: main emits this recovery path once per session, so it must remain visible until acted on or dismissed. - duration: Infinity, - action: { - label: translate('auto.hooks.useIpcEvents.6573cfe955', 'Open Mobile Settings'), - onClick: () => { - const store = useAppStore.getState() - store.openSettingsTarget({ pane: 'mobile', repoId: null }) - store.openSettingsPage() - } - } - } - ) - }) - ) - - unsubs.push( - window.api.ui.onOpenFeatureTour(() => { - useAppStore.getState().openModal('feature-wall', { source: 'help_menu' }) - }) - ) - - // Why: View > Appearance toggles settings in main and broadcasts; merge into the store for an immediate re-render. - unsubs.push( - window.api.settings.onChanged((updates) => { - const store = useAppStore.getState() - if (!store.settings) { - return - } - const { worktreeVisibilityDefaults, ...activeOwnerUpdates } = updates - const settingsUpdates = store.settings.activeRuntimeEnvironmentId - ? activeOwnerUpdates - : updates - useAppStore.setState({ - settings: { - ...store.settings, - ...settingsUpdates, - notifications: { - ...store.settings.notifications, - ...updates.notifications - } - }, - ...(worktreeVisibilityDefaults - ? { - worktreeVisibilityDefaultsByHost: { - ...store.worktreeVisibilityDefaultsByHost, - local: worktreeVisibilityDefaults - } - } - : {}) - }) - if ('worktreeVisibilityDefaults' in updates) { - void store.fetchAllWorktrees({ visibilityOwnerHostId: 'local' }) - } - }) - ) - - // Why: UI view-state is shared with mobile via ui.set; re-hydrate so mobile changes reflect live in the desktop sidebar. - unsubs.push( - window.api.ui.onStateChanged((ui) => { - useAppStore.getState().hydratePersistedUI(ui, 'sync') - }) - ) - - if (window.api.keybindings) { - unsubs.push( - window.api.keybindings.onChanged((snapshot) => { - useAppStore.getState().setKeybindingSnapshot(snapshot) - }) - ) - } - - unsubs.push( - window.api.ui.onToggleLeftSidebar(() => { - useAppStore.getState().toggleSidebar() - }) - ) - - unsubs.push( - window.api.ui.onToggleRightSidebar(() => { - const store = useAppStore.getState() - if (!canShowRightSidebarForView(store.activeView)) { - return - } - store.toggleRightSidebar() - }) - ) - - unsubs.push( - window.api.ui.onToggleWorktreePalette(() => { - const store = useAppStore.getState() - if (store.activeModal === 'worktree-palette') { - store.closeModal() - return - } - store.openModal('worktree-palette') - }) - ) - - unsubs.push( - window.api.ui.onToggleFloatingTerminal(() => { - window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT)) - }) - ) - - if (window.api.ui.onTerminalShortcutCaptured) { - unsubs.push( - window.api.ui.onTerminalShortcutCaptured(({ actionId }) => { - showTerminalShortcutCaptureNotification({ - actionId, - platform: getShortcutPlatform(), - keybindings: useAppStore.getState().keybindings - }) - }) - ) - } - - unsubs.push( - window.api.ui.onOpenQuickOpen(() => { - const store = useAppStore.getState() - if (store.activeView === 'terminal' && store.activeWorktreeId !== null) { - store.openModal('quick-open') - } - }) - ) - - unsubs.push( - window.api.ui.onToggleQuickCommandsMenu(() => { - window.dispatchEvent(new CustomEvent(TOGGLE_QUICK_COMMANDS_MENU_EVENT)) - }) - ) - - unsubs.push( - window.api.ui.onOpenNewWorkspace(() => { - const store = useAppStore.getState() - openNewWorkspaceFromShortcut(store) - }) - ) - - if (window.api.ui.onDeleteCurrentWorkspace) { - unsubs.push( - window.api.ui.onDeleteCurrentWorkspace(() => { - if (isFloatingWorkspacePanelFocused()) { - return - } - deleteHoveredWorkspaceImmediately(useAppStore.getState()) - }) - ) - } - - if (window.api.ui.onOpenWorkspaceBoard) { - unsubs.push( - window.api.ui.onOpenWorkspaceBoard(() => { - const store = useAppStore.getState() - if (store.activeView === 'settings') { - return - } - store.setSidebarOpen(true) - window.dispatchEvent(new CustomEvent(TOGGLE_WORKSPACE_BOARD_EVENT)) - }) - ) - } - - if (window.api.ui.onToggleAgentDashboard) { - unsubs.push( - window.api.ui.onToggleAgentDashboard(() => { - toggleAgentDashboardFromShortcut(useAppStore.getState(), () => { - void window.api.dashboard.openPopout() - }) - }) - ) - } - - unsubs.push( - window.api.ui.onOpenTasks(() => { - const store = useAppStore.getState() - if (store.activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) { - return - } - store.openTaskPage() - }) - ) - - unsubs.push( - window.api.ui.onJumpToWorktreeIndex((index) => { - const store = useAppStore.getState() - // Why: while Cmd+J is open the digit chord means "activate recent row N" — main already - // preventDefault'd it, so routing it here keeps digits out of the palette's search input. - if (store.activeModal === 'worktree-palette') { - emitCmdJRowIndexJump(index) - return - } - if (store.activeView !== 'terminal') { - return - } - const visibleTargets = getVisibleWorktreeShortcutTargets() - const target = visibleTargets[index] - if (target) { - if (target.executionHostId) { - activateAndRevealWorkspace(target.id, { executionHostId: target.executionHostId }) - } else { - activateAndRevealWorkspace(target.id) - } - } - }) - ) - - unsubs.push( - window.api.ui.onJumpToTabIndex((index) => { - // Why: dropped while Cmd+J is open — never switch tabs behind the overlay. - if (useAppStore.getState().activeModal === 'worktree-palette') { - return - } - activateTabNumberShortcut(index) - }) - ) - - unsubs.push( - window.api.ui.onWorktreeHistoryNavigate((direction) => { - const store = useAppStore.getState() - // Why: mirror button visibility — worktree history nav is only meaningful in the terminal view, so no-op elsewhere. - if (store.activeView !== 'terminal') { - return - } - if (direction === 'back') { - store.goBackWorktree() - } else { - store.goForwardWorktree() - } - }) - ) - - unsubs.push( - window.api.ui.onToggleStatusBar(() => { - const store = useAppStore.getState() - store.setStatusBarVisible(!store.statusBarVisible) - }) - ) - - unsubs.push( - window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { - void activateNotifiedWorktree( - { - type: 'activateWorktree', - repoId, - worktreeId, - ...(setup ? { setup } : {}), - ...(startup ? { startup } : {}), - ...(defaultTabs ? { defaultTabs } : {}) - }, - { allowRuntimeEnvironment: false } - ).catch((error) => { - console.error('Failed to activate CLI-created worktree:', error) - }) - }) - ) - - unsubs.push( - window.api.ui.onCreateTerminal( - ({ - requestId, - worktreeId, - command, - cwd, - env, - launchConfig, - resumeProviderSession, - launchToken, - launchAgent, - viewMode, - title, - ptyId, - activate, - focus, - presentation, - surfaceOwner, - tabId, - leafId, - splitFromLeafId, - splitDirection, - splitTelemetrySource - }) => { - try { - const store = useAppStore.getState() - const terminalPresentation = resolveTerminalPresentation({ - presentation, - activate, - focus - }) - const shouldActivate = terminalPresentation === 'focused' - const shouldSurfaceOwner = - terminalPresentation !== 'background' && surfaceOwner !== false - if (shouldActivate) { - activateTerminalInitiatedWorktree(store, worktreeId) - } - const worktreeTabs = store.tabsByWorktree[worktreeId] ?? [] - // Why: a split pane revealed from mobile is only bound in the persisted - // layout until its pane mounts; missing it minted a duplicate tab (#10486). - const ownership = ptyId - ? resolveTerminalTabPtyOwnership( - store, - worktreeId, - ptyId, - tabId !== undefined ? { preferTabId: tabId } : {} - ) - : { kind: 'none' as const } - const existingTab = - ownership.kind === 'owned' - ? worktreeTabs.find((candidate) => candidate.id === ownership.tabId) - : undefined - const isSplitReveal = Boolean(ptyId && tabId && leafId && splitFromLeafId) - const splitTargetTab = isSplitReveal - ? worktreeTabs.find((candidate) => candidate.id === tabId) - : undefined - if (isSplitReveal && !splitTargetTab) { - throw new Error(`Terminal tab ${tabId} not found`) - } - const reusedTab = existingTab ?? splitTargetTab - const tab = - reusedTab ?? - (ptyId - ? store.createTab(worktreeId, undefined, undefined, { - initialPtyId: ptyId, - activate: shouldActivate, - ...(launchAgent - ? { - launchAgent, - // Why: a paired client resolved explicit mode before PTY materialization; only omitted mode uses host defaults. - ...(viewMode - ? { viewMode } - : initialAgentTabViewModeProps(store.settings, { - agent: launchAgent, - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - })) - } - : {}), - ...(cwd ? { startupCwd: cwd } : {}), - // Why: CLI-spawned PTYs bake the pane key into env; adopt the same tab id so hook-event attribution keeps working. - ...(tabId !== undefined ? { id: tabId } : {}) - }) - : store.createTab( - worktreeId, - undefined, - undefined, - shouldActivate - ? cwd - ? { startupCwd: cwd } - : undefined - : { - activate: false, - recordInteraction: false, - ...(cwd ? { startupCwd: cwd } : {}) - } - )) - // Why: a reused tab whose id differs from the hint breaks the PTY's baked-in paneKey attribution; warn during dev. - if (tabId !== undefined && tab.id !== tabId) { - console.warn( - `[onCreateTerminal] tabId hint ${tabId} ignored for ptyId ${ptyId}; existing tab ${tab.id} adopted instead (hook attribution will degrade for this terminal)` - ) - } - if (shouldActivate) { - store.setActiveTabType('terminal') - store.setActiveTab(tab.id) - } - if (shouldSurfaceOwner) { - store.revealWorktreeInSidebar(worktreeId) - focusTerminalInitiatedTab(tab.id, leafId) - } - // Why: only stamp the runtime title on fresh tabs; reused tabs may have a user customTitle it would overwrite on focus. - if (title && !reusedTab) { - store.setTabCustomTitle(tab.id, title, { recordInteraction: false }) - } - if (leafId && ptyId) { - const launchPaneKey = tryMakePaneKey(tab.id, leafId) - if (launchConfig) { - if (launchPaneKey) { - store.registerAgentLaunchConfig(launchPaneKey, launchConfig, { - ...(launchAgent ? { agentType: launchAgent } : {}), - ...(launchToken ? { launchToken } : {}), - tabId: tab.id, - leafId - }) - } - } else if (!splitFromLeafId && launchPaneKey) { - store.clearAgentLaunchConfig(launchPaneKey) - } - if (splitFromLeafId) { - // Why: runtime split PTYs already carry the parent tab's paneKey, so reuse the tab instead of minting a collision tab. - store.updateTabPtyId(tab.id, ptyId) - const existingLayout = store.terminalLayoutsByTabId?.[tab.id] - const sourcePtyId = existingLayout?.ptyIdsByLeafId?.[splitFromLeafId] - store.setTabLayout( - tab.id, - addSplitLeafToLayout( - existingLayout, - splitFromLeafId, - leafId, - ptyId, - splitDirection ?? 'horizontal', - title, - shouldActivate - ) - ) - window.dispatchEvent( - new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { - detail: { - tabId: tab.id, - paneRuntimeId: -1, - direction: splitDirection ?? 'horizontal', - sourceLeafId: splitFromLeafId, - sourcePtyId, - telemetrySource: splitTelemetrySource, - newLeafId: leafId, - ptyId - } - }) - ) - } else { - // Why: CLI/runtime PTYs emit hook events before the tab mounts, so the leaf must exist in layout for paneKey validation. - const existingLayout = reusedTab - ? activateExistingLeafInLayout( - store.terminalLayoutsByTabId?.[tab.id], - leafId, - ptyId, - title - ) - : null - if (existingLayout) { - store.updateTabPtyId(tab.id, ptyId) - store.setTabLayout(tab.id, existingLayout) - } else { - store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title)) - } - } - } - if (command) { - store.queueTabStartupCommand(tab.id, { - command, - ...(env ? { env } : {}), - ...(launchConfig ? { launchConfig } : {}), - ...(resumeProviderSession ? { resumeProviderSession } : {}), - ...(launchToken ? { launchToken } : {}), - ...(launchAgent ? { launchAgent } : {}) - }) - } - if (ptyId && terminalPresentation === 'background') { - requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) - } - if (requestId) { - // Why: attest the actual binding; recovery callers compare it with their expected identity. - const identity = - ptyId && tabId && leafId - ? verifyTerminalRevealIdentity(useAppStore.getState(), { - worktreeId, - tabId: tab.id, - leafId, - ptyId - }) - : undefined - window.api.ui.replyTerminalCreate({ - requestId, - tabId: tab.id, - title: title ?? tab.title, - ...(identity ? { identity } : {}) - }) - } - } catch (err) { - if (!requestId) { - throw err - } - window.api.ui.replyTerminalCreate({ - requestId, - error: err instanceof Error ? err.message : 'Terminal reveal failed' - }) - } - } - ) - ) - - // Why: background-mount a mobile-subscribed tab's PTY without navigating the desktop (STA-1840). - unsubs.push( - window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { - if (!worktreeId) { - return - } - // Why: synthetic pty handles need persisted-tab resolution; a miss must not mount every saved tab in a hidden worktree. - const mount = planMobileTerminalTabMount( - useAppStore.getState(), - { - worktreeId, - ...(tabId ? { tabId } : {}), - ...(ptyId ? { ptyId } : {}) - }, - { - isTabMounted: hasRegisteredRuntimeTerminalTab - } - ) - if (mount) { - requestBackgroundTerminalWorktreeMount(mount) - } - }) - ) - - // Why: CLI-driven terminal creation waits for the tabId reply so it can hand the caller a usable handle immediately. - unsubs.push( - window.api.ui.onRequestTerminalCreate((data) => { - try { - const store = useAppStore.getState() - const worktreeId = data.worktreeId ?? store.activeWorktreeId - if (!worktreeId) { - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') - }) - return - } - const worktreeRoute = resolveTerminalWorktreeRoute(store, worktreeId) - if (!worktreeRoute) { - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.unresolvedTerminalWorktreeOwner', - 'Terminal creation is unavailable because the worktree owner could not be resolved' - ) - }) - return - } - // Why: runtime-session requests are host-owned tabs materialized by this renderer, not ordinary local creates. - if (worktreeRoute.runtimeEnvironmentId && data.source !== 'runtime-session') { - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.7a64b31991', - 'Local terminal creation is unavailable while a remote runtime is active' - ) - }) - return - } - const terminalPresentation = resolveTerminalPresentation(data) - const shouldActivate = terminalPresentation === 'focused' - const shouldSurfaceOwner = - terminalPresentation !== 'background' && data.surfaceOwner !== false - if (shouldActivate) { - activateTerminalInitiatedWorktree(store, worktreeId) - } - // Why: the paired launch client already resolved the mode, so its choice wins over the host renderer's local default. - const tabOptions = data.launchAgent - ? { - ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), - launchAgent: data.launchAgent, - ...(data.viewMode - ? { viewMode: data.viewMode } - : initialAgentTabViewModeProps(store.settings, { - agent: data.launchAgent, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - })), - ...(data.cwd ? { startupCwd: data.cwd } : {}) - } - : shouldActivate - ? data.cwd - ? { startupCwd: data.cwd } - : undefined - : { - activate: false, - recordInteraction: false, - ...(data.cwd ? { startupCwd: data.cwd } : {}) - } - const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions) - if (!shouldActivate) { - // Why: renderer-backed Codex startup must mount its new TerminalPane without switching UI or connecting every saved tab. - requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) - } - if (data.afterTabId) { - const createdUnifiedTab = useAppStore - .getState() - .unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id) - const anchorUnifiedTab = useAppStore - .getState() - .unifiedTabsByWorktree[worktreeId]?.find((item) => item.id === data.afterTabId) - if ( - createdUnifiedTab && - anchorUnifiedTab && - createdUnifiedTab.groupId === anchorUnifiedTab.groupId - ) { - const group = useAppStore - .getState() - .groupsByWorktree[worktreeId]?.find((item) => item.id === createdUnifiedTab.groupId) - const order = (group?.tabOrder ?? []).filter((id) => id !== createdUnifiedTab.id) - const anchorIndex = order.indexOf(anchorUnifiedTab.id) - order.splice( - anchorIndex === -1 ? order.length : anchorIndex + 1, - 0, - createdUnifiedTab.id - ) - useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, { - recordInteraction: false - }) - } - } - if (shouldActivate) { - store.setActiveTabType('terminal') - store.setActiveTab(tab.id) - } - if (shouldSurfaceOwner) { - store.revealWorktreeInSidebar(worktreeId) - focusTerminalInitiatedTab(tab.id) - } - if (data.title) { - store.setTabCustomTitle(tab.id, data.title, { recordInteraction: false }) - } - if (data.command) { - store.queueTabStartupCommand(tab.id, { - command: data.command, - ...(data.env ? { env: data.env } : {}), - ...(data.envToDelete ? { envToDelete: data.envToDelete } : {}), - ...(data.launchConfig ? { launchConfig: data.launchConfig } : {}), - ...(data.resumeProviderSession - ? { resumeProviderSession: data.resumeProviderSession } - : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}), - ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), - ...(data.startupCommandDelivery - ? { startupCommandDelivery: data.startupCommandDelivery } - : {}) - }) - } - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - tabId: tab.id, - title: data.title ?? tab.title - }) - } catch (err) { - window.api.ui.replyTerminalCreate({ - requestId: data.requestId, - error: err instanceof Error ? err.message : 'Terminal creation failed' - }) - } - }) - ) - - unsubs.push( - window.api.ui.onSplitTerminal( - ({ tabId, paneRuntimeId, direction, command, telemetrySource }) => { - const detail: SplitTerminalPaneDetail = { - tabId, - paneRuntimeId, - direction, - command, - telemetrySource - } - window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail })) - } - ) - ) - - unsubs.push( - window.api.ui.onRenameTerminal(({ tabId, title }) => { - useAppStore.getState().setTabCustomTitle(tabId, title) - }) - ) - - unsubs.push( - window.api.ui.onFocusTerminal( - ({ - tabId, - worktreeId, - leafId, - ackPaneKeyOnSuccess, - flashFocusedPane, - scrollToBottomIfOutputSinceLastView - }) => { - const store = useAppStore.getState() - activateTerminalInitiatedWorktree(store, worktreeId) - store.setActiveTab(tabId) - store.revealWorktreeInSidebar(worktreeId) - if (ackPaneKeyOnSuccess || flashFocusedPane || scrollToBottomIfOutputSinceLastView) { - activateTabAndFocusPane(tabId, leafId ?? null, { - ...(ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess } : {}), - ...(flashFocusedPane ? { flashFocusedPane: true } : {}), - ...(scrollToBottomIfOutputSinceLastView - ? { scrollToBottomIfOutputSinceLastView: true } - : {}) - }) - return - } - focusTerminalInitiatedTab(tabId, leafId) - } - ) - ) - - unsubs.push( - window.api.ui.onFocusEditorTab(({ tabId, worktreeId }) => { - const store = useAppStore.getState() - const tab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find( - (item) => item.id === tabId - ) - const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) - if (!tab) { - if (browserTarget) { - // Why: older/mobile fallback snapshots identify browser tabs by workspace id when no unified tab wrapper exists. - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - store.setActiveBrowserTab(browserTarget.workspaceId) - store.setActiveTabType('browser') - store.revealWorktreeInSidebar(worktreeId) - } - return - } - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - store.focusGroup(worktreeId, tab.groupId) - store.activateTab(tab.id) - if (browserTarget) { - // Why: browser tabs need their own active-page state, not the editor file activation path. - store.setActiveBrowserTab(browserTarget.workspaceId) - store.setActiveTabType('browser') - } else { - store.setActiveFile(tab.entityId) - store.setActiveTabType('editor') - } - store.revealWorktreeInSidebar(worktreeId) - }) - ) - - unsubs.push( - window.api.ui.onCloseSessionTab(({ tabId, worktreeId }) => { - const store = useAppStore.getState() - const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) - if (browserTarget) { - guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(store, worktreeId, browserTarget.workspaceId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId), - onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) - }) - return - } - guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(store, worktreeId, tabId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), - onClose: () => { - const currentStore = useAppStore.getState() - closeMobileSessionTabInStore(currentStore, worktreeId, tabId) - } - }) - }) - ) - - unsubs.push( - window.api.ui.onSessionTabCloseRequest(({ requestId, tabId, worktreeId, expiresAt }) => { - const store = useAppStore.getState() - const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) - let cancelConfirmation: (() => void) | undefined - let timeout: ReturnType | undefined - let settled = false - const respond = (error?: string): void => { - if (settled) { - return - } - settled = true - if (timeout !== undefined) { - clearTimeout(timeout) - } - window.api.ui.respondSessionTabClose({ requestId, ...(error ? { error } : {}) }) - } - if (expiresAt !== undefined) { - timeout = setTimeout( - () => { - cancelConfirmation?.() - respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) - }, - Math.max(0, expiresAt - Date.now()) - ) - } - const closeAndRespond = (): void => { - if (expiresAt !== undefined && Date.now() >= expiresAt) { - respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) - return - } - try { - if (browserTarget) { - useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) - respond() - return - } - const closed = closeMobileSessionTabInStore(useAppStore.getState(), worktreeId, tabId) - respond(closed ? undefined : SESSION_TAB_NOT_FOUND_ERROR) - } catch (error) { - respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR) - } - } - const visibleId = browserTarget?.workspaceId ?? tabId - cancelConfirmation = guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(store, worktreeId, visibleId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, visibleId), - onClose: closeAndRespond, - onCancel: () => respond(SESSION_TAB_CLOSE_CANCELED_ERROR) - }) - }) - ) - - unsubs.push( - window.api.ui.onMoveSessionTab((move) => { - const { tabId, targetGroupId } = move - const store = useAppStore.getState() - if (move.kind === 'reorder') { - store.reorderUnifiedTabs(targetGroupId, move.tabOrder) - return - } - store.dropUnifiedTab(tabId, { - groupId: targetGroupId, - ...(move.kind === 'move-to-group' ? { index: move.index } : {}), - ...(move.kind === 'split' ? { splitDirection: move.splitDirection } : {}) - }) - }) - ) - - unsubs.push( - window.api.ui.onOpenFileFromMobile( - ({ worktreeId, filePath, relativePath, runtimeEnvironmentId }) => { - const store = useAppStore.getState() - const basename = relativePath.split(/[\\/]/).pop() || relativePath - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - // Why: renderer owns tab creation so grouped order and markdown bridges share the desktop File Explorer's store path. - store.openFile({ - filePath, - relativePath, - worktreeId, - language: detectLanguage(basename), - runtimeEnvironmentId, - mode: 'edit' - }) - store.setActiveTabType('editor') - store.revealWorktreeInSidebar(worktreeId) - } - ) - ) - - unsubs.push( - window.api.ui.onOpenDiffFromMobile( - ({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId }) => { - const store = useAppStore.getState() - const language = detectLanguage(relativePath) - store.setActiveWorktree(worktreeId) - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') - // Why: mobile renders diffs from metadata; the editor-local Changes shortcut would send plain markdown back to mobile. - store.openDiff(worktreeId, filePath, relativePath, language, staged, { - runtimeEnvironmentId - }) - store.setActiveTabType('editor') - store.revealWorktreeInSidebar(worktreeId) - } - ) - ) - - unsubs.push( - window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => { - if (paneRuntimeId != null) { - // Why: route pane closes via the lifecycle hook for sibling promotion (falls through to closeTab on the last pane). - const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId } - window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) - } else { - // Why: the CLI/RPC caller is answered immediately, so it cannot wait on a modal. - closeTerminalTab(tabId, { skipRunningProcessConfirm: true }) - } - }) - ) - - // Why: during an in-place renderer reload an older preload can linger; keep this listener additive at that seam. - if (window.api.ui.onTerminalTabCloseRequest) { - unsubs.push( - window.api.ui.onTerminalTabCloseRequest( - ({ requestId, tabId, localPtyTeardownOwnedExternally }) => { - let responded = false - const respond = (error?: string): void => { - if (responded) { - return - } - responded = true - window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) - } - closeTerminalTab(tabId, { - rejectPinned: true, - ...(localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}), - onCancel: () => respond('terminal_tab_pinned'), - onClosed: () => { - void (async () => { - const state = useAppStore.getState() - await persistWorkspaceSessionByHost( - window.api.session, - buildWorkspaceSessionPayload(state), - state - ) - respond() - })().catch((error: unknown) => { - respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') - }) - } - }) - } - ) - ) - } - - unsubs.push( - window.api.ui.onSleepWorktree(({ worktreeId }) => { - void runSleepWorktree(worktreeId) - }) - ) - - unsubs.push( - window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { - // Why: a phone opened this worktree; wake its slept agents without changing the desktop's worktree/tab/view. - backgroundSleepingAgentWakeDispatcher.request(worktreeId) - }) - ) - - // Hydrate initial update status then subscribe to changes - window.api.updater.getStatus().then((status) => { - useAppStore.getState().setUpdateStatus(status as UpdateStatus) - }) - - unsubs.push( - window.api.updater.onStatus((raw) => { - const status = raw as UpdateStatus - useAppStore.getState().setUpdateStatus(status) - }) - ) - - unsubs.push( - window.api.updater.onClearDismissal(() => { - useAppStore.getState().clearDismissedUpdateVersion() - }) - ) - - unsubs.push( - window.api.ui.onFullscreenChanged((isFullScreen) => { - useAppStore.getState().setIsFullScreen(isFullScreen) - }) - ) - - unsubs.push( - window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => { - if (isRuntimeEnvironmentActive()) { - return - } - useAppStore.getState().updateBrowserPageState(browserPageId, { - loading: false, - loadError, - canGoBack: false, - canGoForward: false - }) - }) - ) - - const unsubscribeCertificateFailure = window.api.browser.onCertificateFailureChanged?.( - ({ browserPageId, failure }) => { - if (isRuntimeEnvironmentActive()) { - return - } - useAppStore.getState().setBrowserPageCertificateFailure(browserPageId, failure) - } - ) - if (unsubscribeCertificateFailure) { - unsubs.push(unsubscribeCertificateFailure) - } - - // Why: agent-browser navigates via CDP so did-navigate never fires; this IPC pushes live URL/title to the stale store. - unsubs.push( - window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => { - if (isRuntimeEnvironmentActive()) { - return - } - const store = useAppStore.getState() - rememberLiveBrowserUrl(browserPageId, redactKagiSessionToken(url)) - store.setBrowserPageUrl(browserPageId, url) - store.updateBrowserPageState(browserPageId, { title, loading: false }) - }) - ) - - // Why: webviews start their guest only when shown; sent pre-automation so hidden tabs mount without moving the active pane. - unsubs.push( - window.api.browser.onActivateView(({ worktreeId, browserPageId }) => { - if (isRuntimeEnvironmentActive()) { - return - } - acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) - }) - ) - - // Why: `orca tab switch --focus` must NOT call setActiveWorktree — a global focus from one agent's parallel-worktree switch would steal the user's view. - // focusBrowserTabInWorktree updates per-worktree state in place; globals flip only when the user is already on the targeted worktree. - unsubs.push( - window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => { - if (isRuntimeEnvironmentActive()) { - return - } - const store = useAppStore.getState() - // Why: worktreeId is null if the tab closed mid-switch; the activeWorktreeId fallback makes the focus call a safe no-op for a stale page id. - const targetWt = worktreeId ?? store.activeWorktreeId - if (!targetWt) { - return - } - store.focusBrowserTabInWorktree(targetWt, browserPageId) - }) - ) - - unsubs.push( - window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => { - const store = useAppStore.getState() - const sourcePage = Object.values(store.browserPagesByWorkspace) - .flat() - .find((page) => page.id === browserPageId) - if (!sourcePage) { - return - } - if (getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { - return - } - // Why: only the renderer owns Orca's tab model, so main delegates link-open here. - store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) - }) - ) - - // Why: embedded browser guests capture keyboard focus and bypass window-level keydown, so shortcuts are forwarded via IPC. - unsubs.push( - window.api.ui.onNewBrowserTab(() => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - void createFloatingWorkspaceBrowserTab(store).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - return - } - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - const targetGroupId = - store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id - if (!targetGroupId) { - return - } - void store.openNewBrowserTabInActiveWorkspace(targetGroupId).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - }) - ) - - unsubs.push( - window.api.ui.onNewMarkdownTab(() => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - void createFloatingWorkspaceMarkdownTab(store).catch((err) => { - toast.error( - err instanceof Error - ? err.message - : translate( - 'auto.hooks.useIpcEvents.56d3ec4203', - 'Failed to create untitled markdown file.' - ) - ) - }) - return - } - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - const targetGroupId = - store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id - if (targetGroupId) { - void store.openNewMarkdownInActiveWorkspace(targetGroupId) - } - }) - ) - - // Why: emulator IPC is additive; guard so older clients or partial preload mocks don't crash the hook when it's absent. - const unsubscribeNewSimulatorTab = window.api.ui.onNewSimulatorTab?.(() => { - if (isRuntimeEnvironmentActive()) { - return - } - const store = useAppStore.getState() - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - void openMobileEmulatorTab(worktreeId, { placement: 'rightSplit' }).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - }) - if (unsubscribeNewSimulatorTab) { - unsubs.push(unsubscribeNewSimulatorTab) - } - - const unsubscribeEmulatorAutoAttach = window.api.emulator?.onAutoAttach( - ({ worktreeId, info }) => { - if (isRuntimeEnvironmentActive()) { - return - } - if (isManualSimulatorLaunchPending(worktreeId)) { - // Why: manual launches pre-attach so the ready pane opens in the right split, not as a hidden tab in this group. - rememberPrelaunchedSimulatorSession(worktreeId, info) - return - } - ensureSimulatorTab(worktreeId, { - surfacePane: false, - executionHostId: LOCAL_EXECUTION_HOST_ID - }) - // Why: watcher may detect a helper while a simulator tab is already mounted; push stream info so the pane updates without re-attach. - window.setTimeout(() => { - window.dispatchEvent( - new CustomEvent('orca:emulator-auto-attach', { - detail: { worktreeId, info } - }) - ) - }, 0) - } - ) - if (unsubscribeEmulatorAutoAttach) { - unsubs.push(unsubscribeEmulatorAutoAttach) - } - - const unsubscribeEmulatorPaneFocus = window.api.emulator?.onPaneFocus(({ worktreeId }) => { - if (isRuntimeEnvironmentActive()) { - return - } - ensureSimulatorTab(worktreeId, { - surfacePane: true, - executionHostId: LOCAL_EXECUTION_HOST_ID - }) - }) - if (unsubscribeEmulatorPaneFocus) { - unsubs.push(unsubscribeEmulatorPaneFocus) - } - - // Why: reply with the page ID so main can await registerGuest before returning to the CLI. - unsubs.push( - window.api.ui.onRequestTabCreate((data) => { - try { - if (isRuntimeEnvironmentActive()) { - // Why: browser automation targets client-local Electron webviews that runtime agents can't see or control. - window.api.ui.replyTabCreate({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.291c8ed902', - 'Browser tabs are unavailable while a remote runtime is active' - ) - }) - return - } - const store = useAppStore.getState() - const worktreeId = data.worktreeId ?? store.activeWorktreeId - if (!worktreeId) { - window.api.ui.replyTabCreate({ - requestId: data.requestId, - error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree') - }) - return - } - // Why: CLI-created tabs should land in the active browser tab's group, not the terminal's UI-active group. - const activeBrowserTabId = store.activeBrowserTabIdByWorktree[worktreeId] - const activeBrowserUnifiedTab = activeBrowserTabId - ? (store.unifiedTabsByWorktree[worktreeId] ?? []).find( - (t) => t.contentType === 'browser' && t.entityId === activeBrowserTabId - ) - : undefined - - // Why: a user-initiated open (data.activate, e.g. mobile tapping an HTML path) foregrounds the tab so it lands in active-group order and publishes to mobile. - // Agent/automation opens stay in the background (activate:false) in the active browser group. - const workspace = store.createBrowserTab(worktreeId, data.url, { - title: data.url, - browserPageId: data.browserPageId, - targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId, - sessionProfileId: data.sessionProfileId, - sessionPartition: data.sessionPartition, - activate: data.activate === true - }) - // Why: registerGuest fires with the page ID, not the workspace ID; return it so waitForTabRegistration can correlate. - const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? [] - const browserPageId = pages[0]?.id ?? workspace.id - acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) - window.api.ui.replyTabCreate({ requestId: data.requestId, browserPageId }) - } catch (err) { - window.api.ui.replyTabCreate({ - requestId: data.requestId, - error: err instanceof Error ? err.message : 'Tab creation failed' - }) - } - }) - ) - - unsubs.push( - window.api.ui.onRequestTabSetProfile((data) => { - try { - if (isRuntimeEnvironmentActive()) { - window.api.ui.replyTabSetProfile({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.f45fa2b03c', - 'Browser profiles are unavailable while a remote runtime is active' - ) - }) - return - } - const store = useAppStore.getState() - const owningWorkspace = Object.values(store.browserTabsByWorktree) - .flat() - .find((workspace) => { - if (workspace.id === data.browserPageId) { - return true - } - const pages = store.browserPagesByWorkspace[workspace.id] ?? [] - return pages.some((page) => page.id === data.browserPageId) - }) - if (!owningWorkspace) { - window.api.ui.replyTabSetProfile({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.0e3cf53060', - 'Browser tab {{value0}} not found', - { value0: data.browserPageId } - ) - }) - return - } - // Why: a workspace may host several browser pages; profile switch must tear down all sibling webviews, not just the IPC's. - const workspacePages = store.browserPagesByWorkspace[owningWorkspace.id] ?? [] - if (workspacePages.length > 0) { - for (const page of workspacePages) { - destroyPersistentWebview(page.id) - } - } else { - destroyPersistentWebview(data.browserPageId) - } - store.switchBrowserTabProfile(owningWorkspace.id, data.profileId, data.sessionPartition) - window.api.ui.replyTabSetProfile({ requestId: data.requestId }) - } catch (err) { - window.api.ui.replyTabSetProfile({ - requestId: data.requestId, - error: err instanceof Error ? err.message : 'Tab profile update failed' - }) - } - }) - ) - - unsubs.push( - window.api.ui.onRequestTabClose((data) => { - try { - if (isRuntimeEnvironmentActive()) { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.291c8ed902', - 'Browser tabs are unavailable while a remote runtime is active' - ) - }) - return - } - const store = useAppStore.getState() - const explicitTargetId = data.tabId ?? null - const replyBrowserTabNotFound = (tabId: string): void => { - window.api.ui.replyTabClose({ - requestId: data.requestId, - code: 'browser_tab_not_found', - error: translate( - 'auto.hooks.useIpcEvents.0e3cf53060', - 'Browser tab {{value0}} not found', - { value0: tabId } - ) - }) - } - const replyPinnedBrowserCloseCanceled = (tabId: string): void => { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.2f6637fe6c', - 'Browser tab {{value0}} is pinned', - { value0: tabId } - ) - }) - } - const closeBrowserWorkspaceWithReply = ( - worktreeId: string, - workspaceId: string - ): void => { - const currentStore = useAppStore.getState() - guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(currentStore, worktreeId, workspaceId), - tabLabel: resolvePinnedTabLabel(currentStore, worktreeId, workspaceId), - onClose: () => { - useAppStore.getState().closeBrowserTab(workspaceId) - window.api.ui.replyTabClose({ requestId: data.requestId }) - }, - onCancel: () => replyPinnedBrowserCloseCanceled(workspaceId) - }) - } - const tabToClose = - explicitTargetId ?? - (data.worktreeId - ? (store.activeBrowserTabIdByWorktree?.[data.worktreeId] ?? null) - : store.activeBrowserTabId) - if (!tabToClose) { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: translate( - 'auto.hooks.useIpcEvents.a8d2bf8e9e', - 'No active browser tab to close' - ) - }) - return - } - // Why: the bridge keys tabs by browserPageId, but closeBrowserTab expects a workspace id. - // Per the CLI's `tab close --page` contract, close only that page unless it is the last in its workspace. - const isWorkspaceId = Object.values(store.browserTabsByWorktree) - .flat() - .some((ws) => ws.id === tabToClose) - if (!isWorkspaceId) { - const owningWorkspace = Object.entries(store.browserPagesByWorkspace).find( - ([, pages]) => pages.some((p) => p.id === tabToClose) - ) - if (owningWorkspace) { - const [workspaceId, pages] = owningWorkspace - const owningWorktreeId = - Object.entries(store.browserTabsByWorktree).find(([, tabs]) => - tabs.some((tab) => tab.id === workspaceId) - )?.[0] ?? null - if (data.worktreeId && owningWorktreeId !== data.worktreeId) { - replyBrowserTabNotFound(tabToClose) - return - } - if (pages.length <= 1) { - if (owningWorktreeId) { - closeBrowserWorkspaceWithReply(owningWorktreeId, workspaceId) - return - } - store.closeBrowserTab(workspaceId) - } else { - store.closeBrowserPage(tabToClose) - } - window.api.ui.replyTabClose({ requestId: data.requestId }) - return - } - } - const owningWorktreeId = - Object.entries(store.browserTabsByWorktree).find(([, tabs]) => - tabs.some((tab) => tab.id === tabToClose) - )?.[0] ?? null - if (owningWorktreeId) { - if (data.worktreeId && owningWorktreeId !== data.worktreeId) { - replyBrowserTabNotFound(tabToClose) - return - } - closeBrowserWorkspaceWithReply(owningWorktreeId, tabToClose) - return - } - if (explicitTargetId) { - replyBrowserTabNotFound(explicitTargetId) - return - } - store.closeBrowserTab(tabToClose) - window.api.ui.replyTabClose({ requestId: data.requestId }) - } catch (err) { - window.api.ui.replyTabClose({ - requestId: data.requestId, - error: err instanceof Error ? err.message : 'Tab close failed' - }) - } - }) - ) - - unsubs.push( - window.api.ui.onNewTerminalTab(() => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - void createFloatingWorkspaceTerminalTab(store) - return - } - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - void (async () => { - const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) - const outcome = await createWebRuntimeSessionTerminal({ - worktreeId, - environmentId, - activate: true - }) - if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) { - return - } - const newTab = store.createTab(worktreeId) - store.setActiveTabType('terminal') - // Why: mirror Terminal.tsx handleNewTab so a new tab appends at the end, not index 0, when tabBarOrder is unset. - const freshStore = useAppStore.getState() - const currentTerminals = freshStore.tabsByWorktree[worktreeId] ?? [] - const currentEditors = freshStore.openFiles.filter((f) => f.worktreeId === worktreeId) - const currentBrowsers = freshStore.browserTabsByWorktree[worktreeId] ?? [] - const stored = freshStore.tabBarOrderByWorktree[worktreeId] - const termIds = currentTerminals.map((t) => t.id) - const editorIds = currentEditors.map((f) => f.id) - const browserIds = currentBrowsers.map((tab) => tab.id) - const validIds = new Set([...termIds, ...editorIds, ...browserIds]) - const base = (stored ?? []).filter((id) => validIds.has(id)) - const inBase = new Set(base) - for (const id of [...termIds, ...editorIds, ...browserIds]) { - if (!inBase.has(id)) { - base.push(id) - inBase.add(id) - } - } - const order = base.filter((id) => id !== newTab.id) - order.push(newTab.id) - freshStore.setTabBarOrder(worktreeId, order) - focusTerminalTabSurface(newTab.id) - })() - }) - ) - - unsubs.push( - window.api.ui.onCloseActiveTab(() => { - if (isEmptyFloatingWorkspacePanelVisible()) { - window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT)) - return - } - const store = useAppStore.getState() - if (store.activeTabType === 'browser' && store.activeBrowserTabId) { - const tabId = store.activeBrowserTabId - const worktreeId = store.activeWorktreeId - const closeActiveBrowserTab = (): void => { - const currentStore = useAppStore.getState() - const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId) - if (environmentId && worktreeId) { - if (!isWebRuntimeSessionActive(environmentId)) { - currentStore.closeBrowserTab(tabId) - return - } - void closeWebRuntimeSessionTab({ - worktreeId, - tabId, - environmentId, - reason: 'user' - }) - return - } - currentStore.closeBrowserTab(tabId) - } - if (worktreeId && isUnifiedTabPinned(store, worktreeId, tabId)) { - guardPinnedTabClose({ - isPinned: true, - tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), - onClose: closeActiveBrowserTab - }) - return - } - closeActiveBrowserTab() - } - }) - ) - - unsubs.push( - window.api.ui.onCloseFloatingItem(({ sourceId }) => { - // Main forwards the guest's browser *page* id; resolve it to the owning live floating - // browser workspace (the id space the panel closes by), then hand off to the mounted - // panel's own close closure (pin guard + reclaim intent). Stale id = no-op. - const workspaceId = resolveFloatingWorkspaceBrowserWorkspaceId( - useAppStore.getState(), - sourceId - ) - if (!workspaceId) { - return - } - dispatchFloatingWorkspaceGuestClose({ sourceId: workspaceId }) - }) - ) - unsubs.push( - window.api.ui.onSelectFloatingIndex(({ index }) => { - dispatchFloatingWorkspaceGuestSelectIndex({ index }) - }) - ) - - unsubs.push( - window.api.ui.onSwitchTab((direction) => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - switchFloatingWorkspaceTab(store, direction, 'same-type') - return - } - handleSwitchTab(direction) - }) - ) - unsubs.push( - window.api.ui.onSwitchTabAcrossAllTypes((direction) => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - switchFloatingWorkspaceTab(store, direction, 'all-types') - return - } - handleSwitchTabAcrossAllTypes(direction) - }) - ) - unsubs.push(window.api.ui.onSwitchRecentTab(handleSwitchRecentTab)) - unsubs.push( - window.api.ui.onSwitchTerminalTab((direction) => { - const store = useAppStore.getState() - if (isFloatingWorkspacePanelFocused()) { - switchFloatingWorkspaceTab(store, direction, 'terminal') - return - } - handleSwitchTerminalTab(direction) - }) - ) - - let initialRateLimitsSnapshotPending = true - let receivedRateLimitsPushBeforeInitialSnapshot = false - unsubs.push( - window.api.rateLimits.onUpdate((state) => { - if (initialRateLimitsSnapshotPending) { - receivedRateLimitsPushBeforeInitialSnapshot = true - } - useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) - }) - ) - // Why: the startup get is a fallback; a live push may already include account snapshots the get result lacks. - window.api.rateLimits.get().then((state) => { - initialRateLimitsSnapshotPending = false - if (receivedRateLimitsPushBeforeInitialSnapshot) { - return - } - useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) - }) - - const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.( - (progress) => { - useAppStore.getState().applyWorkspaceSpaceProgress(progress) - } - ) - if (unsubscribeWorkspaceSpaceProgress) { - unsubs.push(unsubscribeWorkspaceSpaceProgress) - } - - const sshStateWatermarkByTargetId = new Map() - const pendingPortHydrationByTargetId = new Map< - string, - { receivedForwardPush: boolean; receivedDetectedPush: boolean } - >() - const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => { - const pendingPortHydration = { - receivedForwardPush: false, - receivedDetectedPush: false - } - pendingPortHydrationByTargetId.set(targetId, pendingPortHydration) - const isHydrationAuthorityCurrent = (): boolean => - !directSshEffectStopped && - directSshAuthoritiesEqual(currentDirectSshAuthority(targetId), authority) - const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => { - // Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports. - if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) { - useAppStore.getState().setPortForwards(targetId, forwards) - } - }) - const detectedHydration = window.api.ssh.listDetectedPorts({ targetId }).then((detected) => { - if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedDetectedPush) { - useAppStore.getState().setDetectedPorts(targetId, detected) - } - }) - // Why: one failed or stalled port stream must not block the other stream or later targets. - void Promise.allSettled([forwardHydration, detectedHydration]).then(() => { - if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) { - pendingPortHydrationByTargetId.delete(targetId) - } - }) - } - let applySshConnectionStateChange!: ( - targetId: string, - state: SshConnectionState, - origin: DirectSshConnectedStateOrigin - ) => void - - // Why: hydrate initial SSH state for all targets so worktree cards show correct connect state on launch. - void (async () => { - try { - const targets = await window.api.ssh.listTargets() - if (directSshEffectStopped) { - return - } - useAppStore.getState().setSshTargetsMetadata(targets) - // Why: ghost-host UI (removed target still referenced by a workspace) shows a tombstone name instead of the raw id. - try { - const removedLabels = await window.api.ssh.listRemovedTargetLabels() - if (directSshEffectStopped) { - return - } - useAppStore.getState().setRemovedSshTargetLabels(removedLabels) - } catch { - // Best-effort — a missing map just falls back to the raw target id. - } - for (const target of targets) { - const hydrationWatermark = sshStateWatermarkByTargetId.get(target.id) ?? 0 - const state = await window.api.ssh.getState({ targetId: target.id }) - if ( - !directSshEffectStopped && - state && - (sshStateWatermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark - ) { - applySshConnectionStateChange( - target.id, - state as SshConnectionState, - 'initial-hydration' - ) - } - } - } catch { - // SSH may not be configured - } - })() - - unsubs.push( - window.api.ssh.onCredentialRequest((data) => { - useAppStore.getState().enqueueSshCredentialRequest(data) - }) - ) - - unsubs.push( - window.api.ssh.onCredentialResolved(({ requestId }) => { - useAppStore.getState().removeSshCredentialRequest(requestId) - }) - ) - - unsubs.push( - window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => { - const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) - if (pendingPortHydration) { - pendingPortHydration.receivedForwardPush = true - } - useAppStore.getState().setPortForwards(targetId, forwards) - }) - ) - - unsubs.push( - window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => { - const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) - if (pendingPortHydration) { - pendingPortHydration.receivedDetectedPush = true - } - useAppStore.getState().setDetectedPorts(targetId, ports) - }) - ) - - const reconcileSshAuthority = ( - targetId: string, - initiatingState: SshConnectionState, - origin: DirectSshConnectedStateOrigin, - watermark: number - ): void => { - let pendingDeadline: { timer: ReturnType; settle: () => void } | undefined - const deadline = new Promise((resolve) => { - const settle = (): void => resolve(null) - const timer = setTimeout(settle, 5_000) - pendingDeadline = { timer, settle } - authorityReconciliationDeadlines.add(pendingDeadline) - }) - void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline]) - .then((latest) => { - if ( - directSshEffectStopped || - latest?.targetId !== targetId || - !latest?.providerEpoch || - latest.connectionGeneration === undefined || - (sshStateWatermarkByTargetId.get(targetId) ?? 0) !== watermark - ) { - return - } - const current = useAppStore.getState().sshConnectionStates?.get(targetId) - if ( - current?.status !== initiatingState.status || - latest.status !== initiatingState.status || - current.providerEpoch !== initiatingState.providerEpoch || - current.connectionGeneration !== initiatingState.connectionGeneration || - (current.providerEpoch !== undefined && - current.providerEpoch !== null && - current.providerEpoch !== latest.providerEpoch) || - (current.connectionGeneration !== undefined && - current.connectionGeneration !== latest.connectionGeneration) - ) { - return - } - applySshConnectionStateChange( - targetId, - { - ...current, - providerEpoch: latest.providerEpoch, - connectionGeneration: latest.connectionGeneration - }, - origin - ) - }) - .catch(() => undefined) - .finally(() => { - if (pendingDeadline) { - clearTimeout(pendingDeadline.timer) - authorityReconciliationDeadlines.delete(pendingDeadline) - } - }) - } - - applySshConnectionStateChange = ( - targetId: string, - state: SshConnectionState, - origin: DirectSshConnectedStateOrigin - ): void => { - const store = useAppStore.getState() - const previous = store.sshConnectionStates?.get(targetId) - store.setSshConnectionState(targetId, state) - - if (canConnectSshStatus(state.status)) { - reconnectAuthorityByTarget.delete(targetId) - reconnectCoordinator.invalidate(targetId) - // Why: remote agent list is tied to a live relay; clear on disconnect so reconnect re-detects against the new relay. - store.clearRemoteDetectedAgents(targetId) - - // Why: defensive — clear port state in case the removeAllForwards broadcast races this state change. - store.clearPortForwards(targetId) - store.setDetectedPorts(targetId, []) - - // SSH teardown has no per-PTY exits; clear only exact-target bindings in one store publication. - store.clearDirectSshTargetPtyBindings(targetId) - return - } - - if (state.status !== 'connected') { - return - } - const authority = currentDirectSshAuthority(targetId) - if (!authority) { - reconcileSshAuthority( - targetId, - state, - origin, - sshStateWatermarkByTargetId.get(targetId) ?? 0 - ) - return - } - const previousAuthority = - previous?.status === 'connected' && - previous.providerEpoch && - previous.connectionGeneration !== undefined - ? { - targetId, - providerEpoch: previous.providerEpoch, - connectionGeneration: previous.connectionGeneration - } - : null - routeDirectSshConnectedState( - { - coordinator: reconnectCoordinator, - coordinatorRoutingEnabled: isDirectSshReconnectCoordinatorRoutingEnabled(), - invalidateStaleTerminalBindings: (nextAuthority) => - directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? - 0, - retryTargetPanes: (nextAuthority) => - directSshTerminalActions().retryDirectSshTargetPanes?.(nextAuthority) ?? 0, - prepareAndSync: prepareAndSyncDirectSshTarget, - rememberReconnectAuthority: (nextAuthority) => { - if (nextAuthority) { - reconnectAuthorityByTarget.set(targetId, nextAuthority) - } else { - reconnectAuthorityByTarget.delete(targetId) - } - } - }, - { authority, previousAuthority, origin } - ) - // Why: initial connected state can be partial; hydrate only after reconciliation yields a complete authority. - if (origin === 'initial-hydration') { - hydrateSshPorts(targetId, authority) - } - } - - let sshTargetStateEventId = 0 - const latestSshTargetStateEventByTargetId = new Map() - - handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => { - const store = useAppStore.getState() - const state = data.state as SshConnectionState - const stateEventId = ++sshTargetStateEventId - sshStateWatermarkByTargetId.set( - data.targetId, - (sshStateWatermarkByTargetId.get(data.targetId) ?? 0) + 1 - ) - latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) - if (!store.sshTargetLabels.has(data.targetId)) { - // Why: unknown target id could be a post-boot add or a removed target racing disconnect; confirm with main first. - window.api.ssh - .listTargets() - // Why: refresh doubles as a deletion guard; retry once so a transient IPC failure doesn't drop a real added-target event. - .catch(() => window.api.ssh.listTargets()) - .then((targets) => { - if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { - return - } - latestSshTargetStateEventByTargetId.delete(data.targetId) - if (directSshEffectStopped) { - return - } - const latestStore = useAppStore.getState() - if (!targets.some((target) => target.id === data.targetId)) { - // Why: state events can race after target removal; absence from main's target list means deletion, not a new target. - latestStore.clearRemovedSshTargetState(data.targetId) - return - } - latestStore.setSshTargetsMetadata(targets) - applySshConnectionStateChange(data.targetId, state, 'push') - }) - .catch(() => { - if ( - !directSshEffectStopped && - latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId - ) { - latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state, 'push') - } - }) - return - } - - latestSshTargetStateEventByTargetId.delete(data.targetId) - applySshConnectionStateChange(data.targetId, state, 'push') - } - - unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent)) - unsubs.push( - registerDirectSshWakeRouting({ - getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], - wakeAuthority: (authority) => { - reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh') - void prepareAndSyncDirectSshTarget(authority, 'wake-refresh') - }, - ...(typeof window.api.ui.onSystemResumed === 'function' - ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } - : {}) - }) - ) - - let remoteWorkspaceClientId: string | null = null - let remoteWorkspaceClientIdPromise: Promise | null = null - const getRemoteWorkspaceClientId = (): Promise => { - const remoteWorkspace = window.api.remoteWorkspace - if (!remoteWorkspace) { - return Promise.resolve(null) - } - if (remoteWorkspaceClientId) { - return Promise.resolve(remoteWorkspaceClientId) - } - remoteWorkspaceClientIdPromise ??= remoteWorkspace - .clientId() - .then((id) => { - remoteWorkspaceClientId = id - return id - }) - .catch(() => null) - return remoteWorkspaceClientIdPromise - } - if (window.api.remoteWorkspace) { - void getRemoteWorkspaceClientId() - unsubs.push( - window.api.remoteWorkspace.onChanged((event) => { - void (async () => { - // Why: relay notifications can race the client-id IPC; self-originated writes must never bounce back into restore. - const clientId = await getRemoteWorkspaceClientId() - if (event.sourceClientId && clientId && event.sourceClientId === clientId) { - return - } - await remoteWorkspaceTargetSync - ?.applyUnsolicitedSnapshot(event.targetId, event.snapshot) - .catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { - phase: 'error', - revision: event.snapshot.revision, - message: err instanceof Error ? err.message : 'Failed to apply remote workspace' - }) - }) - })() - }) - ) - } - - // Zoom handling for menu accelerators and keyboard fallback paths. - unsubs.push( - window.api.ui.onTerminalZoom((direction) => { - const store = useAppStore.getState() - const { activeView, activeTabType, editorFontZoomLevel, setEditorFontZoomLevel, settings } = - store - const target = resolveZoomTarget({ - activeView, - activeTabType, - activeElement: document.activeElement - }) - if (target === 'terminal') { - return - } - if (target === 'editor') { - const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction) - setEditorFontZoomLevel(next) - void window.api.ui.set({ editorFontZoomLevel: next }) - - // Why: mirror the editor's base font (terminalFontSize) + clamping so the overlay percent matches the rendered size. - const baseFontSize = settings?.terminalFontSize ?? 13 - const actual = computeEditorFontSize(baseFontSize, next) - const percent = Math.round((actual / baseFontSize) * 100) - dispatchZoomLevelChanged('editor', percent) - return - } - - const current = window.api.ui.getZoomLevel() - const next = stepUIZoomLevel(current, direction) - - applyUIZoom(next) - void window.api.ui.set({ uiZoomLevel: next }) - - dispatchZoomLevelChanged('ui', zoomLevelToPercent(next)) - }) - ) - - // Why: re-parse main-process agent status here so the renderer applies the same normalization regardless of hook vs OSC source. - // Startup pushes are ignored until workspace session hydration finishes; the snapshot pull below replays main's cache once tab identity exists. - function schedulePendingAgentStatusFlush(): void { - if (pendingAgentStatusRetryTimer !== null || pendingAgentStatusEvents.length === 0) { - return - } - pendingAgentStatusRetryTimer = globalThis.setTimeout(() => { - pendingAgentStatusRetryTimer = null - flushPendingAgentStatuses() - }, PENDING_AGENT_STATUS_RETRY_MS) - } - - function enqueuePendingAgentStatus( - data: AgentStatusIpcPayload, - options?: { replay?: boolean } - ): void { - pendingAgentStatusEvents.push({ - data, - firstSeenAt: Date.now(), - replay: options?.replay === true - }) - while (pendingAgentStatusEvents.length > MAX_PENDING_AGENT_STATUS_EVENTS) { - pendingAgentStatusEvents.shift() - } - schedulePendingAgentStatusFlush() - } - - function flushPendingAgentStatuses(): void { - // Why: guard re-entrancy — a subscriber firing mid-loop must not reprocess queued events the outer flush already owns. - if (isFlushingAgentStatuses) { - return - } - if (pendingAgentStatusEvents.length === 0) { - return - } - isFlushingAgentStatuses = true - try { - const now = Date.now() - const candidates = pendingAgentStatusEvents - .splice(0) - .filter((event) => now - event.firstSeenAt <= PENDING_AGENT_STATUS_TTL_MS) - let results: AgentStatusApplyResult[] - try { - results = applyAgentStatusBatch( - candidates.map((event) => ({ data: event.data, replay: event.replay, retry: true })) - ) - } catch (err) { - // Why: the queue was already spliced, so a throwing fold would drop the whole - // burst and strand every pane in it. Requeue ahead of newer arrivals and retry. - pendingAgentStatusEvents.unshift(...candidates) - throw err - } - for (let index = 0; index < candidates.length; index += 1) { - if (results[index] === 'pending') { - pendingAgentStatusEvents.push(candidates[index]) - } - } - if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) { - globalThis.clearTimeout(pendingAgentStatusRetryTimer) - pendingAgentStatusRetryTimer = null - } - } finally { - isFlushingAgentStatuses = false - } - schedulePendingAgentStatusFlush() - } - - const applyAgentStatus = ( - data: AgentStatusIpcPayload, - options?: AgentStatusApplyOptions - ): AgentStatusApplyResult => { - const store = options?.batch?.transaction.getState() ?? useAppStore.getState() - if (!store.workspaceSessionReady) { - return 'dropped' - } - if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) { - return 'dropped' - } - const paneKey = resolveAgentPaneAuthorityKey(data.paneKey) - const ownerTabId = parsePaneKey(paneKey)?.tabId ?? data.tabId - const payload = normalizeAgentStatusPayload({ - state: data.state, - workingMode: data.workingMode, - prompt: data.prompt, - agentType: data.agentType, - model: data.model, - toolName: data.toolName, - toolInput: data.toolInput, - // Why: the live AskUserQuestion prompt rides this field; omitting it drops the native question card on web/mobile. - interactivePrompt: data.interactivePrompt, - lastAssistantMessage: data.lastAssistantMessage, - interrupted: data.interrupted, - sessionBoundary: data.sessionBoundary, - turnCompletedAt: data.turnCompletedAt, - // Why: same trap as interactivePrompt — this rebuild is a field whitelist, so subagent child rows vanish if omitted. - subagents: data.subagents - }) - if (!payload) { - return 'dropped' - } - let { - exists, - title, - identityTitle, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle - } = options?.batch - ? resolvePaneKeyFromRoutingIndex(options.batch.routingIndex, paneKey) - : resolvePaneKey(store, paneKey) - const projectedTitles = - titleUsesTabTitle && ownerTabId - ? options?.batch?.projectedTitlesByTabId.get(ownerTabId) - : undefined - if (projectedTitles) { - title = projectedTitles.title - identityTitle = projectedTitles.identityTitle - } - if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { - // Why: orchestration worker hooks may carry worktree attribution before this renderer has a tab for the pane. - // Require runtime identity too — worktreeId-only snapshots can be stale rows from closed/remounted panes. - const fallbackOwnership = options?.batch - ? resolveWorktreeConnectionFromRoutingIndex(options.batch.routingIndex, data.worktreeId) - : resolveWorktreeConnection(store, data.worktreeId) - if (fallbackOwnership.worktreeExists) { - owningWorktreeId = data.worktreeId - repoConnectionId = fallbackOwnership.repoConnectionId - repoConnectionResolved = fallbackOwnership.repoConnectionResolved - exists = true - } - } - if (!exists) { - // Why: startup snapshot replay can beat tab/layout hydration too. - // Reuse the same bounded retry queue when the row still carries - // runtime-backed worktree provenance so the cached status can adopt - // once the pane becomes visible. - if (options?.replay === true) { - if (data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { - if (options?.retry !== true) { - enqueuePendingAgentStatus(data, { replay: true }) - } - return 'pending' - } - return 'dropped' - } - if (options?.retry !== true) { - // Why: empty paneKeys are dropped in main before IPC fanout. Reaching - // this branch means a non-empty paneKey escaped without a matching - // renderer tab, so track the adoption/routing failure separately. - track('agent_hook_unattributed', { reason: 'unknown_tab_id' }) - enqueuePendingAgentStatus(data) - } - return 'pending' - } - if (options?.replay !== true && options?.retry !== true) { - for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { - if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { - pendingAgentStatusEvents.splice(index, 1) - } - } - } - // Why: drop in-flight events stamped with a dead connection's id after SSH disconnect/reconnect. - // Why: startup snapshot replay can beat SSH repo hydration; accept when worktreeId matches the tab until repo ownership resolves. - // Why: WSL relay stamps a `wsl:` connectionId but the pane is a local repo (ownership null); normalize so the strict check below doesn't drop it. - const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId) - ? null - : data.connectionId - const transientClearWatermark = - typeof data.connectionId === 'string' - ? transientClearWatermarkByConnectionId.get(data.connectionId) - : undefined - // Why: delayed snapshots/queued relay events must not resurrect a status cleared by a newer disconnect on this connection. - if (transientClearWatermark !== undefined && data.receivedAt <= transientClearWatermark) { - return 'dropped' - } - const canAcceptPendingRemoteOwnership = - ownershipConnectionId !== undefined && - ownershipConnectionId !== null && - !repoConnectionResolved && - data.worktreeId !== undefined && - data.worktreeId === owningWorktreeId - if ( - ownershipConnectionId !== undefined && - ownershipConnectionId !== repoConnectionId && - !canAcceptPendingRemoteOwnership - ) { - return 'dropped' - } - const existingStatus = store.agentStatusByPaneKey[paneKey] - if (existingStatus && data.receivedAt < existingStatus.updatedAt) { - // Why: the store rejects out-of-order status rows; keep metadata-only session identity on the same event boundary. - return 'dropped' - } - if (data.providerSessionOnly) { - if (!data.providerSession || data.agentType !== 'pi') { - return 'dropped' - } - const providerSessionUpdate: AgentStatusBatchUpdate = { - kind: 'providerSession', - paneKey, - agent: 'pi', - providerSession: data.providerSession, - timing: { updatedAt: data.receivedAt }, - routing: { - tabId: ownerTabId, - worktreeId: data.worktreeId ?? owningWorktreeId, - // Why: persist the WSL-normalized ownership id, not raw relay provenance; a `wsl:*` connectionId would misroute later resumes. - ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) - }, - metadata: data.launchToken ? { launchToken: data.launchToken } : undefined - } - if (options?.batch) { - return options.batch.transaction.apply(providerSessionUpdate) ? 'applied' : 'dropped' - } - store.recordAgentProviderSession( - providerSessionUpdate.paneKey, - providerSessionUpdate.agent, - providerSessionUpdate.providerSession, - providerSessionUpdate.timing, - providerSessionUpdate.routing, - providerSessionUpdate.metadata - ) - return 'applied' - } - const resolvedPayload = resolveHookPayloadAgentType(payload, identityTitle ?? title) - const statusPayload = data.orchestration - ? { ...resolvedPayload, orchestration: data.orchestration } - : resolvedPayload - const statusPayloadWithTurnBoundary = data.promptInteractionKey - ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } - : statusPayload - // Why: hydrated-unconfirmed provenance is envelope data the payload whitelist above drops; re-thread it or freshness gates confirm restored rows. - const statusPayloadWithProvenance = - data.restoredUnconfirmed === true - ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } - : statusPayloadWithTurnBoundary - // Why: main sequenced this row as the pane authority; carry its stamp rather than - // minting a renderer one, which would claim a second authority for the same observation. - const statusPayloadWithObservation = data.observation - ? { ...statusPayloadWithProvenance, observation: data.observation } - : statusPayloadWithProvenance - const identity = resolveAgentStatusIdentity({ - existing: existingStatus - ? { - agentType: existingStatus.agentType, - state: existingStatus.state, - updatedAt: existingStatus.updatedAt, - restoredUnconfirmed: existingStatus.restoredUnconfirmed - } - : undefined, - incoming: statusPayload.agentType, - now: data.receivedAt - }) - if ( - existingStatus && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: statusPayload.state - }) - ) { - // Why: guards against a stale main-process child completion resurrecting terminal status. - return 'dropped' - } - if ( - shouldSuppressCodexAutoApprovalStatus(statusPayload, { - paneKey, - tabId: ownerTabId, - terminalHandle: data.terminalHandle, - launchToken: data.launchToken, - providerSession: data.providerSession, - existingProviderSession: existingStatus?.providerSession - }) - ) { - // Why: Codex yolo permission hooks are not user-actionable; they must not drive status, titles, badges, or notifications. - return 'dropped' - } - const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title) - const statusWorktreeId = data.worktreeId ?? owningWorktreeId - const update: AgentStatusUpdate = { - paneKey, - payload: statusPayloadWithObservation, - terminalTitle, - timing: { - updatedAt: data.receivedAt, - stateStartedAt: data.stateStartedAt - }, - routing: { - tabId: ownerTabId, - worktreeId: statusWorktreeId, - terminalHandle: data.terminalHandle, - ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) - }, - metadata: - data.providerSession || data.launchToken - ? { - ...(data.providerSession ? { providerSession: data.providerSession } : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}) - } - : undefined - } - const applyPostCommitNotification = (): void => { - if (statusWorktreeId && (options?.replay !== true || resolvedPayload.state === 'working')) { - // Why: local Codex/Claude hooks arrive via this main-process IPC path, not the PTY OSC fallback, so task-complete notifications must observe accepted hook state here too. - const notificationPayload = - typeof data.stateStartedAt === 'number' - ? { ...resolvedPayload, stateStartedAt: data.stateStartedAt } - : resolvedPayload - observeAgentHookCompletionForNotification({ - paneKey, - worktreeId: statusWorktreeId, - payload: notificationPayload, - ...(options?.replay === true ? { seedOnly: true } : {}) - }) - } - } - if (options?.batch) { - if (!options.batch.transaction.apply(update)) { - return 'dropped' - } - options.batch.notificationEffects.push(applyPostCommitNotification) - if ( - terminalTitle && - shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle) - ) { - const tabId = parsePaneKey(paneKey)?.tabId - if (tabId) { - options.batch.tabTitlesByTabId.set(tabId, terminalTitle) - if (titleUsesTabTitle) { - const titleChanges = - !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle) - options.batch.projectedTitlesByTabId.set(tabId, { - title: titleChanges ? terminalTitle : title, - identityTitle: titleChanges ? terminalTitle : identityTitle - }) - } - } - } - } else { - store.setAgentStatus( - update.paneKey, - update.payload, - update.terminalTitle, - update.timing, - update.routing, - update.metadata - ) - applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, title, terminalTitle) - applyPostCommitNotification() - } - return 'applied' - } - - let snapshotRequestedForReadyWindow = false - let snapshotRequestId = 0 - const requestAgentStatusSnapshotIfReady = (): void => { - const store = useAppStore.getState() - if (!store.workspaceSessionReady) { - snapshotRequestedForReadyWindow = false - return - } - if (snapshotRequestedForReadyWindow) { - return - } - const getSnapshot = window.api.agentStatus.getSnapshot - if (typeof getSnapshot !== 'function') { - return - } - snapshotRequestedForReadyWindow = true - const requestId = ++snapshotRequestId - void getSnapshot() - .then((entries) => { - if (agentStatusEffectDisposed || requestId !== snapshotRequestId) { - return - } - const current = useAppStore.getState() - if (!current.workspaceSessionReady) { - return - } - applyAgentStatusBatch(entries.map((data) => ({ data, replay: true }))) - const getMigrationUnsupportedSnapshot = - window.api.agentStatus.getMigrationUnsupportedSnapshot - if (typeof getMigrationUnsupportedSnapshot !== 'function') { - return - } - void getMigrationUnsupportedSnapshot().then((unsupportedEntries) => { - if (agentStatusEffectDisposed || requestId !== snapshotRequestId) { - return - } - const unsupportedStore = useAppStore.getState() - if (!unsupportedStore.workspaceSessionReady) { - return - } - const unsupportedRoutingIndex = createAgentStatusPaneRoutingIndex(unsupportedStore) - for (const entry of unsupportedEntries) { - if ( - entry.paneKey && - resolvePaneKeyFromRoutingIndex(unsupportedRoutingIndex, entry.paneKey).exists - ) { - unsupportedStore.setMigrationUnsupportedPty(entry) - } - } - }) - }) - .catch((err) => { - // Why: stay latched on failure; the store subscriber fires on every update, so resetting here would turn a persistent IPC failure into a retry storm (flag clears on workspaceSessionReady toggle). - console.warn('[agent-status] failed to load startup snapshot:', err) - }) - } - - function applyAgentStatusBatch( - events: readonly AgentStatusBatchEvent[] - ): AgentStatusApplyResult[] { - if (events.length === 0) { - return [] - } - return useAppStore.getState().transactAgentStatuses((transaction) => { - const batch: AgentStatusBatchContext = { - transaction, - routingIndex: createAgentStatusPaneRoutingIndex(transaction.getState()), - projectedTitlesByTabId: new Map(), - tabTitlesByTabId: new Map(), - notificationEffects: [] - } - const results = events.map(({ data, replay, retry }) => - applyAgentStatus(data, { batch, replay, retry }) - ) - if (batch.tabTitlesByTabId.size > 0) { - transaction.afterCommit(() => { - useAppStore - .getState() - .updateTabTitles( - [...batch.tabTitlesByTabId].map(([tabId, title]) => ({ tabId, title })) - ) - }) - } - for (const effect of batch.notificationEffects) { - transaction.afterCommit(effect) - } - return results - }) - } - - function applyLiveAgentStatusBatch(batch: readonly AgentStatusIpcPayload[]): boolean { - return applyAgentStatusBatch(batch.map((data) => ({ data }))).some( - (result) => result === 'applied' - ) - } - - function flushLiveAgentStatusBurst(): void { - liveAgentStatusBurstTimer = null - lastLiveAgentStatusApplyAt = Date.now() - // Why: splice before publishing — synchronous Zustand subscribers can enqueue the next burst. - const batch = liveAgentStatusBurstQueue.splice(0) - if (!applyLiveAgentStatusBatch(batch)) { - lastLiveAgentStatusApplyAt = 0 - } - } - - function drainQueuedLiveAgentStatusesForPane(paneKey: string): void { - const queuedForPane: AgentStatusIpcPayload[] = [] - const remaining: AgentStatusIpcPayload[] = [] - for (const queued of liveAgentStatusBurstQueue) { - if (queued.paneKey === paneKey) { - queuedForPane.push(queued) - } else { - remaining.push(queued) - } - } - liveAgentStatusBurstQueue.length = 0 - liveAgentStatusBurstQueue.push(...remaining) - applyLiveAgentStatusBatch(queuedForPane) - } - - function enqueueLiveAgentStatus(data: AgentStatusIpcPayload): void { - const now = Date.now() - if ( - liveAgentStatusBurstTimer === null && - now - lastLiveAgentStatusApplyAt >= LIVE_AGENT_STATUS_BURST_WINDOW_MS - ) { - lastLiveAgentStatusApplyAt = now - // Why: only an applied event commits state and costs a render pass — - // a dropped/pending leading edge must not make its successor pay - // burst latency (startup replay and unmounted panes stay immediate). - if (applyAgentStatus(data) !== 'applied') { - lastLiveAgentStatusApplyAt = 0 - } - return - } - liveAgentStatusBurstQueue.push(data) - if (liveAgentStatusBurstTimer === null) { - liveAgentStatusBurstTimer = globalThis.setTimeout( - flushLiveAgentStatusBurst, - LIVE_AGENT_STATUS_BURST_WINDOW_MS - ) - } - } - - unsubs.push( - window.api.agentStatus.onSet((data) => { - enqueueLiveAgentStatus(data) - }) - ) - const unsubscribeAgentStatusClear = window.api.agentStatus.onClear?.( - (data: AgentStatusClearIpcPayload) => { - if (typeof data !== 'object' || data === null) { - return - } - if ('transient' in data && data.transient === true) { - if ( - typeof data.connectionId !== 'string' || - data.connectionId.length === 0 || - !Number.isFinite(data.clearedAt) - ) { - return - } - const previousWatermark = - transientClearWatermarkByConnectionId.get(data.connectionId) ?? -1 - const effectiveWatermark = Math.max(previousWatermark, data.clearedAt) - transientClearWatermarkByConnectionId.set(data.connectionId, effectiveWatermark) - for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { - const pending = pendingAgentStatusEvents[index].data - if ( - pending.connectionId === data.connectionId && - pending.receivedAt <= effectiveWatermark - ) { - pendingAgentStatusEvents.splice(index, 1) - } - } - for (let index = liveAgentStatusBurstQueue.length - 1; index >= 0; index -= 1) { - const queued = liveAgentStatusBurstQueue[index] - if ( - queued.connectionId === data.connectionId && - queued.receivedAt <= effectiveWatermark - ) { - liveAgentStatusBurstQueue.splice(index, 1) - } - } - useAppStore.getState().clearTransientAgentStatuses(data.connectionId, effectiveWatermark) - return - } - if (!('paneKey' in data) || typeof data.paneKey !== 'string') { - return - } - // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. - if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { - drainQueuedLiveAgentStatusesForPane(data.paneKey) - } - for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { - if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { - pendingAgentStatusEvents.splice(index, 1) - } - } - const store = useAppStore.getState() - if (store.agentStatusByPaneKey[data.paneKey]?.state === 'done') { - return - } - store.removeAgentStatus(data.paneKey) - } - ) - if (unsubscribeAgentStatusClear) { - unsubs.push(unsubscribeAgentStatusClear) - } - const unsubscribeMigrationUnsupported = window.api.agentStatus.onMigrationUnsupported?.( - (entry) => { - const store = useAppStore.getState() - if (!store.workspaceSessionReady) { - return - } - if (entry.paneKey && resolvePaneKey(store, entry.paneKey).exists) { - store.setMigrationUnsupportedPty(entry) - } - } - ) - if (unsubscribeMigrationUnsupported) { - unsubs.push(unsubscribeMigrationUnsupported) - } - const unsubscribeMigrationUnsupportedClear = - window.api.agentStatus.onMigrationUnsupportedClear?.(({ ptyId }) => { - useAppStore.getState().clearMigrationUnsupportedPty(ptyId) - }) - if (unsubscribeMigrationUnsupportedClear) { - unsubs.push(unsubscribeMigrationUnsupportedClear) - } - const unsubscribeLegacyWorkerTerminalRecovery = - window.api.agentStatus.onLegacyWorkerTerminalRecovery?.((event) => { - const action = resolveLegacyWorkerTerminalRecoveryAction(event) - if (action.kind === 'rollback-surface') { - window.dispatchEvent( - new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail: action.detail }) - ) - rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), action.detail) - } else if (action.kind === 'clear-sleeping') { - useAppStore.getState().clearSleepingAgentSession(action.paneKey) - } - }) - if (unsubscribeLegacyWorkerTerminalRecovery) { - unsubs.push(unsubscribeLegacyWorkerTerminalRecovery) - } - - // Why: main hook server is the durable source of truth; pull the snapshot only after tabs are ready so early startup pushes can be ignored, not buffered. - requestAgentStatusSnapshotIfReady() - const unsubscribeAgentStatusStore = useAppStore.subscribe((state, previousState) => { - requestAgentStatusSnapshotIfReady() - flushPendingAgentStatuses() - syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState) - }) - - let mobileStateHydrated = isRuntimeEnvironmentActive() - type PendingMobileStateEvent = - | { - kind: 'fit' - event: { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - } - } - | { - kind: 'driver' - event: { - ptyId: string - driver: RuntimeTerminalDriverState - } - } - | { - kind: 'browser-driver' - event: { - browserPageId: string - driver: RuntimeBrowserDriverState - } - } - const pendingMobileStateEvents: PendingMobileStateEvent[] = [] - let mobileStateHydrationDisposed = false - - const applyPendingMobileStateEvents = (): void => { - for (const pending of pendingMobileStateEvents) { - if (pending.kind === 'fit') { - const { ptyId, mode, cols, rows } = pending.event - setFitOverride(ptyId, mode, cols, rows) - } else if (pending.kind === 'driver') { - setDriverForPty(pending.event.ptyId, pending.event.driver) - } else { - setDriverForBrowserPage(pending.event.browserPageId, pending.event.driver) - } - } - pendingMobileStateEvents.length = 0 - } - - const enqueuePendingMobileStateEvent = (event: PendingMobileStateEvent): void => { - pendingMobileStateEvents.push(event) - while (pendingMobileStateEvents.length > MAX_PENDING_MOBILE_STATE_EVENTS) { - pendingMobileStateEvents.shift() - } - } - - unsubs.push( - window.api.runtime.onTerminalFitOverrideChanged((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - if (!mobileStateHydrated) { - enqueuePendingMobileStateEvent({ kind: 'fit', event }) - return - } - setFitOverride(event.ptyId, event.mode, event.cols, event.rows) - }) - ) - - unsubs.push( - // Why: mirror presence-lock driver state so TerminalPane / pty-connection guards know which PTYs are mobile-driven. See docs/mobile-presence-lock.md. - window.api.runtime.onTerminalDriverChanged((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - if (!mobileStateHydrated) { - enqueuePendingMobileStateEvent({ kind: 'driver', event }) - return - } - setDriverForPty(event.ptyId, event.driver) - }) - ) - - const unsubscribeLaunchDraftResolution = window.api.runtime.onNativeChatLaunchDraftResolved?.( - (event) => { - applyNativeChatLaunchDraftResolved(useAppStore.getState(), { - type: 'nativeChatLaunchDraftResolved', - ...event - }) - } - ) - if (unsubscribeLaunchDraftResolution) { - unsubs.push(unsubscribeLaunchDraftResolution) - } - - unsubs.push( - window.api.runtime.onBrowserDriverChanged((event) => { - if (isRuntimeEnvironmentActive()) { - return - } - if (!mobileStateHydrated) { - enqueuePendingMobileStateEvent({ kind: 'browser-driver', event }) - return - } - setDriverForBrowserPage(event.browserPageId, event.driver) - }) - ) - - // Why: subscribe before the snapshot round trip and buffer live events; otherwise an older snapshot could overwrite a newer live lock and hide the overlay. - if (!isRuntimeEnvironmentActive()) { - void Promise.all([ - window.api.runtime.getTerminalFitOverrides(), - window.api.runtime.getTerminalDrivers(), - window.api.runtime.getBrowserDrivers() - ]) - .then(([overrides, drivers, browserDrivers]) => { - if (mobileStateHydrationDisposed) { - return - } - hydrateOverrides(overrides) - hydrateDrivers(drivers) - hydrateBrowserDrivers(browserDrivers) - mobileStateHydrated = true - applyPendingMobileStateEvents() - }) - .catch((error: unknown) => { - if (mobileStateHydrationDisposed) { - return - } - console.error('Failed to hydrate mobile terminal state:', error) - mobileStateHydrated = true - applyPendingMobileStateEvents() - }) - } - - return () => { - // Why: React remount can leave an older snapshot promise in flight; it must not write through after the replacement effect processes a clear. - agentStatusEffectDisposed = true - snapshotRequestId += 1 - if (pendingAgentStatusRetryTimer !== null) { - globalThis.clearTimeout(pendingAgentStatusRetryTimer) - } - pendingAgentStatusEvents.length = 0 - if (liveAgentStatusBurstTimer !== null) { - globalThis.clearTimeout(liveAgentStatusBurstTimer) - liveAgentStatusBurstTimer = null - } - liveAgentStatusBurstQueue.length = 0 - mobileStateHydrationDisposed = true - pendingMobileStateEvents.length = 0 - unsubscribeRuntimeEnvironmentStore() - unsubscribeAgentStatusStore() - unsubs.forEach((fn) => fn()) - directSshEffectStopped = true - for (const deadline of authorityReconciliationDeadlines) { - clearTimeout(deadline.timer) - deadline.settle() - } - authorityReconciliationDeadlines.clear() - remoteWorkspaceTargetSync?.stop() - hostHydration.stop() - reconnectCoordinator.stop() - reconnectAuthorityByTarget.clear() - resetAgentHookCompletionNotificationCoordinators() - } - }, []) -} - -function hasRuntimeBackedWorktreeAttribution(data: AgentStatusIpcPayload): boolean { - return ( - (typeof data.terminalHandle === 'string' && data.terminalHandle.length > 0) || - data.orchestration !== undefined - ) -} - -function tryMakePaneKey(tabId: string, leafId: string): string | null { - try { - return makePaneKey(tabId, leafId) - } catch { - return null - } -} - -function applyResolvedAgentTerminalTitleToTab( - store: ReturnType, - paneKey: string, - previousTitle: string | undefined, - nextTitle: string | undefined -): void { - if ( - !nextTitle || - !shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, previousTitle, nextTitle) - ) { - return - } - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return - } - // Why: hook completion can arrive while the pane transport is unmounted; keep the tab label synced to the resolved state title. - store.updateTabTitle(parsed.tabId, nextTitle) -} - -function shouldApplyResolvedAgentTerminalTitleToTab( - store: ReturnType, - paneKey: string, - previousTitle: string | undefined, - nextTitle: string | undefined -): boolean { - if (!nextTitle || nextTitle === previousTitle) { - return false - } - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return false - } - const layout = store.terminalLayoutsByTabId?.[parsed.tabId] - if (layout?.root && layout.activeLeafId && layout.activeLeafId !== parsed.leafId) { - return false - } - return true -} - -type AgentStatusPaneResolution = { - exists: boolean - title: string | undefined - identityTitle: string | undefined - repoConnectionId: string | null - repoConnectionResolved: boolean - owningWorktreeId: string | undefined - titleUsesTabTitle: boolean -} - -type AgentStatusWorktreeConnectionResolution = { - worktreeExists: boolean - repoConnectionId: string | null - repoConnectionResolved: boolean -} - -type IndexedAgentStatusTab = { - title: string | undefined - unifiedLabel: string | undefined - owningWorktreeId: string -} - -type AgentStatusPaneRoutingIndex = { - tabsById: Map - layoutsByTabId: AppState['terminalLayoutsByTabId'] - leafIdsByRoot: WeakMap> - worktreesById: ReturnType - reposById: ReturnType -} - -function createUnifiedTerminalLabelIndex( - entries: AppState['unifiedTabsByWorktree'][string] | undefined -): Map { - const labelsByTabId = new Map() - for (const entry of entries ?? []) { - if (entry.contentType !== 'terminal' || labelsByTabId.has(entry.entityId)) { - continue - } - const rawLabel = entry.label?.trim() - labelsByTabId.set(entry.entityId, rawLabel && rawLabel.length > 0 ? rawLabel : undefined) - } - return labelsByTabId -} - -function createAgentStatusPaneRoutingIndex( - store: ReturnType -): AgentStatusPaneRoutingIndex { - const tabsById = new Map() - for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { - const unifiedLabelsByTabId = createUnifiedTerminalLabelIndex( - store.unifiedTabsByWorktree?.[worktreeId] - ) - for (const tab of tabs) { - const tabId = tab.id - if (!tabsById.has(tabId)) { - tabsById.set(tabId, { - title: tab.title, - unifiedLabel: unifiedLabelsByTabId.get(tabId), - owningWorktreeId: worktreeId - }) - } - } - } - return { - tabsById, - layoutsByTabId: store.terminalLayoutsByTabId, - leafIdsByRoot: new WeakMap(), - worktreesById: getWorktreeMapFromState(store), - reposById: getRepoMapFromState(store) - } -} - -function resolveWorktreeConnectionFromRoutingIndex( - index: AgentStatusPaneRoutingIndex, - worktreeId: string -): AgentStatusWorktreeConnectionResolution { - const worktree = index.worktreesById.get(worktreeId) - if (!worktree) { - return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } - } - const repo = index.reposById.get(worktree.repoId) - return { - worktreeExists: true, - repoConnectionId: repo?.connectionId ?? null, - repoConnectionResolved: repo !== undefined - } -} - -function resolvePaneKeyFromRoutingIndex( - index: AgentStatusPaneRoutingIndex, - paneKey: string -): AgentStatusPaneResolution { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false - } - } - const { tabId, leafId } = parsed - const tab = index.tabsById.get(tabId) - if (!tab) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false - } - } - const connection = resolveWorktreeConnectionFromRoutingIndex(index, tab.owningWorktreeId) - const layout = index.layoutsByTabId?.[tabId] - if (layout?.root) { - let leafIds = index.leafIdsByRoot.get(layout.root) - if (!leafIds) { - leafIds = new Set(collectLeafIdsInOrder(layout.root)) - index.leafIdsByRoot.set(layout.root, leafIds) - } - if (!leafIds.has(leafId)) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: connection.repoConnectionId, - repoConnectionResolved: connection.repoConnectionResolved, - owningWorktreeId: tab.owningWorktreeId, - titleUsesTabTitle: false - } - } - } - const rawPaneTitle = layout?.titlesByLeafId?.[leafId] - const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined - return { - exists: true, - title: paneTitle ?? tab.title, - identityTitle: paneTitle ?? tab.unifiedLabel ?? tab.title, - repoConnectionId: connection.repoConnectionId, - repoConnectionResolved: connection.repoConnectionResolved, - owningWorktreeId: tab.owningWorktreeId, - titleUsesTabTitle: paneTitle === undefined - } -} - -/** Resolve a paneKey (tabId:leafId) to liveness, current title, owning worktree, - * and the owning repo's connectionId. Used for agent-type inference and to drop - * status updates for torn-down tabs or dead connections (an SSH reconnect retires the - * old connectionId, so events still in flight under it must not land). */ -function resolvePaneKey( - store: ReturnType, - paneKey: string -): { - exists: boolean - title: string | undefined - identityTitle: string | undefined - repoConnectionId: string | null - repoConnectionResolved: boolean - owningWorktreeId: string | undefined - titleUsesTabTitle: boolean -} { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false - } - } - const { tabId, leafId } = parsed - const layout = store.terminalLayoutsByTabId?.[tabId] - let exists = false - let tabTitle: string | undefined - let unifiedTabLabel: string | undefined - let owningWorktreeId: string | undefined - for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { - for (const tab of tabs) { - if (tab.id === tabId) { - exists = true - tabTitle = tab.title - owningWorktreeId = worktreeId - const visibleTab = (store.unifiedTabsByWorktree?.[worktreeId] ?? []).find( - (entry) => entry.contentType === 'terminal' && entry.entityId === tabId - ) - const rawVisibleLabel = visibleTab?.label?.trim() - unifiedTabLabel = - rawVisibleLabel && rawVisibleLabel.length > 0 ? rawVisibleLabel : undefined - break - } - } - if (exists) { - break - } - } - // Why: keep "resolved to a local repo" distinct from "not hydrated yet" so callers filter strictly post-hydration but still accept SSH snapshots during the startup ownership gap. - let repoConnectionId: string | null = null - let repoConnectionResolved = false - if (owningWorktreeId !== undefined) { - const worktree = getWorktreeMapFromState(store).get(owningWorktreeId) - if (worktree) { - const repo = getRepoMapFromState(store).get(worktree.repoId) - repoConnectionResolved = repo !== undefined - repoConnectionId = repo?.connectionId ?? null - } - } - if (!exists) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle: false - } - } - // Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed. - const leafExists = layout?.root ? collectLeafIdsInOrder(layout.root).includes(leafId) : true - if (!leafExists) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle: false - } - } - // Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there. - const rawPaneTitle = layout?.titlesByLeafId?.[leafId] - // Why: treat empty-string paneTitle as "no title" so the tab-level fallback fires; nullish-coalescing on '' would short-circuit and erase cached terminalTitle. - const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined - return { - exists, - title: paneTitle ?? tabTitle, - // Why: some agents (OpenClaude) keep the terminal title generic while the tab label carries the agent identity; use only the non-custom label for attribution. - identityTitle: paneTitle ?? unifiedTabLabel ?? tabTitle, - repoConnectionId, - repoConnectionResolved, - owningWorktreeId, - titleUsesTabTitle: paneTitle === undefined - } -} - -function resolveWorktreeConnection( - store: ReturnType, - worktreeId: string -): { - worktreeExists: boolean - repoConnectionId: string | null - repoConnectionResolved: boolean -} { - const worktree = getWorktreeMapFromState(store).get(worktreeId) - if (!worktree) { - return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } - } - const repo = getRepoMapFromState(store).get(worktree.repoId) - return { - worktreeExists: true, - repoConnectionId: repo?.connectionId ?? null, - repoConnectionResolved: repo !== undefined - } -} - -function resolveHookPayloadAgentType( - payload: ParsedAgentStatusPayload, - terminalTitle: string | undefined -): ParsedAgentStatusPayload { - if ( - payload.agentType !== 'claude' || - !terminalTitle || - !titleHasAgentName(terminalTitle, 'openclaude') - ) { - return payload - } - // Why: OpenClaude emits Claude-compatible hooks; the title is the last renderer signal to keep it out of Claude-only status paths. - return { ...payload, agentType: 'openclaude' } + useEffect(() => installAppLifetimeIpcEvents(), []) } diff --git a/src/renderer/src/lib/workspace-activation-path-gate.test.ts b/src/renderer/src/lib/workspace-activation-path-gate.test.ts index 430e9a31571..b4b8e2de049 100644 --- a/src/renderer/src/lib/workspace-activation-path-gate.test.ts +++ b/src/renderer/src/lib/workspace-activation-path-gate.test.ts @@ -135,7 +135,10 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => { // Why: the guard only helps if the IPC handler actually calls it. Pin the source // so re-pointing the handler back at the unguarded activateAndRevealWorktree fails. it('wires onJumpToWorktreeIndex to the guarded workspace activator', async () => { - const source = await readFile(new URL('../hooks/useIpcEvents.ts', import.meta.url), 'utf8') + const source = await readFile( + new URL('../hooks/ipc-events/workspace-shortcut-ipc-bridge.ts', import.meta.url), + 'utf8' + ) const handler = source.slice( source.indexOf('onJumpToWorktreeIndex('), source.indexOf('onJumpToTabIndex(') From b516300b8c53a663ec23017c9d62baa8c4197145 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:38 -0700 Subject: [PATCH 05/10] refactor agent hook listener modules (#16187) --- config/max-lines-baseline.txt | 1 - ...k-status-session-tabs-invalidation.test.ts | 2 +- .../hook-status-session-tabs-invalidation.ts | 2 +- .../manual-compact-hook-stream.test.ts | 2 +- .../server-authority-evidence.test.ts | 2 +- .../agent-hooks/server-grok-discovery.test.ts | 2 +- .../server-hook-http-ingest.test.ts | 94 + .../server-relay-listener-replay.test.ts | 4 +- src/main/agent-hooks/server.ts | 60 +- .../compact-status-registration.test.ts | 8 +- src/relay/agent-hook-envelope-build.ts | 2 +- .../agent-hook-result-retry-scheduler.ts | 12 +- src/relay/agent-hook-server.test.ts | 99 +- src/relay/agent-hook-server.ts | 26 +- src/relay/subprocess.test.ts | 2 +- ...on-coordinator-monitoring-turn-end.test.ts | 6 +- ...tion-background-turn-notifications.test.ts | 3 +- ...gent-hook-completion-notifications.test.ts | 3 +- .../agent-hook-listener-antigravity.test.ts | 6 +- ...listener-claude-compatible-vendors.test.ts | 4 +- ...ent-hook-listener-claude-subagents.test.ts | 10 +- ...nt-hook-listener-claude-turn-state.test.ts | 4 +- ...k-listener-command-code-transcript.test.ts | 4 +- ...stener-extraction-characterization.test.ts | 242 + src/shared/agent-hook-listener-grok.test.ts | 10 +- ...t-hook-listener-hermes-codex-droid.test.ts | 4 +- ...-hook-listener-interactive-prompts.test.ts | 4 +- .../agent-hook-listener-pi-compatible.test.ts | 4 +- ...ent-hook-listener-relay-dependency.test.ts | 135 + ...ent-hook-listener-roster-retention.test.ts | 4 +- ...-hook-listener-session-replacement.test.ts | 4 +- ...listener-startless-child-lifecycle.test.ts | 6 +- .../agent-hook-listener-test-harness.ts | 3 +- .../agent-hook-listener-transport.test.ts | 70 +- src/shared/agent-hook-listener.ts | 4845 +---------------- .../command-code-transcript.ts | 190 + .../endpoint-publication.ts | 82 + .../grok-result-discovery.ts | 221 + .../agent-hook-listener/hook-envelope.ts | 79 + .../agent-hook-listener/interactive-tool.ts | 124 + .../agent-hook-listener/listener-event.ts | 55 + .../agent-hook-listener/listener-limits.ts | 63 + .../agent-hook-listener/listener-state.ts | 216 + .../agent-hook-listener/prompt-fields.ts | 153 + .../agent-hook-listener/provider-dispatch.ts | 154 + .../provider-event-names.ts | 16 + .../provider-event-routing.ts | 174 + .../providers/amp-events.ts | 168 + .../providers/amp-tool-fields.ts | 44 + .../providers/antigravity-event-rules.ts | 5 + .../providers/antigravity-events.ts | 84 + .../providers/antigravity-tool-fields.ts | 54 + .../providers/claude-events.ts | 315 ++ .../providers/claude-lifecycle-events.ts | 144 + .../providers/claude-roster-state.ts | 277 + .../providers/claude-status-build.ts | 53 + .../providers/claude-tool-fields.ts | 62 + .../providers/codex-events.ts | 178 + .../providers/codex-state.ts | 146 + .../providers/codex-tool-fields.ts | 41 + .../providers/command-code-events.ts | 44 + .../providers/command-code-tool-fields.ts | 51 + .../providers/copilot-events.ts | 73 + .../providers/copilot-tool-fields.ts | 203 + .../providers/cursor-events.ts | 66 + .../providers/cursor-tool-fields.ts | 76 + .../providers/devin-events.ts | 61 + .../providers/droid-events.ts | 79 + .../providers/droid-tool-fields.ts | 98 + .../providers/gemini-events.ts | 50 + .../providers/gemini-tool-fields.ts | 36 + .../providers/grok-events.ts | 103 + .../providers/grok-tool-fields.ts | 128 + .../providers/hermes-events.ts | 54 + .../providers/hermes-tool-fields.ts | 70 + .../providers/kimi-events.ts | 73 + .../providers/opencode-family-events.ts | 56 + .../providers/opencode-family-tool-fields.ts | 75 + .../providers/pi-family-events.ts | 67 + .../providers/pi-family-tool-fields.ts | 35 + .../agent-hook-listener/request-body.ts | 125 + .../agent-hook-listener/source-routing.ts | 28 + .../agent-hook-listener/tool-input-preview.ts | 169 + .../transcript-entry-text.ts | 56 + .../agent-hook-listener/transcript-lines.ts | 51 + .../agent-hook-listener/transcript-reader.ts | 107 + .../agent-hook-request-body-memory.test.ts | 2 +- src/shared/agent-hook-status-cache.test.ts | 3 +- src/shared/agent-hook-status-cache.ts | 7 +- .../claude-background-task-status.test.ts | 10 +- .../claude-subagent-row-lifecycle.test.ts | 4 +- .../codex-subagent-rollout-lifecycle.test.ts | 3 +- src/shared/opencode-permission-status.test.ts | 3 +- 93 files changed, 5967 insertions(+), 4886 deletions(-) create mode 100644 src/shared/agent-hook-listener-extraction-characterization.test.ts create mode 100644 src/shared/agent-hook-listener-relay-dependency.test.ts create mode 100644 src/shared/agent-hook-listener/command-code-transcript.ts create mode 100644 src/shared/agent-hook-listener/endpoint-publication.ts create mode 100644 src/shared/agent-hook-listener/grok-result-discovery.ts create mode 100644 src/shared/agent-hook-listener/hook-envelope.ts create mode 100644 src/shared/agent-hook-listener/interactive-tool.ts create mode 100644 src/shared/agent-hook-listener/listener-event.ts create mode 100644 src/shared/agent-hook-listener/listener-limits.ts create mode 100644 src/shared/agent-hook-listener/listener-state.ts create mode 100644 src/shared/agent-hook-listener/prompt-fields.ts create mode 100644 src/shared/agent-hook-listener/provider-dispatch.ts create mode 100644 src/shared/agent-hook-listener/provider-event-names.ts create mode 100644 src/shared/agent-hook-listener/provider-event-routing.ts create mode 100644 src/shared/agent-hook-listener/providers/amp-events.ts create mode 100644 src/shared/agent-hook-listener/providers/amp-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/antigravity-event-rules.ts create mode 100644 src/shared/agent-hook-listener/providers/antigravity-events.ts create mode 100644 src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-events.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-roster-state.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-status-build.ts create mode 100644 src/shared/agent-hook-listener/providers/claude-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/codex-events.ts create mode 100644 src/shared/agent-hook-listener/providers/codex-state.ts create mode 100644 src/shared/agent-hook-listener/providers/codex-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/command-code-events.ts create mode 100644 src/shared/agent-hook-listener/providers/command-code-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/copilot-events.ts create mode 100644 src/shared/agent-hook-listener/providers/copilot-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/cursor-events.ts create mode 100644 src/shared/agent-hook-listener/providers/cursor-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/devin-events.ts create mode 100644 src/shared/agent-hook-listener/providers/droid-events.ts create mode 100644 src/shared/agent-hook-listener/providers/droid-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/gemini-events.ts create mode 100644 src/shared/agent-hook-listener/providers/gemini-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/grok-events.ts create mode 100644 src/shared/agent-hook-listener/providers/grok-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/hermes-events.ts create mode 100644 src/shared/agent-hook-listener/providers/hermes-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/kimi-events.ts create mode 100644 src/shared/agent-hook-listener/providers/opencode-family-events.ts create mode 100644 src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/providers/pi-family-events.ts create mode 100644 src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts create mode 100644 src/shared/agent-hook-listener/request-body.ts create mode 100644 src/shared/agent-hook-listener/source-routing.ts create mode 100644 src/shared/agent-hook-listener/tool-input-preview.ts create mode 100644 src/shared/agent-hook-listener/transcript-entry-text.ts create mode 100644 src/shared/agent-hook-listener/transcript-lines.ts create mode 100644 src/shared/agent-hook-listener/transcript-reader.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 43aa99cf788..2c9a7b1b5af 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -145,7 +145,6 @@ inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/store/slices/workspace-cleanup.ts inline src/renderer/src/web/web-runtime-client.ts -inline src/shared/agent-hook-listener.ts inline src/shared/automation-schedules.ts inline src/shared/commit-message-agent-spec.ts inline src/shared/constants.ts diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index 5f28a40c039..fc2482cbdb0 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' import { createHookStatusSessionTabsInvalidator } from './hook-status-session-tabs-invalidation' diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 0a36eef0f56..04902579855 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -1,4 +1,4 @@ -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' type KnownStatus = { diff --git a/src/main/agent-hooks/manual-compact-hook-stream.test.ts b/src/main/agent-hooks/manual-compact-hook-stream.test.ts index 1a122349ca2..c44a28f13d7 100644 --- a/src/main/agent-hooks/manual-compact-hook-stream.test.ts +++ b/src/main/agent-hooks/manual-compact-hook-stream.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { RelayAgentHookServer } from '../../relay/agent-hook-server' -import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener' +import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-authority-evidence.test.ts b/src/main/agent-hooks/server-authority-evidence.test.ts index 182eddada02..d6acf1886da 100644 --- a/src/main/agent-hooks/server-authority-evidence.test.ts +++ b/src/main/agent-hooks/server-authority-evidence.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-grok-discovery.test.ts b/src/main/agent-hooks/server-grok-discovery.test.ts index cea007c3d2f..f3ea95bc595 100644 --- a/src/main/agent-hooks/server-grok-discovery.test.ts +++ b/src/main/agent-hooks/server-grok-discovery.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import * as agentHookListener from '../../shared/agent-hook-listener' +import * as agentHookListener from '../../shared/agent-hook-listener/grok-result-discovery' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 622fc7d428b..55f2c6e1007 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -28,7 +28,101 @@ afterEach(() => { vi.restoreAllMocks() }) +async function postClaudeHook( + server: AgentHookServer, + payload: Record +): Promise { + const env = server.buildPtyEnv() + return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) +} + describe('AgentHookServer listener replay', () => { + it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const order: string[] = [] + const internal = server as unknown as { + scheduleAssistantMessageRetry: (...args: unknown[]) => void + scheduleCodexSubagentPoll: (...args: unknown[]) => void + } + const originalAssistantRetry = internal.scheduleAssistantMessageRetry.bind(server) + const originalCodexRetry = internal.scheduleCodexSubagentPoll.bind(server) + const assistantRetry = vi + .spyOn(internal, 'scheduleAssistantMessageRetry') + .mockImplementation((...args) => { + order.push('assistant-retry') + originalAssistantRetry(...args) + }) + const codexRetry = vi + .spyOn(internal, 'scheduleCodexSubagentPoll') + .mockImplementation((...args) => { + order.push('codex-retry') + originalCodexRetry(...args) + }) + const unsubscribeStatus = server.subscribeStatusChanges(() => order.push('status-change')) + server.setListener(() => { + expect(server.getStatusSnapshotForPane(PANE)).toHaveLength(1) + order.push('main-listener') + }) + const unsubscribePlugin = server.subscribeEnrichedStatus(() => order.push('plugin-listener')) + try { + const response = await postClaudeHook(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'ordered' + }) + order.push('response') + expect(response.status).toBe(204) + expect(order).toEqual([ + 'status-change', + 'main-listener', + 'plugin-listener', + 'assistant-retry', + 'codex-retry', + 'response' + ]) + } finally { + unsubscribeStatus() + unsubscribePlugin() + assistantRetry.mockRestore() + codexRetry.mockRestore() + server.stop() + } + }) + + it('fails open after a throwing callback with cache retained and retries skipped', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const internal = server as unknown as { + scheduleAssistantMessageRetry: (...args: unknown[]) => void + scheduleCodexSubagentPoll: (...args: unknown[]) => void + } + const assistantRetry = vi.spyOn(internal, 'scheduleAssistantMessageRetry') + const codexRetry = vi.spyOn(internal, 'scheduleCodexSubagentPoll') + server.setListener(() => { + throw new Error('listener failed') + }) + try { + const response = await postClaudeHook(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'cached before callback' + }) + expect(response.status).toBe(204) + expect(server.getStatusSnapshotForPane(PANE)).toHaveLength(1) + expect(assistantRetry).not.toHaveBeenCalled() + expect(codexRetry).not.toHaveBeenCalled() + } finally { + assistantRetry.mockRestore() + codexRetry.mockRestore() + server.stop() + } + }) it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index c7795fb8069..6c96d9c3bb8 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { createShedSubagentsField } from '../../shared/agent-hook-relay' import { buildBody, PANE } from './server.test-fixtures' diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f69ffea2e4c..e447be40262 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -13,33 +13,45 @@ import { clearAllListenerCaches, clearPaneCacheState, paneHasStateClaims, - clearClaudeAnsweredQuestionWait, createHookListenerState, - getEndpointFileName, - hasCodexTranscriptSubagents, - hasPendingAgentResultText, - HOOK_REQUEST_SLOWLORIS_MS, - isNewTurnEvent, - markClaudeLeadTurnInterrupted, - markCodexLeadTurnInterrupted, - MAX_PANE_KEY_LEN, movePaneCacheState, - normalizeClaudePromptId, - normalizeHookPayload, - parseFormEncodedBody, - readRequestBody, - reapRestoredClaudeSubagentsForDeadPane, - reconcileRemoteCodexState, - resolveHookSource, - preparePendingGrokResultDiscovery, - seedClaudeLeadTurnFromPersistedStatus, - seedClaudeSubagentRosterFromSnapshots, - seedCodexStateFromSnapshot, - warnOnHookEnvOrVersionMismatch, - writeEndpointFile, - type AgentHookEventPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { + clearClaudeAnsweredQuestionWait, + markClaudeLeadTurnInterrupted, + reapRestoredClaudeSubagentsForDeadPane, + seedClaudeLeadTurnFromPersistedStatus, + seedClaudeSubagentRosterFromSnapshots +} from '../../shared/agent-hook-listener/providers/claude-roster-state' +import { + getEndpointFileName, + writeEndpointFile +} from '../../shared/agent-hook-listener/endpoint-publication' +import { + hasCodexTranscriptSubagents, + markCodexLeadTurnInterrupted, + reconcileRemoteCodexState, + seedCodexStateFromSnapshot +} from '../../shared/agent-hook-listener/providers/codex-state' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from '../../shared/agent-hook-listener/grok-result-discovery' +import { + HOOK_REQUEST_SLOWLORIS_MS, + MAX_PANE_KEY_LEN, + normalizeClaudePromptId, + warnOnHookEnvOrVersionMismatch +} from '../../shared/agent-hook-listener/listener-limits' +import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { + parseFormEncodedBody, + readRequestBody +} from '../../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../../shared/agent-hook-listener/source-routing' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { canAcceptClaudeCompactCompletion, isClaudeCompactCompletionConsumed, diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts index f17e14630e5..24399161d48 100644 --- a/src/main/claude/compact-status-registration.test.ts +++ b/src/main/claude/compact-status-registration.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, movePaneCacheState, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, - type AgentHookEventPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' import { applyManagedHooks, CLAUDE_EVENTS } from './hook-settings' diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 34b4cbbdec6..14a7430f997 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -1,7 +1,7 @@ // Pure mapping from a parsed hook event plus its raw body onto the `agent.hook` envelope that goes // over the SSH channel. Sits beside agent-hook-envelope-publication.ts, which owns shedding and // redelivery of the same envelope. -import type { AgentHookEventPayload } from '../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' // Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly. diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/relay/agent-hook-result-retry-scheduler.ts index b69abcb49a8..bd6d2796163 100644 --- a/src/relay/agent-hook-result-retry-scheduler.ts +++ b/src/relay/agent-hook-result-retry-scheduler.ts @@ -2,14 +2,14 @@ // when the hook fired, so re-read the same body on a timer and re-apply only if it changed. Both // timer families live in one owner so pane teardown and server stop tear both down in one ordered // place before the listener caches are cleared. +import { hasCodexTranscriptSubagents } from '../shared/agent-hook-listener/providers/codex-state' import { - hasCodexTranscriptSubagents, hasPendingAgentResultText, - normalizeHookPayload, - preparePendingGrokResultDiscovery, - type AgentHookEventPayload, - type HookListenerState -} from '../shared/agent-hook-listener' + preparePendingGrokResultDiscovery +} from '../shared/agent-hook-listener/grok-result-discovery' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookSource } from '../shared/agent-hook-relay' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index f80de335e43..0e3771aff55 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -3,14 +3,20 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' -import * as agentHookListener from '../shared/agent-hook-listener' +import * as agentHookListener from '../shared/agent-hook-listener/grok-result-discovery' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +type RelayServerInternals = { + state: { lastStatusByPaneKey: Map } + retryScheduler: AgentHookResultRetryScheduler +} + describe('RelayAgentHookServer', () => { let dir: string beforeEach(() => { @@ -76,6 +82,97 @@ describe('RelayAgentHookServer', () => { } }) + it('caches before forwarding, schedules retries after forwarding, and responds last', async () => { + const order: string[] = [] + let server!: RelayAgentHookServer + let internals!: RelayServerInternals + server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + expect(internals.state.lastStatusByPaneKey.has(PANE_KEY)).toBe(true) + order.push('forward') + } + }) + // Characterization deliberately observes the server-owned scheduler without widening production API. + internals = server as unknown as RelayServerInternals + const retryScheduler = internals.retryScheduler + const originalAssistantRetry = retryScheduler.scheduleAssistantMessageRetry.bind(retryScheduler) + const originalCodexRetry = retryScheduler.scheduleCodexSubagentPoll.bind(retryScheduler) + const assistantRetry = vi + .spyOn(retryScheduler, 'scheduleAssistantMessageRetry') + .mockImplementation((...args) => { + order.push('assistant-retry') + originalAssistantRetry(...args) + }) + const codexRetry = vi + .spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + .mockImplementation((...args) => { + order.push('codex-retry') + originalCodexRetry(...args) + }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + hook_event_name: 'UserPromptSubmit', + payload: { prompt: 'ordered' } + }) + }) + order.push('response') + expect(res.status).toBe(204) + expect(order).toEqual(['forward', 'assistant-retry', 'codex-retry', 'response']) + } finally { + server.stop() + assistantRetry.mockRestore() + codexRetry.mockRestore() + } + }) + + it('fails open after a throwing forward while retaining the already-cached event', async () => { + const server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + throw new Error('forward failed') + } + }) + // Characterization deliberately observes cache/scheduler order without widening production API. + const internals = server as unknown as RelayServerInternals + const retryScheduler = internals.retryScheduler + const assistantRetry = vi.spyOn(retryScheduler, 'scheduleAssistantMessageRetry') + const codexRetry = vi.spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + hook_event_name: 'UserPromptSubmit', + payload: { prompt: 'cached before throw' } + }) + }) + expect(res.status).toBe(204) + expect(internals.state.lastStatusByPaneKey.has(PANE_KEY)).toBe(true) + expect(assistantRetry).not.toHaveBeenCalled() + expect(codexRetry).not.toHaveBeenCalled() + } finally { + server.stop() + assistantRetry.mockRestore() + codexRetry.mockRestore() + } + }) + it('forwards Claude background monitoring until its authoritative inventory drains', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index fb37dc64e3d..84474cbab00 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,8 +1,3 @@ -// Relay-side adapter for the shared agent-hook listener: hosts a loopback HTTP server and -// forwards each parsed payload via a callback so `relay.ts` re-emits it as an `agent.hook` -// JSON-RPC notification over the SSH channel. Replay cache is bounded one-entry-per-paneKey: a -// reattaching Orca only needs each pane's current status, never its history, and the bound keeps a -// long-lived relay from growing with every event. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -12,15 +7,17 @@ import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, - getEndpointFileName, - HOOK_REQUEST_SLOWLORIS_MS, - normalizeHookPayload, - readRequestBody, - resolveHookSource, - writeEndpointFile, - type AgentHookEventPayload, type HookListenerState -} from '../shared/agent-hook-listener' +} from '../shared/agent-hook-listener/listener-state' +import { + getEndpointFileName, + writeEndpointFile +} from '../shared/agent-hook-listener/endpoint-publication' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { readRequestBody } from '../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import { createHookTransportInterferenceTracker, describeHookTransportInterference, @@ -37,7 +34,6 @@ import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-schedul export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void -// Why: WSL lacks per-pane teardown, so cap replay-cache recency. const MAX_CACHED_PANES = 256 export type RelayHookServerOptions = { @@ -49,7 +45,6 @@ export type RelayHookServerOptions = { token?: string /** Preferred bind port. WSL relay passes the Windows listener's port so env-sourced client coords stay truthful; falls back to :0 if occupied. Defaults to :0. */ preferredPort?: number - /** Called once per parsed payload; the relay wires this to `dispatcher.notify('agent.hook', envelope)`. */ forward: RelayHookForward } @@ -121,7 +116,6 @@ export class RelayAgentHookServer { } } - /** True when the preferred port was occupied and the server fell back to an ephemeral bind. */ get usedPortFallback(): boolean { return this.portFallbackApplied } diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 57b3466707d..18d837bb4ef 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os' import { execFileSync, spawn as spawnChild } from 'node:child_process' import { build } from 'esbuild' import { spawnRelay, type RelayProcess } from './subprocess-test-utils' -import { getEndpointFileName } from '../shared/agent-hook-listener' +import { getEndpointFileName } from '../shared/agent-hook-listener/endpoint-publication' import { relayTestSocketPath } from './relay-test-socket-path' const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts') diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts index f0b2aab1520..64162c8520d 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { createAgentCompletionCoordinator } from './agent-completion-coordinator' import { useAgentCompletionCoordinatorLifecycle } from './agent-completion-coordinator-test-harness' -import { - createHookListenerState, - normalizeHookPayload -} from '../../../../shared/agent-hook-listener' +import { normalizeHookPayload } from '../../../../shared/agent-hook-listener' +import { createHookListenerState } from '../../../../shared/agent-hook-listener/listener-state' import { makePaneKey } from '../../../../shared/stable-pane-id' // Why: STA-4119's second complaint is the missing completion notification. This drives the REAL diff --git a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts index 8583c3bb0de..3898479808a 100644 --- a/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' -import { createHookListenerState, normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { createHookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' import { makePaneKey } from '../../../shared/stable-pane-id' const dispatchTerminalNotification = vi.fn() diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 63c33c7f26e..71233c7f697 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' -import { createHookListenerState, normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { createHookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' const dispatchTerminalNotification = vi.fn() const dispatchAgentHookTerminalLifecycle = vi.fn() diff --git a/src/shared/agent-hook-listener-antigravity.test.ts b/src/shared/agent-hook-listener-antigravity.test.ts index 9eac0d75593..df6ec253c85 100644 --- a/src/shared/agent-hook-listener-antigravity.test.ts +++ b/src/shared/agent-hook-listener-antigravity.test.ts @@ -4,10 +4,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHookListenerState, - hasPendingAgentResultText, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { hasPendingAgentResultText } from './agent-hook-listener/grok-result-discovery' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts index 64390067fcf..601b8aea828 100644 --- a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts +++ b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { normalizeAndAccept, PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index 5278247cf8d..c0d646837ee 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -1,13 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearClaudeAnsweredQuestionWait, + markClaudeLeadTurnInterrupted, + seedClaudeSubagentRosterFromSnapshots +} from './agent-hook-listener/providers/claude-roster-state' +import { clearPaneCacheState, createHookListenerState, - markClaudeLeadTurnInterrupted, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { makePaneKey } from './stable-pane-id' diff --git a/src/shared/agent-hook-listener-claude-turn-state.test.ts b/src/shared/agent-hook-listener-claude-turn-state.test.ts index 15f1973187d..f7e40a52461 100644 --- a/src/shared/agent-hook-listener-claude-turn-state.test.ts +++ b/src/shared/agent-hook-listener-claude-turn-state.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { CLAUDE_PREVIOUS_PROMPT_ID, diff --git a/src/shared/agent-hook-listener-command-code-transcript.test.ts b/src/shared/agent-hook-listener-command-code-transcript.test.ts index 458eb918935..6e8159bf72e 100644 --- a/src/shared/agent-hook-listener-command-code-transcript.test.ts +++ b/src/shared/agent-hook-listener-command-code-transcript.test.ts @@ -4,9 +4,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-extraction-characterization.test.ts b/src/shared/agent-hook-listener-extraction-characterization.test.ts new file mode 100644 index 00000000000..0d4089d4537 --- /dev/null +++ b/src/shared/agent-hook-listener-extraction-characterization.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from 'vitest' + +import { normalizeHookPayload } from './agent-hook-listener' +import { + clearAllListenerCaches, + clearPaneCacheState, + createHookListenerState, + movePaneCacheState +} from './agent-hook-listener/listener-state' +import { warnOnHookEnvOrVersionMismatch } from './agent-hook-listener/listener-limits' +import { resolveHookSource } from './agent-hook-listener/source-routing' +import { makePaneKey } from './stable-pane-id' + +const PANE = makePaneKey('tab-hooks', '11111111-1111-4111-8112-111111111111') +const MOVED_PANE = makePaneKey('tab-hooks', '22222222-2222-4222-8222-222222222222') +const ROUTES = { + '/hook/claude': 'claude', + '/hook/codex': 'codex', + '/hook/gemini': 'gemini', + '/hook/antigravity': 'antigravity', + '/hook/amp': 'amp', + '/hook/opencode': 'opencode', + '/hook/mimo-code': 'mimo-code', + '/hook/cursor': 'cursor', + '/hook/pi': 'pi', + '/hook/omp': 'omp', + '/hook/prime-agent': 'prime-agent', + '/hook/droid': 'droid', + '/hook/command-code': 'command-code', + '/hook/grok': 'grok', + '/hook/copilot': 'copilot', + '/hook/hermes': 'hermes', + '/hook/devin': 'devin', + '/hook/kimi': 'kimi' +} as const +function normalizeProviderState( + source: (typeof ROUTES)[keyof typeof ROUTES], + eventName: string, + payload: Record = {} +) { + return ( + normalizeHookPayload( + createHookListenerState(), + source, + { paneKey: PANE, hook_event_name: eventName, payload }, + 'production' + )?.payload ?? null + ) +} +describe('agent hook extraction boundaries', () => { + it('routes exactly the complete provider vocabulary', () => { + for (const [pathname, source] of Object.entries(ROUTES)) { + expect(resolveHookSource(pathname)).toBe(source) + expect(resolveHookSource(`${pathname}/`)).toBeNull() + expect(resolveHookSource(`${pathname}?v=1`)).toBeNull() + expect(resolveHookSource(pathname.toUpperCase())).toBeNull() + } + expect(resolveHookSource('/hook/unknown')).toBeNull() + }) + + it('preserves thin provider lifecycle branches and source attribution', () => { + const cases = [ + ['devin', 'UserPromptSubmit', 'working'], + ['devin', 'PermissionRequest', 'waiting'], + ['devin', 'Stop', 'done'], + ['mimo-code', 'MessagePart', 'working'], + ['mimo-code', 'AskUserQuestion', 'waiting'], + ['prime-agent', 'before_agent_start', 'working'], + ['prime-agent', 'agent_end', 'done'], + ['kimi', 'PermissionRequest', 'waiting'], + ['kimi', 'StopFailure', 'done'], + ['hermes', 'on_session_finalize', 'done'], + ['hermes', 'on_session_reset', 'done'] + ] as const + + for (const [source, eventName, expectedState] of cases) { + expect(normalizeProviderState(source, eventName)).toMatchObject({ + state: expectedState, + agentType: source + }) + } + for (const source of ['devin', 'mimo-code', 'prime-agent', 'kimi', 'hermes'] as const) { + expect(normalizeProviderState(source, 'UnknownEvent')).toBeNull() + } + }) + + it('warns before tab rejection and caps version and environment warning keys independently', () => { + const orderState = createHookListenerState() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const rejected = normalizeHookPayload( + orderState, + 'devin', + { + paneKey: PANE, + tabId: 'wrong-tab', + version: 'outdated', + payload: { hook_event_name: 'UnknownEvent' } + }, + 'production' + ) + expect(rejected).toBeNull() + expect(warn).toHaveBeenCalledTimes(1) + + const cappedState = createHookListenerState() + for (let index = 0; index < 40; index++) { + warnOnHookEnvOrVersionMismatch(cappedState, { + version: `version-${index}`, + env: `environment-${index}`, + expectedEnv: 'production' + }) + } + expect(cappedState.warnedVersions.size).toBe(32) + expect(cappedState.warnedEnvs.size).toBe(32) + warn.mockRestore() + }) + + it('moves every pane-owned collection and preserves NUL suffixes', () => { + const state = createHookListenerState() + const scoped = `${PANE}\0child` + const movedScoped = `${MOVED_PANE}\0child` + const sibling = `${PANE}-sibling` + const paneMaps = [ + state.lastPromptByPaneKey, + state.lastToolByPaneKey, + state.lastStatusByPaneKey, + state.antigravityCompletedTranscriptByPaneKey, + state.claudeSubagentRosterByPaneKey, + state.claudeLeadStateByPaneKey, + state.codexSubagentRosterByPaneKey, + state.codexSubagentTranscriptByPaneKey, + state.codexLeadStateByPaneKey + ] + for (const map of paneMaps) { + const cache = map as Map + cache.set(PANE, 'exact') + cache.set(scoped, 'scoped') + cache.set(sibling, 'sibling') + } + const paneSets = [ + state.ampCompletedCacheKeys, + state.claudeUnconfirmedRestoredStatusPaneKeys, + state.claudeRunningNonAgentTaskPaneKeys, + state.claudeActiveSessionCronPaneKeys + ] + for (const set of paneSets) { + set.add(PANE) + set.add(scoped) + set.add(sibling) + } + + movePaneCacheState(state, PANE, MOVED_PANE) + + for (const map of paneMaps) { + const cache = map as Map + expect(cache.get(MOVED_PANE)).toBe('exact') + expect(cache.get(movedScoped)).toBe('scoped') + expect(cache.get(sibling)).toBe('sibling') + expect(cache.has(PANE)).toBe(false) + } + for (const set of paneSets) { + expect(set.has(MOVED_PANE)).toBe(true) + expect(set.has(movedScoped)).toBe(true) + expect(set.has(sibling)).toBe(true) + expect(set.has(PANE)).toBe(false) + } + + movePaneCacheState(state, MOVED_PANE, MOVED_PANE) + expect((state.lastPromptByPaneKey as Map).get(MOVED_PANE)).toBe('exact') + }) + + it('clears exact and NUL-scoped cache keys but not sibling prefixes', () => { + const state = createHookListenerState() + const scoped = `${PANE}\0thread` + const sibling = `${PANE}-sibling` + const paneMaps = [ + state.lastPromptByPaneKey, + state.lastToolByPaneKey, + state.lastStatusByPaneKey, + state.antigravityCompletedTranscriptByPaneKey + ] + for (const map of paneMaps) { + const cache = map as Map + cache.set(PANE, 'exact') + cache.set(scoped, 'scoped') + cache.set(sibling, 'sibling') + } + state.ampCompletedCacheKeys.add(PANE) + state.ampCompletedCacheKeys.add(scoped) + state.ampCompletedCacheKeys.add(sibling) + state.claudeLeadStateByPaneKey.set(PANE, { state: 'working' }) + state.codexLeadStateByPaneKey.set(PANE, { state: 'working' }) + + clearPaneCacheState(state, PANE) + + for (const map of paneMaps) { + const cache = map as Map + expect(cache.has(PANE)).toBe(false) + expect(cache.has(scoped)).toBe(false) + expect(cache.get(sibling)).toBe('sibling') + } + expect(state.ampCompletedCacheKeys.has(scoped)).toBe(false) + expect(state.ampCompletedCacheKeys.has(sibling)).toBe(true) + expect(state.claudeLeadStateByPaneKey.has(PANE)).toBe(false) + expect(state.codexLeadStateByPaneKey.has(PANE)).toBe(false) + }) + + it('preserves cache mutation from a provider reset that emits no row', () => { + const state = createHookListenerState() + state.lastPromptByPaneKey.set(PANE, 'old prompt') + state.lastToolByPaneKey.set(PANE, { toolName: 'old tool' }) + state.lastStatusByPaneKey.set(PANE, {} as never) + + const event = normalizeHookPayload( + state, + 'droid', + { paneKey: PANE, payload: { hook_event_name: 'SessionStart' } }, + 'production' + ) + + expect(event).toBeNull() + expect(state.lastPromptByPaneKey.has(PANE)).toBe(false) + expect(state.lastToolByPaneKey.has(PANE)).toBe(false) + expect(state.lastStatusByPaneKey.has(PANE)).toBe(true) + }) + + it('clears warning, provider, and lifecycle caches together', () => { + const state = createHookListenerState() + state.warnedVersions.add('old-version') + state.warnedEnvs.add('development->production') + state.lastPromptByPaneKey.set(PANE, 'prompt') + state.claudeRunningNonAgentTaskPaneKeys.add(PANE) + state.codexLeadStateByPaneKey.set(PANE, { state: 'working' }) + + clearAllListenerCaches(state) + + expect(state.warnedVersions.size).toBe(0) + expect(state.warnedEnvs.size).toBe(0) + expect(state.lastPromptByPaneKey.size).toBe(0) + expect(state.claudeRunningNonAgentTaskPaneKeys.size).toBe(0) + expect(state.codexLeadStateByPaneKey.size).toBe(0) + }) +}) diff --git a/src/shared/agent-hook-listener-grok.test.ts b/src/shared/agent-hook-listener-grok.test.ts index 0ec1d6447ab..9fa6f844114 100644 --- a/src/shared/agent-hook-listener-grok.test.ts +++ b/src/shared/agent-hook-listener-grok.test.ts @@ -4,11 +4,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHookListenerState, - hasPendingAgentResultText, - normalizeHookPayload, - preparePendingGrokResultDiscovery, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from './agent-hook-listener/grok-result-discovery' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests, findGrokChatHistoryBySessionId diff --git a/src/shared/agent-hook-listener-hermes-codex-droid.test.ts b/src/shared/agent-hook-listener-hermes-codex-droid.test.ts index cfd7e1903a4..9fddb88957f 100644 --- a/src/shared/agent-hook-listener-hermes-codex-droid.test.ts +++ b/src/shared/agent-hook-listener-hermes-codex-droid.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-interactive-prompts.test.ts b/src/shared/agent-hook-listener-interactive-prompts.test.ts index e7531f651f8..214035ae575 100644 --- a/src/shared/agent-hook-listener-interactive-prompts.test.ts +++ b/src/shared/agent-hook-listener-interactive-prompts.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-pi-compatible.test.ts b/src/shared/agent-hook-listener-pi-compatible.test.ts index b0519378b18..8e835906f05 100644 --- a/src/shared/agent-hook-listener-pi-compatible.test.ts +++ b/src/shared/agent-hook-listener-pi-compatible.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { PANE_KEY } from './agent-hook-listener-test-harness' diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts new file mode 100644 index 00000000000..e05ef801f7f --- /dev/null +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -0,0 +1,135 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +function readRuntimeSpecifiers(source: string): string[] { + const specifiers: string[] = [] + const lines = source.split('\n') + for (let index = 0; index < lines.length; index += 1) { + const firstLine = lines[index].trimStart() + const isRuntimeImport = + firstLine.startsWith('import ') && + !firstLine.startsWith('import type ') && + !/^import\s+[\w$]+\s*=\s*require\s*\(/.test(firstLine) + const isRuntimeReexport = + (firstLine.startsWith('export {') || firstLine.startsWith('export *')) && + !firstLine.startsWith('export type ') + if (!isRuntimeImport && !isRuntimeReexport) { + continue + } + let statement = firstLine + while (!/(?:^import\s*['"]|\bfrom\s*['"])/.test(statement) && index + 1 < lines.length) { + index += 1 + statement += `\n${lines[index]}` + } + const match = + statement.match(/^import\s*['"]([^'"]+)['"]/) ?? statement.match(/\bfrom\s*['"]([^'"]+)['"]/) + if (match) { + specifiers.push(match[1]) + } + } + for (const match of source.matchAll(/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) { + specifiers.push(match[1]) + } + for (const match of source.matchAll(/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) { + specifiers.push(match[1]) + } + return specifiers +} + +function resolveTypeScriptImport(importer: string, specifier: string): string | null { + const candidate = resolve(dirname(importer), specifier) + for (const path of [`${candidate}.ts`, `${candidate}.tsx`]) { + if (existsSync(path)) { + return path + } + } + return null +} + +describe('agent hook listener relay dependency boundary', () => { + it('detects every supported runtime dependency syntax while excluding type imports', () => { + const source = [ + "import type { TypeOnly } from './type-only'", + 'import {', + ' runtime', + "} from './static-runtime'", + "import './side-effect'", + "const dynamic = import('./dynamic-runtime')", + "export { runtimeExport } from './runtime-export'", + "export * as runtimeNamespace from './namespace-runtime'", + "import imported = require('./ts-import-equals')", + "const required = require('./required-runtime')" + ].join('\n') + + expect(readRuntimeSpecifiers(source)).toEqual([ + './static-runtime', + './side-effect', + './runtime-export', + './namespace-runtime', + './dynamic-runtime', + './ts-import-equals', + './required-runtime' + ]) + }) + + it('keeps the transitive runtime graph inside Node builtins and shared modules', () => { + const sharedRoot = resolve(__dirname) + const listenerPathPrefix = resolve(sharedRoot, 'agent-hook-listener') + const relayConsumers = [ + resolve(sharedRoot, '../relay/agent-hook-server.ts'), + resolve(sharedRoot, '../relay/agent-hook-result-retry-scheduler.ts') + ] + const pending = relayConsumers.flatMap((consumer) => + readRuntimeSpecifiers(readFileSync(consumer, 'utf8')) + .map((specifier) => resolveTypeScriptImport(consumer, specifier)) + .filter( + (dependency): dependency is string => + dependency === `${listenerPathPrefix}.ts` || + dependency?.startsWith(`${listenerPathPrefix}/`) === true + ) + ) + const seeded = new Set(pending) + const visited = new Set() + const forbidden: string[] = [] + + while (pending.length > 0) { + const file = pending.pop()! + if (visited.has(file)) { + continue + } + visited.add(file) + const source = readFileSync(file, 'utf8') + for (const specifier of readRuntimeSpecifiers(source)) { + if (specifier.startsWith('node:')) { + continue + } + if (!specifier.startsWith('.')) { + forbidden.push(`${file}: ${specifier}`) + continue + } + const dependency = resolveTypeScriptImport(file, specifier) + if (!dependency || !dependency.startsWith(sharedRoot)) { + forbidden.push(`${file}: ${specifier}`) + continue + } + pending.push(dependency) + } + } + + expect(forbidden).toEqual([]) + expect([...seeded].map((file) => file.slice(sharedRoot.length + 1)).sort()).toEqual([ + 'agent-hook-listener.ts', + 'agent-hook-listener/endpoint-publication.ts', + 'agent-hook-listener/grok-result-discovery.ts', + 'agent-hook-listener/listener-limits.ts', + 'agent-hook-listener/listener-state.ts', + 'agent-hook-listener/providers/codex-state.ts', + 'agent-hook-listener/request-body.ts', + 'agent-hook-listener/source-routing.ts' + ]) + expect( + [...visited].some((file) => file.endsWith('/agent-hook-listener/provider-dispatch.ts')) + ).toBe(true) + }) +}) diff --git a/src/shared/agent-hook-listener-roster-retention.test.ts b/src/shared/agent-hook-listener-roster-retention.test.ts index f7a9332efb2..dbe5b311f6c 100644 --- a/src/shared/agent-hook-listener-roster-retention.test.ts +++ b/src/shared/agent-hook-listener-roster-retention.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { MAX_AGENT_HOOK_STATUS_CACHE_PANES } from './agent-hook-status-cache' import { makePaneKey } from './stable-pane-id' diff --git a/src/shared/agent-hook-listener-session-replacement.test.ts b/src/shared/agent-hook-listener-session-replacement.test.ts index 918840aac57..da179e68414 100644 --- a/src/shared/agent-hook-listener-session-replacement.test.ts +++ b/src/shared/agent-hook-listener-session-replacement.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '44444444-4444-4444-8444-444444444444' diff --git a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts index aebade35d6c..4046eb3e645 100644 --- a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts +++ b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { createHookListenerState, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' +import { seedClaudeSubagentRosterFromSnapshots } from './agent-hook-listener/providers/claude-roster-state' import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { makePaneKey } from './stable-pane-id' diff --git a/src/shared/agent-hook-listener-test-harness.ts b/src/shared/agent-hook-listener-test-harness.ts index d4b4eb50348..470039265d9 100644 --- a/src/shared/agent-hook-listener-test-harness.ts +++ b/src/shared/agent-hook-listener-test-harness.ts @@ -1,4 +1,5 @@ -import { normalizeHookPayload, type HookListenerState } from './agent-hook-listener' +import { normalizeHookPayload } from './agent-hook-listener' +import type { HookListenerState } from './agent-hook-listener/listener-state' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/shared/agent-hook-listener-transport.test.ts b/src/shared/agent-hook-listener-transport.test.ts index a4ed6998fbd..344be39eca3 100644 --- a/src/shared/agent-hook-listener-transport.test.ts +++ b/src/shared/agent-hook-listener-transport.test.ts @@ -6,13 +6,15 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { getEndpointFileName, - HOOK_REQUEST_MAX_BYTES, isShellSafeEndpointValue, - parseFormEncodedBody, - readRequestBody, - resolveHookSource, writeEndpointFile -} from './agent-hook-listener' +} from './agent-hook-listener/endpoint-publication' +import { + HOOK_REQUEST_MAX_BYTES, + parseFormEncodedBody, + readRequestBody +} from './agent-hook-listener/request-body' +import { resolveHookSource } from './agent-hook-listener/source-routing' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' type FakeIncomingMessage = EventEmitter & { @@ -69,6 +71,64 @@ describe('shared agent-hook-listener', () => { expectRequestParserListenersReleased(req) }) + it('accepts a JSON body at exactly the byte limit', async () => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + const text = JSON.stringify({ x: 'a'.repeat(HOOK_REQUEST_MAX_BYTES - 8) }) + expect(Buffer.byteLength(text)).toBe(HOOK_REQUEST_MAX_BYTES) + const body = readRequestBody(req as unknown as IncomingMessage) + + req.emit('data', Buffer.from(text)) + req.emit('end') + + await expect(body).resolves.toEqual({ x: 'a'.repeat(HOOK_REQUEST_MAX_BYTES - 8) }) + expectRequestParserListenersReleased(req) + }) + + it('strips exactly one outer JSON BOM', async () => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('{"ok":true}')])) + req.emit('end') + await expect(body).resolves.toEqual({ ok: true }) + }) + + it('rejects JSON beyond the nesting-depth limit', async () => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from(`${'['.repeat(65)}0${']'.repeat(65)}`)) + req.emit('end') + await expect(body).rejects.toThrow('JSON nesting exceeds 64 levels') + expectRequestParserListenersReleased(req) + }) + + it('rejects JSON beyond the 128K structural-token limit', async () => { + const req = createReadableRequest({ 'content-type': 'application/json' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from(`[${'0,'.repeat(128 * 1024)}0]`)) + req.emit('end') + await expect(body).rejects.toThrow('JSON structure exceeds 131072 tokens') + expectRequestParserListenersReleased(req) + }) + + it('classifies a premature request error before its original error', async () => { + const req = createReadableRequest({ 'content-length': '10' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from('ab')) + req.emit('error', new Error('socket reset')) + await expect(body).rejects.toThrow('hook request truncated after 2 of 10 bytes') + expectRequestParserListenersReleased(req) + }) + + it('settles a premature close once and ignores late request events', async () => { + const req = createReadableRequest({ 'content-length': '10' }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from('ab')) + req.emit('close') + req.emit('end') + await expect(body).rejects.toThrow('hook request truncated after 2 of 10 bytes') + expectRequestParserListenersReleased(req) + }) + it('routes pathnames to a known source or null', () => { expect(resolveHookSource('/hook/claude')).toBe('claude') expect(resolveHookSource('/hook/cursor')).toBe('cursor') diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 05702db4fcf..f1a05ad1412 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1,4448 +1,22 @@ -/* eslint-disable max-lines -- Why: canonical transport-agnostic listener; parser, normalizer, per-CLI extractors, and endpoint writer share invariants that must not drift between Orca's main process and the relay. */ - -// Why: extracted from src/main/agent-hooks/server.ts so the relay can host the same pipeline without Electron — this file must stay on Node builtins and other shared/ modules, or the relay bundle breaks. -import type { IncomingMessage } from 'node:http' -import { createHash, randomUUID } from 'node:crypto' -import { homedir } from 'node:os' -import { - chmodSync, - closeSync, - mkdirSync, - openSync, - readSync, - renameSync, - statSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { isAbsolute, join } from 'node:path' - -import { - AGENT_MODEL_MAX_LENGTH, - normalizeAgentStatusPayload, - type AgentStatusState, - type AgentSubagentSnapshot, - type AgentWorkingMode, - type ParsedAgentStatusPayload -} from './agent-status-types' -import { normalizeOptionalField } from './agent-status-field-normalization' -import { isAskUserQuestionTool } from './agent-question-answered-intent' -import { - claudeRosterHasRestoredSnapshotSubagent, - claudeRosterHasRuntimeWorkingSubagent, - claudeRosterHasWorkingSubagent, - claudeRosterToSnapshots, - claudeTeammateIdMatchesName, - foldClaudeBackgroundTasksIntoRoster, - idleClaudeTeammateByName, - reapUnconfirmedRestoredClaudeSubagents, - stopClaudeSubagent, - upsertWorkingClaudeSubagent, - type ClaudeSubagentRoster -} from './claude-subagent-roster' -import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory' -import { - codexRosterEffectiveState, - codexRosterToSnapshots, - finishCodexSubagent, - seedCodexSubagentRoster, - upsertCodexSubagent, - type CodexSubagentRoster -} from './codex-subagent-roster' -import { - createCodexSubagentTranscriptState, - hasTrackedCodexTranscriptSubagents, - reconcileCodexSubagentTranscript, - type CodexSubagentTranscriptState -} from './codex-subagent-transcript' -import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' -import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' -import { - extractAgentProviderSession, - type AgentProviderSessionMetadata -} from './agent-session-resume' -import { parsePaneKey } from './stable-pane-id' -import { - isCompactContinuationUserTurnText, - isKnownHarnessInjectedUserTurnText -} from './harness-injected-user-turns' -import { - buildGrokChatHistoryPathCandidates, - findGrokChatHistoryBySessionId, - getCachedGrokChatHistoryBySessionId, - GROK_SESSION_ID_MAX_LENGTH, - isSafeGrokSessionId, - resolveGrokChatHistoryPathSync, - resolveGrokSessionsDir -} from './grok-session-paths' +import { normalizeAgentStatusPayload } from './agent-status-types' +import type { AgentHookSource } from './agent-hook-relay' +import { extractAgentProviderSession } from './agent-session-resume' import { canAcceptClaudeCompactCompletion, isClaudeCompactCompletionConsumed, markClaudeCompactCompletionConsumed } from './claude-compact-completion' -import { sweepStaleAgentHookEndpointTemps } from './agent-hook-endpoint-temp-cleanup' -import { classifyTruncatedHookRequest } from './agent-hook-transport-interference' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' - -/** Maximum request body size accepted by the listener (1 MB). */ -export const HOOK_REQUEST_MAX_BYTES = 1_000_000 -const HOOK_REQUEST_INITIAL_BUFFER_BYTES = 4 * 1024 -const AGENT_HOOK_JSON_STRUCTURE_LIMITS = { - structuralTokens: 128 * 1024, - nestingDepth: 64 -} as const - -function parseAgentHookJson(content: string): unknown { - // Why: Cursor on Windows writes UTF-8-with-BOM to the hook's stdin and `JSON.parse` rejects U+FEFF, - // so the whole event was dropped. Strip exactly one leading BOM — not a trim — to keep every other - // malformed payload rejected as before. - const normalizedContent = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content - assertJsonTextStructureWithinLimits(normalizedContent, AGENT_HOOK_JSON_STRUCTURE_LIMITS) - return JSON.parse(normalizedContent) as unknown -} - -/** Bound the warn-once Sets so a client varying `version`/`env` per request can't grow them unbounded. */ -const MAX_WARNED_KEYS = 32 - -/** Slowloris cap: drop requests that have not finished sending after 5 s. */ -export const HOOK_REQUEST_SLOWLORIS_MS = 5_000 - -/** Why: old OpenCode plugin builds re-post the full accumulated reply on every streamed part (O(n²) bytes/turn); cap at ingest to bound per-event cost. */ -export const OPENCODE_HOOK_TEXT_MAX_CHARS = 8_000 - -function capOpenCodeHookText(text: string): string { - return text.length > OPENCODE_HOOK_TEXT_MAX_CHARS - ? text.slice(0, OPENCODE_HOOK_TEXT_MAX_CHARS) - : text -} - -/** Bound paneKey size (real keys are well under 200); caps per-pane caches against pathological input. Exported so non-HTTP ingest (`ingestRemote`) applies the same cap as defense-in-depth. */ -export const MAX_PANE_KEY_LEN = 200 -const CLAUDE_PROMPT_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i - -export function normalizeClaudePromptId(value: unknown): string | undefined { - if (typeof value !== 'string') { - return undefined - } - const normalized = value.trim().toLowerCase() - return CLAUDE_PROMPT_ID_RE.test(normalized) ? normalized : undefined -} - -/** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */ -export type HookListenerState = { - warnedVersions: Set - warnedEnvs: Set - lastPromptByPaneKey: Map - lastToolByPaneKey: Map - lastStatusByPaneKey: Map - antigravityCompletedTranscriptByPaneKey: Map - ampCompletedCacheKeys: Set - /** Live subagents/teammates per Claude pane; survives turn boundaries since background children outlive the lead turn. */ - claudeSubagentRosterByPaneKey: Map - /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `interrupted` persists so the eventual done still carries it. */ - claudeLeadStateByPaneKey: Map - /** One-normalization provenance marker for a status backed only by restored child state. */ - claudeUnconfirmedRestoredStatusPaneKeys: Set - /** Panes whose latest authoritative Claude task inventory still has running non-agent work. */ - claudeRunningNonAgentTaskPaneKeys: Set - /** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */ - claudeActiveSessionCronPaneKeys: Set - /** Compact whose completion each pane already applied, so relay duplicates can't refresh the row. */ - claudeConsumedCompactPromptIdByPaneKey: Map - /** Claude `session_id` that last reported on the pane from a LEAD event. A different id means the - * conversation was replaced (/clear, relaunch, resume), so claims the old session owned are void - * even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */ - claudeSessionOwnerByPaneKey: Map - /** Live thread-spawn children per Codex pane. */ - codexSubagentRosterByPaneKey: Map - /** Incremental parent/child rollout cursors for Codex collaboration v2. */ - codexSubagentTranscriptByPaneKey: Map - /** Root Codex state/model, kept separate from child hook traffic. */ - codexLeadStateByPaneKey: Map -} - -export type ClaudeLeadTurnState = { - state: AgentStatusState - interrupted?: true - /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ - waitingAgentId?: string - /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ - waitingToolUseId?: string - /** End time of the lead turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ - turnCompletedAt?: number - /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ - stateBeforeWait?: Pick -} - -type CodexLeadTurnState = { - state: 'working' | 'waiting' | 'done' - model?: string -} - -export function createHookListenerState(): HookListenerState { - return { - warnedVersions: new Set(), - warnedEnvs: new Set(), - lastPromptByPaneKey: new Map(), - lastToolByPaneKey: new Map(), - lastStatusByPaneKey: new Map(), - antigravityCompletedTranscriptByPaneKey: new Map(), - ampCompletedCacheKeys: new Set(), - claudeSubagentRosterByPaneKey: new Map(), - claudeLeadStateByPaneKey: new Map(), - claudeUnconfirmedRestoredStatusPaneKeys: new Set(), - claudeRunningNonAgentTaskPaneKeys: new Set(), - claudeActiveSessionCronPaneKeys: new Set(), - claudeConsumedCompactPromptIdByPaneKey: new Map(), - claudeSessionOwnerByPaneKey: new Map(), - codexSubagentRosterByPaneKey: new Map(), - codexSubagentTranscriptByPaneKey: new Map(), - codexLeadStateByPaneKey: new Map() - } -} - -export function clearPaneCacheState(state: HookListenerState, paneKey: string): void { - deletePaneScopedCacheEntry(state.lastPromptByPaneKey, paneKey) - deletePaneScopedCacheEntry(state.lastToolByPaneKey, paneKey) - deletePaneScopedCacheEntry(state.lastStatusByPaneKey, paneKey) - deletePaneScopedCacheEntry(state.antigravityCompletedTranscriptByPaneKey, paneKey) - deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey) - deletePaneScopedCacheEntry(state.claudeConsumedCompactPromptIdByPaneKey, paneKey) - state.claudeSubagentRosterByPaneKey.delete(paneKey) - state.claudeLeadStateByPaneKey.delete(paneKey) - state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - state.claudeSessionOwnerByPaneKey.delete(paneKey) - state.codexSubagentRosterByPaneKey.delete(paneKey) - state.codexSubagentTranscriptByPaneKey.delete(paneKey) - state.codexLeadStateByPaneKey.delete(paneKey) -} - -/** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that - * `resolveClaudePaneState` would re-gate `working` from on the pane's next event? - * - * Deliberately lives next to `clearPaneCacheState` above and enumerates the claim-bearing subset of - * what that function deletes: the two must be edited together, and keeping them three lines apart in - * one file is what makes that obvious. Prompt/tool/transcript caches are excluded — they render a - * row, they never create one. */ -export function paneHasStateClaims(state: HookListenerState, paneKey: string): boolean { - return ( - state.lastStatusByPaneKey.has(paneKey) || - state.claudeSubagentRosterByPaneKey.has(paneKey) || - state.claudeLeadStateByPaneKey.has(paneKey) || - state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey) || - state.claudeSessionOwnerByPaneKey.has(paneKey) || - state.codexSubagentRosterByPaneKey.has(paneKey) || - state.codexLeadStateByPaneKey.has(paneKey) - ) -} - -function movePaneScopedMapEntries( - map: Map, - fromPaneKey: string, - toPaneKey: string -): void { - for (const [key, value] of Array.from(map.entries())) { - if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { - continue - } - map.delete(key) - map.set(`${toPaneKey}${key.slice(fromPaneKey.length)}`, value) - } -} - -function movePaneScopedSetEntries(set: Set, fromPaneKey: string, toPaneKey: string): void { - for (const key of Array.from(set)) { - if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { - continue - } - set.delete(key) - set.add(`${toPaneKey}${key.slice(fromPaneKey.length)}`) - } -} - -export function movePaneCacheState( - state: HookListenerState, - fromPaneKey: string, - toPaneKey: string -): void { - if (fromPaneKey === toPaneKey) { - return - } - movePaneScopedMapEntries(state.lastPromptByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.lastToolByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.lastStatusByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.antigravityCompletedTranscriptByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedSetEntries(state.ampCompletedCacheKeys, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.claudeConsumedCompactPromptIdByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey) - movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey) - movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.claudeSessionOwnerByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) -} - -function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void { - state.lastPromptByPaneKey.delete(paneKey) - state.lastToolByPaneKey.delete(paneKey) - state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) - state.ampCompletedCacheKeys.delete(paneKey) -} - -function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { - map.delete(paneKey) - const scopedPrefix = `${paneKey}\0` - for (const key of map.keys()) { - if (key.startsWith(scopedPrefix)) { - map.delete(key) - } - } -} - -function deletePaneScopedSetEntry(set: Set, paneKey: string): void { - set.delete(paneKey) - const scopedPrefix = `${paneKey}\0` - for (const key of set) { - if (key.startsWith(scopedPrefix)) { - set.delete(key) - } - } -} - -export function clearAllListenerCaches(state: HookListenerState): void { - state.lastPromptByPaneKey.clear() - state.lastToolByPaneKey.clear() - state.lastStatusByPaneKey.clear() - state.antigravityCompletedTranscriptByPaneKey.clear() - state.ampCompletedCacheKeys.clear() - state.claudeConsumedCompactPromptIdByPaneKey.clear() - state.warnedVersions.clear() - state.warnedEnvs.clear() - state.claudeSubagentRosterByPaneKey.clear() - state.claudeLeadStateByPaneKey.clear() - state.claudeUnconfirmedRestoredStatusPaneKeys.clear() - state.claudeRunningNonAgentTaskPaneKeys.clear() - state.claudeActiveSessionCronPaneKeys.clear() - state.claudeSessionOwnerByPaneKey.clear() - state.codexSubagentRosterByPaneKey.clear() - state.codexSubagentTranscriptByPaneKey.clear() - state.codexLeadStateByPaneKey.clear() -} - -/** Warn-once on cross-build (`version`) and dev-vs-prod (`env`) mismatches; the relay's "remote" env marker is a location tag, not a build env, so it must not warn as a stale local hook. */ -export function warnOnHookEnvOrVersionMismatch( - state: HookListenerState, - fields: { version?: string; env?: string; expectedEnv: string } -): void { - const { version, env, expectedEnv } = fields - if ( - version && - version !== ORCA_HOOK_PROTOCOL_VERSION && - !state.warnedVersions.has(version) && - state.warnedVersions.size < MAX_WARNED_KEYS - ) { - state.warnedVersions.add(version) - console.warn( - `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + - 'Reinstall agent hooks from Settings to upgrade the managed script.' - ) - } - if (env && env !== REMOTE_AGENT_HOOK_ENV && env !== expectedEnv) { - const key = `${env}->${expectedEnv}` - if (!state.warnedEnvs.has(key) && state.warnedEnvs.size < MAX_WARNED_KEYS) { - state.warnedEnvs.add(key) - console.warn( - `[agent-hooks] received ${env} hook on ${expectedEnv} server. ` + - 'Likely a stale terminal from another Orca install.' - ) - } - } -} - -export type AgentHookEventPayload = { - paneKey: string - /** Authenticated hook route that produced this event. */ - source?: AgentHookSource - /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ - launchToken?: string - tabId?: string - worktreeId?: string - /** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop - * in-flight events from a superseded connection after an SSH reconnect. */ - connectionId: string | null - /** True when the event carried prompt text directly, not the listener's cached prompt from an earlier event in the pane. */ - hasExplicitPrompt?: boolean - /** The emitted nonterminal state is backed only by child state restored from disk. */ - restoredUnconfirmed?: true - /** Stable per-turn key to distinguish duplicate hook delivery from a same-text prompt rerun (when the source exposes enough context). */ - promptInteractionKey?: string - /** Raw agent hook event name, used by main-process transition guards. */ - hookEventName?: string - /** Claude's provider-owned user-prompt UUID. */ - providerPromptId?: string - /** Active Claude compact generation, keyed by provider prompt identity. */ - compactTrigger?: 'manual' | 'auto' - /** Claude tool-use identifier when the hook source exposes one. */ - toolUseId?: string - /** Claude agent/subagent identifier when the hook source exposes one. */ - toolAgentId?: string - /** Claude teammate name carried by TeammateIdle. */ - teammateName?: string - /** Agent/subagent type from the source hook payload, when present. */ - toolAgentType?: string - /** Provider-owned conversation/session id needed to resume a sleeping agent. */ - providerSession?: AgentProviderSessionMetadata - /** Session identity update with no turn-state transition; refreshes durable resume metadata without a fake status row. */ - providerSessionOnly?: boolean - /** True when this event is a relay cache replay rather than a live hook. */ - isReplay?: boolean - /** Transport-only Claude background-work evidence used to reject false input-based interrupts. */ - claudeRunningNonAgentTask?: boolean - payload: ParsedAgentStatusPayload -} - -// ─── Body parsing ─────────────────────────────────────────────────── - -export function parseFormEncodedBody(body: string): Record { - const params = new URLSearchParams(body) - const parsed: Record = {} - for (const [key, value] of params.entries()) { - parsed[key] = value - } - return parsed -} - -export function readRequestBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let retained = Buffer.alloc(0) - let byteLength = 0 - let settled = false - const cleanup = (): void => { - req.off('data', onData) - req.off('end', onEnd) - req.off('error', onError) - req.off('close', onClose) - // Why: keep a neutral error sink so a late IncomingMessage error after cleanup can't become unhandled. - req.on('error', ignoreSettledRequestError) - } - const settleResolve = (value: unknown): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve(value) - } - const settleReject = (error: unknown): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) - } - const onData = (chunk: Buffer): void => { - // Why: bound by bytes (not UTF-16 units) and stop accumulating after rejection so a client can't push memory past the cap. - const nextByteLength = byteLength + chunk.length - if (nextByteLength > HOOK_REQUEST_MAX_BYTES) { - settleReject(new Error('payload too large')) - req.destroy() - return - } - if (retained.length < nextByteLength) { - const nextCapacity = Math.min( - HOOK_REQUEST_MAX_BYTES, - Math.max(HOOK_REQUEST_INITIAL_BUFFER_BYTES, retained.length * 2, nextByteLength) - ) - const next = Buffer.allocUnsafe(nextCapacity) - retained.copy(next, 0, 0, byteLength) - retained = next - } - chunk.copy(retained, byteLength) - byteLength = nextByteLength - } - const onEnd = (): void => { - try { - const body = retained.toString('utf8', 0, byteLength) - const contentType = req.headers['content-type'] ?? '' - if (typeof contentType === 'string' && contentType.includes('application/json')) { - settleResolve(body ? parseAgentHookJson(body) : {}) - return - } - if ( - typeof contentType === 'string' && - contentType.includes('application/x-www-form-urlencoded') - ) { - settleResolve(parseFormEncodedBody(body)) - return - } - // Why: managed scripts POST JSON, updated POSIX scripts form-encoded; default to JSON for unknown content types. - settleResolve(body ? parseAgentHookJson(body) : {}) - } catch (error) { - settleReject(error) - } - } - // Why (#11217): a body cut short of its own Content-Length is the fingerprint of an IDS - // resetting the connection mid-inspection. Classify on every path that ends the request without - // 'end' — a peer RST surfaces as 'error' (ECONNRESET) and only a local destroy reaches 'close' first. - const settleUnfinished = (fallback: Error): void => { - settleReject( - classifyTruncatedHookRequest(req.headers['content-length'], byteLength) ?? fallback - ) - } - const onError = (err: Error): void => { - settleUnfinished(err) - } - // Why: req.destroy() (slowloris timer) emits 'close' but not 'end'/'error'; without this the promise never settles and buffers leak. - const onClose = (): void => { - settleUnfinished(new Error('aborted')) - } - req.on('data', onData) - req.on('end', onEnd) - req.on('error', onError) - req.on('close', onClose) - }) -} - -function ignoreSettledRequestError(): void {} - -// ─── Per-pane field caches + extractors ───────────────────────────── - -type ExtractedPromptText = { - text: string - source: - | 'prompt' - | 'user_prompt' - | 'userPrompt' - | 'initial_prompt' - | 'initialPrompt' - | 'user_message' - | 'message' - | 'role_user_text' - | null -} - -// Joins text of an Anthropic-style content-block array; returns '' when nothing textual so callers fall through to the next prompt source. -function contentBlockArrayText(value: unknown[]): string { - const parts: string[] = [] - for (const item of value) { - if (typeof item === 'string') { - parts.push(item) - continue - } - if (item && typeof item === 'object') { - const text = (item as Record).text - if (typeof text === 'string') { - parts.push(text) - } - } - } - return parts.join(' ').replace(/\s+/g, ' ').trim() -} - -function extractPromptText(hookPayload: Record): ExtractedPromptText { - const candidateKeys = [ - 'prompt', - 'user_prompt', - 'userPrompt', - 'initial_prompt', - 'initialPrompt', - 'user_message', - 'message' - ] - for (const key of candidateKeys) { - const value = hookPayload[key] - if (typeof value === 'string' && value.trim().length > 0) { - // Why: trim so prompts match readStringField output — whitespace would otherwise leak into UI and caches. - return { text: value.trim(), source: key as Exclude } - } - // Why: Kimi sends `prompt` as a content-block array, not a string; extract it for real prompt keys but skip `message` (ambiguous status field). - if (key !== 'message' && Array.isArray(value)) { - const text = contentBlockArrayText(value) - if (text.length > 0) { - return { text, source: key as Exclude } - } - } - } - // Why: OpenCode sends MessagePart { role, text } with no UserPromptSubmit; when role === 'user' the text is the prompt. - if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { - const trimmed = capOpenCodeHookText(hookPayload.text.trim()) - if (trimmed.length > 0) { - return { text: trimmed, source: 'role_user_text' } - } - } - return { text: '', source: null } -} - -function stripGrokUserQueryWrapper(promptText: string): string { - const opener = '' - if (!promptText.startsWith(opener)) { - return promptText - } - const closer = '' - const wrappedText = promptText.slice(opener.length) - const text = wrappedText.endsWith(closer) ? wrappedText.slice(0, -closer.length) : wrappedText - // Why: Grok wraps the submitted prompt in a `` envelope; the status cache should hold the plain user text. - return text.trim() -} - -// Why: the post-compact continuation prompt has no matching Stop and would resurrect working. -function shouldIgnoreCompactContinuationUserPromptSubmit( - eventName: unknown, - promptText: string -): boolean { - return eventName === 'UserPromptSubmit' && isCompactContinuationUserTurnText(promptText) -} - -function resolvePrompt( - state: HookListenerState, - paneKey: string, - promptText: string, - options?: { resetOnNewTurn?: boolean } -): string { - // Why: harness-injected turns fire UserPromptSubmit but aren't the user's ask — keep cached prompt; match only known tags so real still reset the turn. - if (isKnownHarnessInjectedUserTurnText(promptText)) { - return state.lastPromptByPaneKey.get(paneKey) ?? '' - } - if (options?.resetOnNewTurn) { - state.lastPromptByPaneKey.delete(paneKey) - } - if (promptText) { - state.lastPromptByPaneKey.set(paneKey, promptText) - return promptText - } - return state.lastPromptByPaneKey.get(paneKey) ?? '' -} - -export type ToolSnapshot = { - toolName?: string - toolInput?: string - /** Full JSON of an AskUserQuestion tool input; set only on its own event and NOT inherited (resolveToolState) so no stale prompt lingers. */ - interactivePrompt?: string - hasToolUpdate?: boolean - hasToolInputField?: boolean - lastAssistantMessage?: string - clearLastAssistantMessage?: boolean -} - -function resolveToolState( - state: HookListenerState, - paneKey: string, - update: ToolSnapshot, - options: { resetOnNewTurn: boolean } -): ToolSnapshot { - if (options.resetOnNewTurn) { - state.lastToolByPaneKey.delete(paneKey) - } - const previous = state.lastToolByPaneKey.get(paneKey) ?? {} - // Why: undefined means either "no update" or "input not previewable"; extractor metadata decides whether to inherit stale input. - const clearsUnpreviewableInput = - update.hasToolInputField === true && update.toolInput === undefined - const clearsUnidentifiedTool = - update.hasToolUpdate === true && - update.toolName === undefined && - update.hasToolInputField === true - const toolName = clearsUnidentifiedTool ? undefined : (update.toolName ?? previous.toolName) - const toolInput = - clearsUnpreviewableInput || - (update.toolName !== undefined && - update.toolName !== previous.toolName && - update.toolInput === undefined) - ? undefined - : (update.toolInput ?? previous.toolInput) - const merged: ToolSnapshot = { - toolName, - toolInput, - // Why: don't inherit previous.interactivePrompt — valid only for its one AskUserQuestion event; carrying it forward leaves a stale live card. - interactivePrompt: update.interactivePrompt, - lastAssistantMessage: update.clearLastAssistantMessage - ? undefined - : (update.lastAssistantMessage ?? previous.lastAssistantMessage) - } - state.lastToolByPaneKey.set(paneKey, merged) - return merged -} - -const TOOL_INPUT_KEYS_BY_TOOL: Record = { - Read: ['file_path', 'filePath', 'path'], - Write: ['file_path', 'filePath', 'path'], - Create: ['file_path', 'filePath', 'path'], - Edit: ['file_path', 'filePath', 'path'], - Execute: ['command'], - MultiEdit: ['file_path', 'filePath', 'path'], - NotebookEdit: ['file_path', 'filePath', 'path'], - Bash: ['command'], - Glob: ['pattern'], - Grep: ['pattern'], - WebFetch: ['url'], - WebSearch: ['query'], - FetchUrl: ['url'], - read_file: ['file_path', 'path'], - write_file: ['file_path', 'path'], - read_many_files: ['file_path', 'paths', 'path'], - edit_file: ['file_path', 'path'], - replace: ['file_path', 'path'], - run_shell_command: ['command'], - run_command: ['CommandLine', 'command', 'cmd'], - glob: ['pattern'], - search_file_content: ['pattern'], - web_fetch: ['url'], - google_web_search: ['query'], - exec_command: ['cmd', 'command'], - shell_command: ['cmd', 'command'], - run_terminal_cmd: ['command'], - // Why: Grok maps Bash/Edit/Write to snake_case tool names; without these keys the status row shows blank toolInput for most Grok turns. - run_terminal_command: ['command'], - search_replace: ['file_path', 'path', 'filePath'], - write_to_file: ['TargetFile', 'path', 'file_path'], - execute_code: ['code', 'command', 'cmd'], - apply_patch: ['path', 'file_path'], - view_image: ['path', 'file_path'], - AskUser: ['question', 'prompt', 'message'], - ask_user: ['question', 'prompt', 'message'], - AskUserQuestion: ['questions', 'question', 'prompt', 'message'], - ask_user_question: ['questions', 'question', 'prompt', 'message'], - bash: ['command'], - powershell: ['command'], - create: ['path', 'file_path'], - read: ['path', 'file_path'], - write: ['path', 'file_path'], - edit: ['path', 'file_path'], - view: ['path', 'file_path'], - grep: ['pattern'], - web_search: ['query'], - fetch_content: ['url'], - terminal: ['command'], - patch: ['path', 'file_path'], - search_files: ['query', 'pattern', 'path'], - browser_navigate: ['url'], - browser_click: ['target', 'selector', 'text'], - browser_type: ['text', 'target', 'selector'], - session_search: ['query'], - skill_manage: ['action', 'name', 'file_path'], - delegate_task: ['task', 'prompt', 'description'], - view_file: ['AbsolutePath', 'path', 'file_path'], - replace_file_content: ['TargetFile', 'path', 'file_path'], - multi_replace_file_content: ['TargetFile', 'path', 'file_path'], - list_dir: ['DirectoryPath', 'path'], - find_by_name: ['SearchDirectory', 'Pattern', 'query'], - grep_search: ['SearchPath', 'Query', 'query', 'pattern'], - search_web: ['query'], - read_url_content: ['Url', 'url'], - manage_task: ['TaskId', 'Action'], - schedule: ['Prompt', 'DurationSeconds', 'CronExpression'], - ask_question: ['question', 'questions'], - ask_permission: ['Action', 'Target', 'Reason'], - spawn_subagent: ['prompt', 'description', 'subagent_type'], - open_page: ['url'] -} - -const FALLBACK_TOOL_INPUT_KEYS = [ - 'command', - 'cmd', - 'code', - 'query', - 'pattern', - 'url', - 'path', - 'file_path', - 'filePath', - 'target', - 'selector', - 'text', - 'action', - 'name', - 'description', - 'CommandLine', - 'AbsolutePath', - 'TargetFile', - 'DirectoryPath', - 'SearchPath', - 'Query', - 'Url', - 'Prompt' -] as const - -function deriveToolInputPreview( - toolName: string | undefined, - toolInput: unknown -): string | undefined { - if (typeof toolInput === 'string') { - return toolInput - } - if (typeof toolInput !== 'object' || toolInput === null) { - return undefined - } - if (!toolName) { - return undefined - } - const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] - if (!keys) { - return undefined - } - const record = toolInput as Record - for (const key of keys) { - const value = record[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } - } - return undefined -} - -function deriveFallbackToolInputPreview(toolInput: unknown): string | undefined { - if (typeof toolInput === 'string') { - return toolInput - } - if (typeof toolInput !== 'object' || toolInput === null) { - return undefined - } - const record = toolInput as Record - for (const key of FALLBACK_TOOL_INPUT_KEYS) { - const value = record[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } - } - return undefined -} - -function readString(record: Record, key: string): string | undefined { - const value = record[key] - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -function hasOwnField(record: Record, key: string): boolean { - return Object.hasOwn(record, key) -} - -function hasAnyOwnField(record: Record, keys: readonly string[]): boolean { - return keys.some((key) => hasOwnField(record, key)) -} - -function toolUpdate( - fields: Pick, - options?: { hasToolInputField?: boolean } -): ToolSnapshot { - return { - ...fields, - hasToolUpdate: true, - hasToolInputField: options?.hasToolInputField === true - } -} - -/** Clear active-tool metadata so a failed tool stops looking in-flight (else the compact sidebar hides the error behind the tool name). */ -function clearActiveToolFieldsUpdate(): ToolSnapshot { - return toolUpdate( - { toolName: undefined, toolInput: undefined, interactivePrompt: undefined }, - { hasToolInputField: true } - ) -} - -/** Drop the hook envelope keys a plugin merges into event properties so the serialized prompt holds only the question structure. */ -function stripHookEnvelopeKeys(record: Record): Record { - const { hook_event_name: _h, hookEventName: _he, ...rest } = record - return rest -} - -/** One-line description of a tool call for an approval card (Bash command, file path, else clipped JSON). */ -function summarizeApprovalInput(toolInput: unknown): string { - if (toolInput && typeof toolInput === 'object') { - const obj = toolInput as Record - const direct = obj.command ?? obj.file_path ?? obj.path ?? obj.url ?? obj.pattern - if (typeof direct === 'string' && direct.length > 0) { - return direct.length > 200 ? `${direct.slice(0, 200)}…` : direct - } - } - try { - const json = JSON.stringify(toolInput) ?? '' - return json.length > 200 ? `${json.slice(0, 200)}…` : json - } catch { - return '' - } -} - -/** Normalized JSON envelope for a pending prompt: AskUserQuestion → `{ questions }` (shape kept stable for back-compat); other tool on PermissionRequest → `{ approval }`; else undefined. */ -function deriveInteractivePrompt( - toolName: string | undefined, - toolInput: unknown, - eventName?: unknown -): string | undefined { - // Why: providers vary casing; any post-tool event means the question is no longer pending — don't recreate its answered card. - const normalizedEventName = normalizeHookEventName(eventName) - const isPostToolEvent = - normalizedEventName === 'post_tool_use' || normalizedEventName === 'post_tool_use_failure' - if ( - isAskUserQuestionTool(toolName) && - !isPostToolEvent && - toolInput !== undefined && - toolInput !== null - ) { - try { - return JSON.stringify(toolInput) - } catch { - // Why: circular/unserializable input from a buggy agent — a missing live card beats throwing in the hook hot path. - return undefined - } - } - if (eventName === 'PermissionRequest' && typeof toolName === 'string' && toolName.length > 0) { - try { - return JSON.stringify({ - approval: { tool: toolName, summary: summarizeApprovalInput(toolInput) } - }) - } catch { - return undefined - } - } - return undefined -} - -function readFirstString( - record: Record, - keys: readonly string[] -): string | undefined { - for (const key of keys) { - const value = readString(record, key) - if (value) { - return value - } - } - return undefined -} - -function parseJsonObjectString(value: unknown): Record | undefined { - if (typeof value !== 'string' || value.trim().length === 0) { - return undefined - } - try { - const parsed = parseAgentHookJson(value) - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) - ? (parsed as Record) - : undefined - } catch { - return undefined - } -} - -function extractToolResponseText(toolResponse: unknown): string | undefined { - if (typeof toolResponse === 'string' && toolResponse.length > 0) { - return toolResponse - } - if (typeof toolResponse !== 'object' || toolResponse === null) { - return undefined - } - const record = toolResponse as Record - const directText = readFirstString(record, ['text_result_for_llm', 'textResultForLlm', 'text']) - if (directText) { - return directText - } - const content = record.content - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - return undefined -} - -const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 -const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 -const EMPTY_TRANSCRIPT_REGION = Buffer.alloc(0) -const AMP_THREAD_ID_MAX_LENGTH = 256 -const AMP_MAX_SCOPED_THREAD_CACHE_KEYS = 32 -const GROK_SESSION_CWD_MAX_LENGTH = 4096 -const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 - -function extractAssistantTextFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = parseAgentHookJson(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - if (record.type === 'assistant.message') { - const data = record.data - if (typeof data === 'object' && data !== null) { - const text = extractAssistantContentText((data as Record).content) - if (text) { - return text - } - } - } - if ( - record.source === 'MODEL' && - record.type === 'PLANNER_RESPONSE' && - typeof record.content === 'string' && - record.content.trim().length > 0 - ) { - return record.content - } - const nestedMessage = record.message as Record | undefined - const role = - record.role ?? nestedMessage?.role ?? (record.type === 'assistant' ? 'assistant' : undefined) - if (role !== 'assistant') { - return undefined - } - const content = (nestedMessage ?? record).content - return extractAssistantContentText(content) -} - -function extractAssistantContentText(content: unknown): string | undefined { - if (typeof content === 'string' && content.trim().length > 0) { - return content - } - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - return undefined -} - -function extractAntigravityUserRequest(content: string): string | undefined { - const opener = '' - const startIndex = content.indexOf(opener) - const bodyStartIndex = startIndex === -1 ? -1 : startIndex + opener.length - const endIndex = bodyStartIndex === -1 ? -1 : content.indexOf('', bodyStartIndex) - const text = - bodyStartIndex === -1 || endIndex === -1 ? content : content.slice(bodyStartIndex, endIndex) - const trimmed = text.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -function extractUserPromptTextFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = parseAgentHookJson(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - if ( - (record.source === 'USER_EXPLICIT' || record.source === 'USER') && - (record.type === 'USER_INPUT' || record.type === 'REQUEST') && - typeof record.content === 'string' - ) { - return extractAntigravityUserRequest(record.content) - } - return undefined -} - -function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - return readLastAssistantFromTranscriptOnce(transcriptPath) -} - -function readLastUserPromptFromTranscript(transcriptPath: unknown): string | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - return readLastTextFromTranscriptOnce(transcriptPath, extractUserPromptTextFromLine) -} - -function extractCommandCodeUserPromptFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = parseAgentHookJson(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - return record.role === 'user' ? extractAssistantContentText(record.content) : undefined -} - -function hashInteractionKeyPart(value: string): string { - return createHash('sha256').update(value).digest('hex').slice(0, 12) -} - -// Why byte offsets: the caller's interactionKey embeds the prompt's absolute -// position, so the backward scan has to report the same offset the old -// read-everything-then-take-the-last-match pass produced. -function findLastCommandCodePromptInRegion( - region: Buffer -): { prompt: string; byteOffset: number } | undefined { - let lineEnd = region.length - for (let index = region.length - 1; index >= -1; index--) { - if (index >= 0 && region[index] !== 0x0a) { - continue - } - const lineStart = index + 1 - if (lineEnd > lineStart) { - const prompt = extractCommandCodeUserPromptFromLine( - region.subarray(lineStart, lineEnd).toString('utf8').trim() - ) - if (prompt !== undefined) { - return { prompt, byteOffset: lineStart } - } - } - lineEnd = index - } - return undefined -} - -export function readLastCommandCodeUserPromptEntryFromTranscript( - transcriptPath: unknown -): { text: string; interactionKey: string } | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - try { - const stats = statSync(transcriptPath) - const size = stats.size - if (size <= 0) { - return undefined - } - const fd = openSync(transcriptPath, 'r') - try { - // Why scan backward: the answer is the LAST user line, so walking up from - // EOF returns on the first hit instead of parsing every line of a - // multi-megabyte transcript on every hook event. - // Why a chunk list: carry holds a partial line, and re-concatenating it per - // block made one oversized line (a big tool result) cost O(line^2). - let carryChunks: Buffer[] = [] - let bytesRead = 0 - let scanEnd = size - while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { - const chunkSize = Math.min( - scanEnd, - TRANSCRIPT_CHUNK_BYTES, - TRANSCRIPT_MAX_SCAN_BYTES - bytesRead - ) - const position = scanEnd - chunkSize - const buffer = Buffer.alloc(chunkSize) - let filled = 0 - while (filled < chunkSize) { - const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) - if (n === 0) { - break - } - filled += n - } - // Why bail on a short read: the file shrank under us, so the bytes above - // this block no longer line up and any stitched offset would be wrong. - if (filled < chunkSize) { - break - } - bytesRead += filled - scanEnd = position - // Why search only the new block: carry is always the run before a newline, - // so it holds none of its own. - const firstNewline = buffer.indexOf(0x0a) - // Why only at a true file start: a scan that stops on the size cap must - // discard its leading partial line, exactly as the capped read did. - const atStart = position === 0 - let completeRegion: Buffer - let regionPosition: number - if (atStart) { - completeRegion = - carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) - regionPosition = position - carryChunks = [] - } else if (firstNewline === -1) { - completeRegion = EMPTY_TRANSCRIPT_REGION - regionPosition = position - carryChunks.unshift(buffer) - } else { - const afterNewline = buffer.subarray(firstNewline + 1) - completeRegion = - carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) - regionPosition = position + firstNewline + 1 - carryChunks = [buffer.subarray(0, firstNewline)] - } - if (completeRegion.length > 0) { - const found = findLastCommandCodePromptInRegion(completeRegion) - if (found) { - return { - text: found.prompt, - interactionKey: [ - 'command-code-transcript', - hashInteractionKeyPart(transcriptPath), - String(regionPosition + found.byteOffset), - hashInteractionKeyPart(found.prompt) - ].join('-') - } - } - } - } - return undefined - } finally { - closeSync(fd) - } - } catch { - return undefined - } -} - -function extractCommandCodeAssistantTextFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = parseAgentHookJson(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - if (record.role !== 'assistant') { - return undefined - } - const content = record.content - if (typeof content === 'string' && content.trim().length > 0) { - return content - } - if (Array.isArray(content)) { - const textPart = content.find( - (part) => - typeof part === 'object' && - part !== null && - (part as Record).type === 'text' && - typeof (part as Record).text === 'string' && - ((part as Record).text as string).trim().length > 0 - ) as Record | undefined - if (typeof textPart?.text === 'string') { - return textPart.text - } - } - return extractAssistantContentText(content) -} - -function readLastCommandCodeAssistantFromTranscript(transcriptPath: unknown): string | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - return readLastTextFromTranscriptOnce(transcriptPath, extractCommandCodeAssistantTextFromLine) -} - -function parseHookBodyPayloadRecord(body: unknown): Record | null { - if (typeof body !== 'object' || body === null) { - return null - } - const rawPayload = (body as Record).payload - const payload = - typeof rawPayload === 'string' - ? (() => { - try { - return parseAgentHookJson(rawPayload) - } catch { - return null - } - })() - : rawPayload - return typeof payload === 'object' && payload !== null - ? (payload as Record) - : null -} - -function readBoundedString( - record: Record, - keys: readonly string[], - maxLength: number -): string | undefined { - const value = readFirstString(record, keys) - return value && value.length <= maxLength ? value : undefined -} - -function readGrokHomeEnvelope(record: Record): string | undefined { - const value = readBoundedString(record, ['grokHome'], GROK_HOME_ENVELOPE_MAX_LENGTH) - if (!value || value !== value.trim() || !isAbsolute(value) || hasControlCharacter(value)) { - return undefined - } - return value -} - -function hasControlCharacter(value: string): boolean { - return Array.from(value).some((character) => { - const code = character.charCodeAt(0) - return code <= 0x1f || code === 0x7f - }) -} - -type GrokSessionMetadata = { - sessionId: string - cwd?: string - sessionsDir: string -} - -function readGrokSessionMetadata( - hookPayload: Record, - grokHome?: string -): GrokSessionMetadata | undefined { - const sessionId = readBoundedString( - hookPayload, - ['sessionId', 'session_id'], - GROK_SESSION_ID_MAX_LENGTH - ) - if (!sessionId || !isSafeGrokSessionId(sessionId)) { - return undefined - } - const cwd = readBoundedString( - hookPayload, - ['cwd', 'workspaceRoot', 'workspace_root'], - GROK_SESSION_CWD_MAX_LENGTH - ) - // Why: hook scripts report the effective per-PTY/remote Grok home; old scripts fall back to the runtime's for compatibility. - const sessionsDir = grokHome - ? join(grokHome, 'sessions') - : resolveGrokSessionsDir(process.env, homedir()) - return { sessionId, cwd, sessionsDir } -} - -function getGrokChatHistoryPath( - hookPayload: Record, - grokHome?: string -): string | undefined { - const metadata = readGrokSessionMetadata(hookPayload, grokHome) - if (!metadata) { - return undefined - } - const resolved = resolveGrokChatHistoryPathSync({ - sessionId: metadata.sessionId, - cwd: metadata.cwd ?? null, - sessionsDir: metadata.sessionsDir - }) - if (resolved) { - return resolved - } - const cached = getCachedGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId) - if (cached) { - return cached - } - // Why: SessionEnd can race the last write; return a plausible on-disk candidate (short-cwd preferred) even if the file doesn't exist yet. - if (!metadata.cwd) { - return undefined - } - return ( - buildGrokChatHistoryPathCandidates({ - sessionId: metadata.sessionId, - cwd: metadata.cwd, - sessionsDir: metadata.sessionsDir - })[0] ?? undefined - ) -} - -function readLastAssistantFromGrokChatHistory( - hookPayload: Record, - grokHome?: string -): string | undefined { - const chatHistoryPath = getGrokChatHistoryPath(hookPayload, grokHome) - if (!chatHistoryPath) { - return undefined - } - return readLastAssistantFromTranscriptOnce(chatHistoryPath) -} - -export function hasPendingAgentResultText(source: AgentHookSource, body: unknown): boolean { - const envelope = - typeof body === 'object' && body !== null ? (body as Record) : null - const record = parseHookBodyPayloadRecord(body) - if (!record) { - return false - } - if (hasExplicitLastAssistantResult(record)) { - return false - } - if (source === 'copilot') { - // Why: Copilot Stop uses generic `message` as final assistant text; Grok/Antigravity use that field for status instead. - if (hasNonEmptyString(record.message)) { - return false - } - const transcriptPath = record.transcript_path ?? record.transcriptPath - return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 - } - const eventName = - envelope?.hook_event_name ?? - envelope?.hookEventName ?? - record.hook_event_name ?? - record.hookEventName - if (source === 'antigravity' && eventName === 'Stop') { - if (isAntigravityStopStillBusy(record)) { - return false - } - const transcriptPath = record.transcriptPath ?? record.transcript_path - return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 - } - const pendingGrokDiscovery = preparePendingGrokResultDiscovery(source, body) - if (pendingGrokDiscovery) { - void pendingGrokDiscovery - return true - } - return false -} - -function hasNonEmptyString(value: unknown): boolean { - return typeof value === 'string' && value.trim().length > 0 -} - -function hasExplicitLastAssistantResult(record: Record): boolean { - return ( - hasNonEmptyString(record.last_assistant_message) || - hasNonEmptyString(record.lastAssistantMessage) - ) -} - -/** Start bounded discovery only for a Grok completion that still needs result text. */ -export function preparePendingGrokResultDiscovery( - source: AgentHookSource, - body: unknown -): Promise | null { - if (source !== 'grok') { - return null - } - const envelope = - typeof body === 'object' && body !== null ? (body as Record) : null - const record = parseHookBodyPayloadRecord(body) - if (!record || hasExplicitLastAssistantResult(record)) { - return null - } - const eventName = - envelope?.hook_event_name ?? - envelope?.hookEventName ?? - record.hook_event_name ?? - record.hookEventName - if (!isGrokEvent(eventName, 'stop', 'session_end')) { - return null - } - const metadata = readGrokSessionMetadata( - record, - envelope ? readGrokHomeEnvelope(envelope) : undefined - ) - if (!metadata) { - return null - } - // Why: lets the server await discovery without moving filesystem I/O back into synchronous hook normalization. - return findGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId).then( - () => undefined - ) -} - -function readLastAssistantFromTranscriptOnce(transcriptPath: string): string | undefined { - return readLastTextFromTranscriptOnce(transcriptPath, extractAssistantTextFromLine) -} - -function readLastTextFromTranscriptOnce( - transcriptPath: string, - extractLineText: (line: string) => string | undefined -): string | undefined { - try { - const stats = statSync(transcriptPath) - const size = stats.size - if (size <= 0) { - return undefined - } - const fd = openSync(transcriptPath, 'r') - try { - // Why a chunk list: carry holds a partial line, and re-joining it per block - // made one oversized line (a big tool result or pasted prompt) cost O(line^2). - let carryChunks: Buffer[] = [] - let bytesRead = 0 - let scanEnd = size - while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { - const chunkSize = Math.min(scanEnd, TRANSCRIPT_CHUNK_BYTES) - const position = scanEnd - chunkSize - const buffer = Buffer.alloc(chunkSize) - let filled = 0 - while (filled < chunkSize) { - const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) - if (n === 0) { - break - } - filled += n - } - // Why bail on a short read: the file shrank under us, so the bytes above - // this block no longer line up with what the earlier ones assumed. - if (filled < chunkSize) { - break - } - bytesRead += filled - scanEnd = position - // Why search only the new block: carry is always the run before a newline, - // so it holds none of its own. - const firstNewline = buffer.indexOf(0x0a) - const atStart = position === 0 - let completeRegion: Buffer - if (atStart) { - completeRegion = - carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) - carryChunks = [] - } else if (firstNewline === -1) { - completeRegion = EMPTY_TRANSCRIPT_REGION - carryChunks.unshift(buffer) - } else { - const afterNewline = buffer.subarray(firstNewline + 1) - completeRegion = - carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) - carryChunks = [buffer.subarray(0, firstNewline)] - } - if (completeRegion.length > 0) { - const extracted = findLastExtractedTranscriptLineText( - completeRegion.toString('utf8'), - extractLineText - ) - if (extracted !== undefined) { - return extracted - } - } - } - return undefined - } finally { - closeSync(fd) - } - } catch { - return undefined - } -} - -function findLastExtractedTranscriptLineText( - text: string, - extractLineText: (line: string) => string | undefined -): string | undefined { - let lineEnd = text.length - - for (let index = text.length - 1; index >= -1; index--) { - if (index >= 0 && text.charCodeAt(index) !== 10) { - continue - } - - const line = text.slice(index + 1, lineEnd).trim() - if (line.length > 0) { - const extracted = extractLineText(line) - if (extracted !== undefined) { - return extracted - } - } - lineEnd = index - } - - return undefined -} - -function extractClaudeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - const update: ToolSnapshot = {} - if (eventName === 'PostToolUseFailure') { - Object.assign(update, clearActiveToolFieldsUpdate()) - } else if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PermissionRequest' - ) { - const toolName = readString(hookPayload, 'tool_name') - Object.assign( - update, - toolUpdate( - { - toolName, - toolInput: deriveToolInputPreview(toolName, hookPayload.tool_input), - interactivePrompt: deriveInteractivePrompt(toolName, hookPayload.tool_input, eventName) - }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) - ) - } - if (eventName === 'PostToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_response) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'PostToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_response) ?? - readString(hookPayload, 'error') ?? - readString(hookPayload, 'message') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - if (eventName === 'Stop') { - const direct = readString(hookPayload, 'last_assistant_message') - if (direct) { - update.lastAssistantMessage = direct - } else { - const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) - if (lastFromTranscript) { - update.lastAssistantMessage = lastFromTranscript - } - } - } - return update -} - -function extractCodexToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'PreToolUse' || - eventName === 'PermissionRequest' || - eventName === 'PostToolUse' - ) { - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const rawInput = hookPayload.tool_input ?? hookPayload.input ?? hookPayload.arguments - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) - return toolUpdate( - { - toolName, - toolInput, - interactivePrompt: deriveInteractivePrompt(toolName, rawInput, eventName) - }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } - ) - } - if (eventName === 'Stop') { - const message = readString(hookPayload, 'last_assistant_message') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractGeminiToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'BeforeTool' || - eventName === 'AfterTool' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ) { - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.args) ?? - deriveToolInputPreview(toolName, hookPayload.input) - return toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'args', 'input']) } - ) - } - if (eventName === 'AfterAgent') { - const message = readString(hookPayload, 'prompt_response') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function readAntigravityToolCall(hookPayload: Record): { - toolName?: string - toolInputSource?: unknown -} { - const toolCall = hookPayload.toolCall - if (typeof toolCall !== 'object' || toolCall === null) { - return {} - } - const record = toolCall as Record - return { - toolName: readFirstString(record, ['name', 'toolName', 'tool_name']), - toolInputSource: record.args - } -} - -function extractAntigravityToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { - const toolCall = readAntigravityToolCall(hookPayload) - const toolName = toolCall.toolName - const toolInput = - deriveToolInputPreview(toolName, toolCall.toolInputSource) ?? - deriveFallbackToolInputPreview(toolCall.toolInputSource) - return toolUpdate( - { toolName, toolInput }, - { hasToolInputField: toolCall.toolInputSource !== undefined } - ) - } - if (eventName === 'Stop') { - if (isAntigravityStopStillBusy(hookPayload)) { - return {} - } - const message = - readString(hookPayload, 'last_assistant_message') ?? - readLastAssistantFromTranscript(hookPayload.transcriptPath ?? hookPayload.transcript_path) - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractAmpToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'tool.call' || eventName === 'tool.result') { - const toolName = - readString(hookPayload, 'tool') ?? - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) ?? - // Why: Amp plugin tools can have arbitrary names; fall back to obvious arg fields instead of an empty tool preview. - deriveFallbackToolInputPreview(hookPayload.input) ?? - deriveFallbackToolInputPreview(hookPayload.tool_input) ?? - deriveFallbackToolInputPreview(hookPayload.arguments) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['input', 'tool_input', 'arguments']) } - ) - if (eventName === 'tool.result') { - const responseText = - readFirstString(hookPayload, ['error', 'output', 'result', 'message']) ?? - extractToolResponseText(hookPayload.output) ?? - extractToolResponseText(hookPayload.result) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - return {} -} - -/** - * Retires any cached tool fields. PermissionRequest is the only OpenCode-family event that - * carries them, and the only isNewTurnEvent boundary this family has is opencode's - * SessionStart — which a resumed session never re-emits — so nothing else resets the cache - * mid-session. Without an explicit retire, resolveToolState inherits one answered permission - * onto every later frame in the pane and the row reads a resolved command as the live tool. - */ -const OPENCODE_TOOL_FIELDS_RETIRED: ToolSnapshot = { - hasToolUpdate: true, - hasToolInputField: true -} - -function extractOpenCodeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { - const text = readString(hookPayload, 'text') - if (text) { - return { ...OPENCODE_TOOL_FIELDS_RETIRED, lastAssistantMessage: capOpenCodeHookText(text) } - } - } - if (eventName === 'AskUserQuestion') { - // Why: OpenCode's payload is question.asked's event.properties (hook_event_name merged in); strip envelope or use tool_input, capture JSON for the card. - const toolInputSource = hasOwnField(hookPayload, 'tool_input') - ? hookPayload.tool_input - : stripHookEnvelopeKeys(hookPayload) - return { - ...OPENCODE_TOOL_FIELDS_RETIRED, - interactivePrompt: deriveInteractivePrompt('AskUserQuestion', toolInputSource) - } - } - if (eventName === 'PermissionRequest') { - // Why: the payload is permission.asked's event.properties — `permission` names what is - // being requested and `metadata`/`patterns` say which command or path it covers. Without - // them the row is a bare 'waiting' that cannot tell the user what to approve. - // The SDK types `metadata` as Record, so these keys come from the tools - // themselves: bash sends `command`, edit sends `filepath`, webfetch sends `url` (verified - // against opencode 1.18.18). `file_path`/`path` cover tools that spell it the common way. - // `diff` is deliberately absent — edit ships the whole patch and it would swamp the row. - const metadata = hookPayload.metadata - const metadataInput = - metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata) - ? readFirstString(metadata as Record, [ - 'command', - 'filepath', - 'file_path', - 'path', - 'url' - ]) - : undefined - const patterns = Array.isArray(hookPayload.patterns) - ? hookPayload.patterns.filter( - (pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0 - ) - : [] - return toolUpdate( - { - toolName: readString(hookPayload, 'permission'), - toolInput: metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined) - }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['metadata', 'patterns']) } - ) - } - return OPENCODE_TOOL_FIELDS_RETIRED -} - -function extractCursorToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' - ) { - const update: ToolSnapshot = {} - if (eventName === 'postToolUseFailure') { - Object.assign(update, clearActiveToolFieldsUpdate()) - } else { - const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - Object.assign( - update, - toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) - ) - } - if (eventName === 'postToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'postToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_output) ?? - readString(hookPayload, 'error_message') ?? - readString(hookPayload, 'error') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - return update - } - if (eventName === 'beforeShellExecution') { - const command = readString(hookPayload, 'command') - return toolUpdate( - { toolName: 'Shell', toolInput: command }, - { hasToolInputField: hasOwnField(hookPayload, 'command') } - ) - } - if (eventName === 'beforeMCPExecution') { - const toolName = readString(hookPayload, 'tool_name') ?? 'MCP' - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - readString(hookPayload, 'command') ?? - readString(hookPayload, 'url') - return toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'command', 'url']) } - ) - } - if (eventName === 'afterAgentResponse') { - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -function normalizeCopilotEventName(eventName: unknown): unknown { - if (typeof eventName !== 'string') { - return eventName - } - const eventMap: Record = { - sessionStart: 'SessionStart', - sessionEnd: 'SessionEnd', - userPromptSubmitted: 'UserPromptSubmit', - userPromptSubmit: 'UserPromptSubmit', - preToolUse: 'PreToolUse', - postToolUse: 'PostToolUse', - postToolUseFailure: 'PostToolUseFailure', - subagentStart: 'SubagentStart', - subagentStop: 'SubagentStop', - preCompact: 'PreCompact', - agentStop: 'Stop', - stop: 'Stop', - errorOccurred: 'ErrorOccurred', - permissionRequest: 'PermissionRequest', - notification: 'Notification' - } - return eventMap[eventName] ?? eventName -} - -function resolveCopilotEventName( - eventName: unknown, - hookPayload: Record -): unknown { - const explicit = - eventName ?? - readFirstString(hookPayload, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) - if (explicit) { - return explicit - } - if (readFirstString(hookPayload, ['initial_prompt', 'initialPrompt'])) { - return 'SessionStart' - } - if (readString(hookPayload, 'prompt')) { - return 'UserPromptSubmit' - } - if (readFirstString(hookPayload, ['notification_type', 'notificationType'])) { - return 'Notification' - } - if ( - readFirstString(hookPayload, ['transcript_path', 'transcriptPath', 'stop_reason', 'stopReason']) - ) { - return 'Stop' - } - if (hookPayload.error || readFirstString(hookPayload, ['error_context', 'errorContext'])) { - return 'ErrorOccurred' - } - if ( - Array.isArray(hookPayload.toolCalls) || - readFirstString(hookPayload, ['tool_name', 'toolName', 'name']) - ) { - if ( - hookPayload.tool_result || - hookPayload.toolResult || - hookPayload.tool_response || - hookPayload.toolResponse - ) { - return 'PostToolUse' - } - return 'PreToolUse' - } - return eventName -} - -function readCopilotToolCall(hookPayload: Record): { - toolName?: string - toolInputSource?: unknown -} { - const toolCalls = hookPayload.toolCalls - if (!Array.isArray(toolCalls) || toolCalls.length === 0) { - return {} - } - const first = toolCalls[0] - if (typeof first !== 'object' || first === null) { - return {} - } - const record = first as Record - return { - toolName: readFirstString(record, ['name', 'toolName', 'tool_name']), - toolInputSource: - parseJsonObjectString(record.args) ?? - record.args ?? - parseJsonObjectString(record.arguments) ?? - record.arguments - } -} - -function isAskUserTool(toolName: string | undefined): boolean { - return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuser' -} - -function extractCopilotToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - const update: ToolSnapshot = {} - if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { - Object.assign(update, clearActiveToolFieldsUpdate()) - } else if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PermissionRequest' - ) { - const copilotToolCall = readCopilotToolCall(hookPayload) - const toolName = - readFirstString(hookPayload, ['tool_name', 'toolName', 'name']) ?? copilotToolCall.toolName - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.toolInput) ?? - deriveToolInputPreview(toolName, hookPayload.toolArgs) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) ?? - deriveToolInputPreview(toolName, copilotToolCall.toolInputSource) - Object.assign( - update, - toolUpdate( - { toolName, toolInput }, - { - hasToolInputField: - hasAnyOwnField(hookPayload, [ - 'tool_input', - 'toolInput', - 'toolArgs', - 'input', - 'arguments' - ]) || copilotToolCall.toolInputSource !== undefined - } - ) - ) - if (isAskUserTool(toolName) && toolInput) { - update.lastAssistantMessage = toolInput - } - } - if (eventName === 'PostToolUse') { - const responseText = - extractToolResponseText(hookPayload.tool_result) ?? - extractToolResponseText(hookPayload.toolResult) ?? - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.toolResponse) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { - const errorText = - extractToolResponseText(hookPayload.tool_result) ?? - extractToolResponseText(hookPayload.toolResult) ?? - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.toolResponse) ?? - readFirstString(hookPayload, ['error_message', 'errorMessage', 'error', 'message']) - if (errorText) { - update.lastAssistantMessage = errorText - } - } - if (eventName === 'Notification') { - const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType']) - if (notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog') { - const message = readFirstString(hookPayload, ['message', 'body', 'text', 'title']) - if (message) { - update.lastAssistantMessage = message - } - } - } - if (eventName === 'Stop') { - const direct = readFirstString(hookPayload, [ - 'last_assistant_message', - 'lastAssistantMessage', - 'message' - ]) - if (direct) { - update.lastAssistantMessage = direct - } else { - const lastFromTranscript = readLastAssistantFromTranscript( - hookPayload.transcript_path ?? hookPayload.transcriptPath - ) - if (lastFromTranscript) { - update.lastAssistantMessage = lastFromTranscript - } else { - update.clearLastAssistantMessage = true - } - } - } - return update -} - -function extractPiToolFields( - eventName: unknown, - hookPayload: Record, - agentKind: 'pi' | 'omp' | 'prime-agent' -): ToolSnapshot { - if ( - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' - ) { - const toolName = readString(hookPayload, 'tool_name') - const rawToolInput = hookPayload.tool_input - const toolInput = deriveToolInputPreview(toolName, rawToolInput) - // Why: OMP shares this extractor; only derive interactivePrompt for Pi so OMP ask_user_question metadata stays unchanged. - const interactivePrompt = - agentKind === 'pi' && (eventName === 'tool_call' || eventName === 'tool_execution_start') - ? deriveInteractivePrompt(toolName, rawToolInput, eventName) - : undefined - return toolUpdate( - { toolName, toolInput, interactivePrompt }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) - } - if (eventName === 'message_end' && hookPayload.role === 'assistant') { - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -function isDroidPermissionNotification(message: string | undefined): boolean { - if (!message) { - return false - } - const lower = message.toLowerCase() - // Why: 'confirm' is excluded — it false-positives on benign messages like "task confirmed" that aren't permission prompts. - return lower.includes('permission') || lower.includes('approve') || lower.includes('approval') -} - -function isDroidIdleNotification(message: string | undefined): boolean { - if (!message) { - return false - } - const lower = message.toLowerCase() - return lower.includes('waiting for your input') || lower.includes('waiting for input') -} - -function isDroidAskUserTool(toolName: string | undefined): boolean { - if (!toolName) { - return false - } - return toolName.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuser' -} - -function readDroidToolRiskLevel(hookPayload: Record): string | undefined { - const directRisk = readString(hookPayload, 'riskLevel') ?? readString(hookPayload, 'risk_level') - if (directRisk) { - return directRisk - } - - for (const key of ['tool_input', 'input', 'arguments'] as const) { - const value = hookPayload[key] - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - continue - } - const record = value as Record - const nestedRisk = readString(record, 'riskLevel') ?? readString(record, 'risk_level') - if (nestedRisk) { - return nestedRisk - } - } - return undefined -} - -function isDroidHighRiskToolUse(hookPayload: Record): boolean { - return readDroidToolRiskLevel(hookPayload)?.trim().toLowerCase() === 'high' -} - -function extractDroidToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PermissionRequest' - ) { - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } - ) - if (eventName === 'PostToolUse') { - const responseText = - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.tool_output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - if (eventName === 'Stop') { - const direct = readString(hookPayload, 'last_assistant_message') - if (direct) { - return { lastAssistantMessage: direct } - } - const fromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) - if (fromTranscript) { - return { lastAssistantMessage: fromTranscript } - } - } - return {} -} - -function extractCommandCodeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { - const toolName = - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'tool_display_name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveFallbackToolInputPreview(hookPayload.tool_input) - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } - ) - if (eventName === 'PostToolUse') { - const responseText = - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.tool_output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - if (eventName === 'Stop') { - const direct = readString(hookPayload, 'last_assistant_message') - if (direct) { - return { lastAssistantMessage: direct } - } - const fromTranscript = readLastCommandCodeAssistantFromTranscript( - hookPayload.transcript_path ?? hookPayload.transcriptPath - ) - if (fromTranscript) { - return { lastAssistantMessage: fromTranscript } - } - } - return {} -} - -function normalizeHookEventName(value: unknown): string { - if (typeof value !== 'string') { - return '' - } - return value - .trim() - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .replace(/[-\s]+/g, '_') - .toLowerCase() -} - -function isGrokEvent(eventName: unknown, ...expected: readonly string[]): boolean { - const normalized = normalizeHookEventName(eventName) - return expected.includes(normalized) -} - -function extractGrokToolFields( - eventName: unknown, - hookPayload: Record, - grokHome?: string -): ToolSnapshot { - if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) { - const update: ToolSnapshot = {} - if (isGrokEvent(eventName, 'post_tool_use_failure')) { - Object.assign(update, clearActiveToolFieldsUpdate()) - } else { - const toolName = - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'name') - const rawInput = - hookPayload.toolInput ?? - hookPayload.tool_input ?? - hookPayload.input ?? - hookPayload.arguments - const toolInput = - deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) - // Why: Grok's ask_user_question is auto-allowed via PreToolUse, not PermissionRequest; capture full payload for the live card. - const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) - Object.assign( - update, - toolUpdate( - { toolName, toolInput, interactivePrompt }, - { - hasToolInputField: hasAnyOwnField(hookPayload, [ - 'toolInput', - 'tool_input', - 'input', - 'arguments' - ]) - } - ) - ) - } - if (isGrokEvent(eventName, 'post_tool_use', 'post_tool_use_failure')) { - const responseText = - extractToolResponseText(hookPayload.toolResponse) ?? - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.toolOutput) ?? - extractToolResponseText(hookPayload.tool_output) ?? - readString(hookPayload, 'error') ?? - readString(hookPayload, 'message') - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { - const direct = - readString(hookPayload, 'lastAssistantMessage') ?? - readString(hookPayload, 'last_assistant_message') - if (direct) { - return { lastAssistantMessage: direct } - } - const fromTranscript = readLastAssistantFromTranscript( - hookPayload.transcriptPath ?? hookPayload.transcript_path - ) - if (fromTranscript) { - return { lastAssistantMessage: fromTranscript } - } - const fromChatHistory = readLastAssistantFromGrokChatHistory(hookPayload, grokHome) - if (fromChatHistory) { - return { lastAssistantMessage: fromChatHistory } - } - } - return {} -} - -function extractHermesToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'pre_tool_call' || - eventName === 'post_tool_call' || - eventName === 'pre_approval_request' || - eventName === 'post_approval_response' - ) { - const toolName = - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'name') ?? - (eventName === 'pre_approval_request' || eventName === 'post_approval_response' - ? 'approval' - : undefined) - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.args) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - // Why: Hermes has many tool names; fall back to obvious arg fields so a new name still shows a value, not a blank row. - deriveFallbackToolInputPreview(hookPayload.tool_input) ?? - deriveFallbackToolInputPreview(hookPayload.args) ?? - deriveFallbackToolInputPreview(hookPayload.input) ?? - readString(hookPayload, 'command') ?? - readString(hookPayload, 'description') - const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, - { - hasToolInputField: hasAnyOwnField(hookPayload, [ - 'tool_input', - 'args', - 'input', - 'command', - 'description' - ]) - } - ) - if (eventName === 'post_tool_call') { - const responseText = - extractToolResponseText(hookPayload.result) ?? - extractToolResponseText(hookPayload.tool_response) ?? - extractToolResponseText(hookPayload.output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - return update - } - if (eventName === 'post_llm_call') { - const message = - readString(hookPayload, 'last_assistant_message') ?? - readString(hookPayload, 'assistant_response') ?? - readString(hookPayload, 'response_text') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function isGrokPermissionNotification(message: string | undefined): boolean { - if (!message) { - return false - } - const lower = message.toLowerCase() - return ( - lower.includes('permission') || - lower.includes('approval') || - lower.includes('approve') || - lower.includes('allow') || - lower.includes('confirm') || - lower.includes('needs your') || - lower.includes('requires your') || - lower.includes('feedback') || - lower.includes('clarify') || - lower.includes('question') - ) -} - -function getGrokNotificationType(hookPayload: Record): string | undefined { - return ( - readString(hookPayload, 'notificationType') ?? - readString(hookPayload, 'notification_type') ?? - readString(hookPayload, 'type') - ) -} - -function isGrokRoutinePermissionPromptNotification( - notificationType: string | undefined, - message: string | undefined, - level: string | undefined -): boolean { - // Why: Grok emits this before each tool even under bypassPermissions; PreToolUse already covers progress. - return ( - isGrokEvent(notificationType, 'permission_prompt') && - message?.trim().toLowerCase() === 'tool permission requested' && - (!level || level.trim().toLowerCase() === 'info') - ) -} - -function isGrokIdleNotification(message: string | undefined): boolean { - if (!message) { - return false - } - const lower = message.toLowerCase() - return ( - lower.includes('type your message') || - lower.includes('enter send') || - lower.includes('shift-tab normal') || - lower.includes('ask a side question') - ) -} - -/** The per-provider answer to "is this event a user-initiated new turn?". Exported so the - * observation stamp reuses it instead of minting a second list of event-name literals. */ -export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { - // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. - switch (source) { - case 'claude': - // Why: SessionStart lands an idle row (STA-3386) and must also drop stale - // tool/prompt caches left by the pane's previous session. - return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' - case 'kimi': - // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. - return eventName === 'UserPromptSubmit' - case 'codex': - return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' - case 'gemini': - return eventName === 'BeforeAgent' - case 'antigravity': - return eventName === 'PreInvocation' - case 'amp': - return eventName === 'agent.start' - case 'opencode': - return eventName === 'SessionStart' - case 'mimo-code': - return false - case 'cursor': - return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' - case 'pi': - case 'omp': - case 'prime-agent': - return eventName === 'before_agent_start' - case 'droid': - return eventName === 'UserPromptSubmit' - case 'command-code': - return false - case 'grok': - return isGrokEvent(eventName, 'user_prompt_submit') - case 'copilot': { - const normalizedEventName = normalizeCopilotEventName(eventName) - return normalizedEventName === 'SessionStart' || normalizedEventName === 'UserPromptSubmit' - } - case 'hermes': - return eventName === 'pre_llm_call' || eventName === 'on_session_start' - case 'devin': - // Why: SessionStart is handled by an early return in normalizeDevinEvent, so UserPromptSubmit is Devin's real new-turn boundary here. - return eventName === 'UserPromptSubmit' - } -} - -function hasExplicitUserPrompt( - source: AgentHookSource, - eventName: unknown, - extractedPrompt: ExtractedPromptText, - resolvedPromptText: string, - hasTranscriptPromptEvidence = false -): boolean { - if ( - source === 'command-code' && - (eventName === 'PreToolUse' || eventName === 'Stop') && - (extractedPrompt.source !== 'message' || hasTranscriptPromptEvidence) && - resolvedPromptText.trim().length > 0 - ) { - // Why: Command Code exposes the submitted prompt via its transcript, not direct hook fields; treat the transcript-backed prompt as explicit so telemetry covers real turns. - return true - } - if ( - source === 'antigravity' && - isNewTurnEvent(source, eventName) && - resolvedPromptText.trim().length > 0 - ) { - return true - } - if (extractedPrompt.source === 'role_user_text') { - return (source === 'opencode' || source === 'mimo-code') && eventName === 'MessagePart' - } - if (extractedPrompt.text.length === 0) { - return false - } - // Why: harness-injected turns aren't a user submit (no prompt-sent telemetry or permission stickiness); match only KNOWN tags so a real `` prompt still counts and survives interrupt recovery. - if (isKnownHarnessInjectedUserTurnText(extractedPrompt.text)) { - return false - } - // Why: bare `message` fields often carry permission/status copy — may update visible status prompts but aren't proof of a user submit. - if (extractedPrompt.source === 'message') { - return false - } - if ( - extractedPrompt.source === 'user_prompt' || - extractedPrompt.source === 'userPrompt' || - extractedPrompt.source === 'user_message' - ) { - return isNewTurnEvent(source, eventName) - } - return isNewTurnEvent(source, eventName) -} - -function extractToolFields( - source: AgentHookSource, - eventName: unknown, - hookPayload: Record, - options?: { grokHome?: string } -): ToolSnapshot { - // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor. - switch (source) { - case 'claude': - // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. - // falls through - case 'kimi': - return extractClaudeToolFields(eventName, hookPayload) - case 'codex': - return extractCodexToolFields(eventName, hookPayload) - case 'gemini': - return extractGeminiToolFields(eventName, hookPayload) - case 'antigravity': - return extractAntigravityToolFields(eventName, hookPayload) - case 'amp': - return extractAmpToolFields(eventName, hookPayload) - case 'opencode': - case 'mimo-code': - return extractOpenCodeToolFields(eventName, hookPayload) - case 'cursor': - return extractCursorToolFields(eventName, hookPayload) - case 'pi': - case 'omp': - case 'prime-agent': - return extractPiToolFields(eventName, hookPayload, source) - case 'droid': - return extractDroidToolFields(eventName, hookPayload) - case 'command-code': - return extractCommandCodeToolFields(eventName, hookPayload) - case 'grok': - return extractGrokToolFields(eventName, hookPayload, options?.grokHome) - case 'copilot': - return extractCopilotToolFields(normalizeCopilotEventName(eventName), hookPayload) - case 'hermes': - return extractHermesToolFields(eventName, hookPayload) - case 'devin': - return extractClaudeToolFields(eventName, hookPayload) - } -} - -function getOrCreateClaudeSubagentRoster( - state: HookListenerState, - paneKey: string -): ClaudeSubagentRoster { - let roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if (!roster) { - roster = new Map() - state.claudeSubagentRosterByPaneKey.set(paneKey, roster) - } - return roster -} - -function updateClaudeRunningNonAgentTask( - state: HookListenerState, - paneKey: string, - hasRunningNonAgentTask: boolean, - /** Lead-turn property. Pass `false` from any non-lead fold: an interrupt clears the gate even when - * the inventory positively reports a running shell, which is a live-shell judgement no new call - * site may inherit by copying this signature. */ - interrupted: boolean -): void { - if (hasRunningNonAgentTask && !interrupted) { - state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } -} - -type ClaudePaneStatusResolution = { - stateName: AgentStatusState - workingMode?: AgentWorkingMode -} - -function resolveClaudePaneStatus( - state: HookListenerState, - paneKey: string, - lead: Pick -): ClaudePaneStatusResolution { - if (lead.state !== 'done') { - return { stateName: lead.state } - } - const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if (claudeRosterHasWorkingSubagent(roster)) { - return { stateName: 'working' } - } - if ( - !lead.interrupted && - (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey)) - ) { - return { stateName: 'working', workingMode: 'monitoring' } - } - return { stateName: 'done' } -} - -/** SubagentStart/Stop/TeammateIdle update the roster and re-emit the lead's last known state with the fresh child list, so the sidebar reflects spawn/finish even when a background child outlives the lead turn with no other hook traffic. */ -function normalizeClaudeSubagentLifecycleEvent( - state: HookListenerState, - eventName: 'SubagentStart' | 'SubagentStop' | 'TeammateIdle', - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const lifecycleField = eventName === 'TeammateIdle' ? 'teammate_name' : 'agent_id' - const lifecycleId = readString(hookPayload, lifecycleField) - if (!lifecycleId) { - return null - } - const cachedLead = state.claudeLeadStateByPaneKey.get(paneKey) - const ownsUnbackedWait = - cachedLead?.state === 'waiting' && - cachedLead.stateBeforeWait === undefined && - cachedLead.waitingAgentId !== undefined && - (eventName === 'TeammateIdle' - ? claudeTeammateIdMatchesName(cachedLead.waitingAgentId, lifecycleId) - : cachedLead.waitingAgentId === lifecycleId) - const hasCachedLeadEvidence = cachedLead !== undefined && !ownsUnbackedWait - let roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - let endedChildWork = false - let endedRuntimeChildWork = false - if (eventName === 'TeammateIdle') { - const teammateName = lifecycleId - // Why: on claude 2.1.21x teammates are turn-based — TeammateIdle means "turn over, awaiting mail", not finished. The row parks as idle (confirmed teammate) instead of leaving, so the sidebar keeps showing resumable children. - if (roster) { - let wasWorking = false - for (const [id, tracked] of roster) { - if (tracked.state === 'working' && claudeTeammateIdMatchesName(id, teammateName)) { - wasWorking = true - endedRuntimeChildWork ||= tracked.restoredFromSnapshot !== true - } - } - idleClaudeTeammateByName(roster, teammateName) - endedChildWork = wasWorking - } - clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => - claudeTeammateIdMatchesName(waitingAgentId, teammateName) - ) - } else { - const agentId = lifecycleId - if (eventName === 'SubagentStart') { - roster = getOrCreateClaudeSubagentRoster(state, paneKey) - upsertWorkingClaudeSubagent( - roster, - agentId, - { agentType: readString(hookPayload, 'agent_type') }, - Date.now() - ) - } else { - if (roster) { - const tracked = roster.get(agentId) - const wasWorking = tracked?.state === 'working' - endedRuntimeChildWork = wasWorking && tracked.restoredFromSnapshot !== true - // Why: one-shot stops are true finishes (row removed); teammate-shaped stops are turn ends on 2.1.21x — the row parks idle and a later SubagentStart revives it. - stopClaudeSubagent(roster, agentId) - endedChildWork = wasWorking && roster.get(agentId)?.state !== 'working' - } - // Why: a blocked child that dies without another tool event would pin its permission/question wait on the pane forever — nothing else references that agent again. - clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) - } - } - const workingChildEvidence = claudeRosterHasRuntimeWorkingSubagent(roster) - const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) - const hasConfirmedDoneGate = - cachedLead?.state === 'done' && - cachedLead.interrupted !== true && - (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey)) - const restoredOnlyDoneGate = - cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild - if (roster?.size === 0) { - state.claudeSubagentRosterByPaneKey.delete(paneKey) - } - if ( - !workingChildEvidence && - (restoredOnlyDoneGate || - (endedChildWork && !hasCachedLeadEvidence && (hasUnconfirmedChild || !endedRuntimeChildWork))) - ) { - // Why: a restored-only ending proves no lead boundary, and an unmatched restored sibling proves no current liveness; persist the roster transition without publishing fresh work or completion. - state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey) - } - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { - workingChildEvidence, - endedChildWork, - endedRuntimeChildWork - }) -} - -/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ -export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { - state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true }) - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) -} - -/** Rebuild a pane's working roster from a persisted snapshot; live activity confirms a seed, a complete task inventory may reap an unconfirmed one whose finish hook arrived while Orca was offline. */ -export function seedClaudeSubagentRosterFromSnapshots( - state: HookListenerState, - paneKey: string, - snapshots: readonly AgentSubagentSnapshot[] -): void { - if (snapshots.length === 0 || state.claudeSubagentRosterByPaneKey.has(paneKey)) { - return - } - const roster = getOrCreateClaudeSubagentRoster(state, paneKey) - for (const snapshot of snapshots) { - // Why: idle-teammate liveness can't be proven across a restart (its TeammateIdle confirmation is gone); only working seeds restore, and a live teammate re-earns its row via SubagentStart. - if (snapshot.state !== 'working') { - continue - } - roster.set(snapshot.id, { - state: 'working', - startedAt: snapshot.startedAt, - agentType: snapshot.agentType, - description: snapshot.description, - // Why: the seed can be a phantom (child finished while Orca was down, SubagentStop lost); let a PRESENT background_tasks list omitting the id remove it, not gate the pane 'working' forever. - backgroundTasksAuthoritative: true, - // Why: an idle parent never emits that list, so the inventory reap alone can strand the seed; mark it for the liveness reap below. - restoredFromSnapshot: true - }) - } -} - -export function seedClaudeLeadTurnFromPersistedStatus( - state: HookListenerState, - paneKey: string, - status: Pick, - options: { childOnlyBoundary: boolean } -): void { - if (options.childOnlyBoundary && status.payload.agentType === 'claude') { - state.claudeLeadStateByPaneKey.set(paneKey, { - state: 'done', - ...(status.payload.interrupted === true ? { interrupted: true } : {}), - ...(status.payload.turnCompletedAt !== undefined - ? { turnCompletedAt: status.payload.turnCompletedAt } - : {}) - }) - if (status.payload.prompt) { - state.lastPromptByPaneKey.set(paneKey, status.payload.prompt) - } - if (status.payload.lastAssistantMessage) { - state.lastToolByPaneKey.set(paneKey, { - lastAssistantMessage: status.payload.lastAssistantMessage - }) - } - } -} - -/** Reap this pane's unconfirmed restored seeds because no live agent process backs - * the pane any more (its PTY died while Orca was down, so no finish hook could - * arrive). Callers must have proven the pane is LOCAL-launched — a remote/SSH - * agent runs on the far host and can never appear in a local process index. - * Returns whether the roster changed. */ -export function reapRestoredClaudeSubagentsForDeadPane( - state: HookListenerState, - paneKey: string -): boolean { - const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if (!roster || !reapUnconfirmedRestoredClaudeSubagents(roster)) { - return false - } - if (roster.size === 0) { - state.claudeSubagentRosterByPaneKey.delete(paneKey) - } - return true -} - -/** Drop a child-owned waiting state when the child stops/idles, restoring the displaced lead state. */ -function clearClaudePendingWaitForAgent( - state: HookListenerState, - paneKey: string, - ownsWait: (waitingAgentId: string) => boolean -): void { - const lead = state.claudeLeadStateByPaneKey.get(paneKey) - if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) { - return - } - state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' }) - const previousTool = state.lastToolByPaneKey.get(paneKey) - state.lastToolByPaneKey.set( - paneKey, - previousTool?.lastAssistantMessage - ? { lastAssistantMessage: previousTool.lastAssistantMessage } - : {} - ) -} - -/** Clear an AskUserQuestion wait after the answer is typed (answering emits no hook event; the caller infers it from the submit keystroke). Restores the stashed pre-wait lead state or 'working', drops the cached card, and returns the pane state to emit (gated up to 'working' while children run). */ -export function clearClaudeAnsweredQuestionWait( - state: HookListenerState, - paneKey: string -): Pick & { - workingMode?: AgentWorkingMode -} { - const lead = state.claudeLeadStateByPaneKey.get(paneKey) - const restored = - lead?.state === 'waiting' - ? (lead.stateBeforeWait ?? { state: 'working' as const }) - : { state: 'working' as const } - state.claudeLeadStateByPaneKey.set(paneKey, { ...restored }) - const previousTool = state.lastToolByPaneKey.get(paneKey) - state.lastToolByPaneKey.set( - paneKey, - previousTool?.lastAssistantMessage - ? { lastAssistantMessage: previousTool.lastAssistantMessage } - : {} - ) - const resolved = resolveClaudePaneStatus(state, paneKey, restored) - return resolved.stateName === restored.state && resolved.workingMode === undefined - ? restored - : { - state: resolved.stateName, - ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), - ...(restored.interrupted ? { interrupted: true as const } : {}), - ...(restored.turnCompletedAt !== undefined - ? { turnCompletedAt: restored.turnCompletedAt } - : {}) - } -} - -/** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ -function buildClaudeCachedLeadStatusPayload( - state: HookListenerState, - eventName: unknown, - paneKey: string, - hookPayload: Record, - evidence: { - workingChildEvidence?: boolean - endedChildWork?: boolean - endedRuntimeChildWork?: boolean - } = {} -): ParsedAgentStatusPayload | null { - const lead = state.claudeLeadStateByPaneKey.get(paneKey) - let leadState = lead?.state - if (!leadState) { - if (evidence.workingChildEvidence || evidence.endedRuntimeChildWork) { - // Why: ending a current-runtime child wakes its parent; only a cached lead boundary can prove the whole pane completed. - leadState = 'working' - } else if (evidence.endedChildWork) { - // Why: a restored child ending proves only that child ended; persist its roster transition as unconfirmed working, never as lead completion. - leadState = 'working' - } else { - return null - } - } - return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { - ...resolveClaudePaneStatus(state, paneKey, { - state: leadState, - interrupted: lead?.interrupted - }), - updateToolSnapshot: false, - interrupted: lead?.interrupted, - // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. - turnCompletedAt: lead?.turnCompletedAt - }) -} - -/** Lead events that may re-anchor a pane's owning session. Allow-list, not a deny-list: a payload we - * can't attribute (unknown name, child event missing its agent_id) must void nothing. */ -const CLAUDE_SESSION_OWNER_EVENTS: ReadonlySet = new Set([ - 'SessionStart', - 'UserPromptSubmit', - 'Stop', - 'StopFailure', - 'PreToolUse', - 'PostToolUse', - 'PostToolUseFailure', - 'PermissionRequest' -]) - -/** A pane whose `session_id` changed is running a different conversation, so claims the previous one - * owned are void — the hook-independent backstop for /clear, relaunch and resume, which emit no - * terminating hook (SessionEnd covers about a third of exit paths). - * - * Voids only what the replaced session provably owned. Deliberately NOT voided: - * - `claudeRunningNonAgentTaskPaneKeys`: a background shell is an OS process that survives /clear, - * and the previous inventory is positive evidence it was running. Only a fresh inventory or a - * certified process death may retire it. - * - `confirmedTeammate` roster rows: persistent in-process teammates a lead replacement can't end. - * - `claudeLeadStateByPaneKey`: the caller's own fold overwrites it anyway. */ -function voidClaimsOfReplacedClaudeSession( - state: HookListenerState, - eventName: unknown, - eventAgentId: string | undefined, - paneKey: string, - hookPayload: Record -): void { - if ( - eventAgentId !== undefined || - typeof eventName !== 'string' || - !CLAUDE_SESSION_OWNER_EVENTS.has(eventName) - ) { - return - } - // Compact/unknown SessionStart events are intentionally ignored by the status fold; do not let - // them advance the owner anchor or the next real lead event will miss the replacement. - if ( - eventName === 'SessionStart' && - hookPayload['source'] !== 'startup' && - hookPayload['source'] !== 'resume' && - hookPayload['source'] !== 'clear' - ) { - return - } - const sessionId = readString(hookPayload, 'session_id') - if (!sessionId) { - return - } - const previousOwner = state.claudeSessionOwnerByPaneKey.get(paneKey) - state.claudeSessionOwnerByPaneKey.set(paneKey, sessionId) - if (previousOwner === undefined || previousOwner === sessionId) { - return - } - // Why: a compact restart mints a SessionStart mid-turn under the same conversation; the existing - // handler already fails closed on non-idle sources, and this must not undercut it. No other - // allow-listed event carries a compact `trigger`, so SessionStart is the whole guard — if a - // compact event is ever added to CLAUDE_SESSION_OWNER_EVENTS, re-derive one for it deliberately. - if (eventName === 'SessionStart') { - return - } - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if (!roster) { - return - } - for (const [id, tracked] of roster) { - if (tracked.confirmedTeammate !== true) { - roster.delete(id) - } - } - if (roster.size === 0) { - state.claudeSubagentRosterByPaneKey.delete(paneKey) - } -} - -function normalizeClaudeEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const eventAgentId = readString(hookPayload, 'agent_id') - if ( - eventName === 'SubagentStart' || - eventName === 'SubagentStop' || - eventName === 'TeammateIdle' - ) { - return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) - } - voidClaimsOfReplacedClaudeSession(state, eventName, eventAgentId, paneKey, hookPayload) - if (eventName === 'SessionStart') { - // Why: SessionStart is the only signal a resumed session emits before its first prompt - // (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom - // spinner on an idle TUI (why Devin/Pi/Grok drop the event), and the sessionBoundary - // flag keeps completion-reactive consumers (notifications, automation runs) out of it. - const sessionStartSource = hookPayload['source'] - if ( - eventAgentId !== undefined || - (sessionStartSource !== 'startup' && - sessionStartSource !== 'resume' && - sessionStartSource !== 'clear') - ) { - // Why: allowlist idle boundaries and fail closed — a compact restart (or any unknown - // source) fires mid-turn, and a child-attributed SessionStart must not flip the lead's - // live turn to an idle row. - return null - } - // Why: a new process owns the pane; stale children/tasks/crons must not gate the - // fresh session's idle row back up to 'working' (same reset Codex does on SessionStart). - state.claudeSubagentRosterByPaneKey.delete(paneKey) - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) - return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - stateName: 'done', - updateToolSnapshot: true, - sessionBoundary: true - }) - } - const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) - // Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it. - const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure' - const interrupted = - isTurnBoundary && - ((eventAgentId === undefined && hookPayload['is_interrupt'] === true) || - previousLead?.interrupted === true) - ? true - : undefined - const backgroundTasks = readClaudeBackgroundAgentTasks(hookPayload) - const sessionCrons = hookPayload['session_crons'] - const sessionCronInventoryPresent = Array.isArray(sessionCrons) - const hasActiveSessionCron = sessionCronInventoryPresent && sessionCrons.length > 0 - - if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { - return null - } - - // Why: Claude normally emits PreToolUse while AskUserQuestion is blocked; newer builds can also report it as PermissionRequest. - // Treat the PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. - const eventToolName = readString(hookPayload, 'tool_name') - const isAskUserQuestionWait = - (eventName === 'PreToolUse' || eventName === 'PermissionRequest') && - isAskUserQuestionTool(eventToolName) - const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionWait - // Why: a manual /compact swallows the turn boundary — it ends at an idle prompt and emits no - // Stop, so PostCompact is the pane's only clearing signal (STA-2915). An auto compact runs INSIDE - // a turn that resumes and emits its own Stop, so it must claim nothing. PreCompact fires before - // the compact is validated (an aborted compact emits it alone), so it is neither registered nor - // mapped — see claude-compact-completion.ts. - const isManualCompactCompletion = eventName === 'PostCompact' && hookPayload.trigger === 'manual' - const reportedStateName = - eventName === 'UserPromptSubmit' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' || - (eventName === 'PreToolUse' && !isAskUserQuestion) - ? 'working' - : eventName === 'PermissionRequest' || isAskUserQuestion - ? 'waiting' - : isTurnBoundary || isManualCompactCompletion - ? 'done' - : null - - if (!reportedStateName) { - return null - } - if (backgroundTasks.present && eventAgentId === undefined) { - updateClaudeRunningNonAgentTask( - state, - paneKey, - backgroundTasks.hasRunningNonAgentTask, - interrupted === true - ) - } - if (sessionCronInventoryPresent && eventAgentId === undefined) { - if (hasActiveSessionCron && interrupted !== true) { - state.claudeActiveSessionCronPaneKeys.add(paneKey) - } else { - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - } else if (eventAgentId === undefined && isTurnBoundary && backgroundTasks.present) { - // Why: current Claude may omit an empty cron inventory while still emitting background_tasks. - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - - const eventToolUseId = readFirstString(hookPayload, ['tool_use_id', 'toolUseId']) - const previousTool = state.lastToolByPaneKey.get(paneKey) - const isParallelSiblingCompletionDuringQuestion = - eventAgentId === undefined && - previousLead?.state === 'waiting' && - isAskUserQuestionTool(previousTool?.toolName) && - (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && - previousLead.waitingToolUseId !== undefined && - eventToolUseId !== undefined && - eventToolUseId !== previousLead.waitingToolUseId - if (isParallelSiblingCompletionDuringQuestion) { - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) - } - - // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). - // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). - const isWaitingInducing = reportedStateName === 'waiting' - const subagentOriginId = - !isWaitingInducing && - (eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure') - ? eventAgentId - : undefined - if (eventAgentId && (subagentOriginId || isWaitingInducing)) { - upsertWorkingClaudeSubagent( - getOrCreateClaudeSubagentRoster(state, paneKey), - eventAgentId, - { agentType: readString(hookPayload, 'agent_type') }, - Date.now() - ) - } - if (subagentOriginId) { - const lead = state.claudeLeadStateByPaneKey.get(paneKey) - if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) { - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { - workingChildEvidence: true - }) - } - const isParallelSiblingCompletionDuringChildQuestion = - (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && - lead.waitingToolUseId !== undefined && - eventToolUseId !== undefined && - eventToolUseId !== lead.waitingToolUseId - if (isParallelSiblingCompletionDuringChildQuestion) { - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { - workingChildEvidence: true - }) - } - // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. - // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. - const restored = lead.stateBeforeWait ?? { state: 'working' as const } - state.claudeLeadStateByPaneKey.set(paneKey, restored) - return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - ...resolveClaudePaneStatus(state, paneKey, restored), - updateToolSnapshot: true, - interrupted: restored.interrupted, - turnCompletedAt: restored.turnCompletedAt - }) - } - - // Why: lead events never carry agent_id; even a child missed by lifecycle tracking cannot own the lead turn or its background-work evidence. - if (eventAgentId && !isWaitingInducing) { - return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { - workingChildEvidence: claudeRosterHasRuntimeWorkingSubagent( - state.claudeSubagentRosterByPaneKey.get(paneKey) - ) - }) - } - - if (isTurnBoundary && eventAgentId === undefined) { - // Why: background_tasks is trusted only where unambiguous (see foldClaudeBackgroundTasksIntoRoster) — teammates report "running" here even while idle. - // Older Claude builds without the field keep the incrementally tracked roster. - if (backgroundTasks.present) { - foldClaudeBackgroundTasksIntoRoster( - getOrCreateClaudeSubagentRoster(state, paneKey), - backgroundTasks.tasks, - Date.now(), - { inventoryComplete: !backgroundTasks.truncated } - ) - } - } - // Why: a child-induced wait displaces the lead state; stash it so clearing restores reality (lead may be done). A 2nd child wait carries the ORIGINAL stash, not the intermediate waiting state. - const stateBeforeWait = - isWaitingInducing && eventAgentId && previousLead - ? previousLead.state === 'waiting' - ? previousLead.stateBeforeWait - : { - state: previousLead.state, - ...(previousLead.interrupted ? { interrupted: true as const } : {}), - // Why: a child's permission pause displaces an already-finished lead; keep the end time so the later drain is still that turn's tail. - ...(previousLead.turnCompletedAt !== undefined - ? { turnCompletedAt: previousLead.turnCompletedAt } - : {}) - } - : undefined - const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId - - if (interrupted && eventAgentId === undefined) { - state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - - if (isManualCompactCompletion) { - // Why: a manual /compact only ever completes at an idle prompt, so a child that exists ONLY as - // a disk snapshot has nothing live behind it and must not keep the pane spinning — that - // restored child is what holds the stuck row STA-2915 actually reports. Everything else the - // done-gate consults is live evidence (a child observed in this runtime, an unclassifiable - // running background task, a registered session cron) and still holds the pane. - const restoredRoster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if ( - restoredRoster && - reapUnconfirmedRestoredClaudeSubagents(restoredRoster) && - restoredRoster.size === 0 - ) { - state.claudeSubagentRosterByPaneKey.delete(paneKey) - } - } - - const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { - state: reportedStateName, - interrupted - }) - // Why: #15202's compact-completion guard reads the resolved state; this branch replaced the - // resolver with one that also reports workingMode, so bridge rather than resolve twice. - const effectiveState = resolvedStatus.stateName - // Why: the lead already ended — the pane stays `working` only because background inventory is still registered. `stateStartedAt` is pinned for that whole run, so this end time is the per-turn identity and the later all-clear's pair key. - const turnCompletedAt = - eventAgentId === undefined && - isTurnBoundary && - reportedStateName === 'done' && - resolvedStatus.stateName === 'working' && - interrupted !== true - ? Date.now() - : undefined - - state.claudeLeadStateByPaneKey.set(paneKey, { - state: reportedStateName, - ...(interrupted ? { interrupted } : {}), - ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), - ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), - ...(stateBeforeWait ? { stateBeforeWait } : {}), - ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) - }) - - const effectiveRoster = state.claudeSubagentRosterByPaneKey.get(paneKey) - if ( - isTurnBoundary && - eventAgentId === undefined && - resolvedStatus.stateName === 'working' && - claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) && - !claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) && - !state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) && - !state.claudeActiveSessionCronPaneKeys.has(paneKey) - ) { - // Why: a legacy or partial Stop confirms the lead boundary, not a child restored from disk; keep the child-only gate eligible for reconciliation. - state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey) - } - - if (isManualCompactCompletion && effectiveState !== 'done') { - // Why: a compact completion CLEARS a row or says nothing at all. Live evidence still holds this - // pane, so restating it would only strip `restoredUnconfirmed` off a hydrated row and restart - // the staleness clock for work the compact never observed — strictly worse than the silence - // this event replaced. The lead record above still marks the turn ended, so whichever event - // retires that evidence can clear the pane. - return null - } - - return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - ...resolvedStatus, - updateToolSnapshot: true, - interrupted, - // Why: a finished compact is a session-shaped boundary, not a completed turn. Without this the - // clearing `done` would fire completion notifications, unread counts and automation-run - // completion evidence for work nobody did. - sessionBoundary: isManualCompactCompletion ? true : undefined, - turnCompletedAt - }) -} - -function buildClaudeStatusPayload( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record, - options: { - stateName: AgentStatusState - workingMode?: AgentWorkingMode - updateToolSnapshot: boolean - interrupted?: boolean - sessionBoundary?: boolean - turnCompletedAt?: number - } -): ParsedAgentStatusPayload | null { - // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. - const snapshot = options.updateToolSnapshot - ? resolveToolState(state, paneKey, extractToolFields('claude', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('claude', eventName) - }) - : (state.lastToolByPaneKey.get(paneKey) ?? {}) - - // Why: validate directly — the JSON stringify/parse round trip other normalizers use is pure overhead on this hot per-hook path. - // The normalizer clamps `interrupted` to done payloads, so a gated 'working' emit drops it; claudeLeadStateByPaneKey preserves it for the eventual done. - return normalizeAgentStatusPayload({ - state: options.stateName, - workingMode: options.workingMode, - // Why: only lead-origin events may reset the prompt cache; a child-driven refresh must not blank the lead's prompt label. - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: options.updateToolSnapshot && isNewTurnEvent('claude', eventName) - }), - agentType: 'claude', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted: options.interrupted, - sessionBoundary: options.sessionBoundary, - turnCompletedAt: options.turnCompletedAt, - subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) - }) -} - -// Why: Devin uses Claude-compatible payloads but its own lifecycle event set; normalize those event names while keeping Devin attribution. -function normalizeDevinEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (eventName === 'SessionStart') { - // Why: Devin emits SessionStart on idle TUI open/resume; mapping it to 'working' showed a spinner before the user typed, so only UserPromptSubmit/tool activity may create a row. - clearPaneTurnCacheState(state, paneKey) - return null - } - - const stateName = - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostCompaction' - ? 'working' - : eventName === 'PermissionRequest' - ? 'waiting' - : eventName === 'Stop' || eventName === 'SessionEnd' - ? 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('devin', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('devin', eventName) } - ) - - const interrupted = - eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('devin', eventName) - }), - agentType: 'devin', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) -} - -// Why: Kimi's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest) while awaiting an answer; treat as waiting so the UI shows the attention icon, not a spinner. -function isKimiUserInputTool(toolName: string | undefined): boolean { - return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion' -} - -// Why: Kimi Code emits Claude-compatible payloads/event names; normalize but attribute to Kimi so the sidebar shows Kimi's icon/label, not Claude's. -function normalizeKimiEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { - return null - } - - const toolName = readString(hookPayload, 'tool_name') - const isUserInputTool = isKimiUserInputTool(toolName) - - let stateName: 'working' | 'waiting' | 'done' | null = null - if ( - eventName === 'UserPromptSubmit' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' || - (eventName === 'PreToolUse' && !isUserInputTool) - ) { - stateName = 'working' - } else if (eventName === 'PermissionRequest' || (eventName === 'PreToolUse' && isUserInputTool)) { - stateName = 'waiting' - } else if (eventName === 'Stop' || eventName === 'StopFailure') { - stateName = 'done' - } - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('kimi', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('kimi', eventName) } - ) - - const interrupted = - eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('kimi', eventName) - }), - agentType: 'kimi', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) -} - -function normalizeGeminiEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - // Why: Gemini CLI's native pre-tool event is BeforeTool; PreToolUse/PostToolUse still accepted for legacy Antigravity-compatible payloads. - const stateName = - eventName === 'BeforeAgent' || - eventName === 'BeforeTool' || - eventName === 'AfterTool' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'AfterAgent' - ? 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('gemini', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('gemini', eventName) } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('gemini', eventName) - }), - agentType: 'gemini', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function isAntigravityFeedbackTool(toolName: string | undefined): boolean { - return toolName === 'ask_question' || toolName === 'ask_permission' -} - -function isAntigravityStopStillBusy(hookPayload: Record): boolean { - return hookPayload.fullyIdle === false || hookPayload.fully_idle === false -} - -function normalizeAntigravityEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const transcriptPath = readFirstString(hookPayload, ['transcriptPath', 'transcript_path']) - if (eventName === 'PreInvocation') { - state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) - } else if ( - transcriptPath && - eventName !== 'Stop' && - state.antigravityCompletedTranscriptByPaneKey.get(paneKey) === transcriptPath - ) { - // Why: agy can emit a bookkeeping PostToolUse after Stop; ignore it so a finished row doesn't turn back into a yellow spinner. - return null - } - - const toolName = readAntigravityToolCall(hookPayload).toolName - const stopStillBusy = eventName === 'Stop' && isAntigravityStopStillBusy(hookPayload) - const stateName = - eventName === 'PreToolUse' && isAntigravityFeedbackTool(toolName) - ? 'waiting' - : eventName === 'Stop' - ? stopStillBusy - ? 'working' - : 'done' - : eventName === 'PreInvocation' || - eventName === 'PostInvocation' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : null - - if (!stateName) { - return null - } - - const resetsTurn = isNewTurnEvent('antigravity', eventName) - // Why: once the prompt is cached for this pane, avoid rescanning the (potentially large) Antigravity transcript per hook. - const cachedPrompt = resetsTurn ? undefined : state.lastPromptByPaneKey.get(paneKey) - const effectivePrompt = - promptText || cachedPrompt || readLastUserPromptFromTranscript(transcriptPath) || '' - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('antigravity', eventName, hookPayload), - { resetOnNewTurn: resetsTurn } - ) - - const payload = normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, effectivePrompt, { - resetOnNewTurn: resetsTurn - }), - agentType: 'antigravity', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - // Why: Antigravity can emit Stop with fullyIdle=false between tool steps; only a fully idle Stop is terminal, else the sidebar bounces done -> working and ignores later tool updates. - if (eventName === 'Stop' && !stopStillBusy && transcriptPath) { - state.antigravityCompletedTranscriptByPaneKey.set(paneKey, transcriptPath) - } - return payload -} - -function normalizeAmpEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const ampCacheKey = getAmpCacheKey(paneKey, hookPayload) - if (eventName === 'session.start') { - clearPaneTurnCacheState(state, ampCacheKey) - if (ampCacheKey !== paneKey) { - clearPaneTurnCacheState(state, paneKey) - } - return null - } - - const stateName = - eventName === 'agent.start' || eventName === 'tool.call' || eventName === 'tool.result' - ? 'working' - : eventName === 'agent.end' - ? 'done' - : null - - if (!stateName) { - return null - } - if (eventName === 'agent.start') { - state.ampCompletedCacheKeys.delete(ampCacheKey) - } else if ( - (eventName === 'tool.call' || eventName === 'tool.result') && - state.ampCompletedCacheKeys.has(ampCacheKey) - ) { - // Why: Amp status posts are fire-and-forget, so drop stale tool events that arrive after the thread ended. - return null - } - - const snapshot = resolveToolState( - state, - ampCacheKey, - extractToolFields('amp', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('amp', eventName) } - ) - - const interrupted = - eventName === 'agent.end' && hookPayload.status === 'cancelled' ? true : undefined - const explicitPrompt = readFirstString(hookPayload, [ - 'prompt', - 'user_prompt', - 'userPrompt', - 'initial_prompt', - 'initialPrompt', - 'user_message' - ]) - const canUseMessageAsPrompt = - eventName === 'agent.start' || - (eventName === 'agent.end' && !state.lastPromptByPaneKey.has(ampCacheKey)) - const ampPromptText = explicitPrompt ?? (canUseMessageAsPrompt ? promptText : '') - - const normalized = normalizeAgentStatusPayload({ - state: stateName, - // Why: Amp tool/result events may use `message` for tool output; only lifecycle events may treat it as the turn prompt. - prompt: resolvePrompt(state, ampCacheKey, ampPromptText, { - resetOnNewTurn: isNewTurnEvent('amp', eventName) - }), - agentType: 'amp', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) - if (normalized && eventName === 'agent.end') { - state.ampCompletedCacheKeys.add(ampCacheKey) - } - if (normalized) { - pruneAmpThreadCacheKeys(state, paneKey, ampCacheKey) - } - return normalized -} - -function getAmpCacheKey(paneKey: string, hookPayload: Record): string { - const threadId = readBoundedString( - hookPayload, - ['threadId', 'threadID', 'thread_id'], - AMP_THREAD_ID_MAX_LENGTH - ) - // Why: Amp emits events for multiple threads per pane; cache by thread internally while keeping the visible paneKey stable. - return threadId ? `${paneKey}\0amp:${threadId}` : paneKey -} - -function pruneAmpThreadCacheKeys( - state: HookListenerState, - paneKey: string, - currentCacheKey: string -): void { - const scopedPrefix = `${paneKey}\0amp:` - if (!currentCacheKey.startsWith(scopedPrefix)) { - return - } - - const scopedKeys = new Set() - for (const key of state.lastPromptByPaneKey.keys()) { - if (key.startsWith(scopedPrefix)) { - scopedKeys.add(key) - } - } - for (const key of state.lastToolByPaneKey.keys()) { - if (key.startsWith(scopedPrefix)) { - scopedKeys.add(key) - } - } - for (const key of state.ampCompletedCacheKeys) { - if (key.startsWith(scopedPrefix)) { - scopedKeys.add(key) - } - } - - let overflow = scopedKeys.size - AMP_MAX_SCOPED_THREAD_CACHE_KEYS - if (overflow <= 0) { - return - } - - // Why: Amp multiplexes many thread IDs through one pane; keep the current thread plus the most recent entries instead of retaining every completed thread until teardown. - for (const key of scopedKeys) { - if (overflow <= 0) { - break - } - if (key === currentCacheKey) { - continue - } - state.lastPromptByPaneKey.delete(key) - state.lastToolByPaneKey.delete(key) - state.ampCompletedCacheKeys.delete(key) - overflow-- - } -} - -function hasExplicitPromptForSource( - source: AgentHookSource, - eventName: unknown, - promptText: string, - hookPayload: Record -): boolean { - if (source !== 'amp') { - return promptText.length > 0 - } - if ( - readFirstString(hookPayload, [ - 'prompt', - 'user_prompt', - 'userPrompt', - 'initial_prompt', - 'initialPrompt', - 'user_message' - ]) - ) { - return true - } - // Why: Amp tool/result `message` is output text, not a user prompt. - return eventName === 'agent.start' && promptText.length > 0 -} - -function getOrCreateCodexSubagentRoster( - state: HookListenerState, - paneKey: string -): CodexSubagentRoster { - let roster = state.codexSubagentRosterByPaneKey.get(paneKey) - if (!roster) { - roster = new Map() - state.codexSubagentRosterByPaneKey.set(paneKey, roster) - } - return roster -} - -function getOrCreateCodexSubagentTranscriptState( - state: HookListenerState, - paneKey: string -): CodexSubagentTranscriptState { - let transcriptState = state.codexSubagentTranscriptByPaneKey.get(paneKey) - if (!transcriptState) { - transcriptState = createCodexSubagentTranscriptState() - state.codexSubagentTranscriptByPaneKey.set(paneKey, transcriptState) - } - return transcriptState -} - -export function hasCodexTranscriptSubagents(state: HookListenerState, paneKey: string): boolean { - return hasTrackedCodexTranscriptSubagents(state.codexSubagentTranscriptByPaneKey.get(paneKey)) -} - -export function seedCodexStateFromSnapshot( - state: HookListenerState, - paneKey: string, - payload: Pick -): void { - const snapshots = payload.subagents ?? [] - if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { - seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) - } - if (!state.codexLeadStateByPaneKey.has(paneKey)) { - // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. - state.codexLeadStateByPaneKey.set(paneKey, { - // Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting. - state: - payload.state === 'done' - ? 'done' - : payload.state === 'waiting' && - !snapshots.some((snapshot) => snapshot.state === 'waiting') - ? 'waiting' - : 'working', - model: payload.model - }) - } -} - -/** Sync the Codex lead record when the server infers an interrupt, so delayed child events cannot restore stale working state. */ -export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { - const lead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { state: 'done', model: lead?.model }) -} - -function codexLeadStateForHookEvent( - eventName: string | undefined -): CodexLeadTurnState['state'] | undefined { - if (eventName === 'Stop') { - return 'done' - } - if (eventName === 'PermissionRequest') { - return 'waiting' - } - if ( - eventName === 'SessionStart' || - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ) { - return 'working' - } - return undefined -} - -/** Why: relay restarts lose lead/roster state; merge child events into main's longer-lived cache. */ -export function reconcileRemoteCodexState( - state: HookListenerState, - paneKey: string, - eventName: string | undefined, - agentId: string | undefined, - payload: ParsedAgentStatusPayload, - previous: ParsedAgentStatusPayload | undefined -): ParsedAgentStatusPayload { - if (previous?.agentType === 'codex') { - seedCodexStateFromSnapshot(state, paneKey, previous) - } else { - seedCodexStateFromSnapshot(state, paneKey, payload) - } - - // Why: older relays send child identity without roster snapshots; keep their already-normalized aggregate authoritative. - if (agentId && !payload.subagents && !state.codexSubagentRosterByPaneKey.has(paneKey)) { - return payload - } - const roster = getOrCreateCodexSubagentRoster(state, paneKey) - if (payload.subagents) { - seedCodexSubagentRoster(roster, payload.subagents) - } - if (agentId) { - if (eventName === 'SubagentStop') { - finishCodexSubagent(roster, agentId) - } - } else { - const leadState = codexLeadStateForHookEvent(eventName) - if (eventName === 'SessionStart' || (eventName === 'Stop' && !payload.subagents)) { - roster.clear() - } - if (leadState) { - const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { - state: leadState, - model: payload.model ?? previousLead?.model - }) - } - } - - const lead = state.codexLeadStateByPaneKey.get(paneKey) - if (!lead) { - return payload - } - return { - ...payload, - state: codexRosterEffectiveState(roster, lead.state), - model: lead.model ?? payload.model, - subagents: codexRosterToSnapshots(roster) - } -} - -function buildCodexStatusPayload( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record, - options: { stateName: 'working' | 'waiting' | 'done'; updateLead: boolean } -): ParsedAgentStatusPayload | null { - const snapshot = options.updateLead - ? resolveToolState(state, paneKey, extractToolFields('codex', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('codex', eventName) - }) - : (state.lastToolByPaneKey.get(paneKey) ?? {}) - const lead = state.codexLeadStateByPaneKey.get(paneKey) - - return normalizeAgentStatusPayload({ - state: options.stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: options.updateLead && isNewTurnEvent('codex', eventName) - }), - agentType: 'codex', - model: lead?.model, - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) - }) -} - -function buildCodexChildDrivenStatusPayload( - state: HookListenerState, - eventName: unknown, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const leadState = state.codexLeadStateByPaneKey.get(paneKey)?.state ?? 'working' - const stateName = codexRosterEffectiveState( - state.codexSubagentRosterByPaneKey.get(paneKey), - leadState - ) - return buildCodexStatusPayload(state, eventName, '', paneKey, hookPayload, { - stateName, - updateLead: false - }) -} - -function normalizeCodexSubagentLifecycleEvent( - state: HookListenerState, - eventName: 'SubagentStart' | 'SubagentStop', - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const agentId = readString(hookPayload, 'agent_id') - if (!agentId) { - return null - } - const roster = getOrCreateCodexSubagentRoster(state, paneKey) - if (eventName === 'SubagentStart') { - upsertCodexSubagent( - roster, - agentId, - { - agentType: readString(hookPayload, 'agent_type'), - model: readString(hookPayload, 'model'), - state: 'working' - }, - Date.now() - ) - } else { - finishCodexSubagent(roster, agentId) - } - return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) -} - -function normalizeCodexEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (eventName === 'SubagentStart' || eventName === 'SubagentStop') { - return normalizeCodexSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) - } - - // Why: Codex's request_user_input (0.145+) is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting like grok's ask_user_question. - const isUserInputPreTool = - eventName === 'PreToolUse' && - isAskUserQuestionTool(readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name')) - const stateName = - eventName === 'SessionStart' || - eventName === 'UserPromptSubmit' || - (eventName === 'PreToolUse' && !isUserInputPreTool) || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'PermissionRequest' || isUserInputPreTool - ? 'waiting' - : eventName === 'Stop' - ? 'done' - : null - if (!stateName) { - return null - } - - const agentId = readString(hookPayload, 'agent_id') - if (agentId) { - upsertCodexSubagent( - getOrCreateCodexSubagentRoster(state, paneKey), - agentId, - { - agentType: readString(hookPayload, 'agent_type'), - model: readString(hookPayload, 'model'), - state: stateName === 'waiting' ? 'waiting' : 'working' - }, - Date.now() - ) - return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) - } - - if (eventName === 'SessionStart') { - // Why: a pane can host a new Codex process after the old one exited without child Stop hooks. - state.codexSubagentRosterByPaneKey.delete(paneKey) - state.codexSubagentTranscriptByPaneKey.delete(paneKey) - } - const transcriptPath = readFirstString(hookPayload, ['transcript_path', 'transcriptPath']) - if (transcriptPath) { - reconcileCodexSubagentTranscript( - getOrCreateCodexSubagentTranscriptState(state, paneKey), - getOrCreateCodexSubagentRoster(state, paneKey), - transcriptPath - ) - } - if (eventName === 'Stop' && !hasCodexTranscriptSubagents(state, paneKey)) { - // Why: Codex CLI 0.144 can omit child Stop hooks; later child activity safely recreates any agent still running. - state.codexSubagentRosterByPaneKey.delete(paneKey) - } - const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { - state: stateName, - model: - normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? - (eventName === 'SessionStart' ? undefined : previousLead?.model) - }) - const effectiveState = codexRosterEffectiveState( - state.codexSubagentRosterByPaneKey.get(paneKey), - stateName - ) - return buildCodexStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - stateName: effectiveState, - updateLead: true - }) -} - -function normalizeOpenCodeFamilyEvent( - source: 'opencode' | 'mimo-code', - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const resetsTurn = - isNewTurnEvent(source, eventName) || - (eventName === 'MessagePart' && hookPayload.role === 'user') - const stateName = - eventName === 'SessionBusy' || eventName === 'MessagePart' - ? 'working' - : eventName === 'SessionIdle' - ? 'done' - : source === 'opencode' && eventName === 'SessionStart' - ? 'done' - : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' - ? 'waiting' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields(source, eventName, hookPayload), - { resetOnNewTurn: resetsTurn } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: resetsTurn - }), - agentType: source, - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined - }) -} - -function normalizeCursorEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - // Why: Cursor can emit final response text after `stop`; enrich the completed row, don't resurrect the agent as working. - const previousStatus = state.lastStatusByPaneKey.get(paneKey)?.payload - const stateName = - eventName === 'beforeSubmitPrompt' || - eventName === 'sessionStart' || - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' || - // Why: these fire on every shell/MCP invocation (pre-execution gates, not just approval); treat as working to avoid waiting-notification spam. - eventName === 'beforeShellExecution' || - eventName === 'beforeMCPExecution' - ? 'working' - : eventName === 'afterAgentResponse' - ? previousStatus?.state === 'done' && previousStatus.agentType === 'cursor' - ? 'done' - : 'working' - : eventName === 'stop' || eventName === 'sessionEnd' - ? 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('cursor', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('cursor', eventName) } - ) - - const interrupted = - eventName === 'stop' && - typeof hookPayload.status === 'string' && - hookPayload.status !== 'completed' - ? true - : undefined - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('cursor', eventName) - }), - agentType: 'cursor', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) -} - -// Why: Copilot PermissionRequest fires before allow/ask/deny (stays working); ask_user and notification prompts are the real blocked signals. -function normalizeCopilotEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const normalizedEventName = normalizeCopilotEventName( - resolveCopilotEventName(eventName, hookPayload) - ) - const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType']) - const isBlockingNotification = - normalizedEventName === 'Notification' && - (notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog') - const toolSnapshot = extractToolFields('copilot', normalizedEventName, hookPayload) - const isAskUserPrompt = - (normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest') && - isAskUserTool(toolSnapshot.toolName) - const stateName = - normalizedEventName === 'SessionStart' || - normalizedEventName === 'UserPromptSubmit' || - normalizedEventName === 'PostToolUse' || - normalizedEventName === 'PostToolUseFailure' - ? 'working' - : isBlockingNotification || isAskUserPrompt - ? 'blocked' - : normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest' - ? 'working' - : normalizedEventName === 'Stop' || normalizedEventName === 'SessionEnd' - ? 'done' - : normalizedEventName === 'ErrorOccurred' - ? hookPayload.recoverable === true - ? 'working' - : 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState(state, paneKey, toolSnapshot, { - resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName) - }) - - const effectivePrompt = normalizedEventName === 'Notification' ? '' : promptText - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, effectivePrompt, { - resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName) - }), - agentType: 'copilot', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizePiCompatibleEvent( - state: HookListenerState, - agentType: 'pi' | 'omp' | 'prime-agent', - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (agentType !== 'omp' && eventName === 'session_start') { - // Why: Pi's session_start fires on TUI open/resume; discard stale turn details, no working row before user activity. - clearPaneTurnCacheState(state, paneKey) - return null - } - - // Why: gate on the event's own tool_name so a stale cached question can't re-enter blocked. - const toolName = readString(hookPayload, 'tool_name') - const isPiCompatibleAsk = - ((agentType === 'pi' && isAskUserQuestionTool(toolName)) || - (agentType === 'omp' && toolName === 'ask')) && - (eventName === 'tool_call' || eventName === 'tool_execution_start') - - const stateName = isPiCompatibleAsk - ? 'blocked' - : eventName === 'before_agent_start' || - eventName === 'agent_start' || - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' || - eventName === 'message_end' - ? 'working' - : eventName === 'agent_end' - ? 'done' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields(agentType, eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent(agentType, eventName) } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent(agentType, eventName) - }), - agentType, - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizeDroidEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (eventName === 'SessionStart') { - // Why: Droid's SessionStart fires while idle (TUI open/resume); wait for real activity before a working row. - clearPaneTurnCacheState(state, paneKey) - return null - } - - const notificationMessage = readString(hookPayload, 'message') - const droidToolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - let stateName: 'working' | 'waiting' | 'done' | null = null - if ( - eventName === 'PreToolUse' && - (isDroidAskUserTool(droidToolName) || isDroidHighRiskToolUse(hookPayload)) - ) { - // Why: Droid surfaces AskUser and high-risk approvals as PreToolUse; the approval path emits no Notification hook. - stateName = 'waiting' - } else if ( - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ) { - stateName = 'working' - } else if (eventName === 'Stop') { - stateName = 'done' - } else if (eventName === 'PermissionRequest') { - stateName = 'waiting' - } else if (eventName === 'Notification' && isDroidPermissionNotification(notificationMessage)) { - stateName = 'waiting' - } else if (eventName === 'Notification' && isDroidIdleNotification(notificationMessage)) { - // Why: Droid emits no Stop on user-interrupt, only an idle notification when ready again. - stateName = 'done' - } - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('droid', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('droid', eventName) } - ) - - // Why: Droid Notification.message is status text, not the prompt; '' keeps resolvePrompt's cached UserPromptSubmit value. - const effectivePrompt = eventName === 'Notification' ? '' : promptText - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, effectivePrompt, { - resetOnNewTurn: isNewTurnEvent('droid', eventName) - }), - agentType: 'droid', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizeCommandCodeEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const stateName = - eventName === 'PreToolUse' || eventName === 'PostToolUse' - ? 'working' - : eventName === 'Stop' - ? 'done' - : null - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('command-code', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('command-code', eventName) } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('command-code', eventName) - }), - agentType: 'command-code', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizeGrokEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record, - grokHome?: string -): ParsedAgentStatusPayload | null { - if (isGrokEvent(eventName, 'session_start')) { - // Why: SessionStart resets stale per-turn state but must not create a working row before any prompt/tool event. - clearPaneTurnCacheState(state, paneKey) - return null - } - - const notificationMessage = readString(hookPayload, 'message') - const notificationType = getGrokNotificationType(hookPayload) - const notificationLevel = readString(hookPayload, 'level') - const preToolName = - readString(hookPayload, 'toolName') ?? - readString(hookPayload, 'tool_name') ?? - readString(hookPayload, 'name') - // Why: Grok's ask_user_question is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting. - const isUserInputPreTool = - isGrokEvent(eventName, 'pre_tool_use') && isAskUserQuestionTool(preToolName) - - let stateName: 'working' | 'waiting' | 'done' | null = null - if ( - isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') || - (isGrokEvent(eventName, 'pre_tool_use') && !isUserInputPreTool) - ) { - stateName = 'working' - } else if (isUserInputPreTool) { - stateName = 'waiting' - } else if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { - stateName = 'done' - } else if ( - isGrokEvent(eventName, 'notification') && - isGrokRoutinePermissionPromptNotification( - notificationType, - notificationMessage, - notificationLevel - ) - ) { - return null - } else if ( - isGrokEvent(eventName, 'notification') && - isGrokPermissionNotification(notificationMessage) - ) { - stateName = 'waiting' - } else if ( - isGrokEvent(eventName, 'notification') && - isGrokIdleNotification(notificationMessage) - ) { - stateName = 'done' - } - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('grok', eventName, hookPayload, { grokHome }), - { resetOnNewTurn: isNewTurnEvent('grok', eventName) } - ) - - // Why: Grok Notification.message is status UI text, not the prompt; '' preserves the cached UserPromptSubmit. - const effectivePrompt = isGrokEvent(eventName, 'notification') - ? '' - : stripGrokUserQueryWrapper(promptText) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, effectivePrompt, { - resetOnNewTurn: isNewTurnEvent('grok', eventName) - }), - agentType: 'grok', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function normalizeHermesEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const stateName = - eventName === 'pre_approval_request' - ? 'waiting' - : eventName === 'post_llm_call' || - eventName === 'on_session_end' || - eventName === 'on_session_finalize' || - eventName === 'on_session_reset' - ? 'done' - : eventName === 'on_session_start' || - eventName === 'pre_llm_call' || - eventName === 'pre_tool_call' || - eventName === 'post_tool_call' || - eventName === 'post_approval_response' - ? 'working' - : null - - if (!stateName) { - return null - } - - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('hermes', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('hermes', eventName) } - ) - - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('hermes', eventName) - }), - agentType: 'hermes', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) -} - -function readStringField(record: Record, key: string): string | undefined { - const value = record[key] - if (typeof value !== 'string') { - return undefined - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined -} - +import { parseHookEnvelope } from './agent-hook-listener/hook-envelope' +import { readFirstString } from './agent-hook-listener/interactive-tool' +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import { normalizeClaudePromptId } from './agent-hook-listener/listener-limits' +import type { HookListenerState } from './agent-hook-listener/listener-state' +import { extractPromptText } from './agent-hook-listener/prompt-fields' +import { normalizeProviderEvent } from './agent-hook-listener/provider-dispatch' +import { hasExplicitUserPrompt } from './agent-hook-listener/provider-event-routing' +import { hasExplicitAmpPrompt } from './agent-hook-listener/providers/amp-events' +import { readString } from './agent-hook-listener/tool-input-preview' +/** Canonical transport-agnostic normalization entry shared by main and relay listeners. */ export function normalizeHookPayload( state: HookListenerState, source: AgentHookSource, @@ -4450,65 +24,19 @@ export function normalizeHookPayload( expectedEnv: string, options: { deferCompactOwnershipToClient?: boolean } = {} ): AgentHookEventPayload | null { - if (typeof body !== 'object' || body === null) { + const envelope = parseHookEnvelope(state, source, body, expectedEnv) + if (!envelope) { return null } - - const record = body as Record - const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' - const parsedPaneKey = parsePaneKey(paneKey) - const rawPayload = record.payload - // Why (#15117): some Antigravity events fire with no stdin at all, yet still carry a status - // transition in `hook_event_name`. The POSIX script substitutes `{}` before posting; on - // Windows curl omits the form field entirely when stdin is empty, so the event arrives with - // no `payload` key at all. Accept both shapes rather than dropping the transition. - const antigravityPayloadAbsent = - source === 'antigravity' && - (rawPayload === undefined || (typeof rawPayload === 'string' && rawPayload.trim() === '')) - const hookPayload = antigravityPayloadAbsent - ? {} - : typeof rawPayload === 'string' - ? (() => { - try { - return parseAgentHookJson(rawPayload) - } catch { - return null - } - })() - : rawPayload - if ( - !paneKey || - paneKey.length > MAX_PANE_KEY_LEN || - !parsedPaneKey || - typeof hookPayload !== 'object' || - hookPayload === null - ) { - return null - } - - warnOnHookEnvOrVersionMismatch(state, { - version: readStringField(record, 'version'), - env: readStringField(record, 'env'), - expectedEnv - }) - - const tabId = readStringField(record, 'tabId') - if (tabId && tabId !== parsedPaneKey.tabId) { - return null - } - const worktreeId = readStringField(record, 'worktreeId') - const launchToken = readStringField(record, 'launchToken') - - const hookPayloadRecord = hookPayload as Record + const { record, paneKey, hookPayloadRecord, tabId, worktreeId, launchToken } = envelope if (source === 'claude') { state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) } - let promptInteractionKey: string | undefined const eventName = readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ?? hookPayloadRecord.hook_event_name ?? hookPayloadRecord.hookEventName - // Why: Codex child hooks expose the child's session_id on the parent's pane. + // Codex child hooks expose the child's session_id on the parent's pane. const providerSession = source === 'codex' && readString(hookPayloadRecord, 'agent_id') ? null @@ -4570,295 +98,76 @@ export function normalizeHookPayload( state.lastPromptByPaneKey.set(paneKey, previousStatus.payload.prompt) } } - const extractedPrompt = extractPromptText(hookPayload as Record) - const promptText = extractedPrompt.text - let resolvedPromptText = promptText - let hasTranscriptPromptEvidence = false - // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently misrouting. - let payload: ParsedAgentStatusPayload | null - switch (source) { - case 'claude': - payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'codex': - payload = normalizeCodexEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'gemini': - payload = normalizeGeminiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'antigravity': - if (isNewTurnEvent('antigravity', eventName)) { - resolvedPromptText = - promptText || - readLastUserPromptFromTranscript( - readFirstString(hookPayloadRecord, ['transcriptPath', 'transcript_path']) - ) || - '' - } - payload = normalizeAntigravityEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'amp': - payload = normalizeAmpEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'opencode': - case 'mimo-code': - if (extractedPrompt.source === 'role_user_text') { - const messageId = readFirstString(hookPayloadRecord, [ - 'messageID', - 'messageId', - 'message_id' - ]) - const prefix = source === 'mimo-code' ? 'mimo-code-message' : 'opencode-message' - promptInteractionKey = messageId ? `${prefix}-${messageId}` : undefined - } - payload = normalizeOpenCodeFamilyEvent( - source, - state, - eventName, - promptText, - paneKey, - hookPayloadRecord - ) - break - case 'cursor': - payload = normalizeCursorEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'pi': - payload = normalizePiCompatibleEvent( - state, - 'pi', - eventName, - promptText, - paneKey, - hookPayloadRecord - ) - break - case 'omp': - payload = normalizePiCompatibleEvent( - state, - 'omp', - eventName, - promptText, - paneKey, - hookPayloadRecord - ) - break - case 'prime-agent': - payload = normalizePiCompatibleEvent( - state, - 'prime-agent', - eventName, - promptText, - paneKey, - hookPayloadRecord - ) - break - case 'droid': - payload = normalizeDroidEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'command-code': - { - const transcriptPrompt = readLastCommandCodeUserPromptEntryFromTranscript( - hookPayloadRecord.transcript_path ?? hookPayloadRecord.transcriptPath - ) - hasTranscriptPromptEvidence = transcriptPrompt !== undefined - promptInteractionKey = transcriptPrompt?.interactionKey - resolvedPromptText = transcriptPrompt?.text ?? '' - if (promptText && extractedPrompt.source !== 'message') { - resolvedPromptText = promptText - } - } - payload = normalizeCommandCodeEvent( - state, - eventName, - resolvedPromptText, - paneKey, - hookPayloadRecord - ) - break - case 'grok': - payload = normalizeGrokEvent( - state, - eventName, - promptText, - paneKey, - hookPayloadRecord, - readGrokHomeEnvelope(record) - ) - break - case 'copilot': - payload = normalizeCopilotEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'hermes': - payload = normalizeHermesEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'devin': - payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - case 'kimi': - payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) - break - } + const extractedPrompt = extractPromptText(hookPayloadRecord) + const promptText = extractedPrompt.text + const dispatched = normalizeProviderEvent({ + state, + source, + eventName, + promptText, + paneKey, + hookPayload: hookPayloadRecord, + envelope: record, + extractedPrompt + }) const providerSessionOnly = (source === 'pi' || source === 'prime-agent') && eventName === 'session_start' && providerSession !== null - // Why: transcript session_start carries resume identity while idle; receivers discard the placeholder row. + // A transcript session_start carries resume identity while idle; receivers discard the placeholder row. const transportPayload = - payload ?? + dispatched.payload ?? (providerSessionOnly ? normalizeAgentStatusPayload({ state: 'done', prompt: '', agentType: source }) : null) const restoredUnconfirmed = source === 'claude' && state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) - return transportPayload - ? { - paneKey, - source, - launchToken, - tabId, - worktreeId, - // Why: normalization is transport-agnostic — only ingestRemote knows the mux identity to stamp here. - connectionId: null, - ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - hasExplicitPrompt: - source === 'amp' - ? hasExplicitPromptForSource(source, eventName, promptText, hookPayloadRecord) - ? true - : undefined - : hasExplicitUserPrompt( - source, - eventName, - extractedPrompt, - resolvedPromptText, - hasTranscriptPromptEvidence - ), - promptInteractionKey, - hookEventName: typeof eventName === 'string' ? eventName : undefined, - providerPromptId, - compactTrigger, - toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), - toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), - teammateName: - source === 'claude' && eventName === 'TeammateIdle' - ? readString(hookPayloadRecord, 'teammate_name') - : undefined, - toolAgentType: readString(hookPayloadRecord, 'agent_type'), - ...(source === 'claude' - ? { - claudeRunningNonAgentTask: - state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - state.claudeActiveSessionCronPaneKeys.has(paneKey) - } - : {}), - ...(providerSession ? { providerSession } : {}), - ...(providerSessionOnly ? { providerSessionOnly: true } : {}), - payload: transportPayload - } - : null -} - -// ─── URL routing ──────────────────────────────────────────────────── - -export const HOOK_SOURCE_BY_PATHNAME: Readonly> = Object.freeze({ - '/hook/claude': 'claude', - '/hook/codex': 'codex', - '/hook/gemini': 'gemini', - '/hook/antigravity': 'antigravity', - '/hook/amp': 'amp', - '/hook/opencode': 'opencode', - '/hook/mimo-code': 'mimo-code', - '/hook/cursor': 'cursor', - '/hook/pi': 'pi', - '/hook/omp': 'omp', - '/hook/prime-agent': 'prime-agent', - '/hook/droid': 'droid', - '/hook/command-code': 'command-code', - '/hook/grok': 'grok', - '/hook/copilot': 'copilot', - '/hook/hermes': 'hermes', - '/hook/devin': 'devin', - '/hook/kimi': 'kimi' -}) - -export function resolveHookSource(pathname: string): AgentHookSource | null { - return HOOK_SOURCE_BY_PATHNAME[pathname] ?? null -} - -// ─── Endpoint-file writing ────────────────────────────────────────── - -export function getEndpointFileName(): string { - // Why: per-platform extension lets hook scripts source the file natively (POSIX `. "$file"` / Windows `call "%file%"`); the OpenCode plugin regex accepts both shapes. - return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' -} - -export function isShellSafeEndpointValue(value: string): boolean { - // Why: values are shell-sourced; the + rejects empty strings so a sourced `KEY=` can't clear the env var. - return /^[A-Za-z0-9._:/-]+$/.test(value) -} - -export type EndpointFileFields = { - port: number - token: string - env: string - version: string -} - -/** Atomically write the endpoint file at `endpointDir/`. - * Returns true on success, false on error (caller may fall back to PTY env). - * Kept in sync with `AgentHookServer.writeEndpointFile`. */ -export function writeEndpointFile( - endpointDir: string, - finalPath: string, - fields: EndpointFileFields -): boolean { - const tmpPath = join(endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`) - const prefix = process.platform === 'win32' ? 'set ' : '' - const valuesToWrite: [string, string][] = [ - ['ORCA_AGENT_HOOK_PORT', String(fields.port)], - ['ORCA_AGENT_HOOK_TOKEN', fields.token], - ['ORCA_AGENT_HOOK_ENV', fields.env], - ['ORCA_AGENT_HOOK_VERSION', fields.version] - ] - for (const [key, value] of valuesToWrite) { - if (!isShellSafeEndpointValue(value)) { - console.error( - `[agent-hooks] refusing to write endpoint file: ${key} contains ` + - 'characters unsafe for shell sourcing. Falling back to PTY env.' - ) - return false - } + if (!transportPayload) { + return null } - const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] - let tmpWritten = false - try { - // Why: 0o700 owner-only so the dir doesn't leak this install's existence to other local users. - mkdirSync(endpointDir, { recursive: true, mode: 0o700 }) - if (process.platform !== 'win32') { - // Why: mkdirSync mode only applies on creation; chmod fixes perms on a pre-existing dir (POSIX-only). - try { - chmodSync(endpointDir, 0o700) - } catch { - // best-effort - } - } - // Why: crash-orphan cleanup must not materialize a tampered, enormous directory. - sweepStaleAgentHookEndpointTemps(endpointDir) - const separator = process.platform === 'win32' ? '\r\n' : '\n' - writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) - tmpWritten = true - renameSync(tmpPath, finalPath) - return true - } catch (err) { - console.error('[agent-hooks] failed to write endpoint file:', err) - if (tmpWritten) { - try { - unlinkSync(tmpPath) - } catch { - // tmp may already be gone - } - } - return false + + return { + paneKey, + source, + launchToken, + tabId, + worktreeId, + // Normalization is transport-agnostic; only ingestRemote knows the mux identity to stamp. + connectionId: null, + ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + hasExplicitPrompt: + source === 'amp' + ? hasExplicitAmpPrompt(eventName, promptText, hookPayloadRecord) + ? true + : undefined + : hasExplicitUserPrompt( + source, + eventName, + extractedPrompt, + dispatched.resolvedPromptText, + dispatched.hasTranscriptPromptEvidence + ), + promptInteractionKey: dispatched.promptInteractionKey, + hookEventName: typeof eventName === 'string' ? eventName : undefined, + providerPromptId, + compactTrigger, + toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), + toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), + teammateName: + source === 'claude' && eventName === 'TeammateIdle' + ? readString(hookPayloadRecord, 'teammate_name') + : undefined, + toolAgentType: readString(hookPayloadRecord, 'agent_type'), + ...(source === 'claude' + ? { + claudeRunningNonAgentTask: + state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey) + } + : {}), + ...(providerSession ? { providerSession } : {}), + ...(providerSessionOnly ? { providerSessionOnly: true } : {}), + payload: transportPayload } } diff --git a/src/shared/agent-hook-listener/command-code-transcript.ts b/src/shared/agent-hook-listener/command-code-transcript.ts new file mode 100644 index 00000000000..b3ec2a1d497 --- /dev/null +++ b/src/shared/agent-hook-listener/command-code-transcript.ts @@ -0,0 +1,190 @@ +import { createHash } from 'node:crypto' +import { closeSync, openSync, readSync, statSync } from 'node:fs' + +import { parseAgentHookJson } from './request-body' +import { extractAssistantContentText } from './transcript-entry-text' +import { + EMPTY_TRANSCRIPT_REGION, + readLastTextFromTranscriptOnce, + TRANSCRIPT_CHUNK_BYTES, + TRANSCRIPT_MAX_SCAN_BYTES +} from './transcript-reader' + +export function extractCommandCodeUserPromptFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = parseAgentHookJson(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + return record.role === 'user' ? extractAssistantContentText(record.content) : undefined +} + +export function hashInteractionKeyPart(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12) +} + +// Why byte offsets: the caller's interactionKey embeds the prompt's absolute +// position, so the backward scan has to report the same offset the old +// read-everything-then-take-the-last-match pass produced. +export function findLastCommandCodePromptInRegion( + region: Buffer +): { prompt: string; byteOffset: number } | undefined { + let lineEnd = region.length + for (let index = region.length - 1; index >= -1; index--) { + if (index >= 0 && region[index] !== 0x0a) { + continue + } + const lineStart = index + 1 + if (lineEnd > lineStart) { + const prompt = extractCommandCodeUserPromptFromLine( + region.subarray(lineStart, lineEnd).toString('utf8').trim() + ) + if (prompt !== undefined) { + return { prompt, byteOffset: lineStart } + } + } + lineEnd = index + } + return undefined +} + +export function readLastCommandCodeUserPromptEntryFromTranscript( + transcriptPath: unknown +): { text: string; interactionKey: string } | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + try { + const stats = statSync(transcriptPath) + const size = stats.size + if (size <= 0) { + return undefined + } + const fd = openSync(transcriptPath, 'r') + try { + // Why scan backward: the answer is the LAST user line, so walking up from + // EOF returns on the first hit instead of parsing every line of a + // multi-megabyte transcript on every hook event. + // Why a chunk list: carry holds a partial line, and re-concatenating it per + // block made one oversized line (a big tool result) cost O(line^2). + let carryChunks: Buffer[] = [] + let bytesRead = 0 + let scanEnd = size + while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min( + scanEnd, + TRANSCRIPT_CHUNK_BYTES, + TRANSCRIPT_MAX_SCAN_BYTES - bytesRead + ) + const position = scanEnd - chunkSize + const buffer = Buffer.alloc(chunkSize) + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + // Why bail on a short read: the file shrank under us, so the bytes above + // this block no longer line up and any stitched offset would be wrong. + if (filled < chunkSize) { + break + } + bytesRead += filled + scanEnd = position + // Why search only the new block: carry is always the run before a newline, + // so it holds none of its own. + const firstNewline = buffer.indexOf(0x0a) + // Why only at a true file start: a scan that stops on the size cap must + // discard its leading partial line, exactly as the capped read did. + const atStart = position === 0 + let completeRegion: Buffer + let regionPosition: number + if (atStart) { + completeRegion = + carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + regionPosition = position + carryChunks = [] + } else if (firstNewline === -1) { + completeRegion = EMPTY_TRANSCRIPT_REGION + regionPosition = position + carryChunks.unshift(buffer) + } else { + const afterNewline = buffer.subarray(firstNewline + 1) + completeRegion = + carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) + regionPosition = position + firstNewline + 1 + carryChunks = [buffer.subarray(0, firstNewline)] + } + if (completeRegion.length > 0) { + const found = findLastCommandCodePromptInRegion(completeRegion) + if (found) { + return { + text: found.prompt, + interactionKey: [ + 'command-code-transcript', + hashInteractionKeyPart(transcriptPath), + String(regionPosition + found.byteOffset), + hashInteractionKeyPart(found.prompt) + ].join('-') + } + } + } + } + return undefined + } finally { + closeSync(fd) + } + } catch { + return undefined + } +} + +export function extractCommandCodeAssistantTextFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = parseAgentHookJson(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + if (record.role !== 'assistant') { + return undefined + } + const content = record.content + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + if (Array.isArray(content)) { + const textPart = content.find( + (part) => + typeof part === 'object' && + part !== null && + (part as Record).type === 'text' && + typeof (part as Record).text === 'string' && + ((part as Record).text as string).trim().length > 0 + ) as Record | undefined + if (typeof textPart?.text === 'string') { + return textPart.text + } + } + return extractAssistantContentText(content) +} + +export function readLastCommandCodeAssistantFromTranscript( + transcriptPath: unknown +): string | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + return readLastTextFromTranscriptOnce(transcriptPath, extractCommandCodeAssistantTextFromLine) +} diff --git a/src/shared/agent-hook-listener/endpoint-publication.ts b/src/shared/agent-hook-listener/endpoint-publication.ts new file mode 100644 index 00000000000..cb09f204a01 --- /dev/null +++ b/src/shared/agent-hook-listener/endpoint-publication.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto' +import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { sweepStaleAgentHookEndpointTemps } from '../agent-hook-endpoint-temp-cleanup' + +// ─── Endpoint-file writing ────────────────────────────────────────── + +export function getEndpointFileName(): string { + // Why: per-platform extension lets hook scripts source the file natively (POSIX `. "$file"` / Windows `call "%file%"`); the OpenCode plugin regex accepts both shapes. + return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' +} + +export function isShellSafeEndpointValue(value: string): boolean { + // Why: values are shell-sourced; the + rejects empty strings so a sourced `KEY=` can't clear the env var. + return /^[A-Za-z0-9._:/-]+$/.test(value) +} + +export type EndpointFileFields = { + port: number + token: string + env: string + version: string +} + +/** Atomically write the endpoint file at `endpointDir/`. + * Returns true on success, false on error (caller may fall back to PTY env). + * Kept in sync with `AgentHookServer.writeEndpointFile`. */ +export function writeEndpointFile( + endpointDir: string, + finalPath: string, + fields: EndpointFileFields +): boolean { + const tmpPath = join(endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`) + const prefix = process.platform === 'win32' ? 'set ' : '' + const valuesToWrite: [string, string][] = [ + ['ORCA_AGENT_HOOK_PORT', String(fields.port)], + ['ORCA_AGENT_HOOK_TOKEN', fields.token], + ['ORCA_AGENT_HOOK_ENV', fields.env], + ['ORCA_AGENT_HOOK_VERSION', fields.version] + ] + for (const [key, value] of valuesToWrite) { + if (!isShellSafeEndpointValue(value)) { + console.error( + `[agent-hooks] refusing to write endpoint file: ${key} contains ` + + 'characters unsafe for shell sourcing. Falling back to PTY env.' + ) + return false + } + } + const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] + let tmpWritten = false + try { + // Why: 0o700 owner-only so the dir doesn't leak this install's existence to other local users. + mkdirSync(endpointDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + // Why: mkdirSync mode only applies on creation; chmod fixes perms on a pre-existing dir (POSIX-only). + try { + chmodSync(endpointDir, 0o700) + } catch { + // best-effort + } + } + // Why: crash-orphan cleanup must not materialize a tampered, enormous directory. + sweepStaleAgentHookEndpointTemps(endpointDir) + const separator = process.platform === 'win32' ? '\r\n' : '\n' + writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) + tmpWritten = true + renameSync(tmpPath, finalPath) + return true + } catch (err) { + console.error('[agent-hooks] failed to write endpoint file:', err) + if (tmpWritten) { + try { + unlinkSync(tmpPath) + } catch { + // tmp may already be gone + } + } + return false + } +} diff --git a/src/shared/agent-hook-listener/grok-result-discovery.ts b/src/shared/agent-hook-listener/grok-result-discovery.ts new file mode 100644 index 00000000000..7cdc306082e --- /dev/null +++ b/src/shared/agent-hook-listener/grok-result-discovery.ts @@ -0,0 +1,221 @@ +import { homedir } from 'node:os' +import { isAbsolute, join } from 'node:path' + +import type { AgentHookSource } from '../agent-hook-relay' +import { + buildGrokChatHistoryPathCandidates, + findGrokChatHistoryBySessionId, + getCachedGrokChatHistoryBySessionId, + GROK_SESSION_ID_MAX_LENGTH, + isSafeGrokSessionId, + resolveGrokChatHistoryPathSync, + resolveGrokSessionsDir +} from '../grok-session-paths' +import { readFirstString } from './interactive-tool' +import { parseAgentHookJson } from './request-body' +import { GROK_HOME_ENVELOPE_MAX_LENGTH, GROK_SESSION_CWD_MAX_LENGTH } from './listener-limits' +import { readLastAssistantFromTranscriptOnce } from './transcript-reader' +import { isGrokEvent } from './provider-event-names' +import { isAntigravityStopStillBusy } from './providers/antigravity-event-rules' + +export function parseHookBodyPayloadRecord(body: unknown): Record | null { + if (typeof body !== 'object' || body === null) { + return null + } + const rawPayload = (body as Record).payload + const payload = + typeof rawPayload === 'string' + ? (() => { + try { + return parseAgentHookJson(rawPayload) + } catch { + return null + } + })() + : rawPayload + return typeof payload === 'object' && payload !== null + ? (payload as Record) + : null +} + +export function readBoundedString( + record: Record, + keys: readonly string[], + maxLength: number +): string | undefined { + const value = readFirstString(record, keys) + return value && value.length <= maxLength ? value : undefined +} + +export function readGrokHomeEnvelope(record: Record): string | undefined { + const value = readBoundedString(record, ['grokHome'], GROK_HOME_ENVELOPE_MAX_LENGTH) + if (!value || value !== value.trim() || !isAbsolute(value) || hasControlCharacter(value)) { + return undefined + } + return value +} + +export function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +type GrokSessionMetadata = { + sessionId: string + cwd?: string + sessionsDir: string +} + +export function readGrokSessionMetadata( + hookPayload: Record, + grokHome?: string +): GrokSessionMetadata | undefined { + const sessionId = readBoundedString( + hookPayload, + ['sessionId', 'session_id'], + GROK_SESSION_ID_MAX_LENGTH + ) + if (!sessionId || !isSafeGrokSessionId(sessionId)) { + return undefined + } + const cwd = readBoundedString( + hookPayload, + ['cwd', 'workspaceRoot', 'workspace_root'], + GROK_SESSION_CWD_MAX_LENGTH + ) + // Why: hook scripts report the effective per-PTY/remote Grok home; old scripts fall back to the runtime's for compatibility. + const sessionsDir = grokHome + ? join(grokHome, 'sessions') + : resolveGrokSessionsDir(process.env, homedir()) + return { sessionId, cwd, sessionsDir } +} + +export function getGrokChatHistoryPath( + hookPayload: Record, + grokHome?: string +): string | undefined { + const metadata = readGrokSessionMetadata(hookPayload, grokHome) + if (!metadata) { + return undefined + } + const resolved = resolveGrokChatHistoryPathSync({ + sessionId: metadata.sessionId, + cwd: metadata.cwd ?? null, + sessionsDir: metadata.sessionsDir + }) + if (resolved) { + return resolved + } + const cached = getCachedGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId) + if (cached) { + return cached + } + // Why: SessionEnd can race the last write; return a plausible on-disk candidate (short-cwd preferred) even if the file doesn't exist yet. + if (!metadata.cwd) { + return undefined + } + return ( + buildGrokChatHistoryPathCandidates({ + sessionId: metadata.sessionId, + cwd: metadata.cwd, + sessionsDir: metadata.sessionsDir + })[0] ?? undefined + ) +} + +export function readLastAssistantFromGrokChatHistory( + hookPayload: Record, + grokHome?: string +): string | undefined { + const chatHistoryPath = getGrokChatHistoryPath(hookPayload, grokHome) + if (!chatHistoryPath) { + return undefined + } + return readLastAssistantFromTranscriptOnce(chatHistoryPath) +} + +export function hasPendingAgentResultText(source: AgentHookSource, body: unknown): boolean { + const envelope = + typeof body === 'object' && body !== null ? (body as Record) : null + const record = parseHookBodyPayloadRecord(body) + if (!record) { + return false + } + if (hasExplicitLastAssistantResult(record)) { + return false + } + if (source === 'copilot') { + // Why: Copilot Stop uses generic `message` as final assistant text; Grok/Antigravity use that field for status instead. + if (hasNonEmptyString(record.message)) { + return false + } + const transcriptPath = record.transcript_path ?? record.transcriptPath + return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 + } + const eventName = + envelope?.hook_event_name ?? + envelope?.hookEventName ?? + record.hook_event_name ?? + record.hookEventName + if (source === 'antigravity' && eventName === 'Stop') { + if (isAntigravityStopStillBusy(record)) { + return false + } + const transcriptPath = record.transcriptPath ?? record.transcript_path + return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 + } + const pendingGrokDiscovery = preparePendingGrokResultDiscovery(source, body) + if (pendingGrokDiscovery) { + void pendingGrokDiscovery + return true + } + return false +} + +export function hasNonEmptyString(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0 +} + +export function hasExplicitLastAssistantResult(record: Record): boolean { + return ( + hasNonEmptyString(record.last_assistant_message) || + hasNonEmptyString(record.lastAssistantMessage) + ) +} + +/** Start bounded discovery only for a Grok completion that still needs result text. */ +export function preparePendingGrokResultDiscovery( + source: AgentHookSource, + body: unknown +): Promise | null { + if (source !== 'grok') { + return null + } + const envelope = + typeof body === 'object' && body !== null ? (body as Record) : null + const record = parseHookBodyPayloadRecord(body) + if (!record || hasExplicitLastAssistantResult(record)) { + return null + } + const eventName = + envelope?.hook_event_name ?? + envelope?.hookEventName ?? + record.hook_event_name ?? + record.hookEventName + if (!isGrokEvent(eventName, 'stop', 'session_end')) { + return null + } + const metadata = readGrokSessionMetadata( + record, + envelope ? readGrokHomeEnvelope(envelope) : undefined + ) + if (!metadata) { + return null + } + // Why: lets the server await discovery without moving filesystem I/O back into synchronous hook normalization. + return findGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId).then( + () => undefined + ) +} diff --git a/src/shared/agent-hook-listener/hook-envelope.ts b/src/shared/agent-hook-listener/hook-envelope.ts new file mode 100644 index 00000000000..5435f9fe912 --- /dev/null +++ b/src/shared/agent-hook-listener/hook-envelope.ts @@ -0,0 +1,79 @@ +import type { AgentHookSource } from '../agent-hook-relay' +import { parsePaneKey } from '../stable-pane-id' +import { MAX_PANE_KEY_LEN, warnOnHookEnvOrVersionMismatch } from './listener-limits' +import type { HookListenerState } from './listener-state' +import { parseAgentHookJson } from './request-body' +function readEnvelopeString(record: Record, key: string): string | undefined { + const value = record[key] + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export type ParsedHookEnvelope = { + record: Record + paneKey: string + hookPayloadRecord: Record + tabId?: string + worktreeId?: string + launchToken?: string +} + +/** Validates the transport envelope while preserving warning-before-tab-rejection order. */ +export function parseHookEnvelope( + state: HookListenerState, + source: AgentHookSource, + body: unknown, + expectedEnv: string +): ParsedHookEnvelope | null { + if (typeof body !== 'object' || body === null) { + return null + } + const record = body as Record + const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const parsedPaneKey = parsePaneKey(paneKey) + const rawPayload = record.payload + // Antigravity may carry a transition with absent stdin; every other provider requires payload. + const antigravityPayloadAbsent = + source === 'antigravity' && + (rawPayload === undefined || (typeof rawPayload === 'string' && rawPayload.trim() === '')) + const hookPayload = antigravityPayloadAbsent + ? {} + : typeof rawPayload === 'string' + ? (() => { + try { + return parseAgentHookJson(rawPayload) + } catch { + return null + } + })() + : rawPayload + if ( + !paneKey || + paneKey.length > MAX_PANE_KEY_LEN || + !parsedPaneKey || + typeof hookPayload !== 'object' || + hookPayload === null + ) { + return null + } + warnOnHookEnvOrVersionMismatch(state, { + version: readEnvelopeString(record, 'version'), + env: readEnvelopeString(record, 'env'), + expectedEnv + }) + const tabId = readEnvelopeString(record, 'tabId') + if (tabId && tabId !== parsedPaneKey.tabId) { + return null + } + return { + record, + paneKey, + hookPayloadRecord: hookPayload as Record, + tabId, + worktreeId: readEnvelopeString(record, 'worktreeId'), + launchToken: readEnvelopeString(record, 'launchToken') + } +} diff --git a/src/shared/agent-hook-listener/interactive-tool.ts b/src/shared/agent-hook-listener/interactive-tool.ts new file mode 100644 index 00000000000..bc3042cd5ca --- /dev/null +++ b/src/shared/agent-hook-listener/interactive-tool.ts @@ -0,0 +1,124 @@ +import { isAskUserQuestionTool } from '../agent-question-answered-intent' +import type { ToolSnapshot } from './listener-event' +import { parseAgentHookJson } from './request-body' +import { readString, toolUpdate } from './tool-input-preview' +import { normalizeHookEventName } from './provider-event-names' + +/** Clear active-tool metadata so a failed tool stops looking in-flight (else the compact sidebar hides the error behind the tool name). */ +export function clearActiveToolFieldsUpdate(): ToolSnapshot { + return toolUpdate( + { toolName: undefined, toolInput: undefined, interactivePrompt: undefined }, + { hasToolInputField: true } + ) +} + +/** Drop the hook envelope keys a plugin merges into event properties so the serialized prompt holds only the question structure. */ +export function stripHookEnvelopeKeys(record: Record): Record { + const { hook_event_name: _h, hookEventName: _he, ...rest } = record + return rest +} + +/** One-line description of a tool call for an approval card (Bash command, file path, else clipped JSON). */ +export function summarizeApprovalInput(toolInput: unknown): string { + if (toolInput && typeof toolInput === 'object') { + const obj = toolInput as Record + const direct = obj.command ?? obj.file_path ?? obj.path ?? obj.url ?? obj.pattern + if (typeof direct === 'string' && direct.length > 0) { + return direct.length > 200 ? `${direct.slice(0, 200)}…` : direct + } + } + try { + const json = JSON.stringify(toolInput) ?? '' + return json.length > 200 ? `${json.slice(0, 200)}…` : json + } catch { + return '' + } +} + +/** Normalized JSON envelope for a pending prompt: AskUserQuestion → `{ questions }` (shape kept stable for back-compat); other tool on PermissionRequest → `{ approval }`; else undefined. */ +export function deriveInteractivePrompt( + toolName: string | undefined, + toolInput: unknown, + eventName?: unknown +): string | undefined { + // Why: providers vary casing; any post-tool event means the question is no longer pending — don't recreate its answered card. + const normalizedEventName = normalizeHookEventName(eventName) + const isPostToolEvent = + normalizedEventName === 'post_tool_use' || normalizedEventName === 'post_tool_use_failure' + if ( + isAskUserQuestionTool(toolName) && + !isPostToolEvent && + toolInput !== undefined && + toolInput !== null + ) { + try { + return JSON.stringify(toolInput) + } catch { + // Why: circular/unserializable input from a buggy agent — a missing live card beats throwing in the hook hot path. + return undefined + } + } + if (eventName === 'PermissionRequest' && typeof toolName === 'string' && toolName.length > 0) { + try { + return JSON.stringify({ + approval: { tool: toolName, summary: summarizeApprovalInput(toolInput) } + }) + } catch { + return undefined + } + } + return undefined +} + +export function readFirstString( + record: Record, + keys: readonly string[] +): string | undefined { + for (const key of keys) { + const value = readString(record, key) + if (value) { + return value + } + } + return undefined +} + +export function parseJsonObjectString(value: unknown): Record | undefined { + if (typeof value !== 'string' || value.trim().length === 0) { + return undefined + } + try { + const parsed = parseAgentHookJson(value) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : undefined + } catch { + return undefined + } +} + +export function extractToolResponseText(toolResponse: unknown): string | undefined { + if (typeof toolResponse === 'string' && toolResponse.length > 0) { + return toolResponse + } + if (typeof toolResponse !== 'object' || toolResponse === null) { + return undefined + } + const record = toolResponse as Record + const directText = readFirstString(record, ['text_result_for_llm', 'textResultForLlm', 'text']) + if (directText) { + return directText + } + const content = record.content + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + return undefined +} diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts new file mode 100644 index 00000000000..cfbe6394a71 --- /dev/null +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -0,0 +1,55 @@ +import type { ParsedAgentStatusPayload } from '../agent-status-types' +import type { AgentHookSource } from '../agent-hook-relay' +import type { AgentProviderSessionMetadata } from '../agent-session-resume' + +export type AgentHookEventPayload = { + paneKey: string + /** Authenticated hook route that produced this event. */ + source?: AgentHookSource + /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ + launchToken?: string + tabId?: string + worktreeId?: string + /** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop + * in-flight events from a superseded connection after an SSH reconnect. */ + connectionId: string | null + /** True when the event carried prompt text directly, not the listener's cached prompt from an earlier event in the pane. */ + hasExplicitPrompt?: boolean + /** The emitted nonterminal state is backed only by child state restored from disk. */ + restoredUnconfirmed?: true + /** Stable per-turn key to distinguish duplicate hook delivery from a same-text prompt rerun (when the source exposes enough context). */ + promptInteractionKey?: string + /** Raw agent hook event name, used by main-process transition guards. */ + hookEventName?: string + /** Claude's provider-owned user-prompt UUID. */ + providerPromptId?: string + /** Active Claude compact generation, keyed by provider prompt identity. */ + compactTrigger?: 'manual' | 'auto' + /** Claude tool-use identifier when the hook source exposes one. */ + toolUseId?: string + /** Claude agent/subagent identifier when the hook source exposes one. */ + toolAgentId?: string + /** Claude teammate name carried by TeammateIdle. */ + teammateName?: string + /** Agent/subagent type from the source hook payload, when present. */ + toolAgentType?: string + /** Provider-owned conversation/session id needed to resume a sleeping agent. */ + providerSession?: AgentProviderSessionMetadata + /** Session identity update with no turn-state transition; refreshes durable resume metadata without a fake status row. */ + providerSessionOnly?: boolean + /** True when this event is a relay cache replay rather than a live hook. */ + isReplay?: boolean + /** Transport-only Claude background-work evidence used to reject false input-based interrupts. */ + claudeRunningNonAgentTask?: boolean + payload: ParsedAgentStatusPayload +} +export type ToolSnapshot = { + toolName?: string + toolInput?: string + /** Full JSON of an AskUserQuestion tool input; set only on its own event and NOT inherited (resolveToolState) so no stale prompt lingers. */ + interactivePrompt?: string + hasToolUpdate?: boolean + hasToolInputField?: boolean + lastAssistantMessage?: string + clearLastAssistantMessage?: boolean +} diff --git a/src/shared/agent-hook-listener/listener-limits.ts b/src/shared/agent-hook-listener/listener-limits.ts new file mode 100644 index 00000000000..872f0a617ba --- /dev/null +++ b/src/shared/agent-hook-listener/listener-limits.ts @@ -0,0 +1,63 @@ +import { ORCA_HOOK_PROTOCOL_VERSION } from '../agent-hook-types' +import { REMOTE_AGENT_HOOK_ENV } from '../agent-hook-relay' +import type { HookListenerState } from './listener-state' + +/** Bound the warn-once Sets so a client varying `version`/`env` per request can't grow them unbounded. */ +const MAX_WARNED_KEYS = 32 + +/** Slowloris cap: drop requests that have not finished sending after 5 s. */ +export const HOOK_REQUEST_SLOWLORIS_MS = 5_000 + +/** Why: old OpenCode plugin builds re-post the full accumulated reply on every streamed part (O(n²) bytes/turn); cap at ingest to bound per-event cost. */ +export const OPENCODE_HOOK_TEXT_MAX_CHARS = 8_000 + +export function capOpenCodeHookText(text: string): string { + return text.length > OPENCODE_HOOK_TEXT_MAX_CHARS + ? text.slice(0, OPENCODE_HOOK_TEXT_MAX_CHARS) + : text +} + +/** Bound paneKey size (real keys are well under 200); caps per-pane caches against pathological input. Exported so non-HTTP ingest (`ingestRemote`) applies the same cap as defense-in-depth. */ +export const MAX_PANE_KEY_LEN = 200 +export const AMP_THREAD_ID_MAX_LENGTH = 256 +export const AMP_MAX_SCOPED_THREAD_CACHE_KEYS = 32 +export const GROK_SESSION_CWD_MAX_LENGTH = 4096 +export const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 +const CLAUDE_PROMPT_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i + +export function normalizeClaudePromptId(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const normalized = value.trim().toLowerCase() + return CLAUDE_PROMPT_ID_RE.test(normalized) ? normalized : undefined +} +/** Warn-once on cross-build (`version`) and dev-vs-prod (`env`) mismatches; the relay's "remote" env marker is a location tag, not a build env, so it must not warn as a stale local hook. */ +export function warnOnHookEnvOrVersionMismatch( + state: HookListenerState, + fields: { version?: string; env?: string; expectedEnv: string } +): void { + const { version, env, expectedEnv } = fields + if ( + version && + version !== ORCA_HOOK_PROTOCOL_VERSION && + !state.warnedVersions.has(version) && + state.warnedVersions.size < MAX_WARNED_KEYS + ) { + state.warnedVersions.add(version) + console.warn( + `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + + 'Reinstall agent hooks from Settings to upgrade the managed script.' + ) + } + if (env && env !== REMOTE_AGENT_HOOK_ENV && env !== expectedEnv) { + const key = `${env}->${expectedEnv}` + if (!state.warnedEnvs.has(key) && state.warnedEnvs.size < MAX_WARNED_KEYS) { + state.warnedEnvs.add(key) + console.warn( + `[agent-hooks] received ${env} hook on ${expectedEnv} server. ` + + 'Likely a stale terminal from another Orca install.' + ) + } + } +} diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts new file mode 100644 index 00000000000..f6f9308dc62 --- /dev/null +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -0,0 +1,216 @@ +import type { AgentStatusState } from '../agent-status-types' +import type { ClaudeSubagentRoster } from '../claude-subagent-roster' +import type { CodexSubagentRoster } from '../codex-subagent-roster' +import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' +import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' + +/** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */ +export type HookListenerState = { + warnedVersions: Set + warnedEnvs: Set + lastPromptByPaneKey: Map + lastToolByPaneKey: Map + lastStatusByPaneKey: Map + antigravityCompletedTranscriptByPaneKey: Map + ampCompletedCacheKeys: Set + /** Live subagents/teammates per Claude pane; survives turn boundaries since background children outlive the lead turn. */ + claudeSubagentRosterByPaneKey: Map + /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `interrupted` persists so the eventual done still carries it. */ + claudeLeadStateByPaneKey: Map + /** One-normalization provenance marker for a status backed only by restored child state. */ + claudeUnconfirmedRestoredStatusPaneKeys: Set + /** Panes whose latest authoritative Claude task inventory still has running non-agent work. */ + claudeRunningNonAgentTaskPaneKeys: Set + /** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */ + claudeActiveSessionCronPaneKeys: Set + /** Compact whose completion each pane already applied, so relay duplicates can't refresh the row. */ + claudeConsumedCompactPromptIdByPaneKey: Map + /** Claude `session_id` that last reported on the pane from a LEAD event. A different id means the + * conversation was replaced (/clear, relaunch, resume), so claims the old session owned are void + * even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */ + claudeSessionOwnerByPaneKey: Map + /** Live thread-spawn children per Codex pane. */ + codexSubagentRosterByPaneKey: Map + /** Incremental parent/child rollout cursors for Codex collaboration v2. */ + codexSubagentTranscriptByPaneKey: Map + /** Root Codex state/model, kept separate from child hook traffic. */ + codexLeadStateByPaneKey: Map +} + +export type ClaudeLeadTurnState = { + state: AgentStatusState + interrupted?: true + /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ + waitingAgentId?: string + /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ + waitingToolUseId?: string + /** End time of the lead turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ + turnCompletedAt?: number + /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ + stateBeforeWait?: Pick +} + +export type CodexLeadTurnState = { + state: 'working' | 'waiting' | 'done' + model?: string +} + +export function createHookListenerState(): HookListenerState { + return { + warnedVersions: new Set(), + warnedEnvs: new Set(), + lastPromptByPaneKey: new Map(), + lastToolByPaneKey: new Map(), + lastStatusByPaneKey: new Map(), + antigravityCompletedTranscriptByPaneKey: new Map(), + ampCompletedCacheKeys: new Set(), + claudeSubagentRosterByPaneKey: new Map(), + claudeLeadStateByPaneKey: new Map(), + claudeUnconfirmedRestoredStatusPaneKeys: new Set(), + claudeRunningNonAgentTaskPaneKeys: new Set(), + claudeActiveSessionCronPaneKeys: new Set(), + claudeConsumedCompactPromptIdByPaneKey: new Map(), + claudeSessionOwnerByPaneKey: new Map(), + codexSubagentRosterByPaneKey: new Map(), + codexSubagentTranscriptByPaneKey: new Map(), + codexLeadStateByPaneKey: new Map() + } +} + +export function clearPaneCacheState(state: HookListenerState, paneKey: string): void { + deletePaneScopedCacheEntry(state.lastPromptByPaneKey, paneKey) + deletePaneScopedCacheEntry(state.lastToolByPaneKey, paneKey) + deletePaneScopedCacheEntry(state.lastStatusByPaneKey, paneKey) + deletePaneScopedCacheEntry(state.antigravityCompletedTranscriptByPaneKey, paneKey) + deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey) + deletePaneScopedCacheEntry(state.claudeConsumedCompactPromptIdByPaneKey, paneKey) + state.claudeSubagentRosterByPaneKey.delete(paneKey) + state.claudeLeadStateByPaneKey.delete(paneKey) + state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeSessionOwnerByPaneKey.delete(paneKey) + state.codexSubagentRosterByPaneKey.delete(paneKey) + state.codexSubagentTranscriptByPaneKey.delete(paneKey) + state.codexLeadStateByPaneKey.delete(paneKey) +} + +/** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that + * `resolveClaudePaneStatus` would re-gate `working` from on the pane's next event? + * + * Deliberately lives next to `clearPaneCacheState` above and enumerates the claim-bearing subset of + * what that function deletes: the two must be edited together, and keeping them three lines apart in + * one file is what makes that obvious. Prompt/tool/transcript caches are excluded — they render a + * row, they never create one. */ +export function paneHasStateClaims(state: HookListenerState, paneKey: string): boolean { + return ( + state.lastStatusByPaneKey.has(paneKey) || + state.claudeSubagentRosterByPaneKey.has(paneKey) || + state.claudeLeadStateByPaneKey.has(paneKey) || + state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey) || + state.claudeSessionOwnerByPaneKey.has(paneKey) || + state.codexSubagentRosterByPaneKey.has(paneKey) || + state.codexLeadStateByPaneKey.has(paneKey) + ) +} + +export function movePaneScopedMapEntries( + map: Map, + fromPaneKey: string, + toPaneKey: string +): void { + for (const [key, value] of Array.from(map.entries())) { + if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { + continue + } + map.delete(key) + map.set(`${toPaneKey}${key.slice(fromPaneKey.length)}`, value) + } +} + +export function movePaneScopedSetEntries( + set: Set, + fromPaneKey: string, + toPaneKey: string +): void { + for (const key of Array.from(set)) { + if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) { + continue + } + set.delete(key) + set.add(`${toPaneKey}${key.slice(fromPaneKey.length)}`) + } +} + +export function movePaneCacheState( + state: HookListenerState, + fromPaneKey: string, + toPaneKey: string +): void { + if (fromPaneKey === toPaneKey) { + return + } + movePaneScopedMapEntries(state.lastPromptByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.lastToolByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.lastStatusByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.antigravityCompletedTranscriptByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedSetEntries(state.ampCompletedCacheKeys, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeConsumedCompactPromptIdByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey) + movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey) + movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.claudeSessionOwnerByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) +} + +export function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void { + state.lastPromptByPaneKey.delete(paneKey) + state.lastToolByPaneKey.delete(paneKey) + state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) + state.ampCompletedCacheKeys.delete(paneKey) +} + +export function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { + map.delete(paneKey) + const scopedPrefix = `${paneKey}\0` + for (const key of map.keys()) { + if (key.startsWith(scopedPrefix)) { + map.delete(key) + } + } +} + +export function deletePaneScopedSetEntry(set: Set, paneKey: string): void { + set.delete(paneKey) + const scopedPrefix = `${paneKey}\0` + for (const key of set) { + if (key.startsWith(scopedPrefix)) { + set.delete(key) + } + } +} + +export function clearAllListenerCaches(state: HookListenerState): void { + state.lastPromptByPaneKey.clear() + state.lastToolByPaneKey.clear() + state.lastStatusByPaneKey.clear() + state.antigravityCompletedTranscriptByPaneKey.clear() + state.ampCompletedCacheKeys.clear() + state.claudeConsumedCompactPromptIdByPaneKey.clear() + state.warnedVersions.clear() + state.warnedEnvs.clear() + state.claudeSubagentRosterByPaneKey.clear() + state.claudeLeadStateByPaneKey.clear() + state.claudeUnconfirmedRestoredStatusPaneKeys.clear() + state.claudeRunningNonAgentTaskPaneKeys.clear() + state.claudeActiveSessionCronPaneKeys.clear() + state.claudeSessionOwnerByPaneKey.clear() + state.codexSubagentRosterByPaneKey.clear() + state.codexSubagentTranscriptByPaneKey.clear() + state.codexLeadStateByPaneKey.clear() +} diff --git a/src/shared/agent-hook-listener/prompt-fields.ts b/src/shared/agent-hook-listener/prompt-fields.ts new file mode 100644 index 00000000000..d76f67f06a8 --- /dev/null +++ b/src/shared/agent-hook-listener/prompt-fields.ts @@ -0,0 +1,153 @@ +import { + isCompactContinuationUserTurnText, + isKnownHarnessInjectedUserTurnText +} from '../harness-injected-user-turns' +import { capOpenCodeHookText } from './listener-limits' +import type { HookListenerState } from './listener-state' +import type { ToolSnapshot } from './listener-event' + +// ─── Per-pane field caches + extractors ───────────────────────────── + +export type ExtractedPromptText = { + text: string + source: + | 'prompt' + | 'user_prompt' + | 'userPrompt' + | 'initial_prompt' + | 'initialPrompt' + | 'user_message' + | 'message' + | 'role_user_text' + | null +} + +// Joins text of an Anthropic-style content-block array; returns '' when nothing textual so callers fall through to the next prompt source. +export function contentBlockArrayText(value: unknown[]): string { + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + if (item && typeof item === 'object') { + const text = (item as Record).text + if (typeof text === 'string') { + parts.push(text) + } + } + } + return parts.join(' ').replace(/\s+/g, ' ').trim() +} + +export function extractPromptText(hookPayload: Record): ExtractedPromptText { + const candidateKeys = [ + 'prompt', + 'user_prompt', + 'userPrompt', + 'initial_prompt', + 'initialPrompt', + 'user_message', + 'message' + ] + for (const key of candidateKeys) { + const value = hookPayload[key] + if (typeof value === 'string' && value.trim().length > 0) { + // Why: trim so prompts match readStringField output — whitespace would otherwise leak into UI and caches. + return { text: value.trim(), source: key as Exclude } + } + // Why: Kimi sends `prompt` as a content-block array, not a string; extract it for real prompt keys but skip `message` (ambiguous status field). + if (key !== 'message' && Array.isArray(value)) { + const text = contentBlockArrayText(value) + if (text.length > 0) { + return { text, source: key as Exclude } + } + } + } + // Why: OpenCode sends MessagePart { role, text } with no UserPromptSubmit; when role === 'user' the text is the prompt. + if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { + const trimmed = capOpenCodeHookText(hookPayload.text.trim()) + if (trimmed.length > 0) { + return { text: trimmed, source: 'role_user_text' } + } + } + return { text: '', source: null } +} + +export function stripGrokUserQueryWrapper(promptText: string): string { + const opener = '' + if (!promptText.startsWith(opener)) { + return promptText + } + const closer = '' + const wrappedText = promptText.slice(opener.length) + const text = wrappedText.endsWith(closer) ? wrappedText.slice(0, -closer.length) : wrappedText + // Why: Grok wraps the submitted prompt in a `` envelope; the status cache should hold the plain user text. + return text.trim() +} + +// Why: the post-compact continuation prompt has no matching Stop and would resurrect working. +export function shouldIgnoreCompactContinuationUserPromptSubmit( + eventName: unknown, + promptText: string +): boolean { + return eventName === 'UserPromptSubmit' && isCompactContinuationUserTurnText(promptText) +} + +export function resolvePrompt( + state: HookListenerState, + paneKey: string, + promptText: string, + options?: { resetOnNewTurn?: boolean } +): string { + // Why: harness-injected turns fire UserPromptSubmit but aren't the user's ask — keep cached prompt; match only known tags so real still reset the turn. + if (isKnownHarnessInjectedUserTurnText(promptText)) { + return state.lastPromptByPaneKey.get(paneKey) ?? '' + } + if (options?.resetOnNewTurn) { + state.lastPromptByPaneKey.delete(paneKey) + } + if (promptText) { + state.lastPromptByPaneKey.set(paneKey, promptText) + return promptText + } + return state.lastPromptByPaneKey.get(paneKey) ?? '' +} + +export function resolveToolState( + state: HookListenerState, + paneKey: string, + update: ToolSnapshot, + options: { resetOnNewTurn: boolean } +): ToolSnapshot { + if (options.resetOnNewTurn) { + state.lastToolByPaneKey.delete(paneKey) + } + const previous = state.lastToolByPaneKey.get(paneKey) ?? {} + // Why: undefined means either "no update" or "input not previewable"; extractor metadata decides whether to inherit stale input. + const clearsUnpreviewableInput = + update.hasToolInputField === true && update.toolInput === undefined + const clearsUnidentifiedTool = + update.hasToolUpdate === true && + update.toolName === undefined && + update.hasToolInputField === true + const toolName = clearsUnidentifiedTool ? undefined : (update.toolName ?? previous.toolName) + const toolInput = + clearsUnpreviewableInput || + (update.toolName !== undefined && + update.toolName !== previous.toolName && + update.toolInput === undefined) + ? undefined + : (update.toolInput ?? previous.toolInput) + const merged: ToolSnapshot = { + toolName, + toolInput, + // Why: don't inherit previous.interactivePrompt — valid only for its one AskUserQuestion event; carrying it forward leaves a stale live card. + interactivePrompt: update.interactivePrompt, + lastAssistantMessage: update.clearLastAssistantMessage + ? undefined + : (update.lastAssistantMessage ?? previous.lastAssistantMessage) + } + state.lastToolByPaneKey.set(paneKey, merged) + return merged +} diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts new file mode 100644 index 00000000000..473bb557d09 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -0,0 +1,154 @@ +import type { ParsedAgentStatusPayload } from '../agent-status-types' +import type { AgentHookSource } from '../agent-hook-relay' +import { readLastCommandCodeUserPromptEntryFromTranscript } from './command-code-transcript' +import { readGrokHomeEnvelope } from './grok-result-discovery' +import { readFirstString } from './interactive-tool' +import type { HookListenerState } from './listener-state' +import type { ExtractedPromptText } from './prompt-fields' +import { isNewTurnEvent } from './provider-event-routing' +import { readLastUserPromptFromTranscript } from './transcript-lines' +import { normalizeAntigravityEvent } from './providers/antigravity-events' +import { normalizeAmpEvent } from './providers/amp-events' +import { normalizeClaudeEvent } from './providers/claude-events' +import { normalizeCodexEvent } from './providers/codex-events' +import { normalizeGeminiEvent } from './providers/gemini-events' +import { normalizeOpenCodeFamilyEvent } from './providers/opencode-family-events' +import { normalizeCursorEvent } from './providers/cursor-events' +import { normalizePiCompatibleEvent } from './providers/pi-family-events' +import { normalizeDroidEvent } from './providers/droid-events' +import { normalizeCommandCodeEvent } from './providers/command-code-events' +import { normalizeGrokEvent } from './providers/grok-events' +import { normalizeCopilotEvent } from './providers/copilot-events' +import { normalizeHermesEvent } from './providers/hermes-events' +import { normalizeDevinEvent } from './providers/devin-events' +import { normalizeKimiEvent } from './providers/kimi-events' + +export type ProviderDispatchResult = { + payload: ParsedAgentStatusPayload | null + resolvedPromptText: string + promptInteractionKey?: string + hasTranscriptPromptEvidence: boolean +} + +/** Exhaustive provider routing with provider-specific transcript locality and attribution. */ +export function normalizeProviderEvent(input: { + state: HookListenerState + source: AgentHookSource + eventName: unknown + promptText: string + paneKey: string + hookPayload: Record + envelope: Record + extractedPrompt: ExtractedPromptText +}): ProviderDispatchResult { + const { state, source, eventName, promptText, paneKey, hookPayload, envelope, extractedPrompt } = + input + let resolvedPromptText = promptText + let promptInteractionKey: string | undefined + let hasTranscriptPromptEvidence = false + let payload: ParsedAgentStatusPayload | null + + switch (source) { + case 'claude': + payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'codex': + payload = normalizeCodexEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'gemini': + payload = normalizeGeminiEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'antigravity': + if (isNewTurnEvent('antigravity', eventName)) { + resolvedPromptText = + promptText || + readLastUserPromptFromTranscript( + readFirstString(hookPayload, ['transcriptPath', 'transcript_path']) + ) || + '' + } + payload = normalizeAntigravityEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'amp': + payload = normalizeAmpEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'opencode': + case 'mimo-code': { + if (extractedPrompt.source === 'role_user_text') { + const messageId = readFirstString(hookPayload, ['messageID', 'messageId', 'message_id']) + const prefix = source === 'mimo-code' ? 'mimo-code-message' : 'opencode-message' + promptInteractionKey = messageId ? `${prefix}-${messageId}` : undefined + } + payload = normalizeOpenCodeFamilyEvent( + source, + state, + eventName, + promptText, + paneKey, + hookPayload + ) + break + } + case 'cursor': + payload = normalizeCursorEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'pi': + case 'omp': + case 'prime-agent': + payload = normalizePiCompatibleEvent( + state, + source, + eventName, + promptText, + paneKey, + hookPayload + ) + break + case 'droid': + payload = normalizeDroidEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'command-code': { + const transcriptPrompt = readLastCommandCodeUserPromptEntryFromTranscript( + hookPayload.transcript_path ?? hookPayload.transcriptPath + ) + hasTranscriptPromptEvidence = transcriptPrompt !== undefined + promptInteractionKey = transcriptPrompt?.interactionKey + resolvedPromptText = transcriptPrompt?.text ?? '' + if (promptText && extractedPrompt.source !== 'message') { + resolvedPromptText = promptText + } + payload = normalizeCommandCodeEvent( + state, + eventName, + resolvedPromptText, + paneKey, + hookPayload + ) + break + } + case 'grok': + payload = normalizeGrokEvent( + state, + eventName, + promptText, + paneKey, + hookPayload, + readGrokHomeEnvelope(envelope) + ) + break + case 'copilot': + payload = normalizeCopilotEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'hermes': + payload = normalizeHermesEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'devin': + payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayload) + break + case 'kimi': + payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayload) + break + } + + return { payload, resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence } +} diff --git a/src/shared/agent-hook-listener/provider-event-names.ts b/src/shared/agent-hook-listener/provider-event-names.ts new file mode 100644 index 00000000000..f21d61ac368 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-event-names.ts @@ -0,0 +1,16 @@ +// Provider event-name normalization shared by routing and provider modules. + +export function normalizeHookEventName(value: unknown): string { + if (typeof value !== 'string') { + return '' + } + return value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[-\s]+/g, '_') + .toLowerCase() +} + +export function isGrokEvent(eventName: unknown, ...expected: readonly string[]): boolean { + return expected.includes(normalizeHookEventName(eventName)) +} diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts new file mode 100644 index 00000000000..e74de1fdc44 --- /dev/null +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -0,0 +1,174 @@ +import type { AgentHookSource } from '../agent-hook-relay' +import { isKnownHarnessInjectedUserTurnText } from '../harness-injected-user-turns' +import type { ToolSnapshot } from './listener-event' +import type { ExtractedPromptText } from './prompt-fields' +import { extractClaudeToolFields } from './providers/claude-tool-fields' +import { extractCodexToolFields } from './providers/codex-tool-fields' +import { extractGeminiToolFields } from './providers/gemini-tool-fields' +import { extractAntigravityToolFields } from './providers/antigravity-tool-fields' +import { extractAmpToolFields } from './providers/amp-tool-fields' +import { extractOpenCodeToolFields } from './providers/opencode-family-tool-fields' +import { extractCursorToolFields } from './providers/cursor-tool-fields' +import { + extractCopilotToolFields, + normalizeCopilotEventName +} from './providers/copilot-tool-fields' +import { extractPiToolFields } from './providers/pi-family-tool-fields' +import { extractDroidToolFields } from './providers/droid-tool-fields' +import { extractCommandCodeToolFields } from './providers/command-code-tool-fields' +import { isGrokEvent } from './provider-event-names' +import { extractGrokToolFields } from './providers/grok-tool-fields' +import { extractHermesToolFields } from './providers/hermes-tool-fields' + +export function isGrokIdleNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('type your message') || + lower.includes('enter send') || + lower.includes('shift-tab normal') || + lower.includes('ask a side question') + ) +} + +/** The per-provider answer to "is this event a user-initiated new turn?". Exported so the + * observation stamp reuses it instead of minting a second list of event-name literals. */ +export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { + // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. + switch (source) { + case 'claude': + // Why: SessionStart lands an idle row (STA-3386) and must also drop stale + // tool/prompt caches left by the pane's previous session. + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + case 'kimi': + // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. + return eventName === 'UserPromptSubmit' + case 'codex': + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + case 'gemini': + return eventName === 'BeforeAgent' + case 'antigravity': + return eventName === 'PreInvocation' + case 'amp': + return eventName === 'agent.start' + case 'opencode': + return eventName === 'SessionStart' + case 'mimo-code': + return false + case 'cursor': + return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' + case 'pi': + case 'omp': + case 'prime-agent': + return eventName === 'before_agent_start' + case 'droid': + return eventName === 'UserPromptSubmit' + case 'command-code': + return false + case 'grok': + return isGrokEvent(eventName, 'user_prompt_submit') + case 'copilot': { + const normalizedEventName = normalizeCopilotEventName(eventName) + return normalizedEventName === 'SessionStart' || normalizedEventName === 'UserPromptSubmit' + } + case 'hermes': + return eventName === 'pre_llm_call' || eventName === 'on_session_start' + case 'devin': + // Why: SessionStart is handled by an early return in normalizeDevinEvent, so UserPromptSubmit is Devin's real new-turn boundary here. + return eventName === 'UserPromptSubmit' + } +} + +export function hasExplicitUserPrompt( + source: AgentHookSource, + eventName: unknown, + extractedPrompt: ExtractedPromptText, + resolvedPromptText: string, + hasTranscriptPromptEvidence = false +): boolean { + if ( + source === 'command-code' && + (eventName === 'PreToolUse' || eventName === 'Stop') && + (extractedPrompt.source !== 'message' || hasTranscriptPromptEvidence) && + resolvedPromptText.trim().length > 0 + ) { + // Why: Command Code exposes the submitted prompt via its transcript, not direct hook fields; treat the transcript-backed prompt as explicit so telemetry covers real turns. + return true + } + if ( + source === 'antigravity' && + isNewTurnEvent(source, eventName) && + resolvedPromptText.trim().length > 0 + ) { + return true + } + if (extractedPrompt.source === 'role_user_text') { + return (source === 'opencode' || source === 'mimo-code') && eventName === 'MessagePart' + } + if (extractedPrompt.text.length === 0) { + return false + } + // Why: harness-injected turns aren't a user submit (no prompt-sent telemetry or permission stickiness); match only KNOWN tags so a real `` prompt still counts and survives interrupt recovery. + if (isKnownHarnessInjectedUserTurnText(extractedPrompt.text)) { + return false + } + // Why: bare `message` fields often carry permission/status copy — may update visible status prompts but aren't proof of a user submit. + if (extractedPrompt.source === 'message') { + return false + } + if ( + extractedPrompt.source === 'user_prompt' || + extractedPrompt.source === 'userPrompt' || + extractedPrompt.source === 'user_message' + ) { + return isNewTurnEvent(source, eventName) + } + return isNewTurnEvent(source, eventName) +} + +export function extractToolFields( + source: AgentHookSource, + eventName: unknown, + hookPayload: Record, + options?: { grokHome?: string } +): ToolSnapshot { + // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor. + switch (source) { + case 'claude': + // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. + // falls through + case 'kimi': + return extractClaudeToolFields(eventName, hookPayload) + case 'codex': + return extractCodexToolFields(eventName, hookPayload) + case 'gemini': + return extractGeminiToolFields(eventName, hookPayload) + case 'antigravity': + return extractAntigravityToolFields(eventName, hookPayload) + case 'amp': + return extractAmpToolFields(eventName, hookPayload) + case 'opencode': + case 'mimo-code': + return extractOpenCodeToolFields(eventName, hookPayload) + case 'cursor': + return extractCursorToolFields(eventName, hookPayload) + case 'pi': + case 'omp': + case 'prime-agent': + return extractPiToolFields(eventName, hookPayload, source) + case 'droid': + return extractDroidToolFields(eventName, hookPayload) + case 'command-code': + return extractCommandCodeToolFields(eventName, hookPayload) + case 'grok': + return extractGrokToolFields(eventName, hookPayload, options?.grokHome) + case 'copilot': + return extractCopilotToolFields(normalizeCopilotEventName(eventName), hookPayload) + case 'hermes': + return extractHermesToolFields(eventName, hookPayload) + case 'devin': + return extractClaudeToolFields(eventName, hookPayload) + } +} diff --git a/src/shared/agent-hook-listener/providers/amp-events.ts b/src/shared/agent-hook-listener/providers/amp-events.ts new file mode 100644 index 00000000000..cbca0e4d175 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/amp-events.ts @@ -0,0 +1,168 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { readFirstString } from '../interactive-tool' +import { readBoundedString } from '../grok-result-discovery' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { AMP_MAX_SCOPED_THREAD_CACHE_KEYS, AMP_THREAD_ID_MAX_LENGTH } from '../listener-limits' + +export function normalizeAmpEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const ampCacheKey = getAmpCacheKey(paneKey, hookPayload) + if (eventName === 'session.start') { + clearPaneTurnCacheState(state, ampCacheKey) + if (ampCacheKey !== paneKey) { + clearPaneTurnCacheState(state, paneKey) + } + return null + } + + const stateName = + eventName === 'agent.start' || eventName === 'tool.call' || eventName === 'tool.result' + ? 'working' + : eventName === 'agent.end' + ? 'done' + : null + + if (!stateName) { + return null + } + if (eventName === 'agent.start') { + state.ampCompletedCacheKeys.delete(ampCacheKey) + } else if ( + (eventName === 'tool.call' || eventName === 'tool.result') && + state.ampCompletedCacheKeys.has(ampCacheKey) + ) { + // Why: Amp status posts are fire-and-forget, so drop stale tool events that arrive after the thread ended. + return null + } + + const snapshot = resolveToolState( + state, + ampCacheKey, + extractToolFields('amp', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('amp', eventName) } + ) + + const interrupted = + eventName === 'agent.end' && hookPayload.status === 'cancelled' ? true : undefined + const explicitPrompt = readFirstString(hookPayload, [ + 'prompt', + 'user_prompt', + 'userPrompt', + 'initial_prompt', + 'initialPrompt', + 'user_message' + ]) + const canUseMessageAsPrompt = + eventName === 'agent.start' || + (eventName === 'agent.end' && !state.lastPromptByPaneKey.has(ampCacheKey)) + const ampPromptText = explicitPrompt ?? (canUseMessageAsPrompt ? promptText : '') + + const normalized = normalizeAgentStatusPayload({ + state: stateName, + // Why: Amp tool/result events may use `message` for tool output; only lifecycle events may treat it as the turn prompt. + prompt: resolvePrompt(state, ampCacheKey, ampPromptText, { + resetOnNewTurn: isNewTurnEvent('amp', eventName) + }), + agentType: 'amp', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) + if (normalized && eventName === 'agent.end') { + state.ampCompletedCacheKeys.add(ampCacheKey) + } + if (normalized) { + pruneAmpThreadCacheKeys(state, paneKey, ampCacheKey) + } + return normalized +} + +export function getAmpCacheKey(paneKey: string, hookPayload: Record): string { + const threadId = readBoundedString( + hookPayload, + ['threadId', 'threadID', 'thread_id'], + AMP_THREAD_ID_MAX_LENGTH + ) + // Why: Amp emits events for multiple threads per pane; cache by thread internally while keeping the visible paneKey stable. + return threadId ? `${paneKey}\0amp:${threadId}` : paneKey +} + +export function pruneAmpThreadCacheKeys( + state: HookListenerState, + paneKey: string, + currentCacheKey: string +): void { + const scopedPrefix = `${paneKey}\0amp:` + if (!currentCacheKey.startsWith(scopedPrefix)) { + return + } + + const scopedKeys = new Set() + for (const key of state.lastPromptByPaneKey.keys()) { + if (key.startsWith(scopedPrefix)) { + scopedKeys.add(key) + } + } + for (const key of state.lastToolByPaneKey.keys()) { + if (key.startsWith(scopedPrefix)) { + scopedKeys.add(key) + } + } + for (const key of state.ampCompletedCacheKeys) { + if (key.startsWith(scopedPrefix)) { + scopedKeys.add(key) + } + } + + let overflow = scopedKeys.size - AMP_MAX_SCOPED_THREAD_CACHE_KEYS + if (overflow <= 0) { + return + } + + // Why: Amp multiplexes many thread IDs through one pane; keep the current thread plus the most recent entries instead of retaining every completed thread until teardown. + for (const key of scopedKeys) { + if (overflow <= 0) { + break + } + if (key === currentCacheKey) { + continue + } + state.lastPromptByPaneKey.delete(key) + state.lastToolByPaneKey.delete(key) + state.ampCompletedCacheKeys.delete(key) + overflow-- + } +} + +export function hasExplicitAmpPrompt( + eventName: unknown, + promptText: string, + hookPayload: Record +): boolean { + if ( + readFirstString(hookPayload, [ + 'prompt', + 'user_prompt', + 'userPrompt', + 'initial_prompt', + 'initialPrompt', + 'user_message' + ]) + ) { + return true + } + // Amp tool/result `message` is output text, not a user prompt. + return eventName === 'agent.start' && promptText.length > 0 +} diff --git a/src/shared/agent-hook-listener/providers/amp-tool-fields.ts b/src/shared/agent-hook-listener/providers/amp-tool-fields.ts new file mode 100644 index 00000000000..96d85304ee0 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/amp-tool-fields.ts @@ -0,0 +1,44 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { extractToolResponseText, readFirstString } from '../interactive-tool' + +export function extractAmpToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'tool.call' || eventName === 'tool.result') { + const toolName = + readString(hookPayload, 'tool') ?? + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) ?? + // Why: Amp plugin tools can have arbitrary names; fall back to obvious arg fields instead of an empty tool preview. + deriveFallbackToolInputPreview(hookPayload.input) ?? + deriveFallbackToolInputPreview(hookPayload.tool_input) ?? + deriveFallbackToolInputPreview(hookPayload.arguments) + const update: ToolSnapshot = toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['input', 'tool_input', 'arguments']) } + ) + if (eventName === 'tool.result') { + const responseText = + readFirstString(hookPayload, ['error', 'output', 'result', 'message']) ?? + extractToolResponseText(hookPayload.output) ?? + extractToolResponseText(hookPayload.result) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/antigravity-event-rules.ts b/src/shared/agent-hook-listener/providers/antigravity-event-rules.ts new file mode 100644 index 00000000000..aab23c615a4 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/antigravity-event-rules.ts @@ -0,0 +1,5 @@ +// Antigravity lifecycle predicates shared by normalization and result discovery. + +export function isAntigravityStopStillBusy(hookPayload: Record): boolean { + return hookPayload.fullyIdle === false || hookPayload.fully_idle === false +} diff --git a/src/shared/agent-hook-listener/providers/antigravity-events.ts b/src/shared/agent-hook-listener/providers/antigravity-events.ts new file mode 100644 index 00000000000..8465e9b56d0 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/antigravity-events.ts @@ -0,0 +1,84 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { readFirstString } from '../interactive-tool' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readLastUserPromptFromTranscript } from '../transcript-lines' +import { readAntigravityToolCall } from './antigravity-tool-fields' +import { isAntigravityStopStillBusy } from './antigravity-event-rules' + +export function isAntigravityFeedbackTool(toolName: string | undefined): boolean { + return toolName === 'ask_question' || toolName === 'ask_permission' +} + +export function normalizeAntigravityEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const transcriptPath = readFirstString(hookPayload, ['transcriptPath', 'transcript_path']) + if (eventName === 'PreInvocation') { + state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) + } else if ( + transcriptPath && + eventName !== 'Stop' && + state.antigravityCompletedTranscriptByPaneKey.get(paneKey) === transcriptPath + ) { + // Why: agy can emit a bookkeeping PostToolUse after Stop; ignore it so a finished row doesn't turn back into a yellow spinner. + return null + } + + const toolName = readAntigravityToolCall(hookPayload).toolName + const stopStillBusy = eventName === 'Stop' && isAntigravityStopStillBusy(hookPayload) + const stateName = + eventName === 'PreToolUse' && isAntigravityFeedbackTool(toolName) + ? 'waiting' + : eventName === 'Stop' + ? stopStillBusy + ? 'working' + : 'done' + : eventName === 'PreInvocation' || + eventName === 'PostInvocation' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : null + + if (!stateName) { + return null + } + + const resetsTurn = isNewTurnEvent('antigravity', eventName) + // Why: once the prompt is cached for this pane, avoid rescanning the (potentially large) Antigravity transcript per hook. + const cachedPrompt = resetsTurn ? undefined : state.lastPromptByPaneKey.get(paneKey) + const effectivePrompt = + promptText || cachedPrompt || readLastUserPromptFromTranscript(transcriptPath) || '' + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('antigravity', eventName, hookPayload), + { resetOnNewTurn: resetsTurn } + ) + + const payload = normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, effectivePrompt, { + resetOnNewTurn: resetsTurn + }), + agentType: 'antigravity', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + // Why: Antigravity can emit Stop with fullyIdle=false between tool steps; only a fully idle Stop is terminal, else the sidebar bounces done -> working and ignores later tool updates. + if (eventName === 'Stop' && !stopStillBusy && transcriptPath) { + state.antigravityCompletedTranscriptByPaneKey.set(paneKey, transcriptPath) + } + return payload +} diff --git a/src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts b/src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts new file mode 100644 index 00000000000..ba57349f14b --- /dev/null +++ b/src/shared/agent-hook-listener/providers/antigravity-tool-fields.ts @@ -0,0 +1,54 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + readString, + toolUpdate +} from '../tool-input-preview' +import { readFirstString } from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' +import { isAntigravityStopStillBusy } from './antigravity-event-rules' + +export function readAntigravityToolCall(hookPayload: Record): { + toolName?: string + toolInputSource?: unknown +} { + const toolCall = hookPayload.toolCall + if (typeof toolCall !== 'object' || toolCall === null) { + return {} + } + const record = toolCall as Record + return { + toolName: readFirstString(record, ['name', 'toolName', 'tool_name']), + toolInputSource: record.args + } +} + +export function extractAntigravityToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { + const toolCall = readAntigravityToolCall(hookPayload) + const toolName = toolCall.toolName + const toolInput = + deriveToolInputPreview(toolName, toolCall.toolInputSource) ?? + deriveFallbackToolInputPreview(toolCall.toolInputSource) + return toolUpdate( + { toolName, toolInput }, + { hasToolInputField: toolCall.toolInputSource !== undefined } + ) + } + if (eventName === 'Stop') { + if (isAntigravityStopStillBusy(hookPayload)) { + return {} + } + const message = + readString(hookPayload, 'last_assistant_message') ?? + readLastAssistantFromTranscript(hookPayload.transcriptPath ?? hookPayload.transcript_path) + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts new file mode 100644 index 00000000000..d094afd3367 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -0,0 +1,315 @@ +import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory' +import { + claudeRosterHasRestoredSnapshotSubagent, + claudeRosterHasRuntimeWorkingSubagent, + foldClaudeBackgroundTasksIntoRoster, + reapUnconfirmedRestoredClaudeSubagents, + upsertWorkingClaudeSubagent +} from '../../claude-subagent-roster' +import type { HookListenerState } from '../listener-state' +import { readFirstString } from '../interactive-tool' +import { shouldIgnoreCompactContinuationUserPromptSubmit } from '../prompt-fields' +import { readString } from '../tool-input-preview' +import { + buildClaudeCachedLeadStatusPayload, + normalizeClaudeSubagentLifecycleEvent +} from './claude-lifecycle-events' +import { + getOrCreateClaudeSubagentRoster, + resolveClaudePaneStatus, + updateClaudeRunningNonAgentTask, + voidClaimsOfReplacedClaudeSession +} from './claude-roster-state' +import { buildClaudeStatusPayload } from './claude-status-build' + +export function normalizeClaudeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const eventAgentId = readString(hookPayload, 'agent_id') + if ( + eventName === 'SubagentStart' || + eventName === 'SubagentStop' || + eventName === 'TeammateIdle' + ) { + return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) + } + voidClaimsOfReplacedClaudeSession(state, eventName, eventAgentId, paneKey, hookPayload) + if (eventName === 'SessionStart') { + // Why: SessionStart is the only signal a resumed session emits before its first prompt + // (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom + // spinner on an idle TUI (why Devin/Pi/Grok drop the event), and the sessionBoundary + // flag keeps completion-reactive consumers (notifications, automation runs) out of it. + const sessionStartSource = hookPayload['source'] + if ( + eventAgentId !== undefined || + (sessionStartSource !== 'startup' && + sessionStartSource !== 'resume' && + sessionStartSource !== 'clear') + ) { + // Why: allowlist idle boundaries and fail closed — a compact restart (or any unknown + // source) fires mid-turn, and a child-attributed SessionStart must not flip the lead's + // live turn to an idle row. + return null + } + // Why: a new process owns the pane; stale children/tasks/crons must not gate the + // fresh session's idle row back up to 'working' (same reset Codex does on SessionStart). + state.claudeSubagentRosterByPaneKey.delete(paneKey) + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) + return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + stateName: 'done', + updateToolSnapshot: true, + sessionBoundary: true + }) + } + const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) + // Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it. + const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure' + const interrupted = + isTurnBoundary && + ((eventAgentId === undefined && hookPayload['is_interrupt'] === true) || + previousLead?.interrupted === true) + ? true + : undefined + const backgroundTasks = readClaudeBackgroundAgentTasks(hookPayload) + const sessionCrons = hookPayload['session_crons'] + const sessionCronInventoryPresent = Array.isArray(sessionCrons) + const hasActiveSessionCron = sessionCronInventoryPresent && sessionCrons.length > 0 + + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + + // Why: Claude normally emits PreToolUse while AskUserQuestion is blocked; newer builds can also report it as PermissionRequest. + // Treat the PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. + const eventToolName = readString(hookPayload, 'tool_name') + const isAskUserQuestionWait = + (eventName === 'PreToolUse' || eventName === 'PermissionRequest') && + isAskUserQuestionTool(eventToolName) + const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionWait + // Why: a manual /compact swallows the turn boundary — it ends at an idle prompt and emits no + // Stop, so PostCompact is the pane's only clearing signal (STA-2915). An auto compact runs INSIDE + // a turn that resumes and emits its own Stop, so it must claim nothing. PreCompact fires before + // the compact is validated (an aborted compact emits it alone), so it is neither registered nor + // mapped — see claude-compact-completion.ts. + const isManualCompactCompletion = eventName === 'PostCompact' && hookPayload.trigger === 'manual' + const reportedStateName = + eventName === 'UserPromptSubmit' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' || + (eventName === 'PreToolUse' && !isAskUserQuestion) + ? 'working' + : eventName === 'PermissionRequest' || isAskUserQuestion + ? 'waiting' + : isTurnBoundary || isManualCompactCompletion + ? 'done' + : null + + if (!reportedStateName) { + return null + } + if (backgroundTasks.present && eventAgentId === undefined) { + updateClaudeRunningNonAgentTask( + state, + paneKey, + backgroundTasks.hasRunningNonAgentTask, + interrupted === true + ) + } + if (sessionCronInventoryPresent && eventAgentId === undefined) { + if (hasActiveSessionCron && interrupted !== true) { + state.claudeActiveSessionCronPaneKeys.add(paneKey) + } else { + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + } else if (eventAgentId === undefined && isTurnBoundary && backgroundTasks.present) { + // Why: current Claude may omit an empty cron inventory while still emitting background_tasks. + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + + const eventToolUseId = readFirstString(hookPayload, ['tool_use_id', 'toolUseId']) + const previousTool = state.lastToolByPaneKey.get(paneKey) + const isParallelSiblingCompletionDuringQuestion = + eventAgentId === undefined && + previousLead?.state === 'waiting' && + isAskUserQuestionTool(previousTool?.toolName) && + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + previousLead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== previousLead.waitingToolUseId + if (isParallelSiblingCompletionDuringQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + + // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). + // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). + const isWaitingInducing = reportedStateName === 'waiting' + const subagentOriginId = + !isWaitingInducing && + (eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure') + ? eventAgentId + : undefined + if (eventAgentId && (subagentOriginId || isWaitingInducing)) { + upsertWorkingClaudeSubagent( + getOrCreateClaudeSubagentRoster(state, paneKey), + eventAgentId, + { agentType: readString(hookPayload, 'agent_type') }, + Date.now() + ) + } + if (subagentOriginId) { + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { + workingChildEvidence: true + }) + } + const isParallelSiblingCompletionDuringChildQuestion = + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + lead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== lead.waitingToolUseId + if (isParallelSiblingCompletionDuringChildQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { + workingChildEvidence: true + }) + } + // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. + // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. + const restored = lead.stateBeforeWait ?? { state: 'working' as const } + state.claudeLeadStateByPaneKey.set(paneKey, restored) + return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + ...resolveClaudePaneStatus(state, paneKey, restored), + updateToolSnapshot: true, + interrupted: restored.interrupted, + turnCompletedAt: restored.turnCompletedAt + }) + } + + // Why: lead events never carry agent_id; even a child missed by lifecycle tracking cannot own the lead turn or its background-work evidence. + if (eventAgentId && !isWaitingInducing) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { + workingChildEvidence: claudeRosterHasRuntimeWorkingSubagent( + state.claudeSubagentRosterByPaneKey.get(paneKey) + ) + }) + } + + if (isTurnBoundary && eventAgentId === undefined) { + // Why: background_tasks is trusted only where unambiguous (see foldClaudeBackgroundTasksIntoRoster) — teammates report "running" here even while idle. + // Older Claude builds without the field keep the incrementally tracked roster. + if (backgroundTasks.present) { + foldClaudeBackgroundTasksIntoRoster( + getOrCreateClaudeSubagentRoster(state, paneKey), + backgroundTasks.tasks, + Date.now(), + { inventoryComplete: !backgroundTasks.truncated } + ) + } + } + // Why: a child-induced wait displaces the lead state; stash it so clearing restores reality (lead may be done). A 2nd child wait carries the ORIGINAL stash, not the intermediate waiting state. + const stateBeforeWait = + isWaitingInducing && eventAgentId && previousLead + ? previousLead.state === 'waiting' + ? previousLead.stateBeforeWait + : { + state: previousLead.state, + ...(previousLead.interrupted ? { interrupted: true as const } : {}), + // Why: a child's permission pause displaces an already-finished lead; keep the end time so the later drain is still that turn's tail. + ...(previousLead.turnCompletedAt !== undefined + ? { turnCompletedAt: previousLead.turnCompletedAt } + : {}) + } + : undefined + const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId + + if (interrupted && eventAgentId === undefined) { + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + + if (isManualCompactCompletion) { + // Why: a manual /compact only ever completes at an idle prompt, so a child that exists ONLY as + // a disk snapshot has nothing live behind it and must not keep the pane spinning — that + // restored child is what holds the stuck row STA-2915 actually reports. Everything else the + // done-gate consults is live evidence (a child observed in this runtime, an unclassifiable + // running background task, a registered session cron) and still holds the pane. + const restoredRoster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if ( + restoredRoster && + reapUnconfirmedRestoredClaudeSubagents(restoredRoster) && + restoredRoster.size === 0 + ) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } + } + + const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { + state: reportedStateName, + interrupted + }) + // Why: #15202's compact-completion guard reads the resolved state; this branch replaced the + // resolver with one that also reports workingMode, so bridge rather than resolve twice. + const effectiveState = resolvedStatus.stateName + // Why: the lead already ended — the pane stays `working` only because background inventory is still registered. `stateStartedAt` is pinned for that whole run, so this end time is the per-turn identity and the later all-clear's pair key. + const turnCompletedAt = + eventAgentId === undefined && + isTurnBoundary && + reportedStateName === 'done' && + resolvedStatus.stateName === 'working' && + interrupted !== true + ? Date.now() + : undefined + + state.claudeLeadStateByPaneKey.set(paneKey, { + state: reportedStateName, + ...(interrupted ? { interrupted } : {}), + ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), + ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), + ...(stateBeforeWait ? { stateBeforeWait } : {}), + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }) + + const effectiveRoster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if ( + isTurnBoundary && + eventAgentId === undefined && + resolvedStatus.stateName === 'working' && + claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) && + !claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) && + !state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) && + !state.claudeActiveSessionCronPaneKeys.has(paneKey) + ) { + // Why: a legacy or partial Stop confirms the lead boundary, not a child restored from disk; keep the child-only gate eligible for reconciliation. + state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey) + } + + if (isManualCompactCompletion && effectiveState !== 'done') { + // Why: a compact completion CLEARS a row or says nothing at all. Live evidence still holds this + // pane, so restating it would only strip `restoredUnconfirmed` off a hydrated row and restart + // the staleness clock for work the compact never observed — strictly worse than the silence + // this event replaced. The lead record above still marks the turn ended, so whichever event + // retires that evidence can clear the pane. + return null + } + + return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + ...resolvedStatus, + updateToolSnapshot: true, + interrupted, + // Why: a finished compact is a session-shaped boundary, not a completed turn. Without this the + // clearing `done` would fire completion notifications, unread counts and automation-run + // completion evidence for work nobody did. + sessionBoundary: isManualCompactCompletion ? true : undefined, + turnCompletedAt + }) +} diff --git a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts new file mode 100644 index 00000000000..2e616ddbb8b --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts @@ -0,0 +1,144 @@ +import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { + claudeRosterHasRestoredSnapshotSubagent, + claudeRosterHasRuntimeWorkingSubagent, + claudeTeammateIdMatchesName, + idleClaudeTeammateByName, + stopClaudeSubagent, + upsertWorkingClaudeSubagent +} from '../../claude-subagent-roster' +import type { HookListenerState } from '../listener-state' +import { readString } from '../tool-input-preview' +import { + clearClaudePendingWaitForAgent, + getOrCreateClaudeSubagentRoster, + resolveClaudePaneStatus +} from './claude-roster-state' +import { buildClaudeStatusPayload } from './claude-status-build' + +/** SubagentStart/Stop/TeammateIdle update the roster and re-emit the lead's last known state with the fresh child list, so the sidebar reflects spawn/finish even when a background child outlives the lead turn with no other hook traffic. */ +export function normalizeClaudeSubagentLifecycleEvent( + state: HookListenerState, + eventName: 'SubagentStart' | 'SubagentStop' | 'TeammateIdle', + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const lifecycleField = eventName === 'TeammateIdle' ? 'teammate_name' : 'agent_id' + const lifecycleId = readString(hookPayload, lifecycleField) + if (!lifecycleId) { + return null + } + const cachedLead = state.claudeLeadStateByPaneKey.get(paneKey) + const ownsUnbackedWait = + cachedLead?.state === 'waiting' && + cachedLead.stateBeforeWait === undefined && + cachedLead.waitingAgentId !== undefined && + (eventName === 'TeammateIdle' + ? claudeTeammateIdMatchesName(cachedLead.waitingAgentId, lifecycleId) + : cachedLead.waitingAgentId === lifecycleId) + const hasCachedLeadEvidence = cachedLead !== undefined && !ownsUnbackedWait + let roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + let endedChildWork = false + let endedRuntimeChildWork = false + if (eventName === 'TeammateIdle') { + const teammateName = lifecycleId + // Why: on claude 2.1.21x teammates are turn-based — TeammateIdle means "turn over, awaiting mail", not finished. The row parks as idle (confirmed teammate) instead of leaving, so the sidebar keeps showing resumable children. + if (roster) { + let wasWorking = false + for (const [id, tracked] of roster) { + if (tracked.state === 'working' && claudeTeammateIdMatchesName(id, teammateName)) { + wasWorking = true + endedRuntimeChildWork ||= tracked.restoredFromSnapshot !== true + } + } + idleClaudeTeammateByName(roster, teammateName) + endedChildWork = wasWorking + } + clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => + claudeTeammateIdMatchesName(waitingAgentId, teammateName) + ) + } else { + const agentId = lifecycleId + if (eventName === 'SubagentStart') { + roster = getOrCreateClaudeSubagentRoster(state, paneKey) + upsertWorkingClaudeSubagent( + roster, + agentId, + { agentType: readString(hookPayload, 'agent_type') }, + Date.now() + ) + } else { + if (roster) { + const tracked = roster.get(agentId) + const wasWorking = tracked?.state === 'working' + endedRuntimeChildWork = wasWorking && tracked.restoredFromSnapshot !== true + // Why: one-shot stops are true finishes (row removed); teammate-shaped stops are turn ends on 2.1.21x — the row parks idle and a later SubagentStart revives it. + stopClaudeSubagent(roster, agentId) + endedChildWork = wasWorking && roster.get(agentId)?.state !== 'working' + } + // Why: a blocked child that dies without another tool event would pin its permission/question wait on the pane forever — nothing else references that agent again. + clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) + } + } + const workingChildEvidence = claudeRosterHasRuntimeWorkingSubagent(roster) + const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) + const hasConfirmedDoneGate = + cachedLead?.state === 'done' && + cachedLead.interrupted !== true && + (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey)) + const restoredOnlyDoneGate = + cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild + if (roster?.size === 0) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } + if ( + !workingChildEvidence && + (restoredOnlyDoneGate || + (endedChildWork && !hasCachedLeadEvidence && (hasUnconfirmedChild || !endedRuntimeChildWork))) + ) { + // Why: a restored-only ending proves no lead boundary, and an unmatched restored sibling proves no current liveness; persist the roster transition without publishing fresh work or completion. + state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey) + } + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload, { + workingChildEvidence, + endedChildWork, + endedRuntimeChildWork + }) +} +/** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ +export function buildClaudeCachedLeadStatusPayload( + state: HookListenerState, + eventName: unknown, + paneKey: string, + hookPayload: Record, + evidence: { + workingChildEvidence?: boolean + endedChildWork?: boolean + endedRuntimeChildWork?: boolean + } = {} +): ParsedAgentStatusPayload | null { + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + let leadState = lead?.state + if (!leadState) { + if (evidence.workingChildEvidence || evidence.endedRuntimeChildWork) { + // Why: ending a current-runtime child wakes its parent; only a cached lead boundary can prove the whole pane completed. + leadState = 'working' + } else if (evidence.endedChildWork) { + // Why: a restored child ending proves only that child ended; persist its roster transition as unconfirmed working, never as lead completion. + leadState = 'working' + } else { + return null + } + } + return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { + ...resolveClaudePaneStatus(state, paneKey, { + state: leadState, + interrupted: lead?.interrupted + }), + updateToolSnapshot: false, + interrupted: lead?.interrupted, + // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. + turnCompletedAt: lead?.turnCompletedAt + }) +} diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts new file mode 100644 index 00000000000..852dce162b2 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -0,0 +1,277 @@ +import type { + AgentStatusState, + AgentSubagentSnapshot, + AgentWorkingMode +} from '../../agent-status-types' +import { + claudeRosterHasWorkingSubagent, + reapUnconfirmedRestoredClaudeSubagents, + type ClaudeSubagentRoster +} from '../../claude-subagent-roster' +import type { AgentHookEventPayload } from '../listener-event' +import type { ClaudeLeadTurnState, HookListenerState } from '../listener-state' +import { readString } from '../tool-input-preview' + +/** Lead events that may re-anchor a pane's owning session. Allow-list, not a deny-list: a payload we + * can't attribute (unknown name, child event missing its agent_id) must void nothing. */ +const CLAUDE_SESSION_OWNER_EVENTS: ReadonlySet = new Set([ + 'SessionStart', + 'UserPromptSubmit', + 'Stop', + 'StopFailure', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest' +]) + +/** A pane whose `session_id` changed is running a different conversation, so claims the previous one + * owned are void — the hook-independent backstop for /clear, relaunch and resume, which emit no + * terminating hook (SessionEnd covers about a third of exit paths). + * + * Voids only what the replaced session provably owned. Deliberately NOT voided: + * - `claudeRunningNonAgentTaskPaneKeys`: a background shell is an OS process that survives /clear, + * and the previous inventory is positive evidence it was running. Only a fresh inventory or a + * certified process death may retire it. + * - `confirmedTeammate` roster rows: persistent in-process teammates a lead replacement can't end. + * - `claudeLeadStateByPaneKey`: the caller's own fold overwrites it anyway. */ +export function voidClaimsOfReplacedClaudeSession( + state: HookListenerState, + eventName: unknown, + eventAgentId: string | undefined, + paneKey: string, + hookPayload: Record +): void { + if ( + eventAgentId !== undefined || + typeof eventName !== 'string' || + !CLAUDE_SESSION_OWNER_EVENTS.has(eventName) + ) { + return + } + // Compact/unknown SessionStart events are intentionally ignored by the status fold; do not let + // them advance the owner anchor or the next real lead event will miss the replacement. + if ( + eventName === 'SessionStart' && + hookPayload['source'] !== 'startup' && + hookPayload['source'] !== 'resume' && + hookPayload['source'] !== 'clear' + ) { + return + } + const sessionId = readString(hookPayload, 'session_id') + if (!sessionId) { + return + } + const previousOwner = state.claudeSessionOwnerByPaneKey.get(paneKey) + state.claudeSessionOwnerByPaneKey.set(paneKey, sessionId) + if (previousOwner === undefined || previousOwner === sessionId) { + return + } + // Why: a compact restart mints a SessionStart mid-turn under the same conversation; the existing + // handler already fails closed on non-idle sources, and this must not undercut it. No other + // allow-listed event carries a compact `trigger`, so SessionStart is the whole guard — if a + // compact event is ever added to CLAUDE_SESSION_OWNER_EVENTS, re-derive one for it deliberately. + if (eventName === 'SessionStart') { + return + } + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if (!roster) { + return + } + for (const [id, tracked] of roster) { + if (tracked.confirmedTeammate !== true) { + roster.delete(id) + } + } + if (roster.size === 0) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } +} + +export function getOrCreateClaudeSubagentRoster( + state: HookListenerState, + paneKey: string +): ClaudeSubagentRoster { + let roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if (!roster) { + roster = new Map() + state.claudeSubagentRosterByPaneKey.set(paneKey, roster) + } + return roster +} + +export function updateClaudeRunningNonAgentTask( + state: HookListenerState, + paneKey: string, + hasRunningNonAgentTask: boolean, + /** Lead-turn property. Pass `false` from any non-lead fold: an interrupt clears the gate even when + * the inventory positively reports a running shell, which is a live-shell judgement no new call + * site may inherit by copying this signature. */ + interrupted: boolean +): void { + if (hasRunningNonAgentTask && !interrupted) { + state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } +} + +export type ClaudePaneStatusResolution = { + stateName: AgentStatusState + workingMode?: AgentWorkingMode +} + +export function resolveClaudePaneStatus( + state: HookListenerState, + paneKey: string, + lead: Pick +): ClaudePaneStatusResolution { + if (lead.state !== 'done') { + return { stateName: lead.state } + } + const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if (claudeRosterHasWorkingSubagent(roster)) { + return { stateName: 'working' } + } + if ( + !lead.interrupted && + (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + state.claudeActiveSessionCronPaneKeys.has(paneKey)) + ) { + return { stateName: 'working', workingMode: 'monitoring' } + } + return { stateName: 'done' } +} +/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ +export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { + state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true }) + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) +} + +/** Rebuild a pane's working roster from a persisted snapshot; live activity confirms a seed, a complete task inventory may reap an unconfirmed one whose finish hook arrived while Orca was offline. */ +export function seedClaudeSubagentRosterFromSnapshots( + state: HookListenerState, + paneKey: string, + snapshots: readonly AgentSubagentSnapshot[] +): void { + if (snapshots.length === 0 || state.claudeSubagentRosterByPaneKey.has(paneKey)) { + return + } + const roster = getOrCreateClaudeSubagentRoster(state, paneKey) + for (const snapshot of snapshots) { + // Why: idle-teammate liveness can't be proven across a restart (its TeammateIdle confirmation is gone); only working seeds restore, and a live teammate re-earns its row via SubagentStart. + if (snapshot.state !== 'working') { + continue + } + roster.set(snapshot.id, { + state: 'working', + startedAt: snapshot.startedAt, + agentType: snapshot.agentType, + description: snapshot.description, + // Why: the seed can be a phantom (child finished while Orca was down, SubagentStop lost); let a PRESENT background_tasks list omitting the id remove it, not gate the pane 'working' forever. + backgroundTasksAuthoritative: true, + // Why: an idle parent never emits that list, so the inventory reap alone can strand the seed; mark it for the liveness reap below. + restoredFromSnapshot: true + }) + } +} + +export function seedClaudeLeadTurnFromPersistedStatus( + state: HookListenerState, + paneKey: string, + status: Pick, + options: { childOnlyBoundary: boolean } +): void { + if (options.childOnlyBoundary && status.payload.agentType === 'claude') { + state.claudeLeadStateByPaneKey.set(paneKey, { + state: 'done', + ...(status.payload.interrupted === true ? { interrupted: true } : {}), + ...(status.payload.turnCompletedAt !== undefined + ? { turnCompletedAt: status.payload.turnCompletedAt } + : {}) + }) + if (status.payload.prompt) { + state.lastPromptByPaneKey.set(paneKey, status.payload.prompt) + } + if (status.payload.lastAssistantMessage) { + state.lastToolByPaneKey.set(paneKey, { + lastAssistantMessage: status.payload.lastAssistantMessage + }) + } + } +} + +/** Reap this pane's unconfirmed restored seeds because no live agent process backs + * the pane any more (its PTY died while Orca was down, so no finish hook could + * arrive). Callers must have proven the pane is LOCAL-launched — a remote/SSH + * agent runs on the far host and can never appear in a local process index. + * Returns whether the roster changed. */ +export function reapRestoredClaudeSubagentsForDeadPane( + state: HookListenerState, + paneKey: string +): boolean { + const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) + if (!roster || !reapUnconfirmedRestoredClaudeSubagents(roster)) { + return false + } + if (roster.size === 0) { + state.claudeSubagentRosterByPaneKey.delete(paneKey) + } + return true +} + +/** Drop a child-owned waiting state when the child stops/idles, restoring the displaced lead state. */ +export function clearClaudePendingWaitForAgent( + state: HookListenerState, + paneKey: string, + ownsWait: (waitingAgentId: string) => boolean +): void { + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) { + return + } + state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' }) + const previousTool = state.lastToolByPaneKey.get(paneKey) + state.lastToolByPaneKey.set( + paneKey, + previousTool?.lastAssistantMessage + ? { lastAssistantMessage: previousTool.lastAssistantMessage } + : {} + ) +} + +/** Clear an AskUserQuestion wait after the answer is typed (answering emits no hook event; the caller infers it from the submit keystroke). Restores the stashed pre-wait lead state or 'working', drops the cached card, and returns the pane state to emit (gated up to 'working' while children run). */ +export function clearClaudeAnsweredQuestionWait( + state: HookListenerState, + paneKey: string +): Pick & { + workingMode?: AgentWorkingMode +} { + const lead = state.claudeLeadStateByPaneKey.get(paneKey) + const restored = + lead?.state === 'waiting' + ? (lead.stateBeforeWait ?? { state: 'working' as const }) + : { state: 'working' as const } + state.claudeLeadStateByPaneKey.set(paneKey, { ...restored }) + const previousTool = state.lastToolByPaneKey.get(paneKey) + state.lastToolByPaneKey.set( + paneKey, + previousTool?.lastAssistantMessage + ? { lastAssistantMessage: previousTool.lastAssistantMessage } + : {} + ) + const resolved = resolveClaudePaneStatus(state, paneKey, restored) + return resolved.stateName === restored.state && resolved.workingMode === undefined + ? restored + : { + state: resolved.stateName, + ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), + ...(restored.interrupted ? { interrupted: true as const } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}) + } +} diff --git a/src/shared/agent-hook-listener/providers/claude-status-build.ts b/src/shared/agent-hook-listener/providers/claude-status-build.ts new file mode 100644 index 00000000000..aa5d356d623 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-status-build.ts @@ -0,0 +1,53 @@ +import { + normalizeAgentStatusPayload, + type AgentStatusState, + type AgentWorkingMode, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { claudeRosterToSnapshots } from '../../claude-subagent-roster' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import type { HookListenerState } from '../listener-state' + +export function buildClaudeStatusPayload( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record, + options: { + stateName: AgentStatusState + workingMode?: AgentWorkingMode + updateToolSnapshot: boolean + interrupted?: boolean + sessionBoundary?: boolean + turnCompletedAt?: number + } +): ParsedAgentStatusPayload | null { + // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. + const snapshot = options.updateToolSnapshot + ? resolveToolState(state, paneKey, extractToolFields('claude', eventName, hookPayload), { + resetOnNewTurn: isNewTurnEvent('claude', eventName) + }) + : (state.lastToolByPaneKey.get(paneKey) ?? {}) + + // Why: validate directly — the JSON stringify/parse round trip other normalizers use is pure overhead on this hot per-hook path. + // The normalizer clamps `interrupted` to done payloads, so a gated 'working' emit drops it; claudeLeadStateByPaneKey preserves it for the eventual done. + return normalizeAgentStatusPayload({ + state: options.stateName, + workingMode: options.workingMode, + // Why: only lead-origin events may reset the prompt cache; a child-driven refresh must not blank the lead's prompt label. + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: options.updateToolSnapshot && isNewTurnEvent('claude', eventName) + }), + agentType: 'claude', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted: options.interrupted, + sessionBoundary: options.sessionBoundary, + turnCompletedAt: options.turnCompletedAt, + subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) + }) +} diff --git a/src/shared/agent-hook-listener/providers/claude-tool-fields.ts b/src/shared/agent-hook-listener/providers/claude-tool-fields.ts new file mode 100644 index 00000000000..f980bbb34e7 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/claude-tool-fields.ts @@ -0,0 +1,62 @@ +import type { ToolSnapshot } from '../listener-event' +import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview' +import { + clearActiveToolFieldsUpdate, + deriveInteractivePrompt, + extractToolResponseText +} from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' + +export function extractClaudeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + const update: ToolSnapshot = {} + if (eventName === 'PostToolUseFailure') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else if ( + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PermissionRequest' + ) { + const toolName = readString(hookPayload, 'tool_name') + Object.assign( + update, + toolUpdate( + { + toolName, + toolInput: deriveToolInputPreview(toolName, hookPayload.tool_input), + interactivePrompt: deriveInteractivePrompt(toolName, hookPayload.tool_input, eventName) + }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + ) + } + if (eventName === 'PostToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_response) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'PostToolUseFailure') { + const errorText = + extractToolResponseText(hookPayload.tool_response) ?? + readString(hookPayload, 'error') ?? + readString(hookPayload, 'message') + if (errorText) { + update.lastAssistantMessage = errorText + } + } + if (eventName === 'Stop') { + const direct = readString(hookPayload, 'last_assistant_message') + if (direct) { + update.lastAssistantMessage = direct + } else { + const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) + if (lastFromTranscript) { + update.lastAssistantMessage = lastFromTranscript + } + } + } + return update +} diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts new file mode 100644 index 00000000000..9df00a6ea1e --- /dev/null +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -0,0 +1,178 @@ +import { + AGENT_MODEL_MAX_LENGTH, + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { normalizeOptionalField } from '../../agent-status-field-normalization' +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { + codexRosterEffectiveState, + codexRosterToSnapshots, + finishCodexSubagent, + upsertCodexSubagent +} from '../../codex-subagent-roster' +import { reconcileCodexSubagentTranscript } from '../../codex-subagent-transcript' +import { readFirstString } from '../interactive-tool' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' +import { + getOrCreateCodexSubagentRoster, + getOrCreateCodexSubagentTranscriptState, + hasCodexTranscriptSubagents +} from './codex-state' + +export function buildCodexStatusPayload( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record, + options: { stateName: 'working' | 'waiting' | 'done'; updateLead: boolean } +): ParsedAgentStatusPayload | null { + const snapshot = options.updateLead + ? resolveToolState(state, paneKey, extractToolFields('codex', eventName, hookPayload), { + resetOnNewTurn: isNewTurnEvent('codex', eventName) + }) + : (state.lastToolByPaneKey.get(paneKey) ?? {}) + const lead = state.codexLeadStateByPaneKey.get(paneKey) + + return normalizeAgentStatusPayload({ + state: options.stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: options.updateLead && isNewTurnEvent('codex', eventName) + }), + agentType: 'codex', + model: lead?.model, + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) + }) +} + +export function buildCodexChildDrivenStatusPayload( + state: HookListenerState, + eventName: unknown, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const leadState = state.codexLeadStateByPaneKey.get(paneKey)?.state ?? 'working' + const stateName = codexRosterEffectiveState( + state.codexSubagentRosterByPaneKey.get(paneKey), + leadState + ) + return buildCodexStatusPayload(state, eventName, '', paneKey, hookPayload, { + stateName, + updateLead: false + }) +} + +export function normalizeCodexSubagentLifecycleEvent( + state: HookListenerState, + eventName: 'SubagentStart' | 'SubagentStop', + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const agentId = readString(hookPayload, 'agent_id') + if (!agentId) { + return null + } + const roster = getOrCreateCodexSubagentRoster(state, paneKey) + if (eventName === 'SubagentStart') { + upsertCodexSubagent( + roster, + agentId, + { + agentType: readString(hookPayload, 'agent_type'), + model: readString(hookPayload, 'model'), + state: 'working' + }, + Date.now() + ) + } else { + finishCodexSubagent(roster, agentId) + } + return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) +} + +export function normalizeCodexEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (eventName === 'SubagentStart' || eventName === 'SubagentStop') { + return normalizeCodexSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) + } + + // Why: Codex's request_user_input (0.145+) is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting like grok's ask_user_question. + const isUserInputPreTool = + eventName === 'PreToolUse' && + isAskUserQuestionTool(readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name')) + const stateName = + eventName === 'SessionStart' || + eventName === 'UserPromptSubmit' || + (eventName === 'PreToolUse' && !isUserInputPreTool) || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'PermissionRequest' || isUserInputPreTool + ? 'waiting' + : eventName === 'Stop' + ? 'done' + : null + if (!stateName) { + return null + } + + const agentId = readString(hookPayload, 'agent_id') + if (agentId) { + upsertCodexSubagent( + getOrCreateCodexSubagentRoster(state, paneKey), + agentId, + { + agentType: readString(hookPayload, 'agent_type'), + model: readString(hookPayload, 'model'), + state: stateName === 'waiting' ? 'waiting' : 'working' + }, + Date.now() + ) + return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + } + + if (eventName === 'SessionStart') { + // Why: a pane can host a new Codex process after the old one exited without child Stop hooks. + state.codexSubagentRosterByPaneKey.delete(paneKey) + state.codexSubagentTranscriptByPaneKey.delete(paneKey) + } + const transcriptPath = readFirstString(hookPayload, ['transcript_path', 'transcriptPath']) + if (transcriptPath) { + reconcileCodexSubagentTranscript( + getOrCreateCodexSubagentTranscriptState(state, paneKey), + getOrCreateCodexSubagentRoster(state, paneKey), + transcriptPath + ) + } + if (eventName === 'Stop' && !hasCodexTranscriptSubagents(state, paneKey)) { + // Why: Codex CLI 0.144 can omit child Stop hooks; later child activity safely recreates any agent still running. + state.codexSubagentRosterByPaneKey.delete(paneKey) + } + const previousLead = state.codexLeadStateByPaneKey.get(paneKey) + state.codexLeadStateByPaneKey.set(paneKey, { + state: stateName, + model: + normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? + (eventName === 'SessionStart' ? undefined : previousLead?.model) + }) + const effectiveState = codexRosterEffectiveState( + state.codexSubagentRosterByPaneKey.get(paneKey), + stateName + ) + return buildCodexStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + stateName: effectiveState, + updateLead: true + }) +} diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts new file mode 100644 index 00000000000..c14c1d8a2b1 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -0,0 +1,146 @@ +import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { + codexRosterEffectiveState, + codexRosterToSnapshots, + finishCodexSubagent, + seedCodexSubagentRoster, + type CodexSubagentRoster +} from '../../codex-subagent-roster' +import { + createCodexSubagentTranscriptState, + hasTrackedCodexTranscriptSubagents, + type CodexSubagentTranscriptState +} from '../../codex-subagent-transcript' +import type { CodexLeadTurnState, HookListenerState } from '../listener-state' + +export function getOrCreateCodexSubagentRoster( + state: HookListenerState, + paneKey: string +): CodexSubagentRoster { + let roster = state.codexSubagentRosterByPaneKey.get(paneKey) + if (!roster) { + roster = new Map() + state.codexSubagentRosterByPaneKey.set(paneKey, roster) + } + return roster +} + +export function getOrCreateCodexSubagentTranscriptState( + state: HookListenerState, + paneKey: string +): CodexSubagentTranscriptState { + let transcriptState = state.codexSubagentTranscriptByPaneKey.get(paneKey) + if (!transcriptState) { + transcriptState = createCodexSubagentTranscriptState() + state.codexSubagentTranscriptByPaneKey.set(paneKey, transcriptState) + } + return transcriptState +} + +export function hasCodexTranscriptSubagents(state: HookListenerState, paneKey: string): boolean { + return hasTrackedCodexTranscriptSubagents(state.codexSubagentTranscriptByPaneKey.get(paneKey)) +} + +export function seedCodexStateFromSnapshot( + state: HookListenerState, + paneKey: string, + payload: Pick +): void { + const snapshots = payload.subagents ?? [] + if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { + seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) + } + if (!state.codexLeadStateByPaneKey.has(paneKey)) { + // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. + state.codexLeadStateByPaneKey.set(paneKey, { + // Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting. + state: + payload.state === 'done' + ? 'done' + : payload.state === 'waiting' && + !snapshots.some((snapshot) => snapshot.state === 'waiting') + ? 'waiting' + : 'working', + model: payload.model + }) + } +} + +/** Sync the Codex lead record when the server infers an interrupt, so delayed child events cannot restore stale working state. */ +export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { + const lead = state.codexLeadStateByPaneKey.get(paneKey) + state.codexLeadStateByPaneKey.set(paneKey, { state: 'done', model: lead?.model }) +} + +export function codexLeadStateForHookEvent( + eventName: string | undefined +): CodexLeadTurnState['state'] | undefined { + if (eventName === 'Stop') { + return 'done' + } + if (eventName === 'PermissionRequest') { + return 'waiting' + } + if ( + eventName === 'SessionStart' || + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ) { + return 'working' + } + return undefined +} + +/** Why: relay restarts lose lead/roster state; merge child events into main's longer-lived cache. */ +export function reconcileRemoteCodexState( + state: HookListenerState, + paneKey: string, + eventName: string | undefined, + agentId: string | undefined, + payload: ParsedAgentStatusPayload, + previous: ParsedAgentStatusPayload | undefined +): ParsedAgentStatusPayload { + if (previous?.agentType === 'codex') { + seedCodexStateFromSnapshot(state, paneKey, previous) + } else { + seedCodexStateFromSnapshot(state, paneKey, payload) + } + + // Why: older relays send child identity without roster snapshots; keep their already-normalized aggregate authoritative. + if (agentId && !payload.subagents && !state.codexSubagentRosterByPaneKey.has(paneKey)) { + return payload + } + const roster = getOrCreateCodexSubagentRoster(state, paneKey) + if (payload.subagents) { + seedCodexSubagentRoster(roster, payload.subagents) + } + if (agentId) { + if (eventName === 'SubagentStop') { + finishCodexSubagent(roster, agentId) + } + } else { + const leadState = codexLeadStateForHookEvent(eventName) + if (eventName === 'SessionStart' || (eventName === 'Stop' && !payload.subagents)) { + roster.clear() + } + if (leadState) { + const previousLead = state.codexLeadStateByPaneKey.get(paneKey) + state.codexLeadStateByPaneKey.set(paneKey, { + state: leadState, + model: payload.model ?? previousLead?.model + }) + } + } + + const lead = state.codexLeadStateByPaneKey.get(paneKey) + if (!lead) { + return payload + } + return { + ...payload, + state: codexRosterEffectiveState(roster, lead.state), + model: lead.model ?? payload.model, + subagents: codexRosterToSnapshots(roster) + } +} diff --git a/src/shared/agent-hook-listener/providers/codex-tool-fields.ts b/src/shared/agent-hook-listener/providers/codex-tool-fields.ts new file mode 100644 index 00000000000..09b87c794a5 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/codex-tool-fields.ts @@ -0,0 +1,41 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { deriveInteractivePrompt } from '../interactive-tool' + +export function extractCodexToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'PreToolUse' || + eventName === 'PermissionRequest' || + eventName === 'PostToolUse' + ) { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const rawInput = hookPayload.tool_input ?? hookPayload.input ?? hookPayload.arguments + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) + return toolUpdate( + { + toolName, + toolInput, + interactivePrompt: deriveInteractivePrompt(toolName, rawInput, eventName) + }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } + ) + } + if (eventName === 'Stop') { + const message = readString(hookPayload, 'last_assistant_message') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/command-code-events.ts b/src/shared/agent-hook-listener/providers/command-code-events.ts new file mode 100644 index 00000000000..cfc503993ac --- /dev/null +++ b/src/shared/agent-hook-listener/providers/command-code-events.ts @@ -0,0 +1,44 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeCommandCodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'PreToolUse' || eventName === 'PostToolUse' + ? 'working' + : eventName === 'Stop' + ? 'done' + : null + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('command-code', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('command-code', eventName) } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('command-code', eventName) + }), + agentType: 'command-code', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts b/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts new file mode 100644 index 00000000000..35788f7330f --- /dev/null +++ b/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts @@ -0,0 +1,51 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + hasOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { extractToolResponseText } from '../interactive-tool' +import { readLastCommandCodeAssistantFromTranscript } from '../command-code-transcript' + +export function extractCommandCodeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { + const toolName = + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_display_name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveFallbackToolInputPreview(hookPayload.tool_input) + const update: ToolSnapshot = toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + if (eventName === 'PostToolUse') { + const responseText = + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.tool_output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + if (eventName === 'Stop') { + const direct = readString(hookPayload, 'last_assistant_message') + if (direct) { + return { lastAssistantMessage: direct } + } + const fromTranscript = readLastCommandCodeAssistantFromTranscript( + hookPayload.transcript_path ?? hookPayload.transcriptPath + ) + if (fromTranscript) { + return { lastAssistantMessage: fromTranscript } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/copilot-events.ts b/src/shared/agent-hook-listener/providers/copilot-events.ts new file mode 100644 index 00000000000..a9c599ef3d7 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/copilot-events.ts @@ -0,0 +1,73 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { readFirstString } from '../interactive-tool' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { + isAskUserTool, + normalizeCopilotEventName, + resolveCopilotEventName +} from './copilot-tool-fields' + +// Why: Copilot PermissionRequest fires before allow/ask/deny (stays working); ask_user and notification prompts are the real blocked signals. +export function normalizeCopilotEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const normalizedEventName = normalizeCopilotEventName( + resolveCopilotEventName(eventName, hookPayload) + ) + const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType']) + const isBlockingNotification = + normalizedEventName === 'Notification' && + (notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog') + const toolSnapshot = extractToolFields('copilot', normalizedEventName, hookPayload) + const isAskUserPrompt = + (normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest') && + isAskUserTool(toolSnapshot.toolName) + const stateName = + normalizedEventName === 'SessionStart' || + normalizedEventName === 'UserPromptSubmit' || + normalizedEventName === 'PostToolUse' || + normalizedEventName === 'PostToolUseFailure' + ? 'working' + : isBlockingNotification || isAskUserPrompt + ? 'blocked' + : normalizedEventName === 'PreToolUse' || normalizedEventName === 'PermissionRequest' + ? 'working' + : normalizedEventName === 'Stop' || normalizedEventName === 'SessionEnd' + ? 'done' + : normalizedEventName === 'ErrorOccurred' + ? hookPayload.recoverable === true + ? 'working' + : 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState(state, paneKey, toolSnapshot, { + resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName) + }) + + const effectivePrompt = normalizedEventName === 'Notification' ? '' : promptText + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, effectivePrompt, { + resetOnNewTurn: isNewTurnEvent('copilot', normalizedEventName) + }), + agentType: 'copilot', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts b/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts new file mode 100644 index 00000000000..d33fcd63677 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts @@ -0,0 +1,203 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { + clearActiveToolFieldsUpdate, + extractToolResponseText, + parseJsonObjectString, + readFirstString +} from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' + +export function normalizeCopilotEventName(eventName: unknown): unknown { + if (typeof eventName !== 'string') { + return eventName + } + const eventMap: Record = { + sessionStart: 'SessionStart', + sessionEnd: 'SessionEnd', + userPromptSubmitted: 'UserPromptSubmit', + userPromptSubmit: 'UserPromptSubmit', + preToolUse: 'PreToolUse', + postToolUse: 'PostToolUse', + postToolUseFailure: 'PostToolUseFailure', + subagentStart: 'SubagentStart', + subagentStop: 'SubagentStop', + preCompact: 'PreCompact', + agentStop: 'Stop', + stop: 'Stop', + errorOccurred: 'ErrorOccurred', + permissionRequest: 'PermissionRequest', + notification: 'Notification' + } + return eventMap[eventName] ?? eventName +} + +export function resolveCopilotEventName( + eventName: unknown, + hookPayload: Record +): unknown { + const explicit = + eventName ?? + readFirstString(hookPayload, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) + if (explicit) { + return explicit + } + if (readFirstString(hookPayload, ['initial_prompt', 'initialPrompt'])) { + return 'SessionStart' + } + if (readString(hookPayload, 'prompt')) { + return 'UserPromptSubmit' + } + if (readFirstString(hookPayload, ['notification_type', 'notificationType'])) { + return 'Notification' + } + if ( + readFirstString(hookPayload, ['transcript_path', 'transcriptPath', 'stop_reason', 'stopReason']) + ) { + return 'Stop' + } + if (hookPayload.error || readFirstString(hookPayload, ['error_context', 'errorContext'])) { + return 'ErrorOccurred' + } + if ( + Array.isArray(hookPayload.toolCalls) || + readFirstString(hookPayload, ['tool_name', 'toolName', 'name']) + ) { + if ( + hookPayload.tool_result || + hookPayload.toolResult || + hookPayload.tool_response || + hookPayload.toolResponse + ) { + return 'PostToolUse' + } + return 'PreToolUse' + } + return eventName +} + +export function readCopilotToolCall(hookPayload: Record): { + toolName?: string + toolInputSource?: unknown +} { + const toolCalls = hookPayload.toolCalls + if (!Array.isArray(toolCalls) || toolCalls.length === 0) { + return {} + } + const first = toolCalls[0] + if (typeof first !== 'object' || first === null) { + return {} + } + const record = first as Record + return { + toolName: readFirstString(record, ['name', 'toolName', 'tool_name']), + toolInputSource: + parseJsonObjectString(record.args) ?? + record.args ?? + parseJsonObjectString(record.arguments) ?? + record.arguments + } +} + +export function isAskUserTool(toolName: string | undefined): boolean { + return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuser' +} + +export function extractCopilotToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + const update: ToolSnapshot = {} + if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else if ( + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PermissionRequest' + ) { + const copilotToolCall = readCopilotToolCall(hookPayload) + const toolName = + readFirstString(hookPayload, ['tool_name', 'toolName', 'name']) ?? copilotToolCall.toolName + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.toolInput) ?? + deriveToolInputPreview(toolName, hookPayload.toolArgs) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) ?? + deriveToolInputPreview(toolName, copilotToolCall.toolInputSource) + Object.assign( + update, + toolUpdate( + { toolName, toolInput }, + { + hasToolInputField: + hasAnyOwnField(hookPayload, [ + 'tool_input', + 'toolInput', + 'toolArgs', + 'input', + 'arguments' + ]) || copilotToolCall.toolInputSource !== undefined + } + ) + ) + if (isAskUserTool(toolName) && toolInput) { + update.lastAssistantMessage = toolInput + } + } + if (eventName === 'PostToolUse') { + const responseText = + extractToolResponseText(hookPayload.tool_result) ?? + extractToolResponseText(hookPayload.toolResult) ?? + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.toolResponse) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { + const errorText = + extractToolResponseText(hookPayload.tool_result) ?? + extractToolResponseText(hookPayload.toolResult) ?? + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.toolResponse) ?? + readFirstString(hookPayload, ['error_message', 'errorMessage', 'error', 'message']) + if (errorText) { + update.lastAssistantMessage = errorText + } + } + if (eventName === 'Notification') { + const notificationType = readFirstString(hookPayload, ['notification_type', 'notificationType']) + if (notificationType === 'permission_prompt' || notificationType === 'elicitation_dialog') { + const message = readFirstString(hookPayload, ['message', 'body', 'text', 'title']) + if (message) { + update.lastAssistantMessage = message + } + } + } + if (eventName === 'Stop') { + const direct = readFirstString(hookPayload, [ + 'last_assistant_message', + 'lastAssistantMessage', + 'message' + ]) + if (direct) { + update.lastAssistantMessage = direct + } else { + const lastFromTranscript = readLastAssistantFromTranscript( + hookPayload.transcript_path ?? hookPayload.transcriptPath + ) + if (lastFromTranscript) { + update.lastAssistantMessage = lastFromTranscript + } else { + update.clearLastAssistantMessage = true + } + } + } + return update +} diff --git a/src/shared/agent-hook-listener/providers/cursor-events.ts b/src/shared/agent-hook-listener/providers/cursor-events.ts new file mode 100644 index 00000000000..1c933c96361 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/cursor-events.ts @@ -0,0 +1,66 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeCursorEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + // Why: Cursor can emit final response text after `stop`; enrich the completed row, don't resurrect the agent as working. + const previousStatus = state.lastStatusByPaneKey.get(paneKey)?.payload + const stateName = + eventName === 'beforeSubmitPrompt' || + eventName === 'sessionStart' || + eventName === 'preToolUse' || + eventName === 'postToolUse' || + eventName === 'postToolUseFailure' || + // Why: these fire on every shell/MCP invocation (pre-execution gates, not just approval); treat as working to avoid waiting-notification spam. + eventName === 'beforeShellExecution' || + eventName === 'beforeMCPExecution' + ? 'working' + : eventName === 'afterAgentResponse' + ? previousStatus?.state === 'done' && previousStatus.agentType === 'cursor' + ? 'done' + : 'working' + : eventName === 'stop' || eventName === 'sessionEnd' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('cursor', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('cursor', eventName) } + ) + + const interrupted = + eventName === 'stop' && + typeof hookPayload.status === 'string' && + hookPayload.status !== 'completed' + ? true + : undefined + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('cursor', eventName) + }), + agentType: 'cursor', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) +} diff --git a/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts b/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts new file mode 100644 index 00000000000..648077a0f66 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts @@ -0,0 +1,76 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + hasOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { clearActiveToolFieldsUpdate, extractToolResponseText } from '../interactive-tool' + +export function extractCursorToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'preToolUse' || + eventName === 'postToolUse' || + eventName === 'postToolUseFailure' + ) { + const update: ToolSnapshot = {} + if (eventName === 'postToolUseFailure') { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else { + const toolName = readString(hookPayload, 'tool_name') + const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + Object.assign( + update, + toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + ) + } + if (eventName === 'postToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'postToolUseFailure') { + const errorText = + extractToolResponseText(hookPayload.tool_output) ?? + readString(hookPayload, 'error_message') ?? + readString(hookPayload, 'error') + if (errorText) { + update.lastAssistantMessage = errorText + } + } + return update + } + if (eventName === 'beforeShellExecution') { + const command = readString(hookPayload, 'command') + return toolUpdate( + { toolName: 'Shell', toolInput: command }, + { hasToolInputField: hasOwnField(hookPayload, 'command') } + ) + } + if (eventName === 'beforeMCPExecution') { + const toolName = readString(hookPayload, 'tool_name') ?? 'MCP' + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + readString(hookPayload, 'command') ?? + readString(hookPayload, 'url') + return toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'command', 'url']) } + ) + } + if (eventName === 'afterAgentResponse') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/devin-events.ts b/src/shared/agent-hook-listener/providers/devin-events.ts new file mode 100644 index 00000000000..bcb77020a3b --- /dev/null +++ b/src/shared/agent-hook-listener/providers/devin-events.ts @@ -0,0 +1,61 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +// Why: Devin uses Claude-compatible payloads but its own lifecycle event set; normalize those event names while keeping Devin attribution. +export function normalizeDevinEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (eventName === 'SessionStart') { + // Why: Devin emits SessionStart on idle TUI open/resume; mapping it to 'working' showed a spinner before the user typed, so only UserPromptSubmit/tool activity may create a row. + clearPaneTurnCacheState(state, paneKey) + return null + } + + const stateName = + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostCompaction' + ? 'working' + : eventName === 'PermissionRequest' + ? 'waiting' + : eventName === 'Stop' || eventName === 'SessionEnd' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('devin', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('devin', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('devin', eventName) + }), + agentType: 'devin', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) +} diff --git a/src/shared/agent-hook-listener/providers/droid-events.ts b/src/shared/agent-hook-listener/providers/droid-events.ts new file mode 100644 index 00000000000..dbebc73cc13 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/droid-events.ts @@ -0,0 +1,79 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' +import { + isDroidAskUserTool, + isDroidHighRiskToolUse, + isDroidIdleNotification, + isDroidPermissionNotification +} from './droid-tool-fields' + +export function normalizeDroidEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (eventName === 'SessionStart') { + // Why: Droid's SessionStart fires while idle (TUI open/resume); wait for real activity before a working row. + clearPaneTurnCacheState(state, paneKey) + return null + } + + const notificationMessage = readString(hookPayload, 'message') + const droidToolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( + eventName === 'PreToolUse' && + (isDroidAskUserTool(droidToolName) || isDroidHighRiskToolUse(hookPayload)) + ) { + // Why: Droid surfaces AskUser and high-risk approvals as PreToolUse; the approval path emits no Notification hook. + stateName = 'waiting' + } else if ( + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ) { + stateName = 'working' + } else if (eventName === 'Stop') { + stateName = 'done' + } else if (eventName === 'PermissionRequest') { + stateName = 'waiting' + } else if (eventName === 'Notification' && isDroidPermissionNotification(notificationMessage)) { + stateName = 'waiting' + } else if (eventName === 'Notification' && isDroidIdleNotification(notificationMessage)) { + // Why: Droid emits no Stop on user-interrupt, only an idle notification when ready again. + stateName = 'done' + } + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('droid', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('droid', eventName) } + ) + + // Why: Droid Notification.message is status text, not the prompt; '' keeps resolvePrompt's cached UserPromptSubmit value. + const effectivePrompt = eventName === 'Notification' ? '' : promptText + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, effectivePrompt, { + resetOnNewTurn: isNewTurnEvent('droid', eventName) + }), + agentType: 'droid', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/droid-tool-fields.ts b/src/shared/agent-hook-listener/providers/droid-tool-fields.ts new file mode 100644 index 00000000000..4bcfe5e560f --- /dev/null +++ b/src/shared/agent-hook-listener/providers/droid-tool-fields.ts @@ -0,0 +1,98 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { extractToolResponseText } from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' + +export function isDroidPermissionNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + // Why: 'confirm' is excluded — it false-positives on benign messages like "task confirmed" that aren't permission prompts. + return lower.includes('permission') || lower.includes('approve') || lower.includes('approval') +} + +export function isDroidIdleNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + return lower.includes('waiting for your input') || lower.includes('waiting for input') +} + +export function isDroidAskUserTool(toolName: string | undefined): boolean { + if (!toolName) { + return false + } + return toolName.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuser' +} + +export function readDroidToolRiskLevel(hookPayload: Record): string | undefined { + const directRisk = readString(hookPayload, 'riskLevel') ?? readString(hookPayload, 'risk_level') + if (directRisk) { + return directRisk + } + + for (const key of ['tool_input', 'input', 'arguments'] as const) { + const value = hookPayload[key] + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + continue + } + const record = value as Record + const nestedRisk = readString(record, 'riskLevel') ?? readString(record, 'risk_level') + if (nestedRisk) { + return nestedRisk + } + } + return undefined +} + +export function isDroidHighRiskToolUse(hookPayload: Record): boolean { + return readDroidToolRiskLevel(hookPayload)?.trim().toLowerCase() === 'high' +} + +export function extractDroidToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PermissionRequest' + ) { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) + const update: ToolSnapshot = toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } + ) + if (eventName === 'PostToolUse') { + const responseText = + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.tool_output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + if (eventName === 'Stop') { + const direct = readString(hookPayload, 'last_assistant_message') + if (direct) { + return { lastAssistantMessage: direct } + } + const fromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) + if (fromTranscript) { + return { lastAssistantMessage: fromTranscript } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/gemini-events.ts b/src/shared/agent-hook-listener/providers/gemini-events.ts new file mode 100644 index 00000000000..0d7de6316db --- /dev/null +++ b/src/shared/agent-hook-listener/providers/gemini-events.ts @@ -0,0 +1,50 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeGeminiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + // Why: Gemini CLI's native pre-tool event is BeforeTool; PreToolUse/PostToolUse still accepted for legacy Antigravity-compatible payloads. + const stateName = + eventName === 'BeforeAgent' || + eventName === 'BeforeTool' || + eventName === 'AfterTool' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'AfterAgent' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('gemini', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('gemini', eventName) } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('gemini', eventName) + }), + agentType: 'gemini', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/gemini-tool-fields.ts b/src/shared/agent-hook-listener/providers/gemini-tool-fields.ts new file mode 100644 index 00000000000..9fadfea6e3c --- /dev/null +++ b/src/shared/agent-hook-listener/providers/gemini-tool-fields.ts @@ -0,0 +1,36 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' + +export function extractGeminiToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'BeforeTool' || + eventName === 'AfterTool' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ) { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.args) ?? + deriveToolInputPreview(toolName, hookPayload.input) + return toolUpdate( + { toolName, toolInput }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'args', 'input']) } + ) + } + if (eventName === 'AfterAgent') { + const message = readString(hookPayload, 'prompt_response') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts new file mode 100644 index 00000000000..987c13b61a5 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -0,0 +1,103 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../prompt-fields' +import { + extractToolFields, + isGrokIdleNotification, + isNewTurnEvent +} from '../provider-event-routing' +import { readString } from '../tool-input-preview' +import { isGrokEvent } from '../provider-event-names' +import { + getGrokNotificationType, + isGrokPermissionNotification, + isGrokRoutinePermissionPromptNotification +} from './grok-tool-fields' + +export function normalizeGrokEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record, + grokHome?: string +): ParsedAgentStatusPayload | null { + if (isGrokEvent(eventName, 'session_start')) { + // Why: SessionStart resets stale per-turn state but must not create a working row before any prompt/tool event. + clearPaneTurnCacheState(state, paneKey) + return null + } + + const notificationMessage = readString(hookPayload, 'message') + const notificationType = getGrokNotificationType(hookPayload) + const notificationLevel = readString(hookPayload, 'level') + const preToolName = + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') + // Why: Grok's ask_user_question is auto-allowed, so it fires PreToolUse while blocked on a human answer; map to waiting. + const isUserInputPreTool = + isGrokEvent(eventName, 'pre_tool_use') && isAskUserQuestionTool(preToolName) + + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( + isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') || + (isGrokEvent(eventName, 'pre_tool_use') && !isUserInputPreTool) + ) { + stateName = 'working' + } else if (isUserInputPreTool) { + stateName = 'waiting' + } else if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { + stateName = 'done' + } else if ( + isGrokEvent(eventName, 'notification') && + isGrokRoutinePermissionPromptNotification( + notificationType, + notificationMessage, + notificationLevel + ) + ) { + return null + } else if ( + isGrokEvent(eventName, 'notification') && + isGrokPermissionNotification(notificationMessage) + ) { + stateName = 'waiting' + } else if ( + isGrokEvent(eventName, 'notification') && + isGrokIdleNotification(notificationMessage) + ) { + stateName = 'done' + } + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('grok', eventName, hookPayload, { grokHome }), + { resetOnNewTurn: isNewTurnEvent('grok', eventName) } + ) + + // Why: Grok Notification.message is status UI text, not the prompt; '' preserves the cached UserPromptSubmit. + const effectivePrompt = isGrokEvent(eventName, 'notification') + ? '' + : stripGrokUserQueryWrapper(promptText) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, effectivePrompt, { + resetOnNewTurn: isNewTurnEvent('grok', eventName) + }), + agentType: 'grok', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/grok-tool-fields.ts b/src/shared/agent-hook-listener/providers/grok-tool-fields.ts new file mode 100644 index 00000000000..32d12511a55 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/grok-tool-fields.ts @@ -0,0 +1,128 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { + clearActiveToolFieldsUpdate, + deriveInteractivePrompt, + extractToolResponseText +} from '../interactive-tool' +import { readLastAssistantFromTranscript } from '../transcript-lines' +import { readLastAssistantFromGrokChatHistory } from '../grok-result-discovery' +import { isGrokEvent } from '../provider-event-names' + +export function extractGrokToolFields( + eventName: unknown, + hookPayload: Record, + grokHome?: string +): ToolSnapshot { + if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) { + const update: ToolSnapshot = {} + if (isGrokEvent(eventName, 'post_tool_use_failure')) { + Object.assign(update, clearActiveToolFieldsUpdate()) + } else { + const toolName = + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') + const rawInput = + hookPayload.toolInput ?? + hookPayload.tool_input ?? + hookPayload.input ?? + hookPayload.arguments + const toolInput = + deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) + // Why: Grok's ask_user_question is auto-allowed via PreToolUse, not PermissionRequest; capture full payload for the live card. + const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) + Object.assign( + update, + toolUpdate( + { toolName, toolInput, interactivePrompt }, + { + hasToolInputField: hasAnyOwnField(hookPayload, [ + 'toolInput', + 'tool_input', + 'input', + 'arguments' + ]) + } + ) + ) + } + if (isGrokEvent(eventName, 'post_tool_use', 'post_tool_use_failure')) { + const responseText = + extractToolResponseText(hookPayload.toolResponse) ?? + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.toolOutput) ?? + extractToolResponseText(hookPayload.tool_output) ?? + readString(hookPayload, 'error') ?? + readString(hookPayload, 'message') + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { + const direct = + readString(hookPayload, 'lastAssistantMessage') ?? + readString(hookPayload, 'last_assistant_message') + if (direct) { + return { lastAssistantMessage: direct } + } + const fromTranscript = readLastAssistantFromTranscript( + hookPayload.transcriptPath ?? hookPayload.transcript_path + ) + if (fromTranscript) { + return { lastAssistantMessage: fromTranscript } + } + const fromChatHistory = readLastAssistantFromGrokChatHistory(hookPayload, grokHome) + if (fromChatHistory) { + return { lastAssistantMessage: fromChatHistory } + } + } + return {} +} +export function isGrokPermissionNotification(message: string | undefined): boolean { + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('permission') || + lower.includes('approval') || + lower.includes('approve') || + lower.includes('allow') || + lower.includes('confirm') || + lower.includes('needs your') || + lower.includes('requires your') || + lower.includes('feedback') || + lower.includes('clarify') || + lower.includes('question') + ) +} + +export function getGrokNotificationType(hookPayload: Record): string | undefined { + return ( + readString(hookPayload, 'notificationType') ?? + readString(hookPayload, 'notification_type') ?? + readString(hookPayload, 'type') + ) +} + +export function isGrokRoutinePermissionPromptNotification( + notificationType: string | undefined, + message: string | undefined, + level: string | undefined +): boolean { + // Why: Grok emits this before each tool even under bypassPermissions; PreToolUse already covers progress. + return ( + isGrokEvent(notificationType, 'permission_prompt') && + message?.trim().toLowerCase() === 'tool permission requested' && + (!level || level.trim().toLowerCase() === 'info') + ) +} diff --git a/src/shared/agent-hook-listener/providers/hermes-events.ts b/src/shared/agent-hook-listener/providers/hermes-events.ts new file mode 100644 index 00000000000..3757e13430a --- /dev/null +++ b/src/shared/agent-hook-listener/providers/hermes-events.ts @@ -0,0 +1,54 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeHermesEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'pre_approval_request' + ? 'waiting' + : eventName === 'post_llm_call' || + eventName === 'on_session_end' || + eventName === 'on_session_finalize' || + eventName === 'on_session_reset' + ? 'done' + : eventName === 'on_session_start' || + eventName === 'pre_llm_call' || + eventName === 'pre_tool_call' || + eventName === 'post_tool_call' || + eventName === 'post_approval_response' + ? 'working' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('hermes', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('hermes', eventName) } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('hermes', eventName) + }), + agentType: 'hermes', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts b/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts new file mode 100644 index 00000000000..93502b1c1d2 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts @@ -0,0 +1,70 @@ +import type { ToolSnapshot } from '../listener-event' +import { + deriveFallbackToolInputPreview, + deriveToolInputPreview, + hasAnyOwnField, + readString, + toolUpdate +} from '../tool-input-preview' +import { extractToolResponseText } from '../interactive-tool' + +export function extractHermesToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'pre_tool_call' || + eventName === 'post_tool_call' || + eventName === 'pre_approval_request' || + eventName === 'post_approval_response' + ) { + const toolName = + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') ?? + (eventName === 'pre_approval_request' || eventName === 'post_approval_response' + ? 'approval' + : undefined) + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.args) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + // Why: Hermes has many tool names; fall back to obvious arg fields so a new name still shows a value, not a blank row. + deriveFallbackToolInputPreview(hookPayload.tool_input) ?? + deriveFallbackToolInputPreview(hookPayload.args) ?? + deriveFallbackToolInputPreview(hookPayload.input) ?? + readString(hookPayload, 'command') ?? + readString(hookPayload, 'description') + const update: ToolSnapshot = toolUpdate( + { toolName, toolInput }, + { + hasToolInputField: hasAnyOwnField(hookPayload, [ + 'tool_input', + 'args', + 'input', + 'command', + 'description' + ]) + } + ) + if (eventName === 'post_tool_call') { + const responseText = + extractToolResponseText(hookPayload.result) ?? + extractToolResponseText(hookPayload.tool_response) ?? + extractToolResponseText(hookPayload.output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update + } + if (eventName === 'post_llm_call') { + const message = + readString(hookPayload, 'last_assistant_message') ?? + readString(hookPayload, 'assistant_response') ?? + readString(hookPayload, 'response_text') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/providers/kimi-events.ts b/src/shared/agent-hook-listener/providers/kimi-events.ts new file mode 100644 index 00000000000..c51a8be901a --- /dev/null +++ b/src/shared/agent-hook-listener/providers/kimi-events.ts @@ -0,0 +1,73 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { + resolvePrompt, + resolveToolState, + shouldIgnoreCompactContinuationUserPromptSubmit +} from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +// Why: Kimi's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest) while awaiting an answer; treat as waiting so the UI shows the attention icon, not a spinner. +export function isKimiUserInputTool(toolName: string | undefined): boolean { + return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion' +} + +// Why: Kimi Code emits Claude-compatible payloads/event names; normalize but attribute to Kimi so the sidebar shows Kimi's icon/label, not Claude's. +export function normalizeKimiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + + const toolName = readString(hookPayload, 'tool_name') + const isUserInputTool = isKimiUserInputTool(toolName) + + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( + eventName === 'UserPromptSubmit' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' || + (eventName === 'PreToolUse' && !isUserInputTool) + ) { + stateName = 'working' + } else if (eventName === 'PermissionRequest' || (eventName === 'PreToolUse' && isUserInputTool)) { + stateName = 'waiting' + } else if (eventName === 'Stop' || eventName === 'StopFailure') { + stateName = 'done' + } + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('kimi', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('kimi', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('kimi', eventName) + }), + agentType: 'kimi', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) +} diff --git a/src/shared/agent-hook-listener/providers/opencode-family-events.ts b/src/shared/agent-hook-listener/providers/opencode-family-events.ts new file mode 100644 index 00000000000..913bdbf4c03 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/opencode-family-events.ts @@ -0,0 +1,56 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' + +export function normalizeOpenCodeFamilyEvent( + source: 'opencode' | 'mimo-code', + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const resetsTurn = + isNewTurnEvent(source, eventName) || + (eventName === 'MessagePart' && hookPayload.role === 'user') + const stateName = + eventName === 'SessionBusy' || eventName === 'MessagePart' + ? 'working' + : eventName === 'SessionIdle' + ? 'done' + : source === 'opencode' && eventName === 'SessionStart' + ? 'done' + : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' + ? 'waiting' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields(source, eventName, hookPayload), + { + resetOnNewTurn: resetsTurn + } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: resetsTurn + }), + agentType: source, + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined + }) +} diff --git a/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts new file mode 100644 index 00000000000..4236cace716 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts @@ -0,0 +1,75 @@ +import { capOpenCodeHookText } from '../listener-limits' +import type { ToolSnapshot } from '../listener-event' +import { hasAnyOwnField, hasOwnField, readString, toolUpdate } from '../tool-input-preview' +import { + deriveInteractivePrompt, + readFirstString, + stripHookEnvelopeKeys +} from '../interactive-tool' + +/** + * Retires any cached tool fields. PermissionRequest is the only OpenCode-family event that + * carries them, and the only isNewTurnEvent boundary this family has is opencode's + * SessionStart — which a resumed session never re-emits — so nothing else resets the cache + * mid-session. Without an explicit retire, resolveToolState inherits one answered permission + * onto every later frame in the pane and the row reads a resolved command as the live tool. + */ +const OPENCODE_TOOL_FIELDS_RETIRED: ToolSnapshot = { + hasToolUpdate: true, + hasToolInputField: true +} + +export function extractOpenCodeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { + const text = readString(hookPayload, 'text') + if (text) { + return { ...OPENCODE_TOOL_FIELDS_RETIRED, lastAssistantMessage: capOpenCodeHookText(text) } + } + } + if (eventName === 'AskUserQuestion') { + // Why: OpenCode's payload is question.asked's event.properties (hook_event_name merged in); strip envelope or use tool_input, capture JSON for the card. + const toolInputSource = hasOwnField(hookPayload, 'tool_input') + ? hookPayload.tool_input + : stripHookEnvelopeKeys(hookPayload) + return { + ...OPENCODE_TOOL_FIELDS_RETIRED, + interactivePrompt: deriveInteractivePrompt('AskUserQuestion', toolInputSource) + } + } + if (eventName === 'PermissionRequest') { + // Why: the payload is permission.asked's event.properties — `permission` names what is + // being requested and `metadata`/`patterns` say which command or path it covers. Without + // them the row is a bare 'waiting' that cannot tell the user what to approve. + // The SDK types `metadata` as Record, so these keys come from the tools + // themselves: bash sends `command`, edit sends `filepath`, webfetch sends `url` (verified + // against opencode 1.18.18). `file_path`/`path` cover tools that spell it the common way. + // `diff` is deliberately absent — edit ships the whole patch and it would swamp the row. + const metadata = hookPayload.metadata + const metadataInput = + metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata) + ? readFirstString(metadata as Record, [ + 'command', + 'filepath', + 'file_path', + 'path', + 'url' + ]) + : undefined + const patterns = Array.isArray(hookPayload.patterns) + ? hookPayload.patterns.filter( + (pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0 + ) + : [] + return toolUpdate( + { + toolName: readString(hookPayload, 'permission'), + toolInput: metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined) + }, + { hasToolInputField: hasAnyOwnField(hookPayload, ['metadata', 'patterns']) } + ) + } + return OPENCODE_TOOL_FIELDS_RETIRED +} diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts new file mode 100644 index 00000000000..5f0aeaf42c5 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -0,0 +1,67 @@ +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' +import { resolvePrompt, resolveToolState } from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +export function normalizePiCompatibleEvent( + state: HookListenerState, + agentType: 'pi' | 'omp' | 'prime-agent', + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (agentType !== 'omp' && eventName === 'session_start') { + // Why: Pi's session_start fires on TUI open/resume; discard stale turn details, no working row before user activity. + clearPaneTurnCacheState(state, paneKey) + return null + } + + // Why: gate on the event's own tool_name so a stale cached question can't re-enter blocked. + const toolName = readString(hookPayload, 'tool_name') + const isPiCompatibleAsk = + ((agentType === 'pi' && isAskUserQuestionTool(toolName)) || + (agentType === 'omp' && toolName === 'ask')) && + (eventName === 'tool_call' || eventName === 'tool_execution_start') + + const stateName = isPiCompatibleAsk + ? 'blocked' + : eventName === 'before_agent_start' || + eventName === 'agent_start' || + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' || + eventName === 'message_end' + ? 'working' + : eventName === 'agent_end' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields(agentType, eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent(agentType, eventName) } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent(agentType, eventName) + }), + agentType, + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage + }) +} diff --git a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts new file mode 100644 index 00000000000..21648548036 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts @@ -0,0 +1,35 @@ +import type { ToolSnapshot } from '../listener-event' +import { deriveToolInputPreview, hasOwnField, readString, toolUpdate } from '../tool-input-preview' +import { deriveInteractivePrompt } from '../interactive-tool' + +export function extractPiToolFields( + eventName: unknown, + hookPayload: Record, + agentKind: 'pi' | 'omp' | 'prime-agent' +): ToolSnapshot { + if ( + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' + ) { + const toolName = readString(hookPayload, 'tool_name') + const rawToolInput = hookPayload.tool_input + const toolInput = deriveToolInputPreview(toolName, rawToolInput) + // Why: OMP shares this extractor; only derive interactivePrompt for Pi so OMP ask_user_question metadata stays unchanged. + const interactivePrompt = + agentKind === 'pi' && (eventName === 'tool_call' || eventName === 'tool_execution_start') + ? deriveInteractivePrompt(toolName, rawToolInput, eventName) + : undefined + return toolUpdate( + { toolName, toolInput, interactivePrompt }, + { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } + ) + } + if (eventName === 'message_end' && hookPayload.role === 'assistant') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} diff --git a/src/shared/agent-hook-listener/request-body.ts b/src/shared/agent-hook-listener/request-body.ts new file mode 100644 index 00000000000..b453c7c1f7e --- /dev/null +++ b/src/shared/agent-hook-listener/request-body.ts @@ -0,0 +1,125 @@ +import type { IncomingMessage } from 'node:http' + +import { classifyTruncatedHookRequest } from '../agent-hook-transport-interference' +import { assertJsonTextStructureWithinLimits } from '../json-text-structure-limit' + +/** Maximum request body size accepted by the listener (1 MB). */ +export const HOOK_REQUEST_MAX_BYTES = 1_000_000 +const HOOK_REQUEST_INITIAL_BUFFER_BYTES = 4 * 1024 +const AGENT_HOOK_JSON_STRUCTURE_LIMITS = { + structuralTokens: 128 * 1024, + nestingDepth: 64 +} as const + +export function parseAgentHookJson(content: string): unknown { + // Why: Cursor on Windows writes UTF-8-with-BOM to the hook's stdin and `JSON.parse` rejects U+FEFF, + // so the whole event was dropped. Strip exactly one leading BOM — not a trim — to keep every other + // malformed payload rejected as before. + const normalizedContent = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content + assertJsonTextStructureWithinLimits(normalizedContent, AGENT_HOOK_JSON_STRUCTURE_LIMITS) + return JSON.parse(normalizedContent) as unknown +} +// ─── Body parsing ─────────────────────────────────────────────────── + +export function parseFormEncodedBody(body: string): Record { + const params = new URLSearchParams(body) + const parsed: Record = {} + for (const [key, value] of params.entries()) { + parsed[key] = value + } + return parsed +} + +export function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let retained = Buffer.alloc(0) + let byteLength = 0 + let settled = false + const cleanup = (): void => { + req.off('data', onData) + req.off('end', onEnd) + req.off('error', onError) + req.off('close', onClose) + // Why: keep a neutral error sink so a late IncomingMessage error after cleanup can't become unhandled. + req.on('error', ignoreSettledRequestError) + } + const settleResolve = (value: unknown): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(value) + } + const settleReject = (error: unknown): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const onData = (chunk: Buffer): void => { + // Why: bound by bytes (not UTF-16 units) and stop accumulating after rejection so a client can't push memory past the cap. + const nextByteLength = byteLength + chunk.length + if (nextByteLength > HOOK_REQUEST_MAX_BYTES) { + settleReject(new Error('payload too large')) + req.destroy() + return + } + if (retained.length < nextByteLength) { + const nextCapacity = Math.min( + HOOK_REQUEST_MAX_BYTES, + Math.max(HOOK_REQUEST_INITIAL_BUFFER_BYTES, retained.length * 2, nextByteLength) + ) + const next = Buffer.allocUnsafe(nextCapacity) + retained.copy(next, 0, 0, byteLength) + retained = next + } + chunk.copy(retained, byteLength) + byteLength = nextByteLength + } + const onEnd = (): void => { + try { + const body = retained.toString('utf8', 0, byteLength) + const contentType = req.headers['content-type'] ?? '' + if (typeof contentType === 'string' && contentType.includes('application/json')) { + settleResolve(body ? parseAgentHookJson(body) : {}) + return + } + if ( + typeof contentType === 'string' && + contentType.includes('application/x-www-form-urlencoded') + ) { + settleResolve(parseFormEncodedBody(body)) + return + } + // Why: managed scripts POST JSON, updated POSIX scripts form-encoded; default to JSON for unknown content types. + settleResolve(body ? parseAgentHookJson(body) : {}) + } catch (error) { + settleReject(error) + } + } + // Why (#11217): a body cut short of its own Content-Length is the fingerprint of an IDS + // resetting the connection mid-inspection. Classify on every path that ends the request without + // 'end' — a peer RST surfaces as 'error' (ECONNRESET) and only a local destroy reaches 'close' first. + const settleUnfinished = (fallback: Error): void => { + settleReject( + classifyTruncatedHookRequest(req.headers['content-length'], byteLength) ?? fallback + ) + } + const onError = (err: Error): void => { + settleUnfinished(err) + } + // Why: req.destroy() (slowloris timer) emits 'close' but not 'end'/'error'; without this the promise never settles and buffers leak. + const onClose = (): void => { + settleUnfinished(new Error('aborted')) + } + req.on('data', onData) + req.on('end', onEnd) + req.on('error', onError) + req.on('close', onClose) + }) +} + +export function ignoreSettledRequestError(): void {} diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts new file mode 100644 index 00000000000..470c1bf2410 --- /dev/null +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -0,0 +1,28 @@ +import type { AgentHookSource } from '../agent-hook-relay' + +// ─── URL routing ──────────────────────────────────────────────────── + +export const HOOK_SOURCE_BY_PATHNAME: Readonly> = Object.freeze({ + '/hook/claude': 'claude', + '/hook/codex': 'codex', + '/hook/gemini': 'gemini', + '/hook/antigravity': 'antigravity', + '/hook/amp': 'amp', + '/hook/opencode': 'opencode', + '/hook/mimo-code': 'mimo-code', + '/hook/cursor': 'cursor', + '/hook/pi': 'pi', + '/hook/omp': 'omp', + '/hook/prime-agent': 'prime-agent', + '/hook/droid': 'droid', + '/hook/command-code': 'command-code', + '/hook/grok': 'grok', + '/hook/copilot': 'copilot', + '/hook/hermes': 'hermes', + '/hook/devin': 'devin', + '/hook/kimi': 'kimi' +}) + +export function resolveHookSource(pathname: string): AgentHookSource | null { + return HOOK_SOURCE_BY_PATHNAME[pathname] ?? null +} diff --git a/src/shared/agent-hook-listener/tool-input-preview.ts b/src/shared/agent-hook-listener/tool-input-preview.ts new file mode 100644 index 00000000000..7499763b63d --- /dev/null +++ b/src/shared/agent-hook-listener/tool-input-preview.ts @@ -0,0 +1,169 @@ +import type { ToolSnapshot } from './listener-event' + +const TOOL_INPUT_KEYS_BY_TOOL: Record = { + Read: ['file_path', 'filePath', 'path'], + Write: ['file_path', 'filePath', 'path'], + Create: ['file_path', 'filePath', 'path'], + Edit: ['file_path', 'filePath', 'path'], + Execute: ['command'], + MultiEdit: ['file_path', 'filePath', 'path'], + NotebookEdit: ['file_path', 'filePath', 'path'], + Bash: ['command'], + Glob: ['pattern'], + Grep: ['pattern'], + WebFetch: ['url'], + WebSearch: ['query'], + FetchUrl: ['url'], + read_file: ['file_path', 'path'], + write_file: ['file_path', 'path'], + read_many_files: ['file_path', 'paths', 'path'], + edit_file: ['file_path', 'path'], + replace: ['file_path', 'path'], + run_shell_command: ['command'], + run_command: ['CommandLine', 'command', 'cmd'], + glob: ['pattern'], + search_file_content: ['pattern'], + web_fetch: ['url'], + google_web_search: ['query'], + exec_command: ['cmd', 'command'], + shell_command: ['cmd', 'command'], + run_terminal_cmd: ['command'], + // Why: Grok maps Bash/Edit/Write to snake_case tool names; without these keys the status row shows blank toolInput for most Grok turns. + run_terminal_command: ['command'], + search_replace: ['file_path', 'path', 'filePath'], + write_to_file: ['TargetFile', 'path', 'file_path'], + execute_code: ['code', 'command', 'cmd'], + apply_patch: ['path', 'file_path'], + view_image: ['path', 'file_path'], + AskUser: ['question', 'prompt', 'message'], + ask_user: ['question', 'prompt', 'message'], + AskUserQuestion: ['questions', 'question', 'prompt', 'message'], + ask_user_question: ['questions', 'question', 'prompt', 'message'], + bash: ['command'], + powershell: ['command'], + create: ['path', 'file_path'], + read: ['path', 'file_path'], + write: ['path', 'file_path'], + edit: ['path', 'file_path'], + view: ['path', 'file_path'], + grep: ['pattern'], + web_search: ['query'], + fetch_content: ['url'], + terminal: ['command'], + patch: ['path', 'file_path'], + search_files: ['query', 'pattern', 'path'], + browser_navigate: ['url'], + browser_click: ['target', 'selector', 'text'], + browser_type: ['text', 'target', 'selector'], + session_search: ['query'], + skill_manage: ['action', 'name', 'file_path'], + delegate_task: ['task', 'prompt', 'description'], + view_file: ['AbsolutePath', 'path', 'file_path'], + replace_file_content: ['TargetFile', 'path', 'file_path'], + multi_replace_file_content: ['TargetFile', 'path', 'file_path'], + list_dir: ['DirectoryPath', 'path'], + find_by_name: ['SearchDirectory', 'Pattern', 'query'], + grep_search: ['SearchPath', 'Query', 'query', 'pattern'], + search_web: ['query'], + read_url_content: ['Url', 'url'], + manage_task: ['TaskId', 'Action'], + schedule: ['Prompt', 'DurationSeconds', 'CronExpression'], + ask_question: ['question', 'questions'], + ask_permission: ['Action', 'Target', 'Reason'], + spawn_subagent: ['prompt', 'description', 'subagent_type'], + open_page: ['url'] +} + +const FALLBACK_TOOL_INPUT_KEYS = [ + 'command', + 'cmd', + 'code', + 'query', + 'pattern', + 'url', + 'path', + 'file_path', + 'filePath', + 'target', + 'selector', + 'text', + 'action', + 'name', + 'description', + 'CommandLine', + 'AbsolutePath', + 'TargetFile', + 'DirectoryPath', + 'SearchPath', + 'Query', + 'Url', + 'Prompt' +] as const + +export function deriveToolInputPreview( + toolName: string | undefined, + toolInput: unknown +): string | undefined { + if (typeof toolInput === 'string') { + return toolInput + } + if (typeof toolInput !== 'object' || toolInput === null) { + return undefined + } + if (!toolName) { + return undefined + } + const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] + if (!keys) { + return undefined + } + const record = toolInput as Record + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + } + return undefined +} + +export function deriveFallbackToolInputPreview(toolInput: unknown): string | undefined { + if (typeof toolInput === 'string') { + return toolInput + } + if (typeof toolInput !== 'object' || toolInput === null) { + return undefined + } + const record = toolInput as Record + for (const key of FALLBACK_TOOL_INPUT_KEYS) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + } + return undefined +} + +export function readString(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +export function hasOwnField(record: Record, key: string): boolean { + return Object.hasOwn(record, key) +} + +export function hasAnyOwnField(record: Record, keys: readonly string[]): boolean { + return keys.some((key) => hasOwnField(record, key)) +} + +export function toolUpdate( + fields: Pick, + options?: { hasToolInputField?: boolean } +): ToolSnapshot { + return { + ...fields, + hasToolUpdate: true, + hasToolInputField: options?.hasToolInputField === true + } +} diff --git a/src/shared/agent-hook-listener/transcript-entry-text.ts b/src/shared/agent-hook-listener/transcript-entry-text.ts new file mode 100644 index 00000000000..3daa36b3840 --- /dev/null +++ b/src/shared/agent-hook-listener/transcript-entry-text.ts @@ -0,0 +1,56 @@ +import { parseAgentHookJson } from './request-body' + +export function extractAssistantTextFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = parseAgentHookJson(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + if (record.type === 'assistant.message') { + const data = record.data + if (typeof data === 'object' && data !== null) { + const text = extractAssistantContentText((data as Record).content) + if (text) { + return text + } + } + } + if ( + record.source === 'MODEL' && + record.type === 'PLANNER_RESPONSE' && + typeof record.content === 'string' && + record.content.trim().length > 0 + ) { + return record.content + } + const nestedMessage = record.message as Record | undefined + const role = + record.role ?? nestedMessage?.role ?? (record.type === 'assistant' ? 'assistant' : undefined) + if (role !== 'assistant') { + return undefined + } + const content = (nestedMessage ?? record).content + return extractAssistantContentText(content) +} + +export function extractAssistantContentText(content: unknown): string | undefined { + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + return undefined +} diff --git a/src/shared/agent-hook-listener/transcript-lines.ts b/src/shared/agent-hook-listener/transcript-lines.ts new file mode 100644 index 00000000000..0ea5a06f831 --- /dev/null +++ b/src/shared/agent-hook-listener/transcript-lines.ts @@ -0,0 +1,51 @@ +import { parseAgentHookJson } from './request-body' +import { + readLastAssistantFromTranscriptOnce, + readLastTextFromTranscriptOnce +} from './transcript-reader' + +export function extractAntigravityUserRequest(content: string): string | undefined { + const opener = '' + const startIndex = content.indexOf(opener) + const bodyStartIndex = startIndex === -1 ? -1 : startIndex + opener.length + const endIndex = bodyStartIndex === -1 ? -1 : content.indexOf('', bodyStartIndex) + const text = + bodyStartIndex === -1 || endIndex === -1 ? content : content.slice(bodyStartIndex, endIndex) + const trimmed = text.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export function extractUserPromptTextFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = parseAgentHookJson(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + if ( + (record.source === 'USER_EXPLICIT' || record.source === 'USER') && + (record.type === 'USER_INPUT' || record.type === 'REQUEST') && + typeof record.content === 'string' + ) { + return extractAntigravityUserRequest(record.content) + } + return undefined +} + +export function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + return readLastAssistantFromTranscriptOnce(transcriptPath) +} + +export function readLastUserPromptFromTranscript(transcriptPath: unknown): string | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + return readLastTextFromTranscriptOnce(transcriptPath, extractUserPromptTextFromLine) +} diff --git a/src/shared/agent-hook-listener/transcript-reader.ts b/src/shared/agent-hook-listener/transcript-reader.ts new file mode 100644 index 00000000000..f9b1472384e --- /dev/null +++ b/src/shared/agent-hook-listener/transcript-reader.ts @@ -0,0 +1,107 @@ +import { closeSync, openSync, readSync, statSync } from 'node:fs' + +import { extractAssistantTextFromLine } from './transcript-entry-text' + +export const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 +export const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 +export const EMPTY_TRANSCRIPT_REGION = Buffer.alloc(0) +export function readLastAssistantFromTranscriptOnce(transcriptPath: string): string | undefined { + return readLastTextFromTranscriptOnce(transcriptPath, extractAssistantTextFromLine) +} + +export function readLastTextFromTranscriptOnce( + transcriptPath: string, + extractLineText: (line: string) => string | undefined +): string | undefined { + try { + const stats = statSync(transcriptPath) + const size = stats.size + if (size <= 0) { + return undefined + } + const fd = openSync(transcriptPath, 'r') + try { + // Why a chunk list: carry holds a partial line, and re-joining it per block + // made one oversized line (a big tool result or pasted prompt) cost O(line^2). + let carryChunks: Buffer[] = [] + let bytesRead = 0 + let scanEnd = size + while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min(scanEnd, TRANSCRIPT_CHUNK_BYTES) + const position = scanEnd - chunkSize + const buffer = Buffer.alloc(chunkSize) + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + // Why bail on a short read: the file shrank under us, so the bytes above + // this block no longer line up with what the earlier ones assumed. + if (filled < chunkSize) { + break + } + bytesRead += filled + scanEnd = position + // Why search only the new block: carry is always the run before a newline, + // so it holds none of its own. + const firstNewline = buffer.indexOf(0x0a) + const atStart = position === 0 + let completeRegion: Buffer + if (atStart) { + completeRegion = + carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [] + } else if (firstNewline === -1) { + completeRegion = EMPTY_TRANSCRIPT_REGION + carryChunks.unshift(buffer) + } else { + const afterNewline = buffer.subarray(firstNewline + 1) + completeRegion = + carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] + } + if (completeRegion.length > 0) { + const extracted = findLastExtractedTranscriptLineText( + completeRegion.toString('utf8'), + extractLineText + ) + if (extracted !== undefined) { + return extracted + } + } + } + return undefined + } finally { + closeSync(fd) + } + } catch { + return undefined + } +} + +export function findLastExtractedTranscriptLineText( + text: string, + extractLineText: (line: string) => string | undefined +): string | undefined { + let lineEnd = text.length + + for (let index = text.length - 1; index >= -1; index--) { + if (index >= 0 && text.charCodeAt(index) !== 10) { + continue + } + + const line = text.slice(index + 1, lineEnd).trim() + if (line.length > 0) { + const extracted = extractLineText(line) + if (extracted !== undefined) { + return extracted + } + } + lineEnd = index + } + + return undefined +} diff --git a/src/shared/agent-hook-request-body-memory.test.ts b/src/shared/agent-hook-request-body-memory.test.ts index 0fae9ff87c4..ce0c8029dd0 100644 --- a/src/shared/agent-hook-request-body-memory.test.ts +++ b/src/shared/agent-hook-request-body-memory.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import type { IncomingHttpHeaders, IncomingMessage } from 'node:http' import { describe, expect, it, vi } from 'vitest' -import { readRequestBody } from './agent-hook-listener' +import { readRequestBody } from './agent-hook-listener/request-body' type FakeIncomingMessage = EventEmitter & { headers: IncomingHttpHeaders diff --git a/src/shared/agent-hook-status-cache.test.ts b/src/shared/agent-hook-status-cache.test.ts index fc7238cfbdc..ea9ff66b436 100644 --- a/src/shared/agent-hook-status-cache.test.ts +++ b/src/shared/agent-hook-status-cache.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { createHookListenerState, type AgentHookEventPayload } from './agent-hook-listener' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' import { upsertBoundedAgentHookStatus } from './agent-hook-status-cache' import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types' diff --git a/src/shared/agent-hook-status-cache.ts b/src/shared/agent-hook-status-cache.ts index 283e6d5589d..d46f574db37 100644 --- a/src/shared/agent-hook-status-cache.ts +++ b/src/shared/agent-hook-status-cache.ts @@ -1,8 +1,5 @@ -import { - clearPaneCacheState, - type AgentHookEventPayload, - type HookListenerState -} from './agent-hook-listener' +import { clearPaneCacheState, type HookListenerState } from './agent-hook-listener/listener-state' +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types' export const MAX_AGENT_HOOK_STATUS_CACHE_PANES = 500 diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index 6722dded5d3..8f59ebdd7f5 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -3,12 +3,14 @@ import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, - markClaudeLeadTurnInterrupted, movePaneCacheState, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { + markClaudeLeadTurnInterrupted, + seedClaudeSubagentRosterFromSnapshots +} from './agent-hook-listener/providers/claude-roster-state' +import { normalizeHookPayload } from './agent-hook-listener' import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory' import { makePaneKey } from './stable-pane-id' diff --git a/src/shared/claude-subagent-row-lifecycle.test.ts b/src/shared/claude-subagent-row-lifecycle.test.ts index db3b71634e5..ff793c78384 100644 --- a/src/shared/claude-subagent-row-lifecycle.test.ts +++ b/src/shared/claude-subagent-row-lifecycle.test.ts @@ -30,9 +30,9 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from './agent-hook-listener' +} from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' diff --git a/src/shared/codex-subagent-rollout-lifecycle.test.ts b/src/shared/codex-subagent-rollout-lifecycle.test.ts index 9d476b85504..0f24197e5d9 100644 --- a/src/shared/codex-subagent-rollout-lifecycle.test.ts +++ b/src/shared/codex-subagent-rollout-lifecycle.test.ts @@ -3,7 +3,8 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createHookListenerState, normalizeHookPayload } from './agent-hook-listener' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { makePaneKey } from './stable-pane-id' const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') diff --git a/src/shared/opencode-permission-status.test.ts b/src/shared/opencode-permission-status.test.ts index 6dfb86a7c4d..31316e30391 100644 --- a/src/shared/opencode-permission-status.test.ts +++ b/src/shared/opencode-permission-status.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { createHookListenerState, normalizeHookPayload } from './agent-hook-listener' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { normalizeHookPayload } from './agent-hook-listener' import { makePaneKey } from './stable-pane-id' const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') From 9bcaf0986948b74df659ec6c66c4ea48a56905ad Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:53 -0700 Subject: [PATCH 06/10] refactor loading store into cohesive domains (#16192) --- config/max-lines-baseline.txt | 1 - ...rsistence-loading-store-extraction.test.ts | 199 + ...sistence-loading-store-write-risks.test.ts | 139 + src/main/persistence-test-harness.ts | 14 +- ...tence-workspace-session-scrollback.test.ts | 105 +- .../feature-interaction-recording.ts | 3 +- .../applying-settings/settings-update.ts | 4 +- .../applying-settings/ui-state-update.ts | 3 +- .../ssh-pty-binding-cleanup.ts | 4 +- .../ssh-pty-consumer-recovery.ts | 4 +- .../ssh-pty-lease-operations.ts | 3 +- .../ssh-target-reassignment.ts | 6 +- .../leasing-ssh-ptys/ssh-target-state.ts | 3 +- .../loading-store/automation-persistence.ts | 158 + .../loading-store/backup-recovery-rotation.ts | 147 + .../loading-store/loaded-cohort-migrations.ts | 66 + .../loading-store/loaded-state-adaptation.ts | 116 + .../loading-store/loaded-state-parsing.ts | 267 ++ .../metadata-lineage-operations.ts | 254 ++ .../mobile-tab-selection-persistence.ts | 42 + .../normalize-loaded-global-settings.ts | 138 + .../normalize-loaded-profile-state.ts | 91 + .../normalize-loaded-state-collections.ts | 66 + .../normalize-loaded-ui-state.ts | 206 + .../prepare-loaded-profile-settings.ts | 255 ++ .../prepare-loaded-terminal-settings.ts | 172 + .../loading-store/primary-state-writes.ts | 282 ++ .../loading-store/profile-preferences.ts | 194 + .../project-collection-operations.ts | 233 + .../loading-store/pty-binding-persistence.ts | 240 + .../repo-lifecycle-operations.ts | 264 ++ .../retired-name-registry-normalization.ts | 46 + .../retired-worktree-name-persistence.ts | 119 + .../loading-store/session-host-partitions.ts | 190 + .../session-snapshot-operations.ts | 102 + .../sparse-preset-persistence.ts | 55 + .../ssh-lease-recovery-operations.ts | 182 + .../loading-store/ssh-profile-operations.ts | 142 + .../state-serialization-secret-handling.ts | 125 + .../loading-store/store-domain-composition.ts | 187 + .../loading-store/store-owned-state.ts | 8 - .../loading-store/store-runtime-state.ts | 79 + src/main/persistence/loading-store/store.ts | 3949 +---------------- .../terminal-binding-recovery.ts | 104 + .../loading-store/terminal-session-cleanup.ts | 36 + .../workspace-session-partitions.ts | 56 + .../workspace-session-snapshot-publication.ts | 242 + .../loading-store/write-flush-barriers.ts | 276 ++ .../loading-store/write-scheduling.ts | 71 + .../folder-workspace-operations.ts | 3 +- .../automation-definition-operations.ts | 3 +- .../automation-run-operations.ts | 3 +- .../automation-schedule-operations.ts | 4 +- .../project-group-operations.ts | 3 +- .../tracking-repos/project-host-operations.ts | 3 +- .../project-host-setup-update.ts | 3 +- .../tracking-repos/repo-order-operations.ts | 3 +- .../tracking-repos/repo-update-operations.ts | 3 +- .../tracking-repos/repo-worktree-pruning.ts | 4 +- .../worktree-identity-migration.ts | 4 +- .../worktree-metadata-normalization.test.ts | 11 +- .../worktree-metadata-normalization.ts | 6 +- 62 files changed, 5759 insertions(+), 3942 deletions(-) create mode 100644 src/main/persistence-loading-store-extraction.test.ts create mode 100644 src/main/persistence-loading-store-write-risks.test.ts create mode 100644 src/main/persistence/loading-store/automation-persistence.ts create mode 100644 src/main/persistence/loading-store/backup-recovery-rotation.ts create mode 100644 src/main/persistence/loading-store/loaded-cohort-migrations.ts create mode 100644 src/main/persistence/loading-store/loaded-state-adaptation.ts create mode 100644 src/main/persistence/loading-store/loaded-state-parsing.ts create mode 100644 src/main/persistence/loading-store/metadata-lineage-operations.ts create mode 100644 src/main/persistence/loading-store/mobile-tab-selection-persistence.ts create mode 100644 src/main/persistence/loading-store/normalize-loaded-global-settings.ts create mode 100644 src/main/persistence/loading-store/normalize-loaded-profile-state.ts create mode 100644 src/main/persistence/loading-store/normalize-loaded-state-collections.ts create mode 100644 src/main/persistence/loading-store/normalize-loaded-ui-state.ts create mode 100644 src/main/persistence/loading-store/prepare-loaded-profile-settings.ts create mode 100644 src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts create mode 100644 src/main/persistence/loading-store/primary-state-writes.ts create mode 100644 src/main/persistence/loading-store/profile-preferences.ts create mode 100644 src/main/persistence/loading-store/project-collection-operations.ts create mode 100644 src/main/persistence/loading-store/pty-binding-persistence.ts create mode 100644 src/main/persistence/loading-store/repo-lifecycle-operations.ts create mode 100644 src/main/persistence/loading-store/retired-name-registry-normalization.ts create mode 100644 src/main/persistence/loading-store/retired-worktree-name-persistence.ts create mode 100644 src/main/persistence/loading-store/session-host-partitions.ts create mode 100644 src/main/persistence/loading-store/session-snapshot-operations.ts create mode 100644 src/main/persistence/loading-store/sparse-preset-persistence.ts create mode 100644 src/main/persistence/loading-store/ssh-lease-recovery-operations.ts create mode 100644 src/main/persistence/loading-store/ssh-profile-operations.ts create mode 100644 src/main/persistence/loading-store/state-serialization-secret-handling.ts create mode 100644 src/main/persistence/loading-store/store-domain-composition.ts delete mode 100644 src/main/persistence/loading-store/store-owned-state.ts create mode 100644 src/main/persistence/loading-store/store-runtime-state.ts create mode 100644 src/main/persistence/loading-store/terminal-binding-recovery.ts create mode 100644 src/main/persistence/loading-store/terminal-session-cleanup.ts create mode 100644 src/main/persistence/loading-store/workspace-session-partitions.ts create mode 100644 src/main/persistence/loading-store/workspace-session-snapshot-publication.ts create mode 100644 src/main/persistence/loading-store/write-flush-barriers.ts create mode 100644 src/main/persistence/loading-store/write-scheduling.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 2c9a7b1b5af..9e24dc1b39f 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -43,7 +43,6 @@ inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts inline src/main/memory/collector.ts inline src/main/opencode/hook-service.ts -inline src/main/persistence/loading-store/store.ts inline src/main/ports/advertised-url-watcher.ts inline src/main/ports/local-workspace-port-scanner.ts inline src/main/project-groups/nested-repo-discovery.ts diff --git a/src/main/persistence-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts new file mode 100644 index 00000000000..dafb6deadee --- /dev/null +++ b/src/main/persistence-loading-store-extraction.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../shared/constants' +import type { PersistedState } from '../shared/persisted-state-types' +import type { Store as PersistenceStore } from './persistence/loading-store/store' +import { createStore, dataFile, makeRepo, testState } from './persistence-test-harness' + +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('encrypted:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('encrypted:'.length) + } + } +})) + +vi.mock('./telemetry/client', () => ({ track: trackMock })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +describe('loading Store extraction seams', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-loading-store-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync(dataFile(), '{{corrupt-primary', 'utf-8') + writeFileSync(`${dataFile()}.bak.0`, '{}', 'utf-8') + writeFileSync( + `${dataFile()}.bak.1`, + JSON.stringify({ repos: [makeRepo({ id: 'older-complete-profile' })] }), + 'utf-8' + ) + + const store = await createStore() + + expect(store.getRepos()).toEqual([]) + expect(readFileSync(dataFile(), 'utf-8')).toBe('{}') + }) + + it('leaves backup bytes reusable when publishing recovery to the primary path fails', async () => { + mkdirSync(dataFile(), { recursive: true }) + writeFileSync( + `${dataFile()}.bak.0`, + JSON.stringify({ repos: [makeRepo({ id: 'recovery-survives-publish-failure' })] }), + 'utf-8' + ) + + const failedRecovery = await createStore() + expect(failedRecovery.getRepos()).toEqual([]) + failedRecovery.freezeWrites() + expect(existsSync(`${dataFile()}.bak.0`)).toBe(true) + + rmSync(dataFile(), { recursive: true, force: true }) + const recovered = await createStore() + expect(recovered.getRepos().map((repo) => repo.id)).toEqual([ + 'recovery-survives-publish-failure' + ]) + }) + + it('aliases blank host reads, writes, and patches to the local disk partition', async () => { + const store = await createStore() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), activeRepoId: 'from-blank-set' }, + ' ' + ) + store.patchWorkspaceSession({ activeRepoId: 'from-blank-patch' }, '') + store.flushOrThrow() + + expect(store.getWorkspaceSession(' ').activeRepoId).toBe('from-blank-patch') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.workspaceSession?.activeRepoId).toBe('from-blank-patch') + expect(persisted.workspaceSessionsByHostId).toEqual({}) + }) + + it('keeps the last constructed Store as the global pane-migration listener owner', async () => { + const first = await createStore() + const { Store } = await import('./persistence/loading-store/store') + const { setMigrationUnsupportedPty } = + await import('./agent-hooks/migration-unsupported-pty-state') + const secondDataFile = join(testState.dir, 'second-profile', 'orca-data.json') + const second = new Store({ dataFile: secondDataFile }) + + setMigrationUnsupportedPty({ + ptyId: 'listener-owner-pty', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 123 + }) + first.flushOrThrow() + second.flushOrThrow() + + const firstState = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + const secondState = JSON.parse(readFileSync(secondDataFile, 'utf-8')) as PersistedState + expect( + firstState.migrationUnsupportedPtyEntries?.some( + (entry) => entry.ptyId === 'listener-owner-pty' + ) + ).toBe(false) + expect( + secondState.migrationUnsupportedPtyEntries?.some( + (entry) => entry.ptyId === 'listener-owner-pty' + ) + ).toBe(true) + }) + + it('latches final flush bytes and ignores later in-memory mutations', async () => { + const store = await createStore() + store.updateUI({ sidebarWidth: 731 }) + const finalFlush = store.flushAsync() + await finalFlush + + store.updateUI({ sidebarWidth: 732 }) + expect(store.flushAsync()).toBe(finalFlush) + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.ui.sidebarWidth).toBe(731) + expect(store.getUI().sidebarWidth).toBe(732) + }) + + it('includes a same-tick mutation made after final flush is invoked', async () => { + const store = await createStore() + store.updateUI({ sidebarWidth: 741 }) + + const finalFlush = store.flushAsync() + store.updateUI({ sidebarWidth: 742 }) + await finalFlush + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.ui.sidebarWidth).toBe(742) + }) + + it('keeps delegated operations on the Store prototype with native receiver and override semantics', async () => { + const store = await createStore() + const { Store } = await import('./persistence/loading-store/store') + const descriptor = Object.getOwnPropertyDescriptor(Store.prototype, 'getRepoCount') + + expect(descriptor).toMatchObject({ + configurable: true, + enumerable: false, + writable: true + }) + expect(Object.hasOwn(store, 'getRepoCount')).toBe(false) + const detached = store.getRepoCount + expect(() => detached()).toThrow(TypeError) + expect(detached.call(store)).toBe(0) + expect(new Proxy(store, {}).getRepoCount()).toBe(0) + expect([ + Store.prototype.getRepo.length, + Store.prototype.updateProject.length, + Store.prototype.createAutomationRun.length + ]).toEqual([1, 2, 2]) + + const prototypeSpy = vi.spyOn(Store.prototype, 'getRepoCount') + try { + expect(store.getRepoCount()).toBe(0) + expect(prototypeSpy).toHaveBeenCalledOnce() + } finally { + prototypeSpy.mockRestore() + } + + class StoreWithRepoCountOverride extends Store { + override getRepoCount(): number { + return 47 + } + } + const overridden = new StoreWithRepoCountOverride({ + dataFile: join(testState.dir, 'override-profile', 'orca-data.json') + }) + expect(overridden.getRepoCount()).toBe(47) + expectTypeOf().not.toHaveProperty('scheduleSave') + expectTypeOf().not.toHaveProperty('enqueueWrite') + expectTypeOf().not.toHaveProperty('getProjectHostOperations') + expectTypeOf().not.toHaveProperty('getAutomationDefinitionOperations') + expectTypeOf().not.toHaveProperty('getSshTargetStateOperations') + }) +}) diff --git a/src/main/persistence-loading-store-write-risks.test.ts b/src/main/persistence-loading-store-write-risks.test.ts new file mode 100644 index 00000000000..422573516f1 --- /dev/null +++ b/src/main/persistence-loading-store-write-risks.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { Store } from './persistence/loading-store/store' +import { initDataPath } from './persistence/loading-store/user-data-path' + +const testState = { dir: '' } + +const writeControl = vi.hoisted(() => { + let releaseRename: (() => void) | null = null + let markRenameStarted: (() => void) | null = null + return { + blockPrimaryRename: false, + failPrimaryOpen: false, + renameStarted: Promise.resolve(), + renameRelease: Promise.resolve(), + reset(): void { + this.blockPrimaryRename = false + this.failPrimaryOpen = false + this.renameStarted = new Promise((resolve) => { + markRenameStarted = resolve + }) + this.renameRelease = new Promise((resolve) => { + releaseRename = resolve + }) + }, + markRenameStarted(): void { + markRenameStarted?.() + }, + releaseRename(): void { + releaseRename?.() + } + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const target = String(args[0]) + if ( + writeControl.failPrimaryOpen && + target.includes('orca-data.json.') && + target.endsWith('.tmp') + ) { + throw Object.assign(new Error('profile mount rejected write'), { code: 'EIO' }) + } + return actual.open(...args) + }, + rename: async (...args: Parameters) => { + const target = String(args[1]) + if (writeControl.blockPrimaryRename && target.endsWith('orca-data.json')) { + writeControl.markRenameStarted() + await writeControl.renameRelease + } + return actual.rename(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +function createStore(): Store { + installFakeAppEnvironment({ getPath: () => testState.dir }) + initDataPath() + return new Store({ dataFile: join(testState.dir, 'orca-data.json') }) +} + +describe('loading Store write-risk characterization', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-write-risk-')) + writeControl.reset() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('allows an already-started primary rename to publish after writes are frozen', async () => { + const store = await createStore() + writeControl.blockPrimaryRename = true + store.updateUI({ sidebarWidth: 712 }) + vi.advanceTimersByTime(1_000) + await writeControl.renameStarted + + store.freezeWrites() + writeControl.releaseRename() + await store.waitForPendingWrite() + + const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { + ui: { sidebarWidth: number } + } + expect(persisted.ui.sidebarWidth).toBe(712) + }) + + it('keeps a rejected durable mutation in memory for a later unrelated flush', async () => { + const store = await createStore() + writeControl.failPrimaryOpen = true + await expect( + store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 3, + ownerGeneration: 5, + ownerLease: 'secret-owner-lease' + }) + ).rejects.toThrow('profile mount rejected write') + + writeControl.failPrimaryOpen = false + store.updateUI({ sidebarWidth: 713 }) + await store.flushPendingOrThrowAsync() + const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { + sshPtyConsumerRecoveries: { clientInstanceId: string }[] + } + expect(persisted.sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('client-1') + }) +}) diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index 89f5f03a88f..7b628b4b678 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -1,26 +1,22 @@ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { vi } from 'vitest' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import type { TerminalTab } from '../shared/terminal-tab-types' import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { Store } from './persistence/loading-store/store' +import { initDataPath } from './persistence/loading-store/user-data-path' // Shared mutable state so the electron mock can reference a per-test directory export const testState = { dir: '' } -/** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */ -export async function createStore() { - vi.resetModules() - // Why here and not a per-file vi.mock('electron'): the data-file path resolves through - // AppEnvironment now, and it must point at this test's dir rather than the global - // fake's shared one. Re-installed after resetModules so the fresh graph sees it. +/** Create a profile store without rebuilding its large module graph inside each test timeout. */ +export function createStore(): Store { installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return new Store({ dataFile: dataFile() }) } export async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts index f57a2a1b603..29c664be49b 100644 --- a/src/main/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence-workspace-session-scrollback.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { writeFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' @@ -8,6 +9,7 @@ import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser import { testState, createStore, + dataFile, writeDataFile, makeRepo, makeTerminalTab @@ -19,6 +21,7 @@ import { makeSessionWithBrowserHistory } from './persistence-session-fixtures' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { Store, initDataPath } from './persistence' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -26,6 +29,29 @@ const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => sshConfigHostsToTargetsMock: vi.fn() })) +const asyncPrimaryWriteFailure = vi.hoisted(() => ({ + error: null as Error | null, + targetPrefix: '' +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const target = args[0] + if ( + asyncPrimaryWriteFailure.error && + typeof target === 'string' && + target.startsWith(asyncPrimaryWriteFailure.targetPrefix) + ) { + throw asyncPrimaryWriteFailure.error + } + return actual.open(...args) + } + } +}) + vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: loadUserSshConfigMock, sshConfigHostsToTargets: sshConfigHostsToTargetsMock @@ -66,10 +92,14 @@ describe('Store', () => { trackMock.mockReset() getCohortAtEmitMock.mockReset() getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) + asyncPrimaryWriteFailure.error = null + asyncPrimaryWriteFailure.targetPrefix = '' }) afterEach(() => { rmSync(testState.dir, { recursive: true, force: true }) + asyncPrimaryWriteFailure.error = null + asyncPrimaryWriteFailure.targetPrefix = '' }) // ── GitHub Cache ─────────────────────────────────────────────────── @@ -188,11 +218,9 @@ describe('Store', () => { const profileDataFile = join(profileDataDirectory, 'orca-data.json') mkdirSync(profileDataDirectory, { recursive: true }) - vi.resetModules() // Why: the legacy snapshot dir hangs off userData, which resolves through // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) @@ -221,11 +249,9 @@ describe('Store', () => { mkdirSync(legacySnapshotDir, { recursive: true }) writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') - vi.resetModules() // Why: the legacy snapshot dir hangs off userData, which resolves through // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) @@ -305,6 +331,7 @@ describe('Store', () => { throw new Error('expected scrollback snapshot ref') } expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(true) + store.flushOrThrow() store.setWorkspaceSession({ activeRepoId: null, @@ -315,6 +342,74 @@ describe('Store', () => { }) expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) + const stillPublished = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + workspaceSession: { + terminalLayoutsByTabId: Record }> + } + } + expect( + stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab'] + ?.scrollbackRefsByLeafId?.[TEST_LEAF_2] + ).toBe(ref) + }) + + it('leaves durable JSON pointing at deleted scrollback when the replacement write fails', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) + const session = makeSessionWithTerminalBuffers() + store.setWorkspaceSession({ + ...session, + tabsByWorktree: { + 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] + }, + terminalLayoutsByTabId: { + 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] + } + }) + const ref = + store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ + TEST_LEAF_2 + ] + if (!ref) { + throw new Error('expected scrollback snapshot ref') + } + const snapshotPath = join(testState.dir, 'terminal-scrollback', `${ref}.bin`) + store.flushOrThrow() + const durableBeforeRemoval = readFileSync(dataFile(), 'utf-8') + expect(existsSync(snapshotPath)).toBe(true) + + const writeError = Object.assign(new Error('profile mount rejected replacement write'), { + code: 'EIO' + }) + asyncPrimaryWriteFailure.error = writeError + asyncPrimaryWriteFailure.targetPrefix = `${dataFile()}.` + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + store.setWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + + expect(existsSync(snapshotPath)).toBe(false) + expect(store.getWorkspaceSession().terminalLayoutsByTabId).toEqual({}) + await expect(store.flushPendingOrThrowAsync()).rejects.toBe(writeError) + } finally { + errors.mockRestore() + } + + expect(readFileSync(dataFile(), 'utf-8')).toBe(durableBeforeRemoval) + const stillPublished = JSON.parse(durableBeforeRemoval) as { + workspaceSession: { + terminalLayoutsByTabId: Record }> + } + } + expect( + stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab'] + ?.scrollbackRefsByLeafId?.[TEST_LEAF_2] + ).toBe(ref) }) it('reads only the replay tail from oversized terminal scrollback snapshots', async () => { diff --git a/src/main/persistence/applying-settings/feature-interaction-recording.ts b/src/main/persistence/applying-settings/feature-interaction-recording.ts index 935f11382b3..2a29bc64f66 100644 --- a/src/main/persistence/applying-settings/feature-interaction-recording.ts +++ b/src/main/persistence/applying-settings/feature-interaction-recording.ts @@ -1,5 +1,4 @@ import type { PersistedState } from '../../../shared/persisted-state-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { compareFeatureInteractionUsageBuckets, getFeatureInteractionCategory, @@ -12,7 +11,7 @@ import { track } from '../../telemetry/client' import { getCohortAtEmit } from '../../telemetry/cohort-classifier' export type FeatureInteractionOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void notifyUIChanged: () => void getUI: () => PersistedState['ui'] diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index e6b18da0644..ff4c55b09a8 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -16,7 +16,7 @@ import { normalizeAppIconId } from '../../../shared/app-icon' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { addMobilePairingCustomAddress, normalizeMobilePairingCustomAddress, @@ -39,7 +39,7 @@ import { } from './terminal-settings-migrations' export type SettingsMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState removeRetainedBlob: ( slot: Parameters[0] ) => void diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index f757fa5a1c4..b93ddfb1296 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -24,7 +24,6 @@ import { normalizeContextualTourIds } from '../../../shared/contextual-tours' import { normalizeFeatureInteractions } from '../../../shared/feature-interactions' import { mergeWorkspaceCleanupUIState } from '../../../shared/workspace-cleanup-ui-state' import { persistedUIValuesEqual } from '../../../shared/persisted-ui-equality' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { PROTECTED_SECRET_SLOT, type ProtectedSecretPersistence @@ -44,7 +43,7 @@ import { } from './ui-interaction-merge' export type UIUpdateOperations = { - state: StoreOwnedPersistedState + state: PersistedState removeRetainedBlob: ( slot: Parameters[0] ) => void diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts index 4a094ddb242..65b879795dc 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts @@ -1,10 +1,10 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' export type SshPtyBindingCleanupOperations = { - state: StoreOwnedPersistedState + state: PersistedState toComparablePtyId: (targetId: string, ptyId: string) => string scheduleSave: () => void } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts index f7c8f07f24a..3b259989906 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts @@ -1,11 +1,11 @@ import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { normalizeSshPtyConsumerRecovery } from './ssh-normalization' export type SshPtyConsumerRecoveryOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick flushDurableStateOrThrowAsync: () => Promise } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index ef8fd642f24..2662f7f02da 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,10 +1,9 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' export type SshPtyLeaseOperations = { - state: StoreOwnedPersistedState + state: PersistedState toStoredPtyId: (targetId: string, ptyId: string) => string clearBindingsForTarget: (targetId: string) => void clearBindingsForLeases: (targetId: string, leases: SshRemotePtyLease[]) => boolean diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts index e0722e805c0..8dd5425b833 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts @@ -1,6 +1,6 @@ import type { ProjectHostSetup } from '../../../shared/project-types' import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { migrateUiHostScopeSshTargetId, migrateWorkspaceSessionSshTargetId @@ -13,7 +13,7 @@ import { } from '../../worktree-retirement-namespace' export type SshTargetReassignmentOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void @@ -22,7 +22,7 @@ export type SshTargetReassignmentOperations = { /** Retirement namespaces key on the endpoint a target reaches, so a rotation moves them only when * the endpoint itself changed — plus any pre-identity key that embedded the row id. */ function migrateRetirementNamespaces( - state: StoreOwnedPersistedState, + state: PersistedState, oldTargetId: string, newTargetId: string ): boolean { diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts index dafd76b1b6d..dbdb03b89a1 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -1,6 +1,5 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { @@ -15,7 +14,7 @@ import { } from '../../worktree-retirement-namespace' export type SshTargetStateOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick scheduleSave: () => void flush: () => void diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts new file mode 100644 index 00000000000..fc62a4baf1e --- /dev/null +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -0,0 +1,158 @@ +import type { + Automation, + AutomationCreateInput, + AutomationDispatchResult, + AutomationRun, + AutomationRunTrigger, + AutomationUpdateInput +} from '../../../shared/automations-types' +import { getWorktreePathBasenameFromId } from '../../../shared/worktree/id' +import { normalizeAutomationRunWorkspaceDisplayName } from '../scheduling-automations/automation-context-migration' +import { + createAutomation as createAutomationOperation, + deleteAutomation as deleteAutomationOperation, + listAutomations as listAutomationsOperation, + updateAutomation as updateAutomationOperation, + type AutomationDefinitionOperations +} from '../scheduling-automations/automation-definition-operations' +import { + createAutomationRun as createAutomationRunOperation, + listAutomationRuns as listAutomationRunsOperation, + snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, + updateAutomationRun as updateAutomationRunOperation, + type AutomationRunOperations +} from '../scheduling-automations/automation-run-operations' +import { + advanceAutomationNextRun as advanceAutomationNextRunOperation, + getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation +} from '../scheduling-automations/automation-schedule-operations' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { ProfilePreferences } from './profile-preferences' + +type AutomationPersistenceRuntime = Pick + +const automationPersistenceContext = Symbol('AutomationPersistence') +type AutomationPersistenceContext = { + runtime: AutomationPersistenceRuntime + flushBarriers: WriteFlushBarrierOperations + preferences: ProfilePreferences +} + +export class AutomationPersistence { + readonly [automationPersistenceContext]: AutomationPersistenceContext + + constructor( + runtime: AutomationPersistenceRuntime, + flushBarriers: WriteFlushBarrierOperations, + preferences: ProfilePreferences + ) { + this[automationPersistenceContext] = { runtime, flushBarriers, preferences } + } + + listAutomations(): Automation[] { + return listAutomationsOperation(this[automationPersistenceContext].runtime.state) + } + + listAutomationRuns(automationId?: string): AutomationRun[] { + return listAutomationRunsOperation( + this[automationPersistenceContext].runtime.state, + automationId + ) + } + + createAutomation(input: AutomationCreateInput): Automation { + return createAutomationOperation(getAutomationDefinitionOperations(this), input) + } + + updateAutomation(id: string, updates: AutomationUpdateInput): Automation { + return updateAutomationOperation(getAutomationDefinitionOperations(this), id, updates) + } + + deleteAutomation(id: string): void { + deleteAutomationOperation(getAutomationDefinitionOperations(this), id) + } + + createAutomationRun( + automation: Automation, + scheduledFor: number, + trigger: AutomationRunTrigger = 'scheduled' + ): AutomationRun { + return createAutomationRunOperation( + getAutomationRunOperations(this), + automation, + scheduledFor, + trigger + ) + } + + updateAutomationRun(result: AutomationDispatchResult): AutomationRun { + return updateAutomationRunOperation(getAutomationRunOperations(this), result) + } + + snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { + return snapshotAutomationRunWorkspaceDisplayNameOperation( + getAutomationRunOperations(this), + workspaceId, + displayName + ) + } + + advanceAutomationNextRun(id: string, now = Date.now()): Automation { + return advanceAutomationNextRunOperation( + this[automationPersistenceContext].runtime.state, + () => this[automationPersistenceContext].flushBarriers.flush(), + id, + now + ) + } + + getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { + return getLatestAutomationOccurrenceOperation(automation, now) + } +} + +export function getAutomationDefinitionOperations( + owner: AutomationPersistence +): AutomationDefinitionOperations { + return { + state: owner[automationPersistenceContext].runtime.state, + flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + recordCreated: () => + owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-created') + } +} + +export function getAutomationRunOperations(owner: AutomationPersistence): AutomationRunOperations { + return { + state: owner[automationPersistenceContext].runtime.state, + flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + recordManualRun: () => + owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-run'), + getWorkspaceDisplayName: (workspaceId) => + getAutomationRunWorkspaceDisplayName(owner, workspaceId) + } +} + +export function getAutomationRunWorkspaceDisplayName( + owner: AutomationPersistence, + workspaceId: string | null | undefined +): string | null { + if (!workspaceId) { + return null + } + return normalizeAutomationRunWorkspaceDisplayName( + owner[automationPersistenceContext].runtime.state.worktreeMeta[workspaceId]?.displayName ?? + getWorktreePathBasenameFromId(workspaceId) + ) +} + +export function installAutomationPersistenceContext( + target: object, + source: AutomationPersistence +): void { + Object.defineProperty(target, automationPersistenceContext, { + value: source[automationPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/backup-recovery-rotation.ts b/src/main/persistence/loading-store/backup-recovery-rotation.ts new file mode 100644 index 00000000000..7cf0a7a8078 --- /dev/null +++ b/src/main/persistence/loading-store/backup-recovery-rotation.ts @@ -0,0 +1,147 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { access, copyFile, rename, rm, stat } from 'node:fs/promises' +import { dirname } from 'node:path' + +const BACKUP_COUNT = 5 +const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 + +function backupPath(dataFile: string, index: number): string { + return `${dataFile}.bak.${index}` +} + +/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ +async function exists(path: string): Promise { + return access(path).then( + () => true, + () => false + ) +} + +export function hasStateBackup(dataFile: string): boolean { + for (let index = 0; index < BACKUP_COUNT; index += 1) { + if (existsSync(backupPath(dataFile, index))) { + return true + } + } + return false +} + +import type { StoreRuntimeState } from './store-runtime-state' + +type BackupRecoveryRotationOperationsRuntime = Pick + +export class BackupRecoveryRotationOperations { + constructor(private readonly runtime: BackupRecoveryRotationOperationsRuntime) {} + + shouldRotateBackups(now: number, dataFile: string): boolean { + try { + const mtime = statSync(backupPath(dataFile, 0)).mtimeMs + return now - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + + async shouldRotateBackupsAsync(dataFile: string): Promise { + try { + const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs + return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + + async rotateBackupsAsync(dataFile: string): Promise { + if (this.runtime.backupRotationInFlight) { + return + } + this.runtime.backupRotationInFlight = true + try { + if (!(await this.shouldRotateBackupsAsync(dataFile))) { + return + } + if (!(await exists(dataFile))) { + return + } + await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[persistence] Failed to remove oldest backup:', err) + } + }) + for (let i = BACKUP_COUNT - 2; i >= 0; i--) { + const src = backupPath(dataFile, i) + const dst = backupPath(dataFile, i + 1) + // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an + // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. + if (await exists(src)) { + await rename(src, dst).catch((err) => { + console.error('[persistence] Failed to rotate backup', src, '->', dst, err) + }) + } + } + await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { + console.error('[persistence] Failed to snapshot current file to .bak.0:', err) + }) + } finally { + this.runtime.backupRotationInFlight = false + } + } + + 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) + } + } + + 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 + } +} diff --git a/src/main/persistence/loading-store/loaded-cohort-migrations.ts b/src/main/persistence/loading-store/loaded-cohort-migrations.ts new file mode 100644 index 00000000000..c4f21df6b46 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-cohort-migrations.ts @@ -0,0 +1,66 @@ +import { randomUUID } from 'node:crypto' +import type { PersistedState } from '../../../shared/persisted-state-types' + +import type { StoreRuntimeState } from './store-runtime-state' + +type LoadedCohortMigrationOperationsRuntime = Pick + +export class LoadedCohortMigrationOperations { + constructor(private readonly runtime: LoadedCohortMigrationOperationsRuntime) {} + + migrateTabSwitchKeybindings(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.tabSwitchKeybindingSeed + if (existing === 'pending' || existing === 'done') { + return state + } + // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. + this.runtime.loadNeedsSave = true + return { + ...state, + settings: { + ...state.settings, + // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. + tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' + } + } + } + + migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.telemetry + // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. + if ( + typeof existing?.existedBeforeTelemetryRelease === 'boolean' && + typeof existing.installId === 'string' && + existing.installId.length > 0 && + (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) + ) { + return state + } + // Why: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. + const resolvedExistedBefore = + typeof existing?.existedBeforeTelemetryRelease === 'boolean' + ? existing.existedBeforeTelemetryRelease + : fileExistedOnLoad + return { + ...state, + settings: { + ...state.settings, + telemetry: { + ...existing, + existedBeforeTelemetryRelease: resolvedExistedBefore, + // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. + optedIn: + existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null + ? existing.optedIn + : resolvedExistedBefore + ? null + : true, + installId: + typeof existing?.installId === 'string' && existing.installId.length > 0 + ? existing.installId + : randomUUID() + } + } + } + } +} diff --git a/src/main/persistence/loading-store/loaded-state-adaptation.ts b/src/main/persistence/loading-store/loaded-state-adaptation.ts new file mode 100644 index 00000000000..fe350f6b851 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-state-adaptation.ts @@ -0,0 +1,116 @@ +import { isFolderRepo } from '../../../shared/repo-kind' +import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' +import { createProjectGroup } from '../../../shared/project-groups' +import { createNestedProjectGroupResolver } from '../../project-groups/nested-repo-import' + +import type { StoreRuntimeState } from './store-runtime-state' + +type LoadedStateAdaptationOperationsRuntime = Pick + +export class LoadedStateAdaptationOperations { + constructor(private readonly runtime: LoadedStateAdaptationOperationsRuntime) {} + + hydrateFolderWorkspaceDiffComments(): void { + const stored = this.runtime.state.folderWorkspaceDiffComments + let relocatedInline = false + for (const workspace of this.runtime.state.folderWorkspaces ?? []) { + if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { + // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the + // older map untouched, so inline is the last notes-aware write. Also makes the relocation + // durable even if the user never edits anything this session. + relocatedInline = true + continue + } + const comments = stored?.[workspace.id] + // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. + if (Array.isArray(comments) && comments.length > 0) { + workspace.diffComments = comments + } + } + if (relocatedInline) { + this.runtime.loadNeedsSave = true + } + // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in + // state would make it a stale second source of truth that getDurableState() spreads back out. + delete this.runtime.state.folderWorkspaceDiffComments + } + + adaptFlatFolderScanProjectGroups(): boolean { + // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. + const groups = this.runtime.state.projectGroups ?? [] + const repos = this.runtime.state.repos + if (groups.length === 0 || repos.length === 0) { + return false + } + + let changed = false + let maxOrder = -1 + for (const group of groups) { + maxOrder = Math.max(maxOrder, group.tabOrder) + } + + const childGroupIds = new Set( + groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) + ) + const initialGroupCount = groups.length + for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { + const rootGroup = groups[groupIndex] + if (!rootGroup) { + continue + } + if ( + rootGroup.createdFrom !== 'folder-scan' || + !rootGroup.parentPath || + rootGroup.parentGroupId || + childGroupIds.has(rootGroup.id) + ) { + continue + } + const rootPath = rootGroup.parentPath + const repoCandidates = repos.filter( + (repo) => + !isFolderRepo(repo) && + repo.projectGroupId === rootGroup.id && + isPathInsideOrEqual(rootPath, repo.path) + ) + if (repoCandidates.length < 2) { + continue + } + + const resolver = createNestedProjectGroupResolver({ + parentPath: rootPath, + groupName: rootGroup.name, + mode: 'group', + repoPaths: repoCandidates.map((repo) => repo.path), + createGroup: (input) => { + if (!input.parentGroupId) { + return rootGroup + } + maxOrder += 1 + const group = createProjectGroup({ + ...input, + tabOrder: maxOrder + }) + groups.push(group) + changed = true + return group + } + }) + const nextOrderByGroupId = new Map() + for (const repo of repoCandidates) { + const group = resolver.getGroupForRepo(repo.path) + if (!group) { + continue + } + const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 + nextOrderByGroupId.set(group.id, nextOrder + 1) + if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { + repo.projectGroupId = group.id + repo.projectGroupOrder = nextOrder + changed = true + } + } + } + return changed + } +} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts new file mode 100644 index 00000000000..2d29844ffb0 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -0,0 +1,267 @@ +import { readFileSync, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { normalizeProxyUrl } from '../../../shared/network-proxy' +import { normalizeKagiSessionLink } from '../../../shared/browser-url' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { clearMissingProjectGroupMemberships } from '../../../shared/project-groups' +import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' +import { + isStartupDiagnosticsEnabled, + logStartupDiagnostic +} from '../../startup/startup-diagnostics' +import { + PROTECTED_SECRET_SLOT, + sshPtyOwnerLeaseSecretSlot +} from '../../protected-secret-persistence' +import { + isLegacyOpenCodeSessionCookie, + isLegacySshPtyOwnerLease +} from '../leasing-ssh-ptys/secret-validation' +import { readGithubCacheSnapshot } from './user-data-path' +import { + gcStaleWorktreeMeta, + normalizeWorktreeLinkedItemMetadata +} from '../tracking-repos/worktree-metadata-normalization' +import { backfillLegacyAutomationContexts } from '../scheduling-automations/automation-context-migration' +import { + ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, + normalizeSshPtyConsumerRecovery +} from '../leasing-ssh-ptys/ssh-normalization' +import { + mergeProjectHostSetupCompatibilityState, + projectHostSetupCompatibilityStateEqual +} from '../tracking-repos/project-host-compatibility' +import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' +import { hasStateBackup } from './backup-recovery-rotation' +import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' +import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' + +function logPersistenceStartupMilestone( + event: string, + details: Record = {} +): void { + if (isStartupDiagnosticsEnabled()) { + logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + } +} + +import type { StoreRuntimeState } from './store-runtime-state' +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import type { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' + +type LoadedStateParsingOperationsRuntime = Pick< + StoreRuntimeState, + | 'dataFile' + | 'githubCacheDirty' + | 'loadNeedsSave' + | 'protectedSecrets' + | 'terminalScrollbackSnapshotStorage' +> + +export class LoadedStateParsingOperations { + constructor( + private readonly runtime: LoadedStateParsingOperationsRuntime, + private readonly backups: BackupRecoveryRotationOperations, + private readonly cohorts: LoadedCohortMigrationOperations + ) {} + + load(allowBackupRecovery = true): PersistedState { + // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. + const dataFile = this.runtime.dataFile + const fileExistedOnLoad = existsSync(dataFile) + logPersistenceStartupMilestone('persistence-load-start', { + fileExists: fileExistedOnLoad + }) + + let result: PersistedState | null = null + try { + if (fileExistedOnLoad) { + const readStartedAt = performance.now() + const raw = readFileSync(dataFile, 'utf-8') + logPersistenceStartupMilestone('persistence-read-done', { + bytes: Buffer.byteLength(raw), + durationMs: Math.round(performance.now() - readStartedAt) + }) + logPersistenceStartupMilestone('persistence-json-parse-start') + const parsed = JSON.parse(raw) as PersistedState + logPersistenceStartupMilestone('persistence-json-parse-done') + + // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. + if (parsed.settings?.opencodeSessionCookie) { + parsed.settings.opencodeSessionCookie = this.runtime.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + parsed.settings.opencodeSessionCookie, + isLegacyOpenCodeSessionCookie + ) + } + if (parsed.settings?.httpProxyUrl) { + const decryptedProxy = this.runtime.protectedSecrets.decryptWithStatus( + PROTECTED_SECRET_SLOT.httpProxyUrl, + parsed.settings.httpProxyUrl, + (value) => normalizeProxyUrl(value).ok + ) + // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL + // value must not masquerade as a configured proxy (silent DIRECT fallback) or + // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. + if ( + decryptedProxy.status === 'unavailable' || + (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) + ) { + parsed.settings.httpProxyUrl = '' + } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { + parsed.settings.httpProxyUrl = decryptedProxy.plaintext + } else { + console.warn( + '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' + ) + parsed.settings.httpProxyUrl = '' + this.runtime.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + this.runtime.loadNeedsSave = true + } + } + if (parsed.ui?.browserKagiSessionLink) { + parsed.ui.browserKagiSessionLink = this.runtime.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + parsed.ui.browserKagiSessionLink, + (value) => normalizeKagiSessionLink(value) !== null + ) + } + parsed.sshPtyConsumerRecoveries = ( + Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] + ) + .map((record) => + normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) + ) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + .map((record) => { + const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) + const decrypted = this.runtime.protectedSecrets.decryptWithStatus( + slot, + record.ownerLease, + isLegacySshPtyOwnerLease + ) + const normalized = + decrypted.status === 'unavailable' || + (decrypted.status === 'failed' && !decrypted.plaintext) + ? record + : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) + if (!normalized) { + this.runtime.protectedSecrets.removeRetainedBlob(slot) + } + return normalized + }) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + + const terminalSettings = prepareLoadedTerminalSettings(parsed, () => { + this.runtime.loadNeedsSave = true + }) + const profileSettings = prepareLoadedProfileSettings( + parsed, + terminalSettings.defaults, + () => { + this.runtime.loadNeedsSave = true + } + ) + result = normalizeLoadedProfileState(parsed, terminalSettings, profileSettings, () => { + this.runtime.loadNeedsSave = true + }) + } + } catch (err) { + console.error('[persistence] Failed to load primary state, trying backups:', err) + } + + // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. + if (result === null && allowBackupRecovery) { + const hasBackup = hasStateBackup(dataFile) + if (fileExistedOnLoad || hasBackup) { + if (this.backups.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.runtime.terminalScrollbackSnapshotStorage + ) + if (migratedScrollback.changed) { + this.runtime.loadNeedsSave = true + } + + const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) + const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) + if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { + this.runtime.loadNeedsSave = true + } + + const automationContextMigration = backfillLegacyAutomationContexts({ + ...result, + repos, + ...projectHostSetupCompatibility + }) + if (automationContextMigration.changed) { + this.runtime.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.runtime.loadNeedsSave = true + } + result = folderScopeConnectionMigration.state + + if (normalizeWorktreeLinkedItemMetadata(result)) { + this.runtime.loadNeedsSave = true + } + + if (gcStaleWorktreeMeta(result) > 0) { + this.runtime.loadNeedsSave = true + } + + const migrated = this.cohorts.migrateTabSwitchKeybindings( + this.cohorts.migrateTelemetry(result, fileExistedOnLoad), + fileExistedOnLoad + ) + + // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. + const legacyCache = migrated.githubCache + const hasLegacyCache = + Object.keys(legacyCache?.pr ?? {}).length > 0 || + Object.keys(legacyCache?.issue ?? {}).length > 0 + if (hasLegacyCache) { + this.runtime.loadNeedsSave = true + // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. + this.runtime.githubCacheDirty = true + } else { + migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache + } + + logPersistenceStartupMilestone('persistence-load-done', { + repos: migrated.repos.length, + workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) + }) + return migrated + } +} diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts new file mode 100644 index 00000000000..056991dd1f4 --- /dev/null +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -0,0 +1,254 @@ +import { randomUUID } from 'node:crypto' +import type { WorkspaceKey } from '../../../shared/folder-workspace-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' +import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + workspaceSessionOwnerPartitionForHost, + workspaceSessionPartitionIdsForHost +} from '../restoring-sessions/session-owner-removal' +import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' + +function getDefaultWorktreeMeta(): WorktreeMeta { + return { + instanceId: randomUUID(), + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: Date.now(), + lastActivityAt: 0, + workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID + } +} + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import { scheduleSave } from './write-scheduling' +import { + hasPersistedWorkspaceSession, + partitionOwnsWorktreeTabs, + partitionHasOtherRepoWorktreeTabs, + removeWorkspaceSessionOwnerInPartition +} from './session-host-partitions' + +type MetadataLineageOperationsRuntime = Pick + +const metadataLineageOperationsContext = Symbol('MetadataLineageOperations') +type MetadataLineageOperationsContext = { + runtime: MetadataLineageOperationsRuntime + scheduling: WriteSchedulingOperations + sessions: SessionHostPartitionOperations +} + +export class MetadataLineageOperations { + readonly [metadataLineageOperationsContext]: MetadataLineageOperationsContext + + constructor( + runtime: MetadataLineageOperationsRuntime, + scheduling: WriteSchedulingOperations, + sessions: SessionHostPartitionOperations + ) { + this[metadataLineageOperationsContext] = { runtime, scheduling, sessions } + } + + getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { + return this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] + } + + getAllWorktreeMeta(): Record { + return this[metadataLineageOperationsContext].runtime.state.worktreeMeta + } + + setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { + const existing = + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] || + getDefaultWorktreeMeta() + const updated = { ...existing, ...meta } + updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) + const linkedTaskSourceContext = normalizeStoredTaskSourceContext( + updated.linkedTaskSourceContext + ) + updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + updated.linkedWorkItem, + linkedTaskSourceContext + ) + ? linkedTaskSourceContext + : null + if (!updated.instanceId) { + updated.instanceId = randomUUID() + } + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] = updated + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return updated + } + + removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { + // A host-qualified removal names the owner; the persisted host is the fallback. + const persistedOwner = + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId]?.hostId + const owner = hostId ?? persistedOwner + const preservesDifferentPersistedOwner = Boolean( + hostId && persistedOwner && persistedOwner !== hostId + ) + const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) + const preservesSameIdSessionOwner = Boolean( + preservesDifferentPersistedOwner || + (owner && + hasWorktreeRemovalRepoOwnerOnOtherHost( + { getRepos: () => this[metadataLineageOperationsContext].runtime.state.repos }, + getRepoIdFromWorktreeId(worktreeId), + ownerPartition + )) + ) + // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. + const partitions = new Set( + workspaceSessionPartitionIdsForHost(owner).filter( + (partition) => + hasPersistedWorkspaceSession( + this[metadataLineageOperationsContext].sessions, + partition + ) && + // The local partition can be a remote spill surface or a same-id owner. + // Preserve it whenever another owner may still use the bare id. + (!preservesSameIdSessionOwner || partition === ownerPartition) + ) + ) + // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill + // partition that never held this worktree has no claim on the repo at all. + const fencedPartitions = new Set( + [...partitions].filter( + (partition) => + partitionOwnsWorktreeTabs( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition + ) || + (partition === ownerPartition && + !partitionHasOtherRepoWorktreeTabs( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition + )) + ) + ) + if (!preservesDifferentPersistedOwner) { + delete this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] + delete this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + delete this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + worktreeWorkspaceKey(worktreeId) + ] + } + for (const partition of partitions) { + removeWorkspaceSessionOwnerInPartition( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition, + { + advanceTerminalTopologyRevision: fencedPartitions.has(partition) + } + ) + } + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + + getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { + return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + } + + getAllWorktreeLineage(): Record { + return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById + } + + setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { + this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] = lineage + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return lineage + } + + removeWorktreeLineage(worktreeId: string): void { + delete this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + + migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { + if ( + migrateWorktreeIdentityOperation( + this[metadataLineageOperationsContext].runtime.state, + oldWorktreeId, + newWorktreeId + ) + ) { + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + } + + getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { + return this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childWorkspaceKey + ] + } + + getAllWorkspaceLineage(): Record { + return this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey + } + + setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { + this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + lineage.childWorkspaceKey + ] = lineage + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return lineage + } + + removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { + delete this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childWorkspaceKey + ] + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } +} + +export function removeWorkspaceLineageForFolderParent( + owner: MetadataLineageOperations, + folderWorkspaceId: string +): void { + const parentKey = folderWorkspaceKey(folderWorkspaceId) + for (const [childKey, lineage] of Object.entries( + owner[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey + )) { + if (lineage.parentWorkspaceKey === parentKey) { + delete owner[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childKey as WorkspaceKey + ] + } + } +} + +export function installMetadataLineageOperationsContext( + target: object, + source: MetadataLineageOperations +): void { + Object.defineProperty(target, metadataLineageOperationsContext, { + value: source[metadataLineageOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts new file mode 100644 index 00000000000..321baad1aaf --- /dev/null +++ b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts @@ -0,0 +1,42 @@ +import type { PersistedMobileClientTabSelections } from '../../../shared/persisted-state-types' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type MobileTabSelectionRuntime = Pick + +const mobileTabSelectionPersistenceContext = Symbol('MobileTabSelectionPersistence') +type MobileTabSelectionPersistenceContext = { + runtime: MobileTabSelectionRuntime + scheduling: WriteSchedulingOperations +} + +export class MobileTabSelectionPersistence { + readonly [mobileTabSelectionPersistenceContext]: MobileTabSelectionPersistenceContext + + constructor(runtime: MobileTabSelectionRuntime, scheduling: WriteSchedulingOperations) { + this[mobileTabSelectionPersistenceContext] = { runtime, scheduling } + } + + getMobileClientTabSelections(): PersistedMobileClientTabSelections { + return ( + this[mobileTabSelectionPersistenceContext].runtime.state + .mobileClientTabSelectionsByDeviceId ?? {} + ) + } + + setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { + this[mobileTabSelectionPersistenceContext].runtime.state.mobileClientTabSelectionsByDeviceId = + next + scheduleSave(this[mobileTabSelectionPersistenceContext].scheduling) + } +} + +export function installMobileTabSelectionPersistenceContext( + target: object, + source: MobileTabSelectionPersistence +): void { + Object.defineProperty(target, mobileTabSelectionPersistenceContext, { + value: source[mobileTabSelectionPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts new file mode 100644 index 00000000000..fb5cc9f1fe8 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -0,0 +1,138 @@ +import { getDefaultVoiceSettings } from '../../../shared/constants' +import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' +import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' +import { normalizeOpenInApplications } from '../../../shared/open-in-applications' +import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' +import { normalizeAppIconId } from '../../../shared/app-icon' +import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' +import { normalizeUiLanguage } from '../../../shared/ui-language' +import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { readLegacySidekickFlag } from '../applying-settings/onboarding-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { PreparedLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import type { PreparedLoadedProfileSettings } from './prepare-loaded-profile-settings' + +export function normalizeLoadedGlobalSettings( + parsed: PersistedState, + terminal: PreparedLoadedTerminalSettings, + profile: PreparedLoadedProfileSettings +): PersistedState['settings'] { + const { + defaults, + migratedExternalVisibility, + migratedTerminalScrollback, + migratedTerminalTuiScrollSensitivity, + migratedSourceControlAi, + migratedOptionAsAlt, + migratedFloatingTerminalEnabled, + migratedOsc52Clipboard, + migratedFloatingTerminalCwd, + migratedFloatingTerminalTrustedCwds + } = terminal + const { + migratedExperimentalActivity, + migratedAutoRenameBranchFromWork, + migratedTerminalCursorStyle, + migratedTerminalLineHeight, + terminalRightClickToPasteDefaultedForPlatform, + taskProviderSettings, + primarySelectionDefaultedForLinux, + primarySelectionDefaultedForTerminalDefaults, + migratePrimarySelectionPlatformDefault, + stampPrimarySelectionTerminalDefaults, + migratedDisabledTuiAgents, + migratedAgentYoloDefaults, + migratedWindowsRuntimeDefault, + migratedLocalAccountRuntime, + loadedCompactWorktreeCards, + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + normalizedNotifications, + normalizedSourceControlGroupOrder + } = profile + + return { + ...defaults.settings, + // Why (#7977): v1.4.130 onboarding persisted this as a plain boolean, making the + // old default indistinguishable from a real opt-in. Preserve stored `true`; only + // the default changed. + ...stripRetiredGlobalSettings(parsed.settings), + worktreeVisibilityDefaults: migratedExternalVisibility.defaults, + prBotAuthorOverrides: normalizePRBotAuthorOverrides(parsed.settings?.prBotAuthorOverrides), + // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. + experimentalPet: parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, + // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. + primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault + ? true + : (parsed.settings?.primarySelectionMiddleClickPaste ?? + defaults.settings.primarySelectionMiddleClickPaste), + primarySelectionMiddleClickPasteDefaultedForLinux: + primarySelectionDefaultedForLinux || + (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), + primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: + primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, + ...migratedAutoRenameBranchFromWork, + ...migratedTerminalCursorStyle, + terminalLineHeight: migratedTerminalLineHeight, + // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. + terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform + ? (parsed.settings?.terminalRightClickToPaste ?? defaults.settings.terminalRightClickToPaste) + : parsed.settings?.terminalRightClickToPaste === false + ? false + : defaults.settings.terminalRightClickToPaste, + terminalRightClickToPasteDefaultedForPlatform: true, + ...migratedTerminalTuiScrollSensitivity.settings, + experimentalActivity: migratedExperimentalActivity, + experimentalActivityDefaultedOffForAllUsers: true, + // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. + compactWorktreeCards: loadedCompactWorktreeCards, + experimentalCompactWorktreeCards: undefined, + terminalMacOptionAsAlt: migratedOptionAsAlt, + terminalMacOptionAsAltMigrated: true, + localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, + localAccountRuntime: migratedLocalAccountRuntime, + localAccountRuntimeDefaultedToAutoForAllUsers: true, + ...migratedOsc52Clipboard, + floatingTerminalEnabled: migratedFloatingTerminalEnabled, + floatingTerminalDefaultedForAllUsers: true, + floatingTerminalCwd: migratedFloatingTerminalCwd, + floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, + floatingTerminalCwdMigratedToAppWorkspace: true, + terminalScrollbackRows: migratedTerminalScrollback.rows, + terminalQuickCommands: normalizeTerminalQuickCommands(parsed.settings?.terminalQuickCommands), + terminalCustomThemes: normalizeTerminalCustomThemes(parsed.settings?.terminalCustomThemes), + appIcon: normalizeAppIconId(parsed.settings?.appIcon), + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. + minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, + // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. + showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, + uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), + defaultTaskSource: taskProviderSettings.defaultTaskSource, + visibleTaskProviders: taskProviderSettings.visibleTaskProviders, + visibleTaskProvidersDefaultedForJira: true, + terminalShortcutPolicy: normalizeTerminalShortcutPolicy( + parsed.settings?.terminalShortcutPolicy + ), + disabledTuiAgents: migratedDisabledTuiAgents, + ...migratedAgentYoloDefaults, + claudeAgentTeamsDefaultDisabledMigrated: true, + openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { + seedDefaults: true + }), + notifications: normalizedNotifications, + sourceControlAi: migratedSourceControlAi, + sourceControlGroupOrder: normalizedSourceControlGroupOrder, + // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. + commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( + migratedSourceControlAi, + parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi + ), + voice: { + ...getDefaultVoiceSettings(), + ...parsed.settings?.voice + } + } +} diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts new file mode 100644 index 00000000000..464a1eaa9b8 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -0,0 +1,91 @@ +import { normalizePersistedMobileClientTabSelections } from '../../runtime/client-session-tab-selection-persistence' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { normalizeFeatureInteractionTelemetryBuckets } from '../../../shared/feature-interactions' +import { normalizeFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' +import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' +import { normalizeWorkspaceLineageByChildKey } from '../applying-settings/ui-interaction-merge' +import { + normalizeSshRemotePtyLease, + normalizeSshTarget +} from '../leasing-ssh-ptys/ssh-normalization' +import { + normalizeClaudeLivePtySessionIds, + normalizeLegacyPaneKeyAliasEntries, + normalizeMigrationUnsupportedPtyEntries +} from '../restoring-sessions/pane-alias-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { PreparedLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import type { PreparedLoadedProfileSettings } from './prepare-loaded-profile-settings' +import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings' +import { normalizeLoadedUiState } from './normalize-loaded-ui-state' +import { + normalizeLoadedAutomationRuns, + normalizeLoadedHostSessions, + normalizeLoadedLocalSession +} from './normalize-loaded-state-collections' +import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization' + +export function normalizeLoadedProfileState( + parsed: PersistedState, + terminal: PreparedLoadedTerminalSettings, + profile: PreparedLoadedProfileSettings, + markNeedsSave: () => void +): PersistedState { + const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal + const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile + + return { + ...defaults, + ...parsed, + featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( + parsed.featureInteractionTelemetryBuckets + ), + projectGroups: normalizedProjectGroups, + repos: migratedExternalVisibility.repos, + folderWorkspaces: normalizeFolderWorkspaces(parsed.folderWorkspaces, normalizedProjectGroups), + folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( + parsed.folderWorkspaceDiffComments + ), + worktreeLineageById: parsed.worktreeLineageById ?? {}, + mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( + parsed.mobileClientTabSelectionsByDeviceId + ), + workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( + parsed.workspaceLineageByChildKey + ), + settings: normalizeLoadedGlobalSettings(parsed, terminal, profile), + // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. + ui: normalizeLoadedUiState( + parsed, + defaults, + normalizedOnboarding, + loadedCompactWorktreeCards, + osc52ClipboardNoticePending, + markNeedsSave + ), + // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. + workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave), + // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. + workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave), + sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) + ? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string') + : [], + retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap(parsed.retiredWorktreeNamesByRepo), + retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( + parsed.retiredWorktreeNamesByNamespace + ), + sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) + .map(normalizeSshRemotePtyLease) + .filter((lease): lease is SshRemotePtyLease => lease !== null), + sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, + claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), + migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( + parsed.migrationUnsupportedPtyEntries + ), + legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries(parsed.legacyPaneKeyAliasEntries), + automations: Array.isArray(parsed.automations) ? parsed.automations : [], + automationRuns: normalizeLoadedAutomationRuns(parsed, markNeedsSave), + onboarding: normalizedOnboarding + } +} diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts new file mode 100644 index 00000000000..978515a5752 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -0,0 +1,66 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' +import { + backfillAutomationRunNumbers, + pruneAutomationRuns +} from '../../../shared/automation-run-retention' +import { + parseWorkspaceSessionsByHostId, + workspaceSessionSalvageLogDetails +} from './workspace-session-partitions' + +export function normalizeLoadedLocalSession( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): WorkspaceSessionState { + if (parsed.workspaceSession === undefined) { + return defaults.workspaceSession + } + const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) + if (!result.ok) { + console.error('[persistence] Corrupt workspace session, using defaults:', result.error) + return defaults.workspaceSession + } + if (result.droppedCount > 0) { + console.warn( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + workspaceSessionSalvageLogDetails(result) + ) + // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. + markNeedsSave() + } + return { ...defaults.workspaceSession, ...result.value } +} + +export function normalizeLoadedHostSessions( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): PersistedState['workspaceSessionsByHostId'] { + const { partitions, repaired } = parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ) + if (repaired) { + // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. + markNeedsSave() + } + return partitions +} + +export function normalizeLoadedAutomationRuns( + parsed: PersistedState, + markNeedsSave: () => void +): PersistedState['automationRuns'] { + if (!Array.isArray(parsed.automationRuns)) { + return [] + } + const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) + // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. + if (runs.length !== parsed.automationRuns.length) { + markNeedsSave() + } + return runs +} diff --git a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts new file mode 100644 index 00000000000..d26f4e68430 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts @@ -0,0 +1,206 @@ +import { + getWorktreeCardModeProperties, + isDefaultedCompactWorktreeCardProperties, + normalizeWorktreeCardProperties +} from '../../../shared/constants' +import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' +import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' +import { normalizePersistedWorkspaceStatuses } from '../../../shared/workspace-statuses' +import { + normalizeRightSidebarExplorerView, + normalizeRightSidebarTab, + normalizeShowDotfilesByWorktree, + normalizeSortBy +} from '../applying-settings/ui-selection-normalization' +import { stripMainOwnedTelemetryMarkerFromUI } from '../applying-settings/ui-interaction-merge' +import { + readDeprecatedExperimentFlag, + resolveSetupGuideSidebarDismissedOnLoad +} from '../applying-settings/onboarding-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { OnboardingState } from '../../../shared/onboarding-state-types' + +export function normalizeLoadedUiState( + parsed: PersistedState, + defaults: PersistedState, + normalizedOnboarding: OnboardingState, + loadedCompactWorktreeCards: boolean, + osc52ClipboardNoticePending: boolean, + markNeedsSave: () => void +): PersistedState['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') { + markNeedsSave() + } + const workspaceStatusesDefaultOrderMigrated = + parsed.ui?._workspaceStatusesDefaultOrderMigrated === true + // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. + const workspaceStatusesReorderedDefaultRepaired = + parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true + // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. + const workspaceStatusesDefaultWorkflowMigrated = + parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true + // Why: visual migration has its own guard so later user choices of valid legacy color/icon IDs are preserved. + const workspaceStatusesDefaultVisualsMigrated = + parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true + const workspaceStatuses = normalizePersistedWorkspaceStatuses(parsed.ui?.workspaceStatuses, { + migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, + repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, + migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated + }) + if ( + !workspaceStatusesDefaultOrderMigrated || + !workspaceStatusesReorderedDefaultRepaired || + !workspaceStatusesDefaultWorkflowMigrated || + !workspaceStatusesDefaultVisualsMigrated + ) { + markNeedsSave() + } + const rawCardProps = parsed.ui?.worktreeCardProperties + const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true + const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true + const jiraIssueCardPropDefaulted = parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true + const hadExperimentOn = readDeprecatedExperimentFlag(parsed) + const deliberateUncheck = + hadExperimentOn && Array.isArray(rawCardProps) && !rawCardProps.includes('inline-agents') + const needsInlineAgentsMigration = + !inlineAgentsMigrated && + !deliberateUncheck && + Array.isArray(rawCardProps) && + !rawCardProps.includes('inline-agents') + const needsLegacyDefaultedCompactMigration = + loadedCompactWorktreeCards && + parsed.ui?._worktreeCardModeDefaulted === true && + isDefaultedCompactWorktreeCardProperties(rawCardProps) + const migratedCardProps = (() => { + if (!Array.isArray(rawCardProps)) { + return undefined + } + if (needsLegacyDefaultedCompactMigration) { + return getWorktreeCardModeProperties('Compact') + } + const candidate = needsInlineAgentsMigration + ? [...rawCardProps, 'inline-agents' as const] + : rawCardProps + const expandedCandidate = (() => { + if (expandedCardPropsMigrated) { + return candidate + } + const next = [...candidate] + // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. + if (candidate.includes('issue') && !candidate.includes('linear-issue')) { + next.push('linear-issue' as const) + } + if (!candidate.includes('ports')) { + next.push('ports' as const) + } + return next + })() + // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. + const jiraCandidate = + jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') + ? expandedCandidate + : [...expandedCandidate, 'jira-issue' as const] + const normalized = normalizeWorktreeCardProperties(jiraCandidate) + const changed = + normalized.length !== rawCardProps.length || + normalized.some((property, index) => property !== rawCardProps[index]) + return changed ? normalized : undefined + })() + if ( + migratedCardProps !== undefined || + !inlineAgentsMigrated || + !expandedCardPropsMigrated || + !jiraIssueCardPropDefaulted + ) { + markNeedsSave() + } + const rawExplorerView = parsed.ui?.rightSidebarExplorerView + const rightSidebarExplorerView = normalizeRightSidebarExplorerView( + rawExplorerView, + parsed.ui?.rightSidebarTab + ) + // Why: without a dirty mark the legacy "Search tab, no explorer view" repair stays + // in memory only, so a profile that never writes again redoes it on every launch. + if ( + rawExplorerView === undefined + ? rightSidebarExplorerView !== defaults.ui.rightSidebarExplorerView + : rawExplorerView !== rightSidebarExplorerView + ) { + markNeedsSave() + } + const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( + parsed.ui?.setupGuideSidebarDismissed, + normalizedOnboarding + ) + if ( + parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && + (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) + ) { + markNeedsSave() + } + // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. + const usagePercentageDisplayChangeNoticeDismissed = + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, + rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) + if ( + parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== + usagePercentageDisplayChangeNoticeDismissed + ) { + markNeedsSave() + } + return { + ...defaults.ui, + // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. + worktreeCardProperties: getWorktreeCardModeProperties( + loadedCompactWorktreeCards ? 'Compact' : 'Default' + ), + ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), + // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. + rightSidebarOpen, + rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), + // Why here and not in getPersistedUI: only the raw payload still shows the legacy + // "Search tab, no explorer view" shape — the defaults spread above fills in 'files'. + rightSidebarExplorerView, + setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, + setupGuideBrowserMilestoneMigrated: + typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' + ? parsed.ui.setupGuideBrowserMilestoneMigrated + : false, + setupGuideBrowserMilestoneLegacyComplete: + parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, + // Why persist rather than notify inline: the flip lands during load, before any + // window exists, and it must survive a crash before the user ever sees the notice. + osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, + sortBy: migrate ? ('smart' as const) : sort, + showDotfilesByWorktree: normalizeShowDotfilesByWorktree(parsed.ui?.showDotfilesByWorktree), + workspaceStatuses, + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesReorderedDefaultRepaired: true, + _workspaceStatusesDefaultWorkflowMigrated: true, + _workspaceStatusesDefaultVisualsMigrated: true, + _sortBySmartMigrated: true, + ...(migratedCardProps !== undefined ? { worktreeCardProperties: migratedCardProps } : {}), + // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. + _inlineAgentsDefaultedForExperiment: true, + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true + } +} diff --git a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts new file mode 100644 index 00000000000..2410e68be54 --- /dev/null +++ b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts @@ -0,0 +1,255 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' +import { normalizeTaskProviderSettings } from '../../../shared/task-providers' +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' +import { + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../shared/mobile-pairing-custom-address' +import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' +import { normalizeProjectGroups } from '../../../shared/project-groups' +import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { hasUnsupportedTuiAgentArgs } from '../../../shared/tui-agent-launch-defaults' +import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' +import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' +import { migrateAgentYoloDefaults } from '../applying-settings/terminal-settings-migrations' +import { + normalizeLoadedOnboardingState, + normalizeNotificationSettings, + persistedNotificationSettingsRepaired +} from '../applying-settings/onboarding-normalization' + +export type PreparedLoadedProfileSettings = { + migratedExperimentalActivity: GlobalSettings['experimentalActivity'] + migratedAutoRenameBranchFromWork: Pick< + GlobalSettings, + 'autoRenameBranchFromWork' | 'autoRenameBranchFromWorkDefaultedOn' + > + migratedTerminalCursorStyle: Pick< + GlobalSettings, + 'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock' + > + migratedTerminalLineHeight: GlobalSettings['terminalLineHeight'] + terminalRightClickToPasteDefaultedForPlatform: boolean + taskProviderSettings: Pick + primarySelectionDefaultedForLinux: boolean + primarySelectionDefaultedForTerminalDefaults: boolean + migratePrimarySelectionPlatformDefault: boolean + stampPrimarySelectionTerminalDefaults: boolean + migratedDisabledTuiAgents: GlobalSettings['disabledTuiAgents'] + migratedAgentYoloDefaults: Pick< + GlobalSettings, + 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentYoloDefaultsMigrated' + > + migratedWindowsRuntimeDefault: GlobalSettings['localWindowsRuntimeDefault'] + migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] + loadedCompactWorktreeCards: boolean + mobilePairingCustomAddress: GlobalSettings['mobilePairingCustomAddress'] + mobilePairingCustomAddresses: GlobalSettings['mobilePairingCustomAddresses'] + normalizedNotifications: GlobalSettings['notifications'] + normalizedSourceControlGroupOrder: GlobalSettings['sourceControlGroupOrder'] + normalizedOnboarding: PersistedState['onboarding'] + normalizedProjectGroups: ProjectGroup[] +} + +export function prepareLoadedProfileSettings( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): PreparedLoadedProfileSettings { + const experimentalActivityDefaultedOffForAllUsers = + parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true + // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. + const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers + ? (parsed.settings?.experimentalActivity ?? false) + : false + const autoRenameBranchFromWorkDefaultedOn = + parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true + // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. + const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( + parsed.settings + ) + const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) + if ( + parsed.settings?.terminalCursorStyle !== migratedTerminalCursorStyle.terminalCursorStyle || + parsed.settings?.terminalCursorStyleDefaultedToBlock !== true + ) { + markNeedsSave() + } + const migratedTerminalLineHeight = normalizeTerminalLineHeight( + parsed.settings?.terminalLineHeight + ) + const terminalRightClickToPasteDefaultedForPlatform = + parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true + if (!terminalRightClickToPasteDefaultedForPlatform) { + markNeedsSave() + } + if ( + parsed.settings?.terminalLineHeight !== undefined && + parsed.settings.terminalLineHeight !== migratedTerminalLineHeight + ) { + markNeedsSave() + } + 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) { + markNeedsSave() + } + if (!visibleTaskProvidersDefaultedForJira) { + markNeedsSave() + } + const claudeAgentTeamsDefaultDisabledMigrated = + parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true + if (!claudeAgentTeamsDefaultDisabledMigrated) { + markNeedsSave() + } + 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) + ) { + markNeedsSave() + } + 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' + ) { + markNeedsSave() + } + // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. + const localAccountRuntimeAlreadyMigrated = + parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true + const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = + localAccountRuntimeAlreadyMigrated + ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) + : parsed.settings?.localAccountRuntime === 'wsl' + ? 'wsl' + : 'auto' + if (!localAccountRuntimeAlreadyMigrated) { + markNeedsSave() + } + if (!autoRenameBranchFromWorkDefaultedOn) { + markNeedsSave() + } + const normalizedOnboarding = normalizeLoadedOnboardingState( + parsed.onboarding, + defaults.onboarding + ) + if (!parsed.onboarding) { + markNeedsSave() + } + const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) + const loadedCompactWorktreeCards = + parsed.settings?.compactWorktreeCards ?? + parsed.settings?.experimentalCompactWorktreeCards ?? + defaults.settings.compactWorktreeCards + const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + parsed.settings?.mobilePairingCustomAddress + ) + const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses + const mobilePairingCustomAddresses = mobilePairingCustomAddress + ? addMobilePairingCustomAddress( + normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), + mobilePairingCustomAddress + ) + : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) + if ( + parsed.settings?.mobilePairingCustomAddress !== undefined && + parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress + ) { + markNeedsSave() + } + const customAddressesMatch = + Array.isArray(rawMobilePairingCustomAddresses) && + rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && + rawMobilePairingCustomAddresses.every( + (address, index) => address === mobilePairingCustomAddresses[index] + ) + if ( + (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && + !customAddressesMatch + ) { + markNeedsSave() + } + const normalizedNotifications = normalizeNotificationSettings(parsed.settings?.notifications) + // Why: a type-flipped notification field is repaired in memory only; without a dirty mark the + // bad value stays on disk and the repair reruns on every launch. + if ( + persistedNotificationSettingsRepaired(parsed.settings?.notifications, normalizedNotifications) + ) { + markNeedsSave() + } + const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( + parsed.settings?.sourceControlGroupOrder + ) + if ( + parsed.settings?.sourceControlGroupOrder !== undefined && + parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder + ) { + markNeedsSave() + } + return { + migratedExperimentalActivity, + migratedAutoRenameBranchFromWork, + migratedTerminalCursorStyle, + migratedTerminalLineHeight, + terminalRightClickToPasteDefaultedForPlatform, + taskProviderSettings, + primarySelectionDefaultedForLinux, + primarySelectionDefaultedForTerminalDefaults, + migratePrimarySelectionPlatformDefault, + stampPrimarySelectionTerminalDefaults, + migratedDisabledTuiAgents, + migratedAgentYoloDefaults, + migratedWindowsRuntimeDefault, + migratedLocalAccountRuntime, + loadedCompactWorktreeCards, + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + normalizedNotifications, + normalizedSourceControlGroupOrder, + normalizedOnboarding, + normalizedProjectGroups + } +} diff --git a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts new file mode 100644 index 00000000000..65bf099891d --- /dev/null +++ b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts @@ -0,0 +1,172 @@ +import { homedir } from 'node:os' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + GlobalSettings, + WorktreeVisibilityDefaults +} from '../../../shared/global-settings-types' +import type { Repo } from '../../../shared/repo-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { migrateExternalWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' +import { + mergeLegacyCommitMessageAiIntoSourceControlAi, + sourceControlAiSettingsFromLegacy +} from '../../../shared/source-control-ai' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../shared/osc52-clipboard-settings' +import { + migrateTerminalScrollbackRows, + migrateTerminalTuiScrollSensitivityDefault +} from '../applying-settings/terminal-settings-migrations' +import { + canonicalizePersistedFloatingWorkspaceDirectory, + normalizeFloatingWorkspaceTrustedCwds +} from '../restoring-sessions/floating-workspace-normalization' + +export type PreparedLoadedTerminalSettings = { + defaults: PersistedState + migratedExternalVisibility: { + repos: Repo[] + defaults: WorktreeVisibilityDefaults + changed: boolean + } + migratedTerminalScrollback: { rows: number; needsSave: boolean } + migratedTerminalTuiScrollSensitivity: { + settings: Pick< + GlobalSettings, + 'terminalTuiScrollSensitivity' | 'terminalTuiScrollSensitivityDefaultedToOne' + > + needsSave: boolean + } + migratedSourceControlAi: NonNullable + migratedOptionAsAlt: GlobalSettings['terminalMacOptionAsAlt'] + migratedFloatingTerminalEnabled: GlobalSettings['floatingTerminalEnabled'] + migratedOsc52Clipboard: Pick< + GlobalSettings, + 'terminalAllowOsc52Clipboard' | 'terminalAllowOsc52ClipboardDefaultedOnForAllUsers' + > + migratedFloatingTerminalCwd: GlobalSettings['floatingTerminalCwd'] + migratedFloatingTerminalTrustedCwds: GlobalSettings['floatingTerminalTrustedCwds'] + osc52ClipboardNoticePending: boolean +} + +export function prepareLoadedTerminalSettings( + parsed: PersistedState, + markNeedsSave: () => void +): PreparedLoadedTerminalSettings { + // Merge with defaults in case new fields were added + const homeDir = homedir() + const defaults = getDefaultPersistedState(homeDir) + const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( + Array.isArray(parsed.repos) ? parsed.repos : [], + parsed.settings?.worktreeVisibilityDefaults + ) + if (migratedExternalVisibility.changed) { + markNeedsSave() + } + const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) + if (migratedTerminalScrollback.needsSave) { + markNeedsSave() + } + if ( + parsed.settings && + typeof parsed.settings === 'object' && + Object.hasOwn(parsed.settings, 'enableGitHubAttribution') + ) { + markNeedsSave() + } + const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( + parsed.settings + ) + if (migratedTerminalTuiScrollSensitivity.needsSave) { + markNeedsSave() + } + const rawSourceControlAi = parsed.settings?.sourceControlAi + const rawSourceControlAiMissing = rawSourceControlAi === undefined + const rawSourceControlAiActionsMissing = + rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined + if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { + markNeedsSave() + } + const legacyCommitMessageAi = parsed.settings?.commitMessageAi + const migratedSourceControlAi = rawSourceControlAiMissing + ? sourceControlAiSettingsFromLegacy(legacyCommitMessageAi ?? defaults.settings.commitMessageAi) + : mergeLegacyCommitMessageAiIntoSourceControlAi( + parsed.settings?.sourceControlAi, + legacyCommitMessageAi + ) + // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. + const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt + const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true + const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated + ? (rawOptionAsAlt ?? 'auto') + : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' + ? 'auto' + : rawOptionAsAlt + const floatingTerminalDefaultedForAllUsers = + parsed.settings?.floatingTerminalDefaultedForAllUsers === true + // Why: early builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. + const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers + ? (parsed.settings?.floatingTerminalEnabled ?? true) + : true + // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). + const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) + const osc52ClipboardNoticePending = + osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || + parsed.ui?.osc52ClipboardDefaultOnNoticePending === true + if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { + markNeedsSave() + } + const floatingTerminalCwdMigrated = + parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true + // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. + const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated + ? !parsed.settings?.floatingTerminalCwd + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + : parsed.settings?.floatingTerminalCwd === undefined + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( + parsed.settings?.floatingTerminalTrustedCwds, + homeDir + ) + const migratedFloatingTerminalTrustedCwds = [...normalizedFloatingTerminalTrustedCwds.trustedCwds] + const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd + const shouldTrustLegacyFloatingTerminalCwd = + !floatingTerminalCwdMigrated && + typeof rawLegacyFloatingTerminalCwd === 'string' && + rawLegacyFloatingTerminalCwd.trim().length > 0 && + rawLegacyFloatingTerminalCwd.trim() !== '~' + if (!floatingTerminalCwdMigrated) { + markNeedsSave() + } + if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { + const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( + rawLegacyFloatingTerminalCwd, + homeDir + ) + if (canonicalLegacyCwd && !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd)) { + // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. + migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) + normalizedFloatingTerminalTrustedCwds.changed = true + } + } + if (normalizedFloatingTerminalTrustedCwds.changed) { + markNeedsSave() + } + return { + defaults, + migratedExternalVisibility, + migratedTerminalScrollback, + migratedTerminalTuiScrollSensitivity, + migratedSourceControlAi, + migratedOptionAsAlt, + migratedFloatingTerminalEnabled, + migratedOsc52Clipboard, + migratedFloatingTerminalCwd, + migratedFloatingTerminalTrustedCwds, + osc52ClipboardNoticePending + } +} diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts new file mode 100644 index 00000000000..9d6e476ebe5 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -0,0 +1,282 @@ +import { mkdirSync, existsSync, unlinkSync } from 'node:fs' +import { mkdir, open, rm } from 'node:fs/promises' +import { durableWriteTempPath, renameDurable, writeFileDurableSync } from '../../durable-file-write' +import { dirname } from 'node:path' +import { + parseCodexResetCreditAttemptLedger, + type CodexResetCreditAttemptLedger +} from '../../../shared/codex-reset-credit-attempt-ledger' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' + +type PrimaryStateWriteOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'backupRotationInFlight' + | 'dataFile' + | 'flushOrThrow' + | 'firstPendingSaveAt' + | 'inFlightAsyncTmpFile' + | 'lastDurableWriteGeneration' + | 'lastWrittenStateHash' + | 'pendingSnapshotFileWork' + | 'pendingWrite' + | 'protectedSecrets' + | 'quitFlushStarted' + | 'staleTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') +type PrimaryStateWriteOperationsContext = { + runtime: PrimaryStateWriteOperationsRuntime + serialization: StateSerializationSecretHandlingOperations + backups: BackupRecoveryRotationOperations +} + +export class PrimaryStateWriteOperations { + readonly [primaryStateWriteOperationsContext]: PrimaryStateWriteOperationsContext + + constructor( + runtime: PrimaryStateWriteOperationsRuntime, + serialization: StateSerializationSecretHandlingOperations, + backups: BackupRecoveryRotationOperations + ) { + this[primaryStateWriteOperationsContext] = { runtime, serialization, backups } + } + + flushOrThrow(): void { + if (this[primaryStateWriteOperationsContext].runtime.quitFlushStarted) { + throw new Error('Cannot synchronously flush after final persistence has started') + } + if (this[primaryStateWriteOperationsContext].runtime.writeTimer) { + clearTimeout(this[primaryStateWriteOperationsContext].runtime.writeTimer) + this[primaryStateWriteOperationsContext].runtime.writeTimer = null + } + this[primaryStateWriteOperationsContext].runtime.firstPendingSaveAt = null + const asyncWriteWasInFlight = + this[primaryStateWriteOperationsContext].runtime.pendingWrite !== null + // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. + this[primaryStateWriteOperationsContext].runtime.writeGeneration++ + if (this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) { + try { + unlinkSync(this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) + this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + void enqueueWrite(this).catch(() => {}) + throw error + } + } + } + // Why: later async flushes must remain serialized behind the invalidated writer. + writeToDiskSync(this, { + force: asyncWriteWasInFlight, + skipBackupRotation: this[primaryStateWriteOperationsContext].runtime.backupRotationInFlight + }) + } + + flushActiveViewPreferenceOrThrow(): void { + this[primaryStateWriteOperationsContext].runtime.activeViewPreference.flushOrThrow() + } + + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { + return parseCodexResetCreditAttemptLedger( + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger + ) + } + + replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { + if (this[primaryStateWriteOperationsContext].runtime.writesFrozen) { + throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') + } + const next = parseCodexResetCreditAttemptLedger(ledger) + const previous = this[primaryStateWriteOperationsContext].runtime.state + .codexResetCreditAttemptLedger + ? structuredClone( + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger + ) + : undefined + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = next + try { + this[primaryStateWriteOperationsContext].runtime.flushOrThrow() + } catch (error) { + // Why: callers use a successful return as the durability barrier before + // handing a scarce-credit mutation to the provider. + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = + previous + throw error + } + } +} + +export function enqueueWrite(owner: PrimaryStateWriteOperations): Promise { + const previousWrite = Promise.all([ + owner[primaryStateWriteOperationsContext].runtime.pendingWrite ?? + owner[primaryStateWriteOperationsContext].runtime.staleTempCleanup, + owner[primaryStateWriteOperationsContext].runtime.pendingSnapshotFileWork ?? Promise.resolve() + ]).then(() => {}) + const write = previousWrite.then(() => writeToDiskAsync(owner)) + const trackedWrite = write + .catch((err) => { + console.error('[persistence] Failed to write state:', err) + }) + .finally(() => { + if (owner[primaryStateWriteOperationsContext].runtime.pendingWrite === trackedWrite) { + owner[primaryStateWriteOperationsContext].runtime.pendingWrite = null + } + }) + owner[primaryStateWriteOperationsContext].runtime.pendingWrite = trackedWrite + return write +} + +export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { + if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { + return + } + const gen = owner[primaryStateWriteOperationsContext].runtime.writeGeneration + const { payload, stateHash, protectedSecretUpdates } = + owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() + // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. + if (stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash) { + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + gen + ) + return + } + const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile + const dir = dirname(dataFile) + await mkdir(dir, { recursive: true }).catch(() => {}) + const tmpFile = durableWriteTempPath(dataFile) + + // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. + let renamed = false + try { + // Why: fsync before rename, then fsync the directory; see writeFileDurable. + const handle = await open(tmpFile, 'w') + try { + await handle.writeFile(payload, 'utf-8') + await handle.sync() + } finally { + await handle.close() + } + // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. + if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { + return + } + owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = tmpFile + try { + await renameDurable(tmpFile, dataFile) + renamed = true + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== 'ENOENT' || + owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen + ) { + throw error + } + } finally { + if (owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile === tmpFile) { + owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + } + } + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. + if (renamed && owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen) { + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash + owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( + protectedSecretUpdates + ) + } else if (renamed) { + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = null + } + if (renamed) { + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + gen + ) + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (!renamed) { + return + } + // Why (#1158): rotate only after the primary rename while this write still owns its generation. + if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { + return + } + await owner[primaryStateWriteOperationsContext].backups.rotateBackupsAsync(dataFile) +} + +export function writeToDiskSync( + owner: PrimaryStateWriteOperations, + opts: { force?: boolean; skipBackupRotation?: boolean } = {} +): void { + if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { + return + } + const { payload, stateHash, protectedSecretUpdates } = + owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() + // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. + if ( + !opts.force && + stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash + ) { + return + } + const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile + const dir = dirname(dataFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + + // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. + let renamed = false + try { + // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty + // content after power loss, losing projects/tabs back to the newest usable .bak slot. + writeFileDurableSync(tmpFile, dataFile, payload) + renamed = true + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash + owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( + protectedSecretUpdates + ) + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + owner[primaryStateWriteOperationsContext].runtime.writeGeneration + ) + } finally { + if (!renamed) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup; the write already failed, swallow secondary error. + } + } + } + const now = Date.now() + if ( + !opts.skipBackupRotation && + owner[primaryStateWriteOperationsContext].backups.shouldRotateBackups(now, dataFile) + ) { + owner[primaryStateWriteOperationsContext].backups.rotateBackupsSync(dataFile) + } +} + +export function installPrimaryStateWriteOperationsContext( + target: object, + source: PrimaryStateWriteOperations +): void { + Object.defineProperty(target, primaryStateWriteOperationsContext, { + value: source[primaryStateWriteOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts new file mode 100644 index 00000000000..228229d2584 --- /dev/null +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -0,0 +1,194 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getDefaultOnboardingState } from '../../../shared/constants' +import type { FeatureInteractionId } from '../../../shared/feature-interactions' +import { + updateSettings as updateSettingsOperation, + type SettingsMutationOperations +} from '../applying-settings/settings-update' +import { getPersistedUI } from '../applying-settings/ui-state-read' +import { updatePersistedUI, type UIUpdateOperations } from '../applying-settings/ui-state-update' +import { + recordFeatureInteraction as recordFeatureInteractionOperation, + type FeatureInteractionOperations +} from '../applying-settings/feature-interaction-recording' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type ProfilePreferencesRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'githubCacheDirty' + | 'githubCacheGeneration' + | 'protectedSecrets' + | 'settingsChangeListeners' + | 'state' + | 'uiChangeListeners' +> + +const profilePreferencesContext = Symbol('ProfilePreferences') +type ProfilePreferencesContext = { + runtime: ProfilePreferencesRuntime + scheduling: WriteSchedulingOperations +} + +export class ProfilePreferences { + readonly [profilePreferencesContext]: ProfilePreferencesContext + + constructor(runtime: ProfilePreferencesRuntime, scheduling: WriteSchedulingOperations) { + this[profilePreferencesContext] = { runtime, scheduling } + } + + getSettings(): GlobalSettings { + return this[profilePreferencesContext].runtime.state.settings + } + + onSettingsChanged( + listener: ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + ): () => void { + this[profilePreferencesContext].runtime.settingsChangeListeners.add(listener) + return () => { + this[profilePreferencesContext].runtime.settingsChangeListeners.delete(listener) + } + } + + onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { + this[profilePreferencesContext].runtime.uiChangeListeners.add(listener) + return () => { + this[profilePreferencesContext].runtime.uiChangeListeners.delete(listener) + } + } + + updateSettings( + updates: Partial, + options: { notifyListeners?: boolean; originWebContentsId?: number } = {} + ): GlobalSettings { + return updateSettingsOperation(getSettingsMutationOperations(this), updates, options) + } + + getUI(): PersistedState['ui'] { + return getPersistedUI( + this[profilePreferencesContext].runtime.state, + this[profilePreferencesContext].runtime.activeViewPreference.get() + ) + } + + updateUI(updates: Partial): void { + updatePersistedUI(getUIUpdateOperations(this), updates) + } + + recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { + return recordFeatureInteractionOperation(getFeatureInteractionOperations(this), id) + } + + getOnboarding(): PersistedState['onboarding'] { + const defaults = getDefaultOnboardingState() + return { + ...defaults, + ...this[profilePreferencesContext].runtime.state.onboarding, + checklist: { + ...defaults.checklist, + ...this[profilePreferencesContext].runtime.state.onboarding?.checklist + } + } + } + + updateOnboarding( + updates: Partial> & { + checklist?: Partial + } + ): PersistedState['onboarding'] { + const current = this.getOnboarding() + this[profilePreferencesContext].runtime.state.onboarding = { + ...current, + ...updates, + checklist: { + ...current.checklist, + ...updates.checklist + } + } + scheduleSave(this[profilePreferencesContext].scheduling) + return this.getOnboarding() + } + + getGitHubCache(): PersistedState['githubCache'] { + return this[profilePreferencesContext].runtime.state.githubCache + } + + setGitHubCache(cache: PersistedState['githubCache']): void { + // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. + this[profilePreferencesContext].runtime.state.githubCache = cache + this[profilePreferencesContext].runtime.githubCacheDirty = true + this[profilePreferencesContext].runtime.githubCacheGeneration += 1 + } +} + +export function notifySettingsChanged( + owner: ProfilePreferences, + updates: Partial, + originWebContentsId?: number +): void { + for (const listener of owner[profilePreferencesContext].runtime.settingsChangeListeners) { + listener(updates, owner[profilePreferencesContext].runtime.state.settings, originWebContentsId) + } +} + +export function notifyUIChanged(owner: ProfilePreferences): void { + if (owner[profilePreferencesContext].runtime.uiChangeListeners.size === 0) { + return + } + const ui = owner.getUI() + for (const listener of owner[profilePreferencesContext].runtime.uiChangeListeners) { + listener(ui) + } +} + +export function getSettingsMutationOperations( + owner: ProfilePreferences +): SettingsMutationOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + removeRetainedBlob: (slot) => + owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifySettingsChanged: (updates, originWebContentsId) => + notifySettingsChanged(owner, updates, originWebContentsId) + } +} + +export function getUIUpdateOperations(owner: ProfilePreferences): UIUpdateOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + removeRetainedBlob: (slot) => + owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), + setActiveView: (activeView) => + owner[profilePreferencesContext].runtime.activeViewPreference.set(activeView), + getUI: () => owner.getUI(), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifyUIChanged: () => notifyUIChanged(owner) + } +} + +export function getFeatureInteractionOperations( + owner: ProfilePreferences +): FeatureInteractionOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifyUIChanged: () => notifyUIChanged(owner), + getUI: () => owner.getUI() + } +} + +export function installProfilePreferencesContext(target: object, source: ProfilePreferences): void { + Object.defineProperty(target, profilePreferencesContext, { + value: source[profilePreferencesContext] + }) +} diff --git a/src/main/persistence/loading-store/project-collection-operations.ts b/src/main/persistence/loading-store/project-collection-operations.ts new file mode 100644 index 00000000000..e7100478037 --- /dev/null +++ b/src/main/persistence/loading-store/project-collection-operations.ts @@ -0,0 +1,233 @@ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + ProjectUpdateArgs +} from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' +import { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' +import { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { MetadataLineageOperations } from './metadata-lineage-operations' +import { + hydrateRepo, + updateRepoBackedProjectHostSetup, + updateIndependentProjectHostSetup, + pruneMobileClientTabSelections +} from './repo-lifecycle-operations' +import { scheduleSave } from './write-scheduling' +import { removeWorkspaceLineageForFolderParent } from './metadata-lineage-operations' + +type ProjectCollectionOperationsRuntime = Pick< + StoreRuntimeState, + | 'folderWorkspaceOperations' + | 'gitUsernameCache' + | 'projectGroupOperations' + | 'projectHostOperations' + | 'state' +> + +const projectCollectionOperationsContext = Symbol('ProjectCollectionOperations') +type ProjectCollectionOperationsContext = { + runtime: ProjectCollectionOperationsRuntime + repos: RepoLifecycleOperations + scheduling: WriteSchedulingOperations + metadata: MetadataLineageOperations +} + +export class ProjectCollectionOperations { + readonly [projectCollectionOperationsContext]: ProjectCollectionOperationsContext + + constructor( + runtime: ProjectCollectionOperationsRuntime, + repos: RepoLifecycleOperations, + scheduling: WriteSchedulingOperations, + metadata: MetadataLineageOperations + ) { + this[projectCollectionOperationsContext] = { runtime, repos, scheduling, metadata } + } + + getRepos(): Repo[] { + return getProjectHostOperations(this).getRepos() + } + + getProjects(): Project[] { + return getProjectHostOperations(this).getProjects() + } + + updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { + return getProjectHostOperations(this).updateProject(id, updates) + } + + getProjectHostSetups(): ProjectHostSetup[] { + return getProjectHostOperations(this).getProjectHostSetups() + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { + return getProjectHostOperations(this).createProjectHostSetup(args) + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { + return getProjectHostOperations(this).updateProjectHostSetup(args) + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { + return getProjectHostOperations(this).deleteProjectHostSetup(args) + } + + getRepoCount(): number { + return getProjectHostOperations(this).getRepoCount() + } + + getRepo(id: string): Repo | undefined { + return getProjectHostOperations(this).getRepo(id) + } + + setResolvedRepoGitUsername( + target: Pick, + username: string + ): boolean { + return getProjectHostOperations(this).setResolvedRepoGitUsername(target, username) + } + + getProjectGroups(): ProjectGroup[] { + return getProjectGroupOperations(this).getProjectGroups() + } + + createProjectGroup( + input: Parameters[0] + ): ProjectGroup { + return getProjectGroupOperations(this).createProjectGroup(input) + } + + updateProjectGroup( + groupId: string, + updates: Parameters[1] + ): ProjectGroup | null { + return getProjectGroupOperations(this).updateProjectGroup(groupId, updates) + } + + deleteProjectGroup(groupId: string): boolean { + return getProjectGroupOperations(this).deleteProjectGroup(groupId) + } + + getFolderWorkspaces(): FolderWorkspace[] { + return getFolderWorkspaceOperations(this).getFolderWorkspaces() + } + + getFolderWorkspace(id: string): FolderWorkspace | undefined { + return getFolderWorkspaceOperations(this).getFolderWorkspace(id) + } + + createFolderWorkspace( + input: Parameters[0] + ): FolderWorkspace { + return getFolderWorkspaceOperations(this).createFolderWorkspace(input) + } + + updateFolderWorkspace( + id: string, + updates: Parameters[1] + ): FolderWorkspace | null { + return getFolderWorkspaceOperations(this).updateFolderWorkspace(id, updates) + } + + removeFolderWorkspace(id: string): boolean { + return getFolderWorkspaceOperations(this).removeFolderWorkspace(id) + } + + moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { + return getFolderWorkspaceOperations(this).moveProjectToGroup(repoId, groupId, order) + } +} + +export function getProjectHostOperations( + owner: ProjectCollectionOperations +): ProjectHostPersistenceOperations { + owner[projectCollectionOperationsContext].runtime.projectHostOperations ??= + new ProjectHostPersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + gitUsernameCache: owner[projectCollectionOperationsContext].runtime.gitUsernameCache, + hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo), + updateRepoBackedProjectHostSetup: (setup, repo, updates) => + updateRepoBackedProjectHostSetup( + owner[projectCollectionOperationsContext].repos, + setup, + repo, + updates + ), + updateIndependentProjectHostSetup: (setup, updates) => + updateIndependentProjectHostSetup( + owner[projectCollectionOperationsContext].repos, + setup, + updates + ), + removeProjectForHost: (id, hostId) => + owner[projectCollectionOperationsContext].repos.removeProjectForHost(id, hostId), + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling) + }) + return owner[projectCollectionOperationsContext].runtime.projectHostOperations +} + +export function getProjectGroupOperations( + owner: ProjectCollectionOperations +): ProjectGroupPersistenceOperations { + owner[projectCollectionOperationsContext].runtime.projectGroupOperations ??= + new ProjectGroupPersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + removeWorkspaceLineageForFolderParent( + owner[projectCollectionOperationsContext].metadata, + folderWorkspaceId + ), + pruneMobileClientTabSelections: (matchesWorktreeId) => + pruneMobileClientTabSelections( + owner[projectCollectionOperationsContext].repos, + matchesWorktreeId + ) + }) + return owner[projectCollectionOperationsContext].runtime.projectGroupOperations +} + +export function getFolderWorkspaceOperations( + owner: ProjectCollectionOperations +): FolderWorkspacePersistenceOperations { + owner[projectCollectionOperationsContext].runtime.folderWorkspaceOperations ??= + new FolderWorkspacePersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + removeWorkspaceLineageForFolderParent( + owner[projectCollectionOperationsContext].metadata, + folderWorkspaceId + ), + pruneMobileClientTabSelections: (matchesWorktreeId) => + pruneMobileClientTabSelections( + owner[projectCollectionOperationsContext].repos, + matchesWorktreeId + ), + hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo) + }) + return owner[projectCollectionOperationsContext].runtime.folderWorkspaceOperations +} + +export function installProjectCollectionOperationsContext( + target: object, + source: ProjectCollectionOperations +): void { + Object.defineProperty(target, projectCollectionOperationsContext, { + value: source[projectCollectionOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts new file mode 100644 index 00000000000..3c5ff20c480 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -0,0 +1,240 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + cloneLayoutNode, + layoutContainsLeafId +} from '../restoring-sessions/terminal-layout-normalization' +import { + cloneWorkspaceSessionState, + createMinimalPersistedTerminalTab +} from '../restoring-sessions/session-owner-fields' + +import type { PtyBindingSourceExpectation } from './store' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import { resolveHostId } from './session-host-partitions' + +type PtyBindingPersistenceOperationsRuntime = Pick + +const ptyBindingPersistenceOperationsContext = Symbol('PtyBindingPersistenceOperations') +type PtyBindingPersistenceOperationsContext = { + runtime: PtyBindingPersistenceOperationsRuntime + sessions: SessionHostPartitionOperations +} + +export class PtyBindingPersistenceOperations { + readonly [ptyBindingPersistenceOperationsContext]: PtyBindingPersistenceOperationsContext + + constructor( + runtime: PtyBindingPersistenceOperationsRuntime, + sessions: SessionHostPartitionOperations + ) { + this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } + } + + persistPtyBinding( + args: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + startupCwd?: string + expectedBinding?: { ptyId: string; incarnationId?: string } + expectedSourceBinding?: PtyBindingSourceExpectation + /** Set by host-initiated creates, which have no renderer session writer behind them. */ + hostAdmittedMembership?: boolean + }, + hostId?: string | null + ): boolean { + const resolvedHostId = resolveHostId(hostId) + const session = + this[ptyBindingPersistenceOperationsContext].sessions.getWorkspaceSession(resolvedHostId) + const paneKey = `${args.tabId}:${args.leafId}` + const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + if (args.expectedSourceBinding) { + const expected = args.expectedSourceBinding + if (expected.tabId !== args.tabId) { + return false + } + const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId + ) + const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] + const sourcePaneKey = `${expected.tabId}:${expected.leafId}` + if ( + !sourceTab || + sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || + !layoutContainsLeafId(sourceLayout.root, expected.leafId) || + (expected.incarnationId !== undefined && + session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) + ) { + return false + } + } + if (args.expectedBinding) { + const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId + ) + const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + if ( + !tab || + boundPtyId !== args.expectedBinding.ptyId || + session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId + ) { + return false + } + } + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: session + } + } + const sessionBeforeBinding = cloneWorkspaceSessionState(session) + const reconciledIncarnation = + args.expectedBinding !== undefined && + args.incarnationId !== args.expectedBinding.incarnationId + let terminalMembershipChanged = false + let hostAdmittedTabCreated = false + const advanceTopologyFence = (): void => { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + // Why: a split, or a host-admitted tab the renderer has never seen, is itself + // the authority — with no fence the renderer's pre-create tab list replays + // over it and the tab is lost even on the repo's first such change. + const establishesMembershipAuthority = + args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) + ) { + return + } + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + const restoreSession = (): void => { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSession = + sessionBeforeBinding + } else { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: sessionBeforeBinding + } + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + const tabs = session.tabsByWorktree?.[bindingWorktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = args.ptyId + } else { + terminalMembershipChanged = true + hostAdmittedTabCreated = args.hostAdmittedMembership === true + // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [bindingWorktreeId]: nextTabs + } + session.activeWorktreeId ??= bindingWorktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + } + if (!isTerminalLeafId(args.leafId)) { + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyFence() + try { + this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + if (!layout.root) { + terminalMembershipChanged = true + // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true + // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. + layout.root = { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } + layout.activeLeafId = args.leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + } + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + terminalMembershipChanged = true + // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + advanceTopologyFence() + try { + this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } +} + +export function installPtyBindingPersistenceOperationsContext( + target: object, + source: PtyBindingPersistenceOperations +): void { + Object.defineProperty(target, ptyBindingPersistenceOperationsContext, { + value: source[ptyBindingPersistenceOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts new file mode 100644 index 00000000000..bdd3718d9a6 --- /dev/null +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -0,0 +1,264 @@ +import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { + removeRepoFromHostWorkspaceSessions, + removeRepoFromWorkspaceSession +} from '../../orca-profiles/profile-project-session-state' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility' +import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' +import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' +import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' +import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' +import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RepoLifecycleOperationsRuntime = Pick< + StoreRuntimeState, + | 'gitUsernameCache' + | 'projectHostSetupOperations' + | 'repoOrderOperations' + | 'repoUpdateOperations' + | 'state' +> + +const repoLifecycleOperationsContext = Symbol('RepoLifecycleOperations') +type RepoLifecycleOperationsContext = { + runtime: RepoLifecycleOperationsRuntime + scheduling: WriteSchedulingOperations +} + +export class RepoLifecycleOperations { + readonly [repoLifecycleOperationsContext]: RepoLifecycleOperationsContext + + constructor(runtime: RepoLifecycleOperationsRuntime, scheduling: WriteSchedulingOperations) { + this[repoLifecycleOperationsContext] = { runtime, scheduling } + } + + addRepo(repo: Repo): void { + getRepoOrderOperations(this).addRepo(repo) + } + + reorderRepos(orderedIds: string[]): boolean { + return getRepoOrderOperations(this).reorderRepos(orderedIds) + } + + reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { + return getRepoOrderOperations(this).reorderReposForHost(orderedIds, hostId) + } + + removeProject(id: string): void { + this[repoLifecycleOperationsContext].runtime.state.repos = this[ + repoLifecycleOperationsContext + ].runtime.state.repos.filter((r) => r.id !== id) + syncProjectHostSetupCompatibilityState(this) + // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. + delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] + delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] + pruneWorktreeStateForRepo(this, id, null) + this[repoLifecycleOperationsContext].runtime.state.workspaceSession = + removeRepoFromWorkspaceSession( + this[repoLifecycleOperationsContext].runtime.state.workspaceSession, + id + ) + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = + removeRepoFromHostWorkspaceSessions( + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + id + ) + scheduleSave(this[repoLifecycleOperationsContext].scheduling) + } + + removeProjectForHost(id: string, hostId: ExecutionHostId): void { + this[repoLifecycleOperationsContext].runtime.state.repos = this[ + repoLifecycleOperationsContext + ].runtime.state.repos.filter((r) => !(r.id === id && getRepoExecutionHostId(r) === hostId)) + const idStillPresent = this[repoLifecycleOperationsContext].runtime.state.repos.some( + (r) => r.id === id + ) + // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. + if (!idStillPresent) { + delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] + delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] + } + syncProjectHostSetupCompatibilityState(this) + // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). + pruneWorktreeStateForRepo(this, id, idStillPresent ? hostId : null) + if (!idStillPresent) { + this[repoLifecycleOperationsContext].runtime.state.workspaceSession = + removeRepoFromWorkspaceSession( + this[repoLifecycleOperationsContext].runtime.state.workspaceSession, + id + ) + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = + removeRepoFromHostWorkspaceSessions( + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + id + ) + } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { + const session = + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] + if (session) { + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + [hostId]: removeRepoFromWorkspaceSession(session, id) + } + } + } + scheduleSave(this[repoLifecycleOperationsContext].scheduling) + } + + updateRepo( + id: string, + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'gitRemoteIdentity' + | 'hookSettings' + | 'worktreeBaseRef' + | 'worktreeBasePath' + | 'kind' + | 'executionHostId' + | 'symlinkPaths' + | 'issueSourcePreference' + | 'forkSyncMode' + | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + | 'projectGroupId' + | 'projectGroupOrder' + | 'projectHostSetupMethod' + > + > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + }, + hostId?: ExecutionHostId + ): Repo | null { + return getRepoUpdateOperations(this).updateRepo(id, updates, hostId) + } +} + +export function getRepoOrderOperations( + owner: RepoLifecycleOperations +): RepoOrderPersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.repoOrderOperations ??= + new RepoOrderPersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling) + }) + return owner[repoLifecycleOperationsContext].runtime.repoOrderOperations +} + +export function pruneWorktreeStateForRepo( + owner: RepoLifecycleOperations, + id: string, + hostId: ExecutionHostId | null +): void { + pruneWorktreeStateForRepoOperation( + owner[repoLifecycleOperationsContext].runtime.state, + id, + hostId, + (matchesWorktreeId) => pruneMobileClientTabSelections(owner, matchesWorktreeId) + ) +} + +export function pruneMobileClientTabSelections( + owner: RepoLifecycleOperations, + matchesWorktreeId: (worktreeId: string) => boolean +): void { + for (const [clientNavigationId, selectionsByWorktree] of Object.entries( + owner[repoLifecycleOperationsContext].runtime.state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + for (const worktreeId of Object.keys(selectionsByWorktree)) { + if (matchesWorktreeId(worktreeId)) { + delete selectionsByWorktree[worktreeId] + } + } + if (Object.keys(selectionsByWorktree).length === 0) { + delete owner[repoLifecycleOperationsContext].runtime.state + .mobileClientTabSelectionsByDeviceId?.[clientNavigationId] + } + } +} + +export function getRepoUpdateOperations( + owner: RepoLifecycleOperations +): RepoUpdatePersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations ??= + new RepoUpdatePersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling), + hydrateRepo: (repo) => hydrateRepo(owner, repo) + }) + return owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations +} + +export function syncProjectHostSetupCompatibilityState(owner: RepoLifecycleOperations): void { + const compatibilityState = mergeProjectHostSetupCompatibilityState( + owner[repoLifecycleOperationsContext].runtime.state, + owner[repoLifecycleOperationsContext].runtime.state.repos + ) + owner[repoLifecycleOperationsContext].runtime.state.projects = compatibilityState.projects + owner[repoLifecycleOperationsContext].runtime.state.projectHostSetups = + compatibilityState.projectHostSetups +} + +export function getProjectHostSetupOperations( + owner: RepoLifecycleOperations +): ProjectHostSetupPersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations ??= + new ProjectHostSetupPersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + updateRepo: (id, updates, hostId) => owner.updateRepo(id, updates, hostId), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling) + }) + return owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations +} + +export function updateRepoBackedProjectHostSetup( + owner: RepoLifecycleOperations, + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] +): { setup: ProjectHostSetup; repo: Repo } | null { + return getProjectHostSetupOperations(owner).updateRepoBackedProjectHostSetup(setup, repo, updates) +} + +export function updateIndependentProjectHostSetup( + owner: RepoLifecycleOperations, + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] +): ProjectHostSetup { + return getProjectHostSetupOperations(owner).updateIndependentProjectHostSetup(setup, updates) +} + +export function hydrateRepo(owner: RepoLifecycleOperations, repo: Repo): Repo { + return hydrateRepoOperation(repo, owner[repoLifecycleOperationsContext].runtime.gitUsernameCache) +} + +export function installRepoLifecycleOperationsContext( + target: object, + source: RepoLifecycleOperations +): void { + Object.defineProperty(target, repoLifecycleOperationsContext, { + value: source[repoLifecycleOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/retired-name-registry-normalization.ts b/src/main/persistence/loading-store/retired-name-registry-normalization.ts new file mode 100644 index 00000000000..d1c412f0f46 --- /dev/null +++ b/src/main/persistence/loading-store/retired-name-registry-normalization.ts @@ -0,0 +1,46 @@ +import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' +import { + clampExhaustedTiers, + compactRetiredNames, + isEmptyRetiredNameRegistry, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' + +function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { + const isPlainArray = Array.isArray(row) + const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined + const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] + const names = new Set() + for (const entry of rawNames) { + if (typeof entry !== 'string') { + continue + } + const normalized = normalizeRetirableGeneratedName(entry) + if (normalized) { + names.add(normalized) + } + } + return compactRetiredNames({ + exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), + names: [...names] + }) +} + +export function normalizeRetiredNameRegistryMap( + value: unknown +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const byRepo: Record = {} + for (const [repoId, row] of Object.entries(value as Record)) { + if (!repoId) { + continue + } + const registry = normalizeRetiredNameRegistry(row) + if (!isEmptyRetiredNameRegistry(registry)) { + byRepo[repoId] = registry + } + } + return byRepo +} diff --git a/src/main/persistence/loading-store/retired-worktree-name-persistence.ts b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts new file mode 100644 index 00000000000..c5260850522 --- /dev/null +++ b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts @@ -0,0 +1,119 @@ +import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' +import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' +import { + addRetiredNames, + EMPTY_RETIRED_NAME_REGISTRY, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RetiredWorktreeNameRuntime = Pick + +const retiredWorktreeNamePersistenceContext = Symbol('RetiredWorktreeNamePersistence') +type RetiredWorktreeNamePersistenceContext = { + runtime: RetiredWorktreeNameRuntime + scheduling: WriteSchedulingOperations +} + +export class RetiredWorktreeNamePersistence { + readonly [retiredWorktreeNamePersistenceContext]: RetiredWorktreeNamePersistenceContext + + constructor(runtime: RetiredWorktreeNameRuntime, scheduling: WriteSchedulingOperations) { + this[retiredWorktreeNamePersistenceContext] = { runtime, scheduling } + } + + getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { + const stored = + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo?.[repoId] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { + const stored = + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace?.[ + namespaceKey + ] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + addRetiredWorktreeName(repoId: string, name: string): void { + const normalized = normalizeRetirableGeneratedName(name) + if (!repoId || !normalized) { + return + } + applyRetiredWorktreeNames(this, repoId, [normalized]) + } + + mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { + if (!repoId) { + return false + } + const incoming = new Set() + for (const name of names) { + const normalized = normalizeRetirableGeneratedName(name) + if (normalized) { + incoming.add(normalized) + } + } + return incoming.size > 0 && applyRetiredWorktreeNames(this, repoId, incoming) + } + + mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { + if (!namespaceKey) { + return false + } + const normalized = new Set() + for (const name of names) { + const candidate = normalizeRetirableGeneratedName(name) + if (candidate) { + normalized.add(candidate) + } + } + const next = addRetiredNames( + this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), + normalized + ) + if (!next) { + return false + } + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace ??= {} + recordRetirementNamespaceRegistry( + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace, + namespaceKey, + next + ) + scheduleSave(this[retiredWorktreeNamePersistenceContext].scheduling) + return true + } +} + +export function applyRetiredWorktreeNames( + owner: RetiredWorktreeNamePersistence, + repoId: string, + names: Iterable +): boolean { + const next = addRetiredNames(owner.getRetiredWorktreeNameRegistry(repoId), names) + if (!next) { + return false + } + owner[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo ??= {} + owner[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo[repoId] = + next + scheduleSave(owner[retiredWorktreeNamePersistenceContext].scheduling) + return true +} + +export function installRetiredWorktreeNamePersistenceContext( + target: object, + source: RetiredWorktreeNamePersistence +): void { + Object.defineProperty(target, retiredWorktreeNamePersistenceContext, { + value: source[retiredWorktreeNamePersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts new file mode 100644 index 00000000000..53c7ed5ea06 --- /dev/null +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -0,0 +1,190 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' +import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' +import { + removeWorkspaceSessionOwner, + workspaceSessionPartitionIdsForHost +} from '../restoring-sessions/session-owner-removal' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type SessionHostPartitionOperationsRuntime = Pick< + StoreRuntimeState, + 'state' | 'terminalScrollbackSnapshotStorage' +> + +const sessionHostPartitionOperationsContext = Symbol('SessionHostPartitionOperations') +type SessionHostPartitionOperationsContext = { + runtime: SessionHostPartitionOperationsRuntime + scheduling: WriteSchedulingOperations +} + +export class SessionHostPartitionOperations { + readonly [sessionHostPartitionOperationsContext]: SessionHostPartitionOperationsContext + + constructor( + runtime: SessionHostPartitionOperationsRuntime, + scheduling: WriteSchedulingOperations + ) { + this[sessionHostPartitionOperationsContext] = { runtime, scheduling } + } + + getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + return ( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSession ?? + getDefaultWorkspaceSession() + ) + } + return ( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[ + resolved + ] ?? getDefaultWorkspaceSession() + ) + } + + getWorkspaceSessionHostIds(): ExecutionHostId[] { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const key of Object.keys( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId ?? {} + )) { + const hostId = normalizeExecutionHostId(key) + if (hostId) { + hostIds.add(hostId) + } + } + return [...hostIds] + } + + readTerminalScrollbackSnapshot(ref: string): string | null { + return readTerminalScrollbackSnapshotSync( + ref, + this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage + ) + } + + getWorktreeIdForTab(tabId: string): string | undefined { + return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) + } + + removeWorkspaceSessionStateForWorktree( + worktreeId: string, + hostId?: ExecutionHostId | null, + options: { advanceTerminalTopologyRevision?: boolean } = {} + ): void { + for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { + removeWorkspaceSessionOwnerInPartition(this, worktreeId, resolved, options) + } + } +} + +export function resolveHostId(hostId?: string | null): ExecutionHostId { + return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID +} + +export function hasPersistedWorkspaceSession( + owner: SessionHostPartitionOperations, + hostId: ExecutionHostId +): boolean { + return ( + hostId === LOCAL_EXECUTION_HOST_ID || + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[ + hostId + ] !== undefined + ) +} + +export function removeWorkspaceSessionOwnerInPartition( + owner: SessionHostPartitionOperations, + worktreeId: string, + resolved: ExecutionHostId, + options: { advanceTerminalTopologyRevision?: boolean } +): void { + if (!hasPersistedWorkspaceSession(owner, resolved)) { + return + } + const current = owner.getWorkspaceSession(resolved) + const session = removeWorkspaceSessionOwner(current, worktreeId, { + advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true + }) + if (!session) { + return + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSession = session + } else { + // Host scoping matters because identical repo/path ids may exist on two servers. + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolved]: session + } + } + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) +} + +export function partitionOwnsWorktreeTabs( + owner: SessionHostPartitionOperations, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + return owner.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined +} + +export function partitionHasOtherRepoWorktreeTabs( + owner: SessionHostPartitionOperations, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const tabsByWorktree = owner.getWorkspaceSession(hostId).tabsByWorktree ?? {} + return Object.entries(tabsByWorktree).some( + ([id, tabs]) => + id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 + ) +} + +export function setHostWorkspaceSession( + owner: SessionHostPartitionOperations, + hostId: ExecutionHostId, + session: WorkspaceSessionState +): void { + // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. + session = sanitizeWorkspaceSessionTerminalRetirements( + session, + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] + ) + const pruned = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers( + session, + owner[sessionHostPartitionOperationsContext].runtime.state.repos + ) + ) + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, + [hostId]: pruned + } + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) +} + +export function installSessionHostPartitionOperationsContext( + target: object, + source: SessionHostPartitionOperations +): void { + Object.defineProperty(target, sessionHostPartitionOperationsContext, { + value: source[sessionHostPartitionOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts new file mode 100644 index 00000000000..2f5f1c64b86 --- /dev/null +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -0,0 +1,102 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' + +import { workspaceSessionPatchNeedsFullNormalization } from './terminal-session-cleanup' + +import { setLocalWorkspaceSession } from './workspace-session-snapshot-publication' +import type { StoreRuntimeState } from './store-runtime-state' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import type { WriteSchedulingOperations } from './write-scheduling' +import { resolveHostId, setHostWorkspaceSession } from './session-host-partitions' +import { scheduleSave } from './write-scheduling' + +type SessionSnapshotOperationsRuntime = Pick< + StoreRuntimeState, + 'pendingSnapshotFileWork' | 'state' | 'terminalScrollbackSnapshotStorage' +> + +const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') +type SessionSnapshotOperationsContext = { + runtime: SessionSnapshotOperationsRuntime + sessions: SessionHostPartitionOperations + bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations +} + +export class SessionSnapshotOperations { + readonly [sessionSnapshotOperationsContext]: SessionSnapshotOperationsContext + + constructor( + runtime: SessionSnapshotOperationsRuntime, + sessions: SessionHostPartitionOperations, + bindingRecovery: TerminalBindingRecoveryOperations, + scheduling: WriteSchedulingOperations + ) { + this[sessionSnapshotOperationsContext] = { runtime, sessions, bindingRecovery, scheduling } + } + + setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + setLocalWorkspaceSession(this, session) + return + } + setHostWorkspaceSession(this[sessionSnapshotOperationsContext].sessions, resolved, session) + } + + stageWorkspaceSessionBeforeUnload( + session: PersistedState['workspaceSession'], + hostId?: string | null + ): void { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + setLocalWorkspaceSession(this, session, true) + return + } + setHostWorkspaceSession(this[sessionSnapshotOperationsContext].sessions, resolved, session) + } + + patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { + const resolved = resolveHostId(hostId) + // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. + let next: WorkspaceSessionState = { + ...this[sessionSnapshotOperationsContext].sessions.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[sessionSnapshotOperationsContext].runtime.state.workspaceSession = next + } else { + this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolved]: next + } + } + scheduleSave(this[sessionSnapshotOperationsContext].scheduling) + } +} + +export function getSessionSnapshotOperationsContext(owner: SessionSnapshotOperations) { + return owner[sessionSnapshotOperationsContext] +} + +export function installSessionSnapshotOperationsContext( + target: object, + source: SessionSnapshotOperations +): void { + Object.defineProperty(target, sessionSnapshotOperationsContext, { + value: source[sessionSnapshotOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/sparse-preset-persistence.ts b/src/main/persistence/loading-store/sparse-preset-persistence.ts new file mode 100644 index 00000000000..2de83313ecd --- /dev/null +++ b/src/main/persistence/loading-store/sparse-preset-persistence.ts @@ -0,0 +1,55 @@ +import type { SparsePreset } from '../../../shared/worktree/create-types' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type SparsePresetRuntime = Pick + +const sparsePresetPersistenceContext = Symbol('SparsePresetPersistence') +type SparsePresetPersistenceContext = { + runtime: SparsePresetRuntime + scheduling: WriteSchedulingOperations +} + +export class SparsePresetPersistence { + readonly [sparsePresetPersistenceContext]: SparsePresetPersistenceContext + + constructor(runtime: SparsePresetRuntime, scheduling: WriteSchedulingOperations) { + this[sparsePresetPersistenceContext] = { runtime, scheduling } + } + + getSparsePresets(repoId: string): SparsePreset[] { + return [ + ...(this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] ?? []) + ].sort((left, right) => left.name.localeCompare(right.name)) + } + + saveSparsePreset(preset: SparsePreset): SparsePreset { + const existing = + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[preset.repoId] ?? [] + const index = existing.findIndex((entry) => entry.id === preset.id) + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[preset.repoId] = + index === -1 + ? [...existing, preset] + : existing.map((entry, entryIndex) => (entryIndex === index ? preset : entry)) + scheduleSave(this[sparsePresetPersistenceContext].scheduling) + return preset + } + + removeSparsePreset(repoId: string, presetId: string): void { + const existing = + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] ?? [] + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] = + existing.filter((entry) => entry.id !== presetId) + scheduleSave(this[sparsePresetPersistenceContext].scheduling) + } +} + +export function installSparsePresetPersistenceContext( + target: object, + source: SparsePresetPersistence +): void { + Object.defineProperty(target, sparsePresetPersistenceContext, { + value: source[sparsePresetPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts new file mode 100644 index 00000000000..e7cea585765 --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -0,0 +1,182 @@ +import type { SshPtyConsumerRecovery, SshRemotePtyLease } from '../../../shared/ssh-types' +import { + getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, + markSshRemotePtyLease as markSshRemotePtyLeaseOperation, + markSshRemotePtyLeases as markSshRemotePtyLeasesOperation, + markSshRemotePtyLeasesAsync as markSshRemotePtyLeasesAsyncOperation, + markSshRemotePtyLeasesAttachedAsync as markSshRemotePtyLeasesAttachedAsyncOperation, + markSshRemotePtyLeasesForShutdown as markSshRemotePtyLeasesForShutdownOperation, + removeSshRemotePtyLease as removeSshRemotePtyLeaseOperation, + removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, + type SshPtyLeaseOperations, + upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation +} from '../leasing-ssh-ptys/ssh-pty-lease-operations' +import { + getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, + removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, + type SshPtyConsumerRecoveryOperations, + upsertSshPtyConsumerRecovery as upsertSshPtyConsumerRecoveryOperation +} from '../leasing-ssh-ptys/ssh-pty-consumer-recovery' +import { + clearSshRemotePtyBindingsForLeases as clearSshRemotePtyBindingsForLeasesOperation, + clearSshRemotePtyBindingsForTarget as clearSshRemotePtyBindingsForTargetOperation, + type SshPtyBindingCleanupOperations +} from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import type { WriteSchedulingOperations } from './write-scheduling' +import { flushDurableStateOrThrowAsync } from './write-flush-barriers' +import { scheduleSave } from './write-scheduling' + +type SshLeaseRecoveryOperationsRuntime = Pick + +const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') +type SshLeaseRecoveryOperationsContext = { + runtime: SshLeaseRecoveryOperationsRuntime + flushBarriers: WriteFlushBarrierOperations + bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations +} + +export class SshLeaseRecoveryOperations { + readonly [sshLeaseRecoveryOperationsContext]: SshLeaseRecoveryOperationsContext + + constructor( + runtime: SshLeaseRecoveryOperationsRuntime, + flushBarriers: WriteFlushBarrierOperations, + bindingRecovery: TerminalBindingRecoveryOperations, + scheduling: WriteSchedulingOperations + ) { + this[sshLeaseRecoveryOperationsContext] = { + runtime, + flushBarriers, + bindingRecovery, + scheduling + } + } + + getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { + return getSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + } + + async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { + await upsertSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), record) + } + + async removeSshPtyConsumerRecovery(targetId: string): Promise { + await removeSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + } + + getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { + return getSshRemotePtyLeasesOperation( + this[sshLeaseRecoveryOperationsContext].runtime.state, + targetId + ) + } + + upsertSshRemotePtyLease( + lease: Omit & + Partial> + ): void { + upsertSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), lease) + } + + markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesForShutdownOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + async markSshRemotePtyLeasesAsync( + targetId: string, + state: SshRemotePtyLease['state'] + ): Promise { + await markSshRemotePtyLeasesAsyncOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + async markSshRemotePtyLeasesAttachedAsync( + targetId: string, + ptyIds: readonly string[] + ): Promise { + await markSshRemotePtyLeasesAttachedAsyncOperation( + getSshPtyLeaseOperations(this), + targetId, + ptyIds + ) + } + + markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId, state) + } + + removeSshRemotePtyLease(targetId: string, ptyId: string): void { + removeSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId) + } + + removeSshRemotePtyLeases(targetId: string): void { + removeSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId) + } +} + +export function getSshPtyConsumerRecoveryOperations( + owner: SshLeaseRecoveryOperations +): SshPtyConsumerRecoveryOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, + flushDurableStateOrThrowAsync: () => + flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } +} + +export function getSshPtyBindingCleanupOperations( + owner: SshLeaseRecoveryOperations +): SshPtyBindingCleanupOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + toComparablePtyId: (targetId, ptyId) => + owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseComparison( + targetId, + ptyId + ), + scheduleSave: () => scheduleSave(owner[sshLeaseRecoveryOperationsContext].scheduling) + } +} + +export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): SshPtyLeaseOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + toStoredPtyId: (targetId, ptyId) => + owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseStorage( + targetId, + ptyId + ), + clearBindingsForTarget: (targetId) => + clearSshRemotePtyBindingsForTargetOperation( + getSshPtyBindingCleanupOperations(owner), + targetId + ), + clearBindingsForLeases: (targetId, leases) => + clearSshRemotePtyBindingsForLeasesOperation( + getSshPtyBindingCleanupOperations(owner), + targetId, + leases + ), + flush: () => owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush(), + flushDurableStateOrThrowAsync: () => + flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } +} + +export function installSshLeaseRecoveryOperationsContext( + target: object, + source: SshLeaseRecoveryOperations +): void { + Object.defineProperty(target, sshLeaseRecoveryOperationsContext, { + value: source[sshLeaseRecoveryOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts new file mode 100644 index 00000000000..5ab7c635f27 --- /dev/null +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -0,0 +1,142 @@ +import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' +import { + type SshTargetStateOperations, + addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, + addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, + addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, + addSshTarget as addSshTargetOperation, + clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, + getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, + getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, + getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, + getSshTarget as getSshTargetOperation, + getSshTargets as getSshTargetsOperation, + removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, + removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, + removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, + removeSshTarget as removeSshTargetOperation, + updateSshTarget as updateSshTargetOperation +} from '../leasing-ssh-ptys/ssh-target-state' +import { + reassignSshTargetId as reassignSshTargetIdOperation, + type SshTargetReassignmentOperations +} from '../leasing-ssh-ptys/ssh-target-reassignment' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operations' +import { scheduleSave } from './write-scheduling' + +type SshProfileOperationsRuntime = Pick + +const sshProfileOperationsContext = Symbol('SshProfileOperations') +type SshProfileOperationsContext = { + runtime: SshProfileOperationsRuntime + scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations + repos: RepoLifecycleOperations +} + +export class SshProfileOperations { + readonly [sshProfileOperationsContext]: SshProfileOperationsContext + + constructor( + runtime: SshProfileOperationsRuntime, + scheduling: WriteSchedulingOperations, + flushBarriers: WriteFlushBarrierOperations, + repos: RepoLifecycleOperations + ) { + this[sshProfileOperationsContext] = { runtime, scheduling, flushBarriers, repos } + } + + getSshTargets(): SshTarget[] { + return getSshTargetsOperation(this[sshProfileOperationsContext].runtime.state) + } + + getSshTarget(id: string): SshTarget | undefined { + return getSshTargetOperation(this[sshProfileOperationsContext].runtime.state, id) + } + + addSshTarget(target: SshTarget): void { + addSshTargetOperation(getSshTargetStateOperations(this), target) + } + + updateSshTarget(id: string, updates: Partial>): SshTarget | null { + return updateSshTargetOperation(getSshTargetStateOperations(this), id, updates) + } + + removeSshTarget(id: string): void { + removeSshTargetOperation(getSshTargetStateOperations(this), id) + } + + getClaudeLivePtySessionIds(): string[] { + return getClaudeLivePtySessionIdsOperation(this[sshProfileOperationsContext].runtime.state) + } + + addClaudeLivePtySessionId(sessionId: string): void { + addClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + + removeClaudeLivePtySessionId(sessionId: string): void { + removeClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + + getDeletedSshConfigAliases(): string[] { + return getDeletedSshConfigAliasesOperation(this[sshProfileOperationsContext].runtime.state) + } + + addDeletedSshConfigAlias(alias: string): void { + addDeletedSshConfigAliasOperation(getSshTargetStateOperations(this), alias) + } + + removeDeletedSshConfigAlias(alias: string): void { + removeDeletedSshConfigAliasOperation(getSshTargetStateOperations(this), alias) + } + + clearDeletedSshConfigAliases(): void { + clearDeletedSshConfigAliasesOperation(getSshTargetStateOperations(this)) + } + + getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { + return getRemovedSshTargetTombstonesOperation(this[sshProfileOperationsContext].runtime.state) + } + + addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { + addRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), tombstone) + } + + removeRemovedSshTargetTombstone(oldTargetId: string): void { + removeRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), oldTargetId) + } + + reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { + const operations: SshTargetReassignmentOperations = { + state: this[sshProfileOperationsContext].runtime.state, + protectedSecrets: this[sshProfileOperationsContext].runtime.protectedSecrets, + syncProjectHostSetupCompatibilityState: () => + syncProjectHostSetupCompatibilityState(this[sshProfileOperationsContext].repos), + scheduleSave: () => scheduleSave(this[sshProfileOperationsContext].scheduling) + } + return reassignSshTargetIdOperation(operations, oldTargetId, newTargetId) + } +} + +export function getSshTargetStateOperations(owner: SshProfileOperations): SshTargetStateOperations { + return { + state: owner[sshProfileOperationsContext].runtime.state, + protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, + scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), + flush: () => owner[sshProfileOperationsContext].flushBarriers.flush() + } +} + +export function installSshProfileOperationsContext( + target: object, + source: SshProfileOperations +): void { + Object.defineProperty(target, sshProfileOperationsContext, { + value: source[sshProfileOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts new file mode 100644 index 00000000000..c9d81f071a5 --- /dev/null +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -0,0 +1,125 @@ +import { createHash, randomUUID } from 'node:crypto' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' +import { + PROTECTED_SECRET_SLOT, + sshPtyOwnerLeaseSecretSlot, + type ProtectedSecretRetentionUpdate +} from '../../protected-secret-persistence' +import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' + +import type { StoreRuntimeState } from './store-runtime-state' + +type StateSerializationSecretHandlingOperationsRuntime = Pick< + StoreRuntimeState, + 'protectedSecrets' | 'state' +> + +export class StateSerializationSecretHandlingOperations { + constructor(private readonly runtime: StateSerializationSecretHandlingOperationsRuntime) {} + + getDurableState(): Omit { + const { githubCache: _memoryOnly, ...durable } = this.runtime.state + return durable + } + + buildStateToSave(): { + payload: string + stateHash: string + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } { + // Why sentinels (not a blob/key string match): the substitution must be + // position-exact. A plain search for the ciphertext — or even for a + // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an + // agentDefaultEnv var named after a secret field, or a value equal to a + // ciphertext), which would substitute the wrong site and let two DISTINCT + // states normalize equal → a silently dropped write (data loss), reachable + // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot + // random UUID can't occur anywhere else in the serialized state (the user + // sets their data before it is minted), so it appears exactly once. + const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + let protectedStorageDegraded = false + const encryptToSentinel = (slot: string, plaintext: string): string => { + const encrypted = this.runtime.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + protectedStorageDegraded ||= encrypted.degraded + const { blob, hashValue = plaintext } = encrypted + // Values already identical in payload and hash need no sentinel substitution. + if (blob === plaintext && hashValue === plaintext) { + return blob + } + const sentinel = `orca-secret-slot-${randomUUID()}` + secretSubs.push({ sentinel, blob, hashValue }) + return sentinel + } + const encryptOptionalToSentinel = ( + slot: string, + plaintext: string | null | undefined + ): string | null => { + const encrypted = encryptToSentinel(slot, plaintext ?? '') + return encrypted || null + } + // Why: clone before encrypting secrets so in-memory this.state stays plaintext. + const stateToSave = { + ...this.getDurableState(), + // Why both keys unconditionally: the explicit keys always win over the spread, and + // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. + // The strip builds a new array here only; this.state records keep their notes in memory. + folderWorkspaces: (this.runtime.state.folderWorkspaces ?? []).map( + ({ diffComments: _relocated, ...rest }) => rest + ), + folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments( + this.runtime.state.folderWorkspaces + ), + sshPtyConsumerRecoveries: (this.runtime.state.sshPtyConsumerRecoveries ?? []).map( + (record) => ({ + ...record, + ownerLease: encryptToSentinel( + sshPtyOwnerLeaseSecretSlot(record.targetId), + record.ownerLease + ) + }) + ), + settings: { + ...stripRetiredGlobalSettings(this.runtime.state.settings), + opencodeSessionCookie: encryptToSentinel( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.runtime.state.settings.opencodeSessionCookie + ), + httpProxyUrl: encryptToSentinel( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.runtime.state.settings.httpProxyUrl ?? '' + ) + }, + ui: { + ...this.runtime.state.ui, + browserKagiSessionLink: encryptOptionalToSentinel( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.runtime.state.ui.browserKagiSessionLink + ) + } + } + // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. + // One full-state stringify; secret slots currently hold sentinels. + const serialized = JSON.stringify(stateToSave) + // Substitute each unique sentinel exactly once: ciphertext for the on-disk + // payload, a stable normalized value for the guard hash. Function-form + // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped + // in `serialized`, so each replace is byte-for-byte position-exact. + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, hashValue } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) + } + const stateHash = createHash('sha1') + .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') + .update(hashInput) + .digest('hex') + return { payload, stateHash, protectedSecretUpdates } + } +} diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts new file mode 100644 index 00000000000..425ee2f1745 --- /dev/null +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -0,0 +1,187 @@ +import type { StoreRuntimeState } from './store-runtime-state' +import { LoadedStateAdaptationOperations } from './loaded-state-adaptation' +import { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' +import { LoadedStateParsingOperations } from './loaded-state-parsing' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import { + PrimaryStateWriteOperations, + installPrimaryStateWriteOperationsContext +} from './primary-state-writes' +import { + WriteSchedulingOperations, + installWriteSchedulingOperationsContext +} from './write-scheduling' +import { + WriteFlushBarrierOperations, + installWriteFlushBarrierOperationsContext +} from './write-flush-barriers' +import { ProfilePreferences, installProfilePreferencesContext } from './profile-preferences' +import { + RepoLifecycleOperations, + installRepoLifecycleOperationsContext +} from './repo-lifecycle-operations' +import { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import { + SessionHostPartitionOperations, + installSessionHostPartitionOperationsContext +} from './session-host-partitions' +import { + SessionSnapshotOperations, + installSessionSnapshotOperationsContext +} from './session-snapshot-operations' +import { + MetadataLineageOperations, + installMetadataLineageOperationsContext +} from './metadata-lineage-operations' +import { + ProjectCollectionOperations, + installProjectCollectionOperationsContext +} from './project-collection-operations' +import { + AutomationPersistence, + installAutomationPersistenceContext +} from './automation-persistence' +import { + MobileTabSelectionPersistence, + installMobileTabSelectionPersistenceContext +} from './mobile-tab-selection-persistence' +import { + SparsePresetPersistence, + installSparsePresetPersistenceContext +} from './sparse-preset-persistence' +import { + PtyBindingPersistenceOperations, + installPtyBindingPersistenceOperationsContext +} from './pty-binding-persistence' +import { SshProfileOperations, installSshProfileOperationsContext } from './ssh-profile-operations' +import { + RetiredWorktreeNamePersistence, + installRetiredWorktreeNamePersistenceContext +} from './retired-worktree-name-persistence' +import { + SshLeaseRecoveryOperations, + installSshLeaseRecoveryOperationsContext +} from './ssh-lease-recovery-operations' + +export type StoreDomains = { + adaptation: LoadedStateAdaptationOperations + backups: BackupRecoveryRotationOperations + cohorts: LoadedCohortMigrationOperations + loader: LoadedStateParsingOperations + serialization: StateSerializationSecretHandlingOperations + writes: PrimaryStateWriteOperations + scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations + preferences: ProfilePreferences + repos: RepoLifecycleOperations + bindingRecovery: TerminalBindingRecoveryOperations + sessions: SessionHostPartitionOperations + sessionSnapshots: SessionSnapshotOperations + metadata: MetadataLineageOperations + projects: ProjectCollectionOperations + automations: AutomationPersistence + mobileTabSelections: MobileTabSelectionPersistence + sparsePresets: SparsePresetPersistence + ptyBindings: PtyBindingPersistenceOperations + sshProfiles: SshProfileOperations + retiredWorktreeNames: RetiredWorktreeNamePersistence + sshLeases: SshLeaseRecoveryOperations +} + +export const STORE_DOMAIN_OPERATION_CLASSES = [ + WriteSchedulingOperations, + PrimaryStateWriteOperations, + ProjectCollectionOperations, + RepoLifecycleOperations, + MobileTabSelectionPersistence, + SparsePresetPersistence, + AutomationPersistence, + MetadataLineageOperations, + ProfilePreferences, + SessionHostPartitionOperations, + SessionSnapshotOperations, + PtyBindingPersistenceOperations, + SshProfileOperations, + RetiredWorktreeNamePersistence, + SshLeaseRecoveryOperations, + WriteFlushBarrierOperations +] as const + +export function installStoreDomainContexts(target: object, domains: StoreDomains): void { + installWriteSchedulingOperationsContext(target, domains.scheduling) + installPrimaryStateWriteOperationsContext(target, domains.writes) + installProjectCollectionOperationsContext(target, domains.projects) + installRepoLifecycleOperationsContext(target, domains.repos) + installMobileTabSelectionPersistenceContext(target, domains.mobileTabSelections) + installSparsePresetPersistenceContext(target, domains.sparsePresets) + installAutomationPersistenceContext(target, domains.automations) + installMetadataLineageOperationsContext(target, domains.metadata) + installProfilePreferencesContext(target, domains.preferences) + installSessionHostPartitionOperationsContext(target, domains.sessions) + installSessionSnapshotOperationsContext(target, domains.sessionSnapshots) + installPtyBindingPersistenceOperationsContext(target, domains.ptyBindings) + installSshProfileOperationsContext(target, domains.sshProfiles) + installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) + installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) + installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) +} + +export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { + const adaptation = new LoadedStateAdaptationOperations(runtime) + const backups = new BackupRecoveryRotationOperations(runtime) + const cohorts = new LoadedCohortMigrationOperations(runtime) + const loader = new LoadedStateParsingOperations(runtime, backups, cohorts) + const serialization = new StateSerializationSecretHandlingOperations(runtime) + const writes = new PrimaryStateWriteOperations(runtime, serialization, backups) + const scheduling = new WriteSchedulingOperations(runtime, writes) + const flushBarriers = new WriteFlushBarrierOperations(runtime, writes) + const preferences = new ProfilePreferences(runtime, scheduling) + const repos = new RepoLifecycleOperations(runtime, scheduling) + const bindingRecovery = new TerminalBindingRecoveryOperations(runtime) + const sessions = new SessionHostPartitionOperations(runtime, scheduling) + const sessionSnapshots = new SessionSnapshotOperations( + runtime, + sessions, + bindingRecovery, + scheduling + ) + const metadata = new MetadataLineageOperations(runtime, scheduling, sessions) + const projects = new ProjectCollectionOperations(runtime, repos, scheduling, metadata) + const automations = new AutomationPersistence(runtime, flushBarriers, preferences) + const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) + const sparsePresets = new SparsePresetPersistence(runtime, scheduling) + const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) + const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, repos) + const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) + const sshLeases = new SshLeaseRecoveryOperations( + runtime, + flushBarriers, + bindingRecovery, + scheduling + ) + return { + adaptation, + backups, + cohorts, + loader, + serialization, + writes, + scheduling, + flushBarriers, + preferences, + repos, + bindingRecovery, + sessions, + sessionSnapshots, + metadata, + projects, + automations, + mobileTabSelections, + sparsePresets, + ptyBindings, + sshProfiles, + retiredWorktreeNames, + sshLeases + } +} diff --git a/src/main/persistence/loading-store/store-owned-state.ts b/src/main/persistence/loading-store/store-owned-state.ts deleted file mode 100644 index 9c752b06f92..00000000000 --- a/src/main/persistence/loading-store/store-owned-state.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PersistedState } from '../../../shared/persisted-state-types' - -/** - * The Store's live state object, never a copy. Extracted operations read and mutate it in place, so a caller that - * passes a clone or a spread silently loses every write. The Store stays its sole owner and is the only thing that - * may hand this reference out. - */ -export type StoreOwnedPersistedState = PersistedState diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts new file mode 100644 index 00000000000..284f556ae41 --- /dev/null +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -0,0 +1,79 @@ +import { removeStaleDurableWriteTempFiles } from '../../durable-file-write' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ActiveViewPreference } from '../../active-view-preference' +import { + getProfileTerminalScrollbackSnapshotRoot, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' +import { ProtectedSecretPersistence } from '../../protected-secret-persistence' +import { getDataFile, getGithubCacheFile } from './user-data-path' +import { STALE_DURABLE_WRITE_TEMP_AGE_MS } from '../tracking-repos/worktree-metadata-normalization' +import type { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' +import type { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' +import type { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' +import type { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' +import type { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' +import type { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' + +export type StoreRuntimeOptions = { dataFile?: string } + +/** Mutable coordination state shared only with this Store's private collaborators. */ +export class StoreRuntimeState { + state!: PersistedState + readonly dataFile: string + activeViewPreference!: ActiveViewPreference + readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage + writeTimer: ReturnType | null = null + pendingWrite: Promise | null = null + pendingSnapshotFileWork: Promise | null = null + readonly staleTempCleanup: Promise + writeGeneration = 0 + inFlightAsyncTmpFile: string | null = null + backupRotationInFlight = false + writesFrozen = false + quitFlushStarted = false + quitFlushPromise: Promise | null = null + lastWrittenStateHash: string | null = null + lastDurableWriteGeneration = -1 + firstPendingSaveAt: number | null = null + githubCacheDirty = false + githubCacheGeneration = 0 + pendingGithubCacheWrite: Promise | null = null + readonly staleGithubCacheTempCleanup: Promise + readonly gitUsernameCache = new Map() + readonly protectedSecrets = new ProtectedSecretPersistence() + loadNeedsSave = false + flushOrThrow!: () => void + settingsChangeListeners = new Set< + ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + >() + uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() + projectHostOperations: ProjectHostPersistenceOperations | null = null + projectGroupOperations: ProjectGroupPersistenceOperations | null = null + folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null + repoOrderOperations: RepoOrderPersistenceOperations | null = null + repoUpdateOperations: RepoUpdatePersistenceOperations | null = null + projectHostSetupOperations: ProjectHostSetupPersistenceOperations | null = null + + constructor(options: StoreRuntimeOptions = {}) { + this.dataFile = options.dataFile ?? getDataFile() + this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { + minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS + }) + this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( + getGithubCacheFile(this.dataFile), + { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } + ) + const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) + const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) + this.terminalScrollbackSnapshotStorage = { + snapshotRoot: profileSnapshotRoot, + fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot + } + } +} diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index b977f40074e..c66615a03df 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -1,505 +1,39 @@ -/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ -import { - readFileSync, - writeFileSync, - mkdirSync, - existsSync, - renameSync, - unlinkSync, - copyFileSync, - statSync -} from 'node:fs' -import { rename, mkdir, rm, copyFile, open, stat, access, writeFile } from 'node:fs/promises' -import { - durableWriteTempPath, - removeStaleDurableWriteTempFiles, - renameDurable, - writeFileDurableSync -} from '../../durable-file-write' import { dirname } from 'node:path' -import { homedir } from 'node:os' -import { createHash, randomUUID } from 'node:crypto' -import type { - Automation, - AutomationCreateInput, - AutomationDispatchResult, - AutomationRun, - AutomationRunTrigger, - AutomationUpdateInput -} from '../../../shared/automations-types' -import { normalizeProxyUrl } from '../../../shared/network-proxy' -import { normalizeKagiSessionLink } from '../../../shared/browser-url' -import type { FolderWorkspace, WorkspaceKey } from '../../../shared/folder-workspace-types' -import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' -import type { - PersistedMobileClientTabSelections, - PersistedState -} from '../../../shared/persisted-state-types' -import type { ProjectGroup } from '../../../shared/project-group-types' -import type { - Project, - ProjectHostSetup, - ProjectHostSetupCreateArgs, - ProjectHostSetupCreateResult, - ProjectHostSetupDeleteArgs, - ProjectHostSetupDeleteResult, - ProjectHostSetupUpdateArgs, - ProjectHostSetupUpdateResult, - ProjectUpdateArgs -} from '../../../shared/project-types' -import type { Repo } from '../../../shared/repo-types' -import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' -import type { - WorkspaceSessionPatch, - WorkspaceSessionState -} from '../../../shared/workspace-session-state-types' -import type { SparsePreset } from '../../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' -import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' -import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import { normalizePersistedMobileClientTabSelections } from '../../runtime/client-session-tab-selection-persistence' -import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' -import { - removeRepoFromHostWorkspaceSessions, - removeRepoFromWorkspaceSession -} from '../../orca-profiles/profile-project-session-state' -import type { - RemovedSshTargetTombstone, - SshPtyConsumerRecovery, - SshRemotePtyLease, - SshTarget -} from '../../../shared/ssh-types' -import { isFolderRepo } from '../../../shared/repo-kind' -import { - getRepoExecutionHostId, - parseExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { - getDefaultPersistedState, - getDefaultOnboardingState, - getDefaultVoiceSettings, - getDefaultWorkspaceSession, - getWorktreeCardModeProperties, - isDefaultedCompactWorktreeCardProperties, - normalizeWorktreeCardProperties -} from '../../../shared/constants' -import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' -import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' -import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' -import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' -import { toRelaySshPtyId } from '../../providers/ssh-pty-id' -import { isTerminalLeafId } from '../../../shared/stable-pane-id' import { setMigrationUnsupportedPty, setMigrationUnsupportedPtyPersistenceListener } from '../../agent-hooks/migration-unsupported-pty-state' import { agentHookServer } from '../../agent-hooks/server' -import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' -import { - backfillAutomationRunNumbers, - pruneAutomationRuns -} from '../../../shared/automation-run-retention' -import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' -import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' -import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' -import { - addRetiredNames, - clampExhaustedTiers, - compactRetiredNames, - EMPTY_RETIRED_NAME_REGISTRY, - isEmptyRetiredNameRegistry, - type RetiredNameRegistry -} from '../../../shared/worktree/retired-name-registry' -import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id' -import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' -import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' -import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' -import { normalizeTaskProviderSettings } from '../../../shared/task-providers' -import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' -import { - addMobilePairingCustomAddress, - normalizeMobilePairingCustomAddress, - normalizeMobilePairingCustomAddresses -} from '../../../shared/mobile-pairing-custom-address' -import { normalizeOpenInApplications } from '../../../shared/open-in-applications' -import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' -import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' -import { normalizeAppIconId } from '../../../shared/app-icon' -import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' -import { - normalizeFeatureInteractionTelemetryBuckets, - type FeatureInteractionId -} from '../../../shared/feature-interactions' -import { - parseCodexResetCreditAttemptLedger, - type CodexResetCreditAttemptLedger -} from '../../../shared/codex-reset-credit-attempt-ledger' -import { - DEFAULT_WORKSPACE_STATUS_ID, - normalizePersistedWorkspaceStatuses -} from '../../../shared/workspace-statuses' -import { migrateExternalWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' -import { - clearMissingProjectGroupMemberships, - createProjectGroup, - normalizeProjectGroups -} from '../../../shared/project-groups' -import { createNestedProjectGroupResolver } from '../../project-groups/nested-repo-import' -import { - mergeLegacyCommitMessageAiIntoSourceControlAi, - projectSourceControlAiToLegacyCommitMessageAi, - sourceControlAiSettingsFromLegacy -} from '../../../shared/source-control-ai' -import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' -import { hasUnsupportedTuiAgentArgs } from '../../../shared/tui-agent-launch-defaults' -import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' -import { - normalizeOsc52ClipboardDefaultOn, - osc52ClipboardDefaultOnOverridesPersistedOff -} from '../../../shared/osc52-clipboard-settings' -import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' -import { normalizeUiLanguage } from '../../../shared/ui-language' import { ActiveViewPreference } from '../../active-view-preference' +import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' +import { normalizePersistedPaneIdentityState } from '../restoring-sessions/workspace-pane-normalization' +import { StoreRuntimeState } from './store-runtime-state' import { - collectFolderWorkspaceDiffComments, - normalizeFolderWorkspaceDiffComments -} from '../../folder-workspace-diff-comments' -import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import { - collectTerminalScrollbackSnapshotRefs, - deleteTerminalScrollbackSnapshotSync, - getProfileTerminalScrollbackSnapshotRoot, - migrateWorkspaceSessionTerminalScrollbackSnapshots, - readTerminalScrollbackSnapshotSync, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' -import { - deleteRemovedTerminalScrollbackSnapshotsAsync, - migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync -} from '../../terminal-scrollback-snapshot-async-migration' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic -} from '../../startup/startup-diagnostics' -import { - PROTECTED_SECRET_SLOT, - ProtectedSecretPersistence, - sshPtyOwnerLeaseSecretSlot, - type ProtectedSecretRetentionUpdate -} from '../../protected-secret-persistence' -import { - isLegacyOpenCodeSessionCookie, - isLegacySshPtyOwnerLease -} from '../leasing-ssh-ptys/secret-validation' -import { getDataFile, getGithubCacheFile, readGithubCacheSnapshot } from './user-data-path' -import { - STALE_DURABLE_WRITE_TEMP_AGE_MS, - gcStaleWorktreeMeta, - normalizeWorktreeLinkedItemMetadata -} from '../tracking-repos/worktree-metadata-normalization' -import { - migrateAgentYoloDefaults, - migrateTerminalScrollbackRows, - migrateTerminalTuiScrollSensitivityDefault, - stripRetiredGlobalSettings -} from '../applying-settings/terminal-settings-migrations' -import { - normalizeRightSidebarExplorerView, - normalizeRightSidebarTab, - normalizeShowDotfilesByWorktree, - normalizeSortBy -} from '../applying-settings/ui-selection-normalization' -import { - normalizeWorkspaceLineageByChildKey, - stripMainOwnedTelemetryMarkerFromUI -} from '../applying-settings/ui-interaction-merge' -import { - backfillLegacyAutomationContexts, - normalizeAutomationRunWorkspaceDisplayName -} from '../scheduling-automations/automation-context-migration' -import { - normalizeLoadedOnboardingState, - normalizeNotificationSettings, - persistedNotificationSettingsRepaired, - readDeprecatedExperimentFlag, - readLegacySidekickFlag, - resolveSetupGuideSidebarDismissedOnLoad -} from '../applying-settings/onboarding-normalization' -import { - canonicalizePersistedFloatingWorkspaceDirectory, - normalizeFloatingWorkspaceTrustedCwds -} from '../restoring-sessions/floating-workspace-normalization' -import { - ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, - normalizeSshPtyConsumerRecovery, - normalizeSshRemotePtyLease, - normalizeSshTarget -} from '../leasing-ssh-ptys/ssh-normalization' -import { - type SshTargetStateOperations, - addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, - addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, - addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, - addSshTarget as addSshTargetOperation, - clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, - getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, - getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, - getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, - getSshTarget as getSshTargetOperation, - getSshTargets as getSshTargetsOperation, - removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, - removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, - removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, - removeSshTarget as removeSshTargetOperation, - updateSshTarget as updateSshTargetOperation -} from '../leasing-ssh-ptys/ssh-target-state' -import { - reassignSshTargetId as reassignSshTargetIdOperation, - type SshTargetReassignmentOperations -} from '../leasing-ssh-ptys/ssh-target-reassignment' -import { - getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, - markSshRemotePtyLease as markSshRemotePtyLeaseOperation, - markSshRemotePtyLeases as markSshRemotePtyLeasesOperation, - markSshRemotePtyLeasesAsync as markSshRemotePtyLeasesAsyncOperation, - markSshRemotePtyLeasesAttachedAsync as markSshRemotePtyLeasesAttachedAsyncOperation, - markSshRemotePtyLeasesForShutdown as markSshRemotePtyLeasesForShutdownOperation, - removeSshRemotePtyLease as removeSshRemotePtyLeaseOperation, - removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, - type SshPtyLeaseOperations, - upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation -} from '../leasing-ssh-ptys/ssh-pty-lease-operations' -import { - getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, - removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, - type SshPtyConsumerRecoveryOperations, - upsertSshPtyConsumerRecovery as upsertSshPtyConsumerRecoveryOperation -} from '../leasing-ssh-ptys/ssh-pty-consumer-recovery' -import { - clearSshRemotePtyBindingsForLeases as clearSshRemotePtyBindingsForLeasesOperation, - clearSshRemotePtyBindingsForTarget as clearSshRemotePtyBindingsForTargetOperation, - type SshPtyBindingCleanupOperations -} from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' -import { - cloneLayoutNode, - layoutContainsLeafId, - preserveMissingLeafRecordEntries -} from '../restoring-sessions/terminal-layout-normalization' -import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' -import { - normalizeClaudeLivePtySessionIds, - normalizeLegacyPaneKeyAliasEntries, - normalizeMigrationUnsupportedPtyEntries, - registerPersistedPaneKeyAlias -} from '../restoring-sessions/pane-alias-normalization' -import { - normalizePersistedPaneIdentityState, - normalizeWorkspaceSessionPaneIdentities, - remapAcknowledgedAgentPaneKeys, - remapSshRemotePtyLeaseLeafIds, - type WorkspaceSessionPaneIdentityRemap -} from '../restoring-sessions/workspace-pane-normalization' -import { - mergeProjectHostSetupCompatibilityState, - projectHostSetupCompatibilityStateEqual -} from '../tracking-repos/project-host-compatibility' -import { - cloneWorkspaceSessionState, - createMinimalPersistedTerminalTab -} from '../restoring-sessions/session-owner-fields' -import { - removeWorkspaceSessionOwner, - workspaceSessionOwnerPartitionForHost, - workspaceSessionPartitionIdsForHost -} from '../restoring-sessions/session-owner-removal' -import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' -import { - createAutomation as createAutomationOperation, - deleteAutomation as deleteAutomationOperation, - listAutomations as listAutomationsOperation, - updateAutomation as updateAutomationOperation, - type AutomationDefinitionOperations -} from '../scheduling-automations/automation-definition-operations' -import { - createAutomationRun as createAutomationRunOperation, - listAutomationRuns as listAutomationRunsOperation, - snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, - updateAutomationRun as updateAutomationRunOperation, - type AutomationRunOperations -} from '../scheduling-automations/automation-run-operations' -import { - advanceAutomationNextRun as advanceAutomationNextRunOperation, - getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation -} from '../scheduling-automations/automation-schedule-operations' -import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' -import { - updateSettings as updateSettingsOperation, - type SettingsMutationOperations -} from '../applying-settings/settings-update' -import { getPersistedUI } from '../applying-settings/ui-state-read' -import { updatePersistedUI, type UIUpdateOperations } from '../applying-settings/ui-state-update' -import { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' -import { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' -import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' -import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' -import { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' -import { - recordFeatureInteraction as recordFeatureInteractionOperation, - type FeatureInteractionOperations -} from '../applying-settings/feature-interaction-recording' -import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' -import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' -import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' - -// Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable. -const BACKUP_COUNT = 5 -const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 -const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ - 'tabsByWorktree', - 'terminalLayoutsByTabId' -]) - -function logPersistenceStartupMilestone( - event: string, - details: Record = {} -): void { - if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) - } -} - -function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { - return Object.keys(patch).some((key) => - WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) - ) -} - -function workspaceSessionSalvageLogDetails(result: { - droppedCount: number - droppedPaths: string[] -}): { count: number; fields: string[]; detailsTruncated: boolean } { - return { - count: result.droppedCount, - fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], - detailsTruncated: result.droppedCount > result.droppedPaths.length - } -} - -/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. - * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ -function parseWorkspaceSessionsByHostId( - raw: unknown, - defaults: WorkspaceSessionState -): { partitions: Partial>; repaired: boolean } { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return { partitions: {}, repaired: raw !== undefined } - } - let repaired = false - const partitions: Partial> = {} - for (const [key, value] of Object.entries(raw as Record)) { - const hostId = normalizeExecutionHostId(key) - // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. - if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { - continue - } - const result = parseWorkspaceSessionSalvaging(value) - if (!result.ok) { - repaired = true - console.error( - `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, - result.error - ) - continue - } - if (result.droppedCount > 0) { - console.warn( - `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, - workspaceSessionSalvageLogDetails(result) - ) - repaired = true - } - partitions[hostId] = { ...defaults, ...result.value } - } - return { partitions, repaired } -} - -function backupPath(dataFile: string, index: number): string { - return `${dataFile}.bak.${index}` -} - -/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ -async function exists(path: string): Promise { - return access(path).then( - () => true, - () => false - ) -} - -function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { - const isPlainArray = Array.isArray(row) - const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined - const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] - const names = new Set() - for (const entry of rawNames) { - if (typeof entry !== 'string') { - continue - } - const normalized = normalizeRetirableGeneratedName(entry) - if (normalized) { - names.add(normalized) - } - } - return compactRetiredNames({ - exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), - names: [...names] - }) -} - -function normalizeRetiredNameRegistryMap(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const byRepo: Record = {} - for (const [repoId, row] of Object.entries(value as Record)) { - if (!repoId) { - continue - } - const registry = normalizeRetiredNameRegistry(row) - if (!isEmptyRetiredNameRegistry(registry)) { - byRepo[repoId] = registry - } - } - return byRepo -} - -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 -} + createStoreDomains, + installStoreDomainContexts, + STORE_DOMAIN_OPERATION_CLASSES, + type StoreDomains +} from './store-domain-composition' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { scheduleSave } from './write-scheduling' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import type { ProjectCollectionOperations } from './project-collection-operations' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import type { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' +import type { SparsePresetPersistence } from './sparse-preset-persistence' +import type { AutomationPersistence } from './automation-persistence' +import type { MetadataLineageOperations } from './metadata-lineage-operations' +import type { ProfilePreferences } from './profile-preferences' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import type { SessionSnapshotOperations } from './session-snapshot-operations' +import type { PtyBindingPersistenceOperations } from './pty-binding-persistence' +import type { SshProfileOperations } from './ssh-profile-operations' +import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence' +import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +export type StoreOptions = { dataFile?: string } export type PtyBindingSourceExpectation = { worktreeId?: string tabId: string @@ -508,69 +42,28 @@ export type PtyBindingSourceExpectation = { incarnationId?: string } +/** Concrete composition root for profile persistence. */ +// oxlint-disable-next-line typescript-eslint/no-unsafe-declaration-merging -- Store installs the exact concrete domain class descriptors and contexts below export class Store { - // Why readonly: the operations wrappers below capture this reference once and are memoized. + private readonly runtime: StoreRuntimeState + private readonly domains: StoreDomains private readonly state: PersistedState - private readonly dataFile: string - private readonly activeViewPreference: ActiveViewPreference - private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage - private writeTimer: ReturnType | null = null - private pendingWrite: Promise | null = null - private pendingSnapshotFileWork: Promise | null = null - private readonly staleTempCleanup: Promise - private writeGeneration = 0 - private inFlightAsyncTmpFile: string | null = null - // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. - private backupRotationInFlight = false - // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. - private writesFrozen = false - /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ - private quitFlushStarted = false - private quitFlushPromise: Promise | null = null - // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. - private lastWrittenStateHash: string | null = null - private lastDurableWriteGeneration = -1 - private firstPendingSaveAt: number | null = null - private githubCacheDirty = false - private githubCacheGeneration = 0 - private pendingGithubCacheWrite: Promise | null = null - private readonly staleGithubCacheTempCleanup: Promise - private readonly gitUsernameCache = new Map() - private readonly protectedSecrets = new ProtectedSecretPersistence() - private loadNeedsSave = false - private settingsChangeListeners = new Set< - ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - >() - private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() constructor(options: StoreOptions = {}) { - // Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path. - this.dataFile = options.dataFile ?? getDataFile() - this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { - minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS - }) - this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( - getGithubCacheFile(this.dataFile), - { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } - ) - const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) - const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) - this.terminalScrollbackSnapshotStorage = { - snapshotRoot: profileSnapshotRoot, - fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot - } - const loaded = this.load() + this.runtime = new StoreRuntimeState(options) + this.domains = createStoreDomains(this.runtime) + installStoreDomainContexts(this, this.domains) + this.runtime.flushOrThrow = () => this.flushOrThrow() + const loaded = this.domains.loader.load() const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state - // Why: activeView is a frequent, tiny preference; keeping it beside the - // profile avoids serializing the multi-MB recovery store on navigation. - this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView) - const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() - this.hydrateFolderWorkspaceDiffComments() + this.runtime.state = this.state + this.runtime.activeViewPreference = new ActiveViewPreference( + this.runtime.dataFile, + this.state.ui?.activeView + ) + const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups() + this.domains.adaptation.hydrateFolderWorkspaceDiffComments() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -579,3341 +72,55 @@ export class Store { } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries - this.scheduleSave() + scheduleSave(this.domains.scheduling) }) agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { this.state.legacyPaneKeyAliasEntries = entries - this.scheduleSave() + scheduleSave(this.domains.scheduling) }) - if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) { - // Why: rewrite legacy pane:1 leaves so older renderer writes can't revive them; other migrations also set loadNeedsSave. - this.scheduleSave() + if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) { + scheduleSave(this.domains.scheduling) } } - // Why: notes live top-level on disk so an older build's field-by-field - // normalizeFolderWorkspaces can't drop them; re-attach them to the in-memory records here. - private hydrateFolderWorkspaceDiffComments(): void { - const stored = this.state.folderWorkspaceDiffComments - let relocatedInline = false - for (const workspace of this.state.folderWorkspaces ?? []) { - if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { - // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the - // older map untouched, so inline is the last notes-aware write. Also makes the relocation - // durable even if the user never edits anything this session. - relocatedInline = true - continue - } - const comments = stored?.[workspace.id] - // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. - if (Array.isArray(comments) && comments.length > 0) { - workspace.diffComments = comments - } - } - if (relocatedInline) { - this.loadNeedsSave = true - } - // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in - // state would make it a stale second source of truth that getDurableState() spreads back out. - delete this.state.folderWorkspaceDiffComments - } - - private adaptFlatFolderScanProjectGroups(): boolean { - // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. - const groups = this.state.projectGroups ?? [] - const repos = this.state.repos - if (groups.length === 0 || repos.length === 0) { - return false - } - - let changed = false - let maxOrder = -1 - for (const group of groups) { - maxOrder = Math.max(maxOrder, group.tabOrder) - } - - const childGroupIds = new Set( - groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) - ) - const initialGroupCount = groups.length - for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { - const rootGroup = groups[groupIndex] - if (!rootGroup) { - continue - } - if ( - rootGroup.createdFrom !== 'folder-scan' || - !rootGroup.parentPath || - rootGroup.parentGroupId || - childGroupIds.has(rootGroup.id) - ) { - continue - } - const rootPath = rootGroup.parentPath - const repoCandidates = repos.filter( - (repo) => - !isFolderRepo(repo) && - repo.projectGroupId === rootGroup.id && - isPathInsideOrEqual(rootPath, repo.path) - ) - if (repoCandidates.length < 2) { - continue - } - - const resolver = createNestedProjectGroupResolver({ - parentPath: rootPath, - groupName: rootGroup.name, - mode: 'group', - repoPaths: repoCandidates.map((repo) => repo.path), - createGroup: (input) => { - if (!input.parentGroupId) { - return rootGroup - } - maxOrder += 1 - const group = createProjectGroup({ - ...input, - tabOrder: maxOrder - }) - groups.push(group) - changed = true - return group - } - }) - const nextOrderByGroupId = new Map() - for (const repo of repoCandidates) { - const group = resolver.getGroupForRepo(repo.path) - if (!group) { - continue - } - const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 - nextOrderByGroupId.set(group.id, nextOrder + 1) - if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { - repo.projectGroupId = group.id - repo.projectGroupOrder = nextOrder - changed = true - } - } - } - return changed - } - - // Why (#1158): debounced writes fire ~every 300ms; throttle backups to distinct moments, not near-identical snapshots. - private shouldRotateBackups(now: number, dataFile: string): boolean { - try { - const mtime = statSync(backupPath(dataFile, 0)).mtimeMs - return now - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - // Why separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible - // sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate. - private async shouldRotateBackupsAsync(dataFile: string): Promise { - try { - const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs - return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - // Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt. - private async rotateBackupsAsync(dataFile: string): Promise { - if (this.backupRotationInFlight) { - return - } - this.backupRotationInFlight = true - try { - if (!(await this.shouldRotateBackupsAsync(dataFile))) { - return - } - if (!(await exists(dataFile))) { - return - } - await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - }) - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an - // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. - if (await exists(src)) { - await rename(src, dst).catch((err) => { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - }) - } - } - await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - }) - } finally { - this.backupRotationInFlight = false - } - } - - private rotateBackupsSync(dataFile: string): void { - if (!existsSync(dataFile)) { - return - } - try { - unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1)) - } catch (err) { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - } - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - if (existsSync(src)) { - try { - renameSync(src, dst) - } catch (err) { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - } - } - } - try { - copyFileSync(dataFile, backupPath(dataFile, 0)) - } catch (err) { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - } - } - - private restoreFromBackup(dataFile: string): boolean { - for (let i = 0; i < BACKUP_COUNT; i++) { - const path = backupPath(dataFile, i) - if (!existsSync(path)) { - continue - } - try { - const raw = readFileSync(path, 'utf-8') - JSON.parse(raw) - mkdirSync(dirname(dataFile), { recursive: true }) - writeFileSync(dataFile, raw, 'utf-8') - console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) - return true - } catch (err) { - console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) - } - } - return false - } - - private load(allowBackupRecovery = true): PersistedState { - // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. - const dataFile = this.dataFile - const fileExistedOnLoad = existsSync(dataFile) - logPersistenceStartupMilestone('persistence-load-start', { - fileExists: fileExistedOnLoad - }) - - let result: PersistedState | null = null - try { - if (fileExistedOnLoad) { - const readStartedAt = performance.now() - const raw = readFileSync(dataFile, 'utf-8') - logPersistenceStartupMilestone('persistence-read-done', { - bytes: Buffer.byteLength(raw), - durationMs: Math.round(performance.now() - readStartedAt) - }) - logPersistenceStartupMilestone('persistence-json-parse-start') - const parsed = JSON.parse(raw) as PersistedState - logPersistenceStartupMilestone('persistence-json-parse-done') - - // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. - if (parsed.settings?.opencodeSessionCookie) { - parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - parsed.settings.opencodeSessionCookie, - isLegacyOpenCodeSessionCookie - ) - } - if (parsed.settings?.httpProxyUrl) { - const decryptedProxy = this.protectedSecrets.decryptWithStatus( - PROTECTED_SECRET_SLOT.httpProxyUrl, - parsed.settings.httpProxyUrl, - (value) => normalizeProxyUrl(value).ok - ) - // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL - // value must not masquerade as a configured proxy (silent DIRECT fallback) or - // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. - if ( - decryptedProxy.status === 'unavailable' || - (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) - ) { - parsed.settings.httpProxyUrl = '' - } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { - parsed.settings.httpProxyUrl = decryptedProxy.plaintext - } else { - console.warn( - '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' - ) - parsed.settings.httpProxyUrl = '' - this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) - this.loadNeedsSave = true - } - } - if (parsed.ui?.browserKagiSessionLink) { - parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - parsed.ui.browserKagiSessionLink, - (value) => normalizeKagiSessionLink(value) !== null - ) - } - parsed.sshPtyConsumerRecoveries = ( - Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] - ) - .map((record) => - normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) - ) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - .map((record) => { - const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) - const decrypted = this.protectedSecrets.decryptWithStatus( - slot, - record.ownerLease, - isLegacySshPtyOwnerLease - ) - const normalized = - decrypted.status === 'unavailable' || - (decrypted.status === 'failed' && !decrypted.plaintext) - ? record - : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) - if (!normalized) { - this.protectedSecrets.removeRetainedBlob(slot) - } - return normalized - }) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - - // Merge with defaults in case new fields were added - const homeDir = homedir() - const defaults = getDefaultPersistedState(homeDir) - const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( - Array.isArray(parsed.repos) ? parsed.repos : [], - parsed.settings?.worktreeVisibilityDefaults - ) - if (migratedExternalVisibility.changed) { - this.loadNeedsSave = true - } - const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) - if (migratedTerminalScrollback.needsSave) { - this.loadNeedsSave = true - } - if ( - parsed.settings && - typeof parsed.settings === 'object' && - Object.hasOwn(parsed.settings, 'enableGitHubAttribution') - ) { - this.loadNeedsSave = true - } - const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( - parsed.settings - ) - if (migratedTerminalTuiScrollSensitivity.needsSave) { - this.loadNeedsSave = true - } - const rawSourceControlAi = parsed.settings?.sourceControlAi - const rawSourceControlAiMissing = rawSourceControlAi === undefined - const rawSourceControlAiActionsMissing = - rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined - if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { - this.loadNeedsSave = true - } - const legacyCommitMessageAi = parsed.settings?.commitMessageAi - const migratedSourceControlAi = rawSourceControlAiMissing - ? sourceControlAiSettingsFromLegacy( - legacyCommitMessageAi ?? defaults.settings.commitMessageAi - ) - : mergeLegacyCommitMessageAiIntoSourceControlAi( - parsed.settings?.sourceControlAi, - legacyCommitMessageAi - ) - // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. - const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt - const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true - const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated - ? (rawOptionAsAlt ?? 'auto') - : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' - ? 'auto' - : rawOptionAsAlt - const floatingTerminalDefaultedForAllUsers = - parsed.settings?.floatingTerminalDefaultedForAllUsers === true - // Why: early builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. - const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers - ? (parsed.settings?.floatingTerminalEnabled ?? true) - : true - // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). - const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) - const osc52ClipboardNoticePending = - osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || - parsed.ui?.osc52ClipboardDefaultOnNoticePending === true - if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { - this.loadNeedsSave = true - } - const floatingTerminalCwdMigrated = - parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true - // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. - const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated - ? !parsed.settings?.floatingTerminalCwd - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - : parsed.settings?.floatingTerminalCwd === undefined - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( - parsed.settings?.floatingTerminalTrustedCwds, - homeDir - ) - const migratedFloatingTerminalTrustedCwds = [ - ...normalizedFloatingTerminalTrustedCwds.trustedCwds - ] - const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd - const shouldTrustLegacyFloatingTerminalCwd = - !floatingTerminalCwdMigrated && - typeof rawLegacyFloatingTerminalCwd === 'string' && - rawLegacyFloatingTerminalCwd.trim().length > 0 && - rawLegacyFloatingTerminalCwd.trim() !== '~' - if (!floatingTerminalCwdMigrated) { - this.loadNeedsSave = true - } - if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { - const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( - rawLegacyFloatingTerminalCwd, - homeDir - ) - if ( - canonicalLegacyCwd && - !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd) - ) { - // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. - migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) - normalizedFloatingTerminalTrustedCwds.changed = true - } - } - if (normalizedFloatingTerminalTrustedCwds.changed) { - this.loadNeedsSave = true - } - const experimentalActivityDefaultedOffForAllUsers = - parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true - // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. - const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers - ? (parsed.settings?.experimentalActivity ?? false) - : false - const autoRenameBranchFromWorkDefaultedOn = - parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true - // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. - const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( - parsed.settings - ) - const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) - if ( - parsed.settings?.terminalCursorStyle !== - migratedTerminalCursorStyle.terminalCursorStyle || - parsed.settings?.terminalCursorStyleDefaultedToBlock !== true - ) { - this.loadNeedsSave = true - } - const migratedTerminalLineHeight = normalizeTerminalLineHeight( - parsed.settings?.terminalLineHeight - ) - const terminalRightClickToPasteDefaultedForPlatform = - parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true - if (!terminalRightClickToPasteDefaultedForPlatform) { - this.loadNeedsSave = true - } - if ( - parsed.settings?.terminalLineHeight !== undefined && - parsed.settings.terminalLineHeight !== migratedTerminalLineHeight - ) { - this.loadNeedsSave = true - } - const rawTaskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: parsed.settings?.visibleTaskProviders, - defaultTaskSource: parsed.settings?.defaultTaskSource - }) - const visibleTaskProvidersDefaultedForJira = - parsed.settings?.visibleTaskProvidersDefaultedForJira === true - const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira - ? rawTaskProviderSettings.visibleTaskProviders - : rawTaskProviderSettings.visibleTaskProviders.includes('jira') - ? rawTaskProviderSettings.visibleTaskProviders - : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] - const taskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: migratedVisibleTaskProviders, - defaultTaskSource: rawTaskProviderSettings.defaultTaskSource - }) - const primarySelectionDefaultedForLinux = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true - const primarySelectionDefaultedForTerminalDefaults = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true - const primarySelectionPlatformDefaultEnabled = - defaults.settings.primarySelectionMiddleClickPaste === true - const primarySelectionAlreadyDefaultedForPlatform = - primarySelectionDefaultedForTerminalDefaults || - (process.platform === 'linux' && primarySelectionDefaultedForLinux) - const migratePrimarySelectionPlatformDefault = - primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform - const stampPrimarySelectionTerminalDefaults = - primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults - if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { - this.loadNeedsSave = true - } - if (!visibleTaskProvidersDefaultedForJira) { - this.loadNeedsSave = true - } - const claudeAgentTeamsDefaultDisabledMigrated = - parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true - if (!claudeAgentTeamsDefaultDisabledMigrated) { - this.loadNeedsSave = true - } - const migratedDisabledTuiAgents = normalizeDisabledTuiAgents( - parsed.settings?.disabledTuiAgents - ) - const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) - if ( - parsed.settings?.agentYoloDefaultsMigrated !== true || - hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || - hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) - ) { - this.loadNeedsSave = true - } - if ( - !claudeAgentTeamsDefaultDisabledMigrated && - !migratedDisabledTuiAgents.includes('claude-agent-teams') - ) { - migratedDisabledTuiAgents.push('claude-agent-teams') - } - const migratedWindowsRuntimeDefault = - parsed.settings?.localWindowsRuntimeDefault === undefined - ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime - : parsed.settings.localWindowsRuntimeDefault - if ( - parsed.settings?.localWindowsRuntimeDefault === undefined && - migratedWindowsRuntimeDefault.kind === 'wsl' - ) { - this.loadNeedsSave = true - } - // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. - const localAccountRuntimeAlreadyMigrated = - parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true - const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = - localAccountRuntimeAlreadyMigrated - ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) - : parsed.settings?.localAccountRuntime === 'wsl' - ? 'wsl' - : 'auto' - if (!localAccountRuntimeAlreadyMigrated) { - this.loadNeedsSave = true - } - if (!autoRenameBranchFromWorkDefaultedOn) { - this.loadNeedsSave = true - } - const normalizedOnboarding = normalizeLoadedOnboardingState( - parsed.onboarding, - defaults.onboarding - ) - if (!parsed.onboarding) { - this.loadNeedsSave = true - } - const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) - const loadedCompactWorktreeCards = - parsed.settings?.compactWorktreeCards ?? - parsed.settings?.experimentalCompactWorktreeCards ?? - defaults.settings.compactWorktreeCards - const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( - parsed.settings?.mobilePairingCustomAddress - ) - const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses - const mobilePairingCustomAddresses = mobilePairingCustomAddress - ? addMobilePairingCustomAddress( - normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), - mobilePairingCustomAddress - ) - : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) - if ( - parsed.settings?.mobilePairingCustomAddress !== undefined && - parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress - ) { - this.loadNeedsSave = true - } - const customAddressesMatch = - Array.isArray(rawMobilePairingCustomAddresses) && - rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && - rawMobilePairingCustomAddresses.every( - (address, index) => address === mobilePairingCustomAddresses[index] - ) - if ( - (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && - !customAddressesMatch - ) { - this.loadNeedsSave = true - } - const normalizedNotifications = normalizeNotificationSettings( - parsed.settings?.notifications - ) - // Why: a type-flipped notification field is repaired in memory only; without a dirty mark the - // bad value stays on disk and the repair reruns on every launch. - if ( - persistedNotificationSettingsRepaired( - parsed.settings?.notifications, - normalizedNotifications - ) - ) { - this.loadNeedsSave = true - } - const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( - parsed.settings?.sourceControlGroupOrder - ) - if ( - parsed.settings?.sourceControlGroupOrder !== undefined && - parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder - ) { - this.loadNeedsSave = true - } - result = { - ...defaults, - ...parsed, - featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( - parsed.featureInteractionTelemetryBuckets - ), - projectGroups: normalizedProjectGroups, - repos: migratedExternalVisibility.repos, - folderWorkspaces: normalizeFolderWorkspaces( - parsed.folderWorkspaces, - normalizedProjectGroups - ), - folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( - parsed.folderWorkspaceDiffComments - ), - worktreeLineageById: parsed.worktreeLineageById ?? {}, - mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( - parsed.mobileClientTabSelectionsByDeviceId - ), - workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( - parsed.workspaceLineageByChildKey - ), - settings: { - ...defaults.settings, - // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripRetiredGlobalSettings(parsed.settings), - worktreeVisibilityDefaults: migratedExternalVisibility.defaults, - prBotAuthorOverrides: normalizePRBotAuthorOverrides( - parsed.settings?.prBotAuthorOverrides - ), - // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. - experimentalPet: - parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, - // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. - primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault - ? true - : (parsed.settings?.primarySelectionMiddleClickPaste ?? - defaults.settings.primarySelectionMiddleClickPaste), - primarySelectionMiddleClickPasteDefaultedForLinux: - primarySelectionDefaultedForLinux || - (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), - primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: - primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, - ...migratedAutoRenameBranchFromWork, - ...migratedTerminalCursorStyle, - terminalLineHeight: migratedTerminalLineHeight, - // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. - terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform - ? (parsed.settings?.terminalRightClickToPaste ?? - defaults.settings.terminalRightClickToPaste) - : parsed.settings?.terminalRightClickToPaste === false - ? false - : defaults.settings.terminalRightClickToPaste, - terminalRightClickToPasteDefaultedForPlatform: true, - ...migratedTerminalTuiScrollSensitivity.settings, - experimentalActivity: migratedExperimentalActivity, - experimentalActivityDefaultedOffForAllUsers: true, - // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. - compactWorktreeCards: loadedCompactWorktreeCards, - experimentalCompactWorktreeCards: undefined, - terminalMacOptionAsAlt: migratedOptionAsAlt, - terminalMacOptionAsAltMigrated: true, - localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, - localAccountRuntime: migratedLocalAccountRuntime, - localAccountRuntimeDefaultedToAutoForAllUsers: true, - ...migratedOsc52Clipboard, - floatingTerminalEnabled: migratedFloatingTerminalEnabled, - floatingTerminalDefaultedForAllUsers: true, - floatingTerminalCwd: migratedFloatingTerminalCwd, - floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, - floatingTerminalCwdMigratedToAppWorkspace: true, - terminalScrollbackRows: migratedTerminalScrollback.rows, - terminalQuickCommands: normalizeTerminalQuickCommands( - parsed.settings?.terminalQuickCommands - ), - terminalCustomThemes: normalizeTerminalCustomThemes( - parsed.settings?.terminalCustomThemes - ), - appIcon: normalizeAppIconId(parsed.settings?.appIcon), - mobilePairingCustomAddress, - mobilePairingCustomAddresses, - // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. - minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, - // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. - showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, - uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), - defaultTaskSource: taskProviderSettings.defaultTaskSource, - visibleTaskProviders: taskProviderSettings.visibleTaskProviders, - visibleTaskProvidersDefaultedForJira: true, - terminalShortcutPolicy: normalizeTerminalShortcutPolicy( - parsed.settings?.terminalShortcutPolicy - ), - disabledTuiAgents: migratedDisabledTuiAgents, - ...migratedAgentYoloDefaults, - claudeAgentTeamsDefaultDisabledMigrated: true, - openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { - seedDefaults: true - }), - notifications: normalizedNotifications, - sourceControlAi: migratedSourceControlAi, - sourceControlGroupOrder: normalizedSourceControlGroupOrder, - // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. - commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( - migratedSourceControlAi, - parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi - ), - voice: { - ...getDefaultVoiceSettings(), - ...parsed.settings?.voice - } - }, - // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. - ui: (() => { - const rawSort = parsed.ui?.sortBy - const sort = normalizeSortBy(rawSort) - const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' - const rightSidebarOpen = - typeof parsed.ui?.rightSidebarOpen === 'boolean' - ? parsed.ui.rightSidebarOpen - : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' - ? parsed.settings.rightSidebarOpenByDefault - : defaults.ui.rightSidebarOpen - if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { - this.loadNeedsSave = true - } - const workspaceStatusesDefaultOrderMigrated = - parsed.ui?._workspaceStatusesDefaultOrderMigrated === true - // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. - const workspaceStatusesReorderedDefaultRepaired = - parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true - // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. - const workspaceStatusesDefaultWorkflowMigrated = - parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true - // Why: visual migration has its own guard so later user choices of valid legacy color/icon IDs are preserved. - const workspaceStatusesDefaultVisualsMigrated = - parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true - const workspaceStatuses = normalizePersistedWorkspaceStatuses( - parsed.ui?.workspaceStatuses, - { - migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, - repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, - migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated - } - ) - if ( - !workspaceStatusesDefaultOrderMigrated || - !workspaceStatusesReorderedDefaultRepaired || - !workspaceStatusesDefaultWorkflowMigrated || - !workspaceStatusesDefaultVisualsMigrated - ) { - this.loadNeedsSave = true - } - const rawCardProps = parsed.ui?.worktreeCardProperties - const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true - const expandedCardPropsMigrated = - parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true - const jiraIssueCardPropDefaulted = - parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true - const hadExperimentOn = readDeprecatedExperimentFlag(parsed) - const deliberateUncheck = - hadExperimentOn && - Array.isArray(rawCardProps) && - !rawCardProps.includes('inline-agents') - const needsInlineAgentsMigration = - !inlineAgentsMigrated && - !deliberateUncheck && - Array.isArray(rawCardProps) && - !rawCardProps.includes('inline-agents') - const needsLegacyDefaultedCompactMigration = - loadedCompactWorktreeCards && - parsed.ui?._worktreeCardModeDefaulted === true && - isDefaultedCompactWorktreeCardProperties(rawCardProps) - const migratedCardProps = (() => { - if (!Array.isArray(rawCardProps)) { - return undefined - } - if (needsLegacyDefaultedCompactMigration) { - return getWorktreeCardModeProperties('Compact') - } - const candidate = needsInlineAgentsMigration - ? [...rawCardProps, 'inline-agents' as const] - : rawCardProps - const expandedCandidate = (() => { - if (expandedCardPropsMigrated) { - return candidate - } - const next = [...candidate] - // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. - if (candidate.includes('issue') && !candidate.includes('linear-issue')) { - next.push('linear-issue' as const) - } - if (!candidate.includes('ports')) { - next.push('ports' as const) - } - return next - })() - // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. - const jiraCandidate = - jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') - ? expandedCandidate - : [...expandedCandidate, 'jira-issue' as const] - const normalized = normalizeWorktreeCardProperties(jiraCandidate) - const changed = - normalized.length !== rawCardProps.length || - normalized.some((property, index) => property !== rawCardProps[index]) - return changed ? normalized : undefined - })() - if ( - migratedCardProps !== undefined || - !inlineAgentsMigrated || - !expandedCardPropsMigrated || - !jiraIssueCardPropDefaulted - ) { - this.loadNeedsSave = true - } - const rawExplorerView = parsed.ui?.rightSidebarExplorerView - const rightSidebarExplorerView = normalizeRightSidebarExplorerView( - rawExplorerView, - parsed.ui?.rightSidebarTab - ) - // Why: without a dirty mark the legacy "Search tab, no explorer view" repair stays - // in memory only, so a profile that never writes again redoes it on every launch. - if ( - rawExplorerView === undefined - ? rightSidebarExplorerView !== defaults.ui.rightSidebarExplorerView - : rawExplorerView !== rightSidebarExplorerView - ) { - this.loadNeedsSave = true - } - const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( - parsed.ui?.setupGuideSidebarDismissed, - normalizedOnboarding - ) - if ( - parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && - (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) - ) { - this.loadNeedsSave = true - } - // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. - const usagePercentageDisplayChangeNoticeDismissed = - resolveUsagePercentageDisplayChangeNoticeDismissed({ - rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, - rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, - isExistingProfile: isExistingPersistedProfile({ - repoCount: parsed.repos?.length ?? 0, - onboardingClosedAt: normalizedOnboarding.closedAt, - ui: parsed.ui - }) - }) - if ( - parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== - usagePercentageDisplayChangeNoticeDismissed - ) { - this.loadNeedsSave = true - } - return { - ...defaults.ui, - // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. - worktreeCardProperties: getWorktreeCardModeProperties( - loadedCompactWorktreeCards ? 'Compact' : 'Default' - ), - ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), - // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. - rightSidebarOpen, - rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), - // Why here and not in getPersistedUI: only the raw payload still shows the legacy - // "Search tab, no explorer view" shape — the defaults spread above fills in 'files'. - rightSidebarExplorerView, - setupGuideSidebarDismissed, - usagePercentageDisplayChangeNoticeDismissed, - setupGuideBrowserMilestoneMigrated: - typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' - ? parsed.ui.setupGuideBrowserMilestoneMigrated - : false, - setupGuideBrowserMilestoneLegacyComplete: - parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, - // Why persist rather than notify inline: the flip lands during load, before any - // window exists, and it must survive a crash before the user ever sees the notice. - osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, - sortBy: migrate ? ('smart' as const) : sort, - showDotfilesByWorktree: normalizeShowDotfilesByWorktree( - parsed.ui?.showDotfilesByWorktree - ), - workspaceStatuses, - _workspaceStatusesDefaultOrderMigrated: true, - _workspaceStatusesReorderedDefaultRepaired: true, - _workspaceStatusesDefaultWorkflowMigrated: true, - _workspaceStatusesDefaultVisualsMigrated: true, - _sortBySmartMigrated: true, - ...(migratedCardProps !== undefined - ? { worktreeCardProperties: migratedCardProps } - : {}), - // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. - _inlineAgentsDefaultedForExperiment: true, - _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true, - _jiraIssueWorktreeCardPropertyDefaulted: true - } - })(), - // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. - workspaceSession: (() => { - if (parsed.workspaceSession === undefined) { - return defaults.workspaceSession - } - const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) - if (!result.ok) { - console.error( - '[persistence] Corrupt workspace session, using defaults:', - result.error - ) - return defaults.workspaceSession - } - if (result.droppedCount > 0) { - console.warn( - '[persistence] Salvaged workspace session; dropped corrupt entries:', - workspaceSessionSalvageLogDetails(result) - ) - // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return { ...defaults.workspaceSession, ...result.value } - })(), - // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: (() => { - const { partitions, repaired } = parseWorkspaceSessionsByHostId( - parsed.workspaceSessionsByHostId, - defaults.workspaceSession - ) - if (repaired) { - // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return partitions - })(), - sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), - deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) - ? parsed.deletedSshConfigAliases.filter( - (alias): alias is string => typeof alias === 'string' - ) - : [], - retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByRepo - ), - retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByNamespace - ), - sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) - .map(normalizeSshRemotePtyLease) - .filter((lease): lease is SshRemotePtyLease => lease !== null), - sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, - claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), - migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( - parsed.migrationUnsupportedPtyEntries - ), - legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( - parsed.legacyPaneKeyAliasEntries - ), - automations: Array.isArray(parsed.automations) ? parsed.automations : [], - automationRuns: (() => { - if (!Array.isArray(parsed.automationRuns)) { - return [] - } - const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) - // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. - if (runs.length !== parsed.automationRuns.length) { - this.loadNeedsSave = true - } - return runs - })(), - onboarding: normalizedOnboarding - } - } - } catch (err) { - console.error('[persistence] Failed to load primary state, trying backups:', err) - } - - // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. - if (result === null && allowBackupRecovery) { - let hasBackup = false - for (let i = 0; i < BACKUP_COUNT; i++) { - if (existsSync(backupPath(dataFile, i))) { - hasBackup = true - break - } - } - if (fileExistedOnLoad || hasBackup) { - if (this.restoreFromBackup(dataFile)) { - return this.load(false) - } - console.error('[persistence] No usable state file or backup found, using defaults') - } - } - - if (result === null) { - result = getDefaultPersistedState(homedir()) - } - - const workspaceSession = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) - ) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - if (migratedScrollback.changed) { - this.loadNeedsSave = true - } - - const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) - const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) - if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { - this.loadNeedsSave = true - } - - const automationContextMigration = backfillLegacyAutomationContexts({ - ...result, - repos, - ...projectHostSetupCompatibility - }) - if (automationContextMigration.changed) { - this.loadNeedsSave = true - } - result = { - ...result, - automations: automationContextMigration.state.automations, - automationRuns: automationContextMigration.state.automationRuns - } - - const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ - ...result, - repos, - ...projectHostSetupCompatibility, - workspaceSession: migratedScrollback.session - }) - if (folderScopeConnectionMigration.changed) { - this.loadNeedsSave = true - } - result = folderScopeConnectionMigration.state - - if (normalizeWorktreeLinkedItemMetadata(result)) { - this.loadNeedsSave = true - } - - if (gcStaleWorktreeMeta(result) > 0) { - this.loadNeedsSave = true - } - - const migrated = this.migrateTabSwitchKeybindings( - this.migrateTelemetry(result, fileExistedOnLoad), - fileExistedOnLoad - ) - - // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. - const legacyCache = migrated.githubCache - const hasLegacyCache = - Object.keys(legacyCache?.pr ?? {}).length > 0 || - Object.keys(legacyCache?.issue ?? {}).length > 0 - if (hasLegacyCache) { - this.loadNeedsSave = true - // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. - this.githubCacheDirty = true - } else { - migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache - } - - logPersistenceStartupMilestone('persistence-load-done', { - repos: migrated.repos.length, - workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) - }) - return migrated - } - - // One-shot telemetry cohort migration: seeds existedBeforeTelemetryRelease, optedIn, and installId (no-op once set). - // One-shot tab-switch cohort freeze: fileExistedOnLoad tells existing vs fresh only on the first launch, so persist now. - private migrateTabSwitchKeybindings( - state: PersistedState, - fileExistedOnLoad: boolean - ): PersistedState { - const existing = state.settings?.tabSwitchKeybindingSeed - if (existing === 'pending' || existing === 'done') { - return state - } - // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. - this.loadNeedsSave = true - return { - ...state, - settings: { - ...state.settings, - // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. - tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' - } - } - } - - private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { - const existing = state.settings?.telemetry - // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. - if ( - typeof existing?.existedBeforeTelemetryRelease === 'boolean' && - typeof existing.installId === 'string' && - existing.installId.length > 0 && - (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) - ) { - return state - } - // Why: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. - const resolvedExistedBefore = - typeof existing?.existedBeforeTelemetryRelease === 'boolean' - ? existing.existedBeforeTelemetryRelease - : fileExistedOnLoad - return { - ...state, - settings: { - ...state.settings, - telemetry: { - ...existing, - existedBeforeTelemetryRelease: resolvedExistedBefore, - // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. - optedIn: - existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null - ? existing.optedIn - : resolvedExistedBefore - ? null - : true, - installId: - typeof existing?.installId === 'string' && existing.installId.length > 0 - ? existing.installId - : randomUUID() - } - } - } - } - - // Why 1s trailing + 5s max-wait (was 300ms unbounded): coalesce mutation bursts; max-wait bounds crash staleness at 5s. - private static SAVE_DEBOUNCE_MS = 1_000 - private static SAVE_MAX_WAIT_MS = 5_000 - - private scheduleSave(): void { - // Why: once the quit flush has snapshotted, a newly debounced write would fire during - // teardown with nothing awaiting it, and the process can exit mid-rename. The quit - // flush is the last write by construction. - if (this.quitFlushStarted) { - return - } - this.writeGeneration += 1 - const now = Date.now() - this.firstPendingSaveAt ??= now - if (this.writeTimer) { - clearTimeout(this.writeTimer) - } - const untilMaxWait = Math.max(0, this.firstPendingSaveAt + Store.SAVE_MAX_WAIT_MS - now) - const delay = Math.min(Store.SAVE_DEBOUNCE_MS, untilMaxWait) - this.writeTimer = setTimeout(() => { - this.writeTimer = null - this.firstPendingSaveAt = null - void this.enqueueWrite() - }, delay) - } - - private enqueueWrite(): Promise { - const previousWrite = Promise.all([ - this.pendingWrite ?? this.staleTempCleanup, - this.pendingSnapshotFileWork ?? Promise.resolve() - ]).then(() => {}) - const write = previousWrite.then(() => this.writeToDiskAsync()) - const trackedWrite = write - .catch((err) => { - console.error('[persistence] Failed to write state:', err) - }) - .finally(() => { - if (this.pendingWrite === trackedWrite) { - this.pendingWrite = null - } - }) - this.pendingWrite = trackedWrite - return write - } - - /** Wait for any in-flight async disk write to complete. Used in tests. */ - async waitForPendingWrite(): Promise { - await Promise.all([this.pendingWrite, this.activeViewPreference.waitForPendingWrite()]) - } - - // Why githubCache is omitted: memory-only this session (see getGithubCacheFile), so refreshes never touch the durable file. - private getDurableState(): Omit { - const { githubCache: _memoryOnly, ...durable } = this.state - return durable - } - - // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. - private buildStateToSave(): { - payload: string - stateHash: string - protectedSecretUpdates: ProtectedSecretRetentionUpdate[] - } { - // Why sentinels (not a blob/key string match): the substitution must be - // position-exact. A plain search for the ciphertext — or even for a - // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an - // agentDefaultEnv var named after a secret field, or a value equal to a - // ciphertext), which would substitute the wrong site and let two DISTINCT - // states normalize equal → a silently dropped write (data loss), reachable - // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot - // random UUID can't occur anywhere else in the serialized state (the user - // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] - const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] - let protectedStorageDegraded = false - const encryptToSentinel = (slot: string, plaintext: string): string => { - const encrypted = this.protectedSecrets.encrypt(slot, plaintext) - if (encrypted.retentionUpdate) { - protectedSecretUpdates.push(encrypted.retentionUpdate) - } - protectedStorageDegraded ||= encrypted.degraded - const { blob, hashValue = plaintext } = encrypted - // Values already identical in payload and hash need no sentinel substitution. - if (blob === plaintext && hashValue === plaintext) { - return blob - } - const sentinel = `orca-secret-slot-${randomUUID()}` - secretSubs.push({ sentinel, blob, hashValue }) - return sentinel - } - const encryptOptionalToSentinel = ( - slot: string, - plaintext: string | null | undefined - ): string | null => { - const encrypted = encryptToSentinel(slot, plaintext ?? '') - return encrypted || null - } - // Why: clone before encrypting secrets so in-memory this.state stays plaintext. - const stateToSave = { - ...this.getDurableState(), - // Why both keys unconditionally: the explicit keys always win over the spread, and - // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. - // The strip builds a new array here only; this.state records keep their notes in memory. - folderWorkspaces: (this.state.folderWorkspaces ?? []).map( - ({ diffComments: _relocated, ...rest }) => rest - ), - folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments(this.state.folderWorkspaces), - sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ - ...record, - ownerLease: encryptToSentinel( - sshPtyOwnerLeaseSecretSlot(record.targetId), - record.ownerLease - ) - })), - settings: { - ...stripRetiredGlobalSettings(this.state.settings), - opencodeSessionCookie: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - this.state.settings.opencodeSessionCookie - ), - httpProxyUrl: encryptToSentinel( - PROTECTED_SECRET_SLOT.httpProxyUrl, - this.state.settings.httpProxyUrl ?? '' - ) - }, - ui: { - ...this.state.ui, - browserKagiSessionLink: encryptOptionalToSentinel( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - this.state.ui.browserKagiSessionLink - ) - } - } - // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. - // One full-state stringify; secret slots currently hold sentinels. - const serialized = JSON.stringify(stateToSave) - // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, a stable normalized value for the guard hash. Function-form - // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped - // in `serialized`, so each replace is byte-for-byte position-exact. - let payload = serialized - let hashInput = serialized - for (const { sentinel, blob, hashValue } of secretSubs) { - const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) - } - const stateHash = createHash('sha1') - .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') - .update(hashInput) - .digest('hex') - return { payload, stateHash, protectedSecretUpdates } - } - - // Why: async writes avoid blocking the main Electron thread on every debounced save. - private async writeToDiskAsync(): Promise { - if (this.writesFrozen) { - return - } - const gen = this.writeGeneration - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. - if (stateHash === this.lastWrittenStateHash) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - return - } - const dataFile = this.dataFile - const dir = dirname(dataFile) - await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = durableWriteTempPath(dataFile) - - // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. - let renamed = false - try { - // Why: fsync before rename, then fsync the directory; see writeFileDurable. - const handle = await open(tmpFile, 'w') - try { - await handle.writeFile(payload, 'utf-8') - await handle.sync() - } finally { - await handle.close() - } - // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. - if (this.writeGeneration !== gen) { - return - } - this.inFlightAsyncTmpFile = tmpFile - try { - await renameDurable(tmpFile, dataFile) - renamed = true - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || this.writeGeneration === gen) { - throw error - } - } finally { - if (this.inFlightAsyncTmpFile === tmpFile) { - this.inFlightAsyncTmpFile = null - } - } - // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. - if (renamed && this.writeGeneration === gen) { - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - } else if (renamed) { - this.lastWrittenStateHash = null - } - if (renamed) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (!renamed) { - return - } - // Why (#1158): rotate only after the primary rename while this write still owns its generation. - if (this.writeGeneration !== gen) { - return - } - await this.rotateBackupsAsync(dataFile) - } - - // Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes. - private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void { - if (this.writesFrozen) { - return - } - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. - if (!opts.force && stateHash === this.lastWrittenStateHash) { - return - } - const dataFile = this.dataFile - const dir = dirname(dataFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - - // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. - let renamed = false - try { - // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty - // content after power loss, losing projects/tabs back to the newest usable .bak slot. - writeFileDurableSync(tmpFile, dataFile, payload) - renamed = true - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - this.lastDurableWriteGeneration = Math.max( - this.lastDurableWriteGeneration, - this.writeGeneration - ) - } finally { - if (!renamed) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup; the write already failed, swallow secondary error. - } - } - } - const now = Date.now() - if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) { - this.rotateBackupsSync(dataFile) - } - } - - flushOrThrow(): void { - if (this.quitFlushStarted) { - throw new Error('Cannot synchronously flush after final persistence has started') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const asyncWriteWasInFlight = this.pendingWrite !== null - // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. - this.writeGeneration++ - if (this.inFlightAsyncTmpFile) { - try { - unlinkSync(this.inFlightAsyncTmpFile) - this.inFlightAsyncTmpFile = null - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - void this.enqueueWrite().catch(() => {}) - throw error - } - } - } - // Why: later async flushes must remain serialized behind the invalidated writer. - this.writeToDiskSync({ - force: asyncWriteWasInFlight, - skipBackupRotation: this.backupRotationInFlight - }) - } - - flushActiveViewPreferenceOrThrow(): void { - this.activeViewPreference.flushOrThrow() - } - - getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { - return parseCodexResetCreditAttemptLedger(this.state.codexResetCreditAttemptLedger) - } - - replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { - if (this.writesFrozen) { - throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') - } - const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = this.state.codexResetCreditAttemptLedger - ? structuredClone(this.state.codexResetCreditAttemptLedger) - : undefined - this.state.codexResetCreditAttemptLedger = next - try { - this.flushOrThrow() - } catch (error) { - // Why: callers use a successful return as the durability barrier before - // handing a scarce-credit mutation to the provider. - this.state.codexResetCreditAttemptLedger = previous - throw error - } - } - - // ── Repos ────────────────────────────────────────────────────────── - getProfileStorageDirectory(): string { - return dirname(this.dataFile) + return dirname(this.runtime.dataFile) } - private projectHostOperations: ProjectHostPersistenceOperations | null = null - - private getProjectHostOperations(): ProjectHostPersistenceOperations { - this.projectHostOperations ??= new ProjectHostPersistenceOperations({ - state: this.state, - gitUsernameCache: this.gitUsernameCache, - hydrateRepo: (repo) => this.hydrateRepo(repo), - updateRepoBackedProjectHostSetup: (setup, repo, updates) => - this.updateRepoBackedProjectHostSetup(setup, repo, updates), - updateIndependentProjectHostSetup: (setup, updates) => - this.updateIndependentProjectHostSetup(setup, updates), - removeProjectForHost: (id, hostId) => this.removeProjectForHost(id, hostId), - scheduleSave: () => this.scheduleSave() - }) - return this.projectHostOperations - } - - getRepos(): Repo[] { - return this.getProjectHostOperations().getRepos() - } - - getProjects(): Project[] { - return this.getProjectHostOperations().getProjects() - } - - updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { - return this.getProjectHostOperations().updateProject(id, updates) - } - - getProjectHostSetups(): ProjectHostSetup[] { - return this.getProjectHostOperations().getProjectHostSetups() - } - - createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { - return this.getProjectHostOperations().createProjectHostSetup(args) - } - - updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { - return this.getProjectHostOperations().updateProjectHostSetup(args) - } - - deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { - return this.getProjectHostOperations().deleteProjectHostSetup(args) - } - - getRepoCount(): number { - return this.getProjectHostOperations().getRepoCount() - } - - getRepo(id: string): Repo | undefined { - return this.getProjectHostOperations().getRepo(id) - } - - setResolvedRepoGitUsername( - target: Pick, - username: string - ): boolean { - return this.getProjectHostOperations().setResolvedRepoGitUsername(target, username) - } - - private projectGroupOperations: ProjectGroupPersistenceOperations | null = null - - private getProjectGroupOperations(): ProjectGroupPersistenceOperations { - this.projectGroupOperations ??= new ProjectGroupPersistenceOperations({ - state: this.state, - scheduleSave: () => this.scheduleSave(), - removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => - this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), - pruneMobileClientTabSelections: (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId) - }) - return this.projectGroupOperations - } - - getProjectGroups(): ProjectGroup[] { - return this.getProjectGroupOperations().getProjectGroups() - } - - createProjectGroup( - input: Parameters[0] - ): ProjectGroup { - return this.getProjectGroupOperations().createProjectGroup(input) - } - - updateProjectGroup( - groupId: string, - updates: Parameters[1] - ): ProjectGroup | null { - return this.getProjectGroupOperations().updateProjectGroup(groupId, updates) - } - - deleteProjectGroup(groupId: string): boolean { - return this.getProjectGroupOperations().deleteProjectGroup(groupId) - } - - private folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null - - private getFolderWorkspaceOperations(): FolderWorkspacePersistenceOperations { - this.folderWorkspaceOperations ??= new FolderWorkspacePersistenceOperations({ - state: this.state, - scheduleSave: () => this.scheduleSave(), - removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => - this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), - pruneMobileClientTabSelections: (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId), - hydrateRepo: (repo) => this.hydrateRepo(repo) - }) - return this.folderWorkspaceOperations - } - - getFolderWorkspaces(): FolderWorkspace[] { - return this.getFolderWorkspaceOperations().getFolderWorkspaces() - } - - getFolderWorkspace(id: string): FolderWorkspace | undefined { - return this.getFolderWorkspaceOperations().getFolderWorkspace(id) - } - - createFolderWorkspace( - input: Parameters[0] - ): FolderWorkspace { - return this.getFolderWorkspaceOperations().createFolderWorkspace(input) - } - - updateFolderWorkspace( - id: string, - updates: Parameters[1] - ): FolderWorkspace | null { - return this.getFolderWorkspaceOperations().updateFolderWorkspace(id, updates) - } - - removeFolderWorkspace(id: string): boolean { - return this.getFolderWorkspaceOperations().removeFolderWorkspace(id) - } - - moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { - return this.getFolderWorkspaceOperations().moveProjectToGroup(repoId, groupId, order) - } - - private repoOrderOperations: RepoOrderPersistenceOperations | null = null - - private getRepoOrderOperations(): RepoOrderPersistenceOperations { - this.repoOrderOperations ??= new RepoOrderPersistenceOperations({ - state: this.state, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave() - }) - return this.repoOrderOperations - } - - addRepo(repo: Repo): void { - this.getRepoOrderOperations().addRepo(repo) - } - - reorderRepos(orderedIds: string[]): boolean { - return this.getRepoOrderOperations().reorderRepos(orderedIds) - } - - reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { - return this.getRepoOrderOperations().reorderReposForHost(orderedIds, hostId) - } - - removeProject(id: string): void { - this.state.repos = this.state.repos.filter((r) => r.id !== id) - this.syncProjectHostSetupCompatibilityState() - // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. - delete this.state.sparsePresetsByRepo[id] - delete this.state.retiredWorktreeNamesByRepo?.[id] - this.pruneWorktreeStateForRepo(id, null) - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - this.scheduleSave() - } - - // Why: the same repo id can exist on multiple execution hosts; remove only this host's row and metadata, never another host's. - removeProjectForHost(id: string, hostId: ExecutionHostId): void { - this.state.repos = this.state.repos.filter( - (r) => !(r.id === id && getRepoExecutionHostId(r) === hostId) - ) - const idStillPresent = this.state.repos.some((r) => r.id === id) - // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. - if (!idStillPresent) { - delete this.state.sparsePresetsByRepo[id] - delete this.state.retiredWorktreeNamesByRepo?.[id] - } - this.syncProjectHostSetupCompatibilityState() - // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). - this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) - if (!idStillPresent) { - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { - const session = this.state.workspaceSessionsByHostId?.[hostId] - if (session) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: removeRepoFromWorkspaceSession(session, id) - } - } - } - this.scheduleSave() - } - - // Prune worktree meta/lineage for a repo id; hostId null prunes all entries, else only that host's (missing meta.hostId = local). - private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void { - pruneWorktreeStateForRepoOperation(this.state, id, hostId, (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId) - ) - } - - private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { - for (const [clientNavigationId, selectionsByWorktree] of Object.entries( - this.state.mobileClientTabSelectionsByDeviceId ?? {} - )) { - for (const worktreeId of Object.keys(selectionsByWorktree)) { - if (matchesWorktreeId(worktreeId)) { - delete selectionsByWorktree[worktreeId] - } - } - if (Object.keys(selectionsByWorktree).length === 0) { - delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId] - } - } - } - - private repoUpdateOperations: RepoUpdatePersistenceOperations | null = null - - private getRepoUpdateOperations(): RepoUpdatePersistenceOperations { - this.repoUpdateOperations ??= new RepoUpdatePersistenceOperations({ - state: this.state, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave(), - hydrateRepo: (repo) => this.hydrateRepo(repo) - }) - return this.repoUpdateOperations - } - - updateRepo( - id: string, - updates: Partial< - Pick< - Repo, - | 'displayName' - | 'badgeColor' - | 'repoIcon' - | 'upstream' - | 'gitRemoteIdentity' - | 'hookSettings' - | 'worktreeBaseRef' - | 'worktreeBasePath' - | 'kind' - | 'executionHostId' - | 'symlinkPaths' - | 'issueSourcePreference' - | 'forkSyncMode' - | 'externalWorktreeVisibilityPromptDismissedAt' - | 'externalWorktreeInboxBaselinePaths' - | 'importedExternalWorktreePaths' - | 'customWorktreeVisibilitySources' - | 'worktreeVisibilitySourcePreferences' - | 'projectGroupId' - | 'projectGroupOrder' - | 'projectHostSetupMethod' - > - > & { - externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null - agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null - sourceControlAi?: Repo['sourceControlAi'] | null - externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null - }, - hostId?: ExecutionHostId - ): Repo | null { - return this.getRepoUpdateOperations().updateRepo(id, updates, hostId) - } - - private syncProjectHostSetupCompatibilityState(): void { - const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) - this.state.projects = compatibilityState.projects - this.state.projectHostSetups = compatibilityState.projectHostSetups - } - - private projectHostSetupOperations: ProjectHostSetupPersistenceOperations | null = null - - private getProjectHostSetupOperations(): ProjectHostSetupPersistenceOperations { - this.projectHostSetupOperations ??= new ProjectHostSetupPersistenceOperations({ - state: this.state, - updateRepo: (id, updates, hostId) => this.updateRepo(id, updates, hostId), - scheduleSave: () => this.scheduleSave() - }) - return this.projectHostSetupOperations - } - - private updateRepoBackedProjectHostSetup( - setup: ProjectHostSetup, - repo: Repo, - updates: ProjectHostSetupUpdateArgs['updates'] - ): { setup: ProjectHostSetup; repo: Repo } | null { - return this.getProjectHostSetupOperations().updateRepoBackedProjectHostSetup( - setup, - repo, - updates - ) - } - - private updateIndependentProjectHostSetup( - setup: ProjectHostSetup, - updates: ProjectHostSetupUpdateArgs['updates'] - ): ProjectHostSetup { - return this.getProjectHostSetupOperations().updateIndependentProjectHostSetup(setup, updates) - } - - private hydrateRepo(repo: Repo): Repo { - return hydrateRepoOperation(repo, this.gitUsernameCache) - } - - // ── Sparse Presets ───────────────────────────────────────────────── - - // ── Mobile client tab selections ────────────────────────────────── - - getMobileClientTabSelections(): PersistedMobileClientTabSelections { - return this.state.mobileClientTabSelectionsByDeviceId ?? {} - } - - setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { - this.state.mobileClientTabSelectionsByDeviceId = next - this.scheduleSave() - } - - getSparsePresets(repoId: string): SparsePreset[] { - return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) => - left.name.localeCompare(right.name) - ) - } - - saveSparsePreset(preset: SparsePreset): SparsePreset { - const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? [] - const index = existing.findIndex((entry) => entry.id === preset.id) - this.state.sparsePresetsByRepo[preset.repoId] = - index === -1 - ? [...existing, preset] - : existing.map((entry, i) => (i === index ? preset : entry)) - this.scheduleSave() - return preset - } - - removeSparsePreset(repoId: string, presetId: string): void { - const existing = this.state.sparsePresetsByRepo[repoId] ?? [] - this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId) - this.scheduleSave() - } - - // ── Automations ─────────────────────────────────────────────────── - - private getAutomationDefinitionOperations(): AutomationDefinitionOperations { - return { - state: this.state, - flush: () => this.flush(), - recordCreated: () => this.recordFeatureInteraction('automation-created') - } - } - - private getAutomationRunOperations(): AutomationRunOperations { - return { - state: this.state, - flush: () => this.flush(), - recordManualRun: () => this.recordFeatureInteraction('automation-run'), - getWorkspaceDisplayName: (workspaceId) => - this.getAutomationRunWorkspaceDisplayName(workspaceId) - } - } - - listAutomations(): Automation[] { - return listAutomationsOperation(this.state) - } - - listAutomationRuns(automationId?: string): AutomationRun[] { - return listAutomationRunsOperation(this.state, automationId) - } - - createAutomation(input: AutomationCreateInput): Automation { - return createAutomationOperation(this.getAutomationDefinitionOperations(), input) - } - - updateAutomation(id: string, updates: AutomationUpdateInput): Automation { - return updateAutomationOperation(this.getAutomationDefinitionOperations(), id, updates) - } - - deleteAutomation(id: string): void { - deleteAutomationOperation(this.getAutomationDefinitionOperations(), id) - } - - createAutomationRun( - automation: Automation, - scheduledFor: number, - trigger: AutomationRunTrigger = 'scheduled' - ): AutomationRun { - return createAutomationRunOperation( - this.getAutomationRunOperations(), - automation, - scheduledFor, - trigger - ) - } - - updateAutomationRun(result: AutomationDispatchResult): AutomationRun { - return updateAutomationRunOperation(this.getAutomationRunOperations(), result) - } - - snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { - return snapshotAutomationRunWorkspaceDisplayNameOperation( - this.getAutomationRunOperations(), - workspaceId, - displayName - ) - } - - 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 { - return advanceAutomationNextRunOperation(this.state, () => this.flush(), id, now) - } - - getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { - return getLatestAutomationOccurrenceOperation(automation, now) - } - - // ── Worktree Meta ────────────────────────────────────────────────── - - getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { - return this.state.worktreeMeta[worktreeId] - } - - getAllWorktreeMeta(): Record { - return this.state.worktreeMeta - } - - setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { - const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() - const updated = { ...existing, ...meta } - updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) - const linkedTaskSourceContext = normalizeStoredTaskSourceContext( - updated.linkedTaskSourceContext - ) - updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - updated.linkedWorkItem, - linkedTaskSourceContext - ) - ? linkedTaskSourceContext - : null - if (!updated.instanceId) { - updated.instanceId = randomUUID() - } - this.state.worktreeMeta[worktreeId] = updated - this.scheduleSave() - return updated - } - - removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { - // A host-qualified removal names the owner; the persisted host is the fallback. - const persistedOwner = this.state.worktreeMeta[worktreeId]?.hostId - const owner = hostId ?? persistedOwner - const preservesDifferentPersistedOwner = Boolean( - hostId && persistedOwner && persistedOwner !== hostId - ) - const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) - const preservesSameIdSessionOwner = Boolean( - preservesDifferentPersistedOwner || - (owner && - hasWorktreeRemovalRepoOwnerOnOtherHost( - this, - getRepoIdFromWorktreeId(worktreeId), - ownerPartition - )) - ) - // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. - const partitions = new Set( - workspaceSessionPartitionIdsForHost(owner).filter( - (partition) => - this.hasPersistedWorkspaceSession(partition) && - // The local partition can be a remote spill surface or a same-id owner. - // Preserve it whenever another owner may still use the bare id. - (!preservesSameIdSessionOwner || partition === ownerPartition) - ) - ) - // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill - // partition that never held this worktree has no claim on the repo at all. - const fencedPartitions = new Set( - [...partitions].filter( - (partition) => - this.partitionOwnsWorktreeTabs(worktreeId, partition) || - (partition === ownerPartition && - !this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition)) - ) - ) - if (!preservesDifferentPersistedOwner) { - delete this.state.worktreeMeta[worktreeId] - delete this.state.worktreeLineageById[worktreeId] - delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] - } - for (const partition of partitions) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, { - advanceTerminalTopologyRevision: fencedPartitions.has(partition) - }) - } - this.scheduleSave() - } - - getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { - return this.state.worktreeLineageById[worktreeId] - } - - getAllWorktreeLineage(): Record { - return this.state.worktreeLineageById - } - - setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { - this.state.worktreeLineageById[worktreeId] = lineage - this.scheduleSave() - return lineage - } - - removeWorktreeLineage(worktreeId: string): void { - delete this.state.worktreeLineageById[worktreeId] - this.scheduleSave() - } - - /** - * Re-key every worktreeId-keyed record from `oldWorktreeId` to `newWorktreeId` after the worktree folder (and its - * `${repoId}::${path}` id) was renamed on disk, so a refresh re-binds state instead of orphaning it. Records the old id on - * the new meta's `priorWorktreeIds` so session GC/hydration still recognizes PTY sessions minted under it. No-op when ids match. - * Renderer counterpart: `buildWorktreeRenameState` in store/slices/worktrees.ts. - */ - migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { - if (migrateWorktreeIdentityOperation(this.state, oldWorktreeId, newWorktreeId)) { - this.scheduleSave() - } - } - - getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { - return this.state.workspaceLineageByChildKey[childWorkspaceKey] - } - - getAllWorkspaceLineage(): Record { - return this.state.workspaceLineageByChildKey - } - - setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { - this.state.workspaceLineageByChildKey[lineage.childWorkspaceKey] = lineage - this.scheduleSave() - return lineage - } - - removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { - delete this.state.workspaceLineageByChildKey[childWorkspaceKey] - this.scheduleSave() - } - - private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { - const parentKey = folderWorkspaceKey(folderWorkspaceId) - for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { - if (lineage.parentWorkspaceKey === parentKey) { - delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] - } - } - } - - // ── Settings ─────────────────────────────────────────────────────── - - getSettings(): GlobalSettings { - return this.state.settings - } - - onSettingsChanged( - listener: ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - ): () => void { - this.settingsChangeListeners.add(listener) - return () => { - this.settingsChangeListeners.delete(listener) - } - } - - private notifySettingsChanged( - updates: Partial, - originWebContentsId?: number - ): void { - for (const listener of this.settingsChangeListeners) { - listener(updates, this.state.settings, originWebContentsId) - } - } - - // Why: renderer-visible UI state is written from desktop and mobile, so notify to keep 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) - } - } - - private getSettingsMutationOperations(): SettingsMutationOperations { - return { - state: this.state, - removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), - scheduleSave: () => this.scheduleSave(), - notifySettingsChanged: (updates, originWebContentsId) => - this.notifySettingsChanged(updates, originWebContentsId) - } - } - - updateSettings( - updates: Partial, - options: { notifyListeners?: boolean; originWebContentsId?: number } = {} - ): GlobalSettings { - return updateSettingsOperation(this.getSettingsMutationOperations(), updates, options) - } - - // ── UI State ─────────────────────────────────────────────────────── - - private getUIUpdateOperations(): UIUpdateOperations { - return { - state: this.state, - removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), - setActiveView: (activeView) => this.activeViewPreference.set(activeView), - getUI: () => this.getUI(), - scheduleSave: () => this.scheduleSave(), - notifyUIChanged: () => this.notifyUIChanged() - } - } - - getUI(): PersistedState['ui'] { - return getPersistedUI(this.state, this.activeViewPreference.get()) - } - - updateUI(updates: Partial): void { - updatePersistedUI(this.getUIUpdateOperations(), updates) - } - - private getFeatureInteractionOperations(): FeatureInteractionOperations { - return { - state: this.state, - scheduleSave: () => this.scheduleSave(), - notifyUIChanged: () => this.notifyUIChanged(), - getUI: () => this.getUI() - } - } - - recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { - return recordFeatureInteractionOperation(this.getFeatureInteractionOperations(), id) - } - - // ── Onboarding ──────────────────────────────────────────────────── - - getOnboarding(): PersistedState['onboarding'] { - const defaults = getDefaultOnboardingState() - return { - ...defaults, - ...this.state.onboarding, - checklist: { - ...defaults.checklist, - ...this.state.onboarding?.checklist - } - } - } - - updateOnboarding( - updates: Partial> & { - checklist?: Partial - } - ): PersistedState['onboarding'] { - const current = this.getOnboarding() - this.state.onboarding = { - ...current, - ...updates, - checklist: { - ...current.checklist, - ...updates.checklist - } - } - this.scheduleSave() - return this.getOnboarding() - } - - // ── GitHub Cache ────────────────────────────────────────────────── - - getGitHubCache(): PersistedState['githubCache'] { - return this.state.githubCache - } - - setGitHubCache(cache: PersistedState['githubCache']): void { - // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. - this.state.githubCache = cache - this.githubCacheDirty = true - this.githubCacheGeneration += 1 - } - - // ── Workspace Session ───────────────────────────────────────────── - - /** Resolve an execution host argument to a canonical id; unknown/empty falls back to 'local' for legacy callers. */ - private resolveHostId(hostId?: string | null): ExecutionHostId { - return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - } - - getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - return this.state.workspaceSession ?? getDefaultWorkspaceSession() - } - return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() - } - - /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ - private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { - return ( - hostId === LOCAL_EXECUTION_HOST_ID || - this.state.workspaceSessionsByHostId?.[hostId] !== undefined - ) - } - - getWorkspaceSessionHostIds(): ExecutionHostId[] { - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) { - const hostId = normalizeExecutionHostId(key) - if (hostId) { - hostIds.add(hostId) - } - } - return [...hostIds] - } - - readTerminalScrollbackSnapshot(ref: string): string | null { - return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage) - } - - /** Resolve the worktree a terminal tab belongs to; more reliable than agent-echoed hook fields. */ - getWorktreeIdForTab(tabId: string): string | undefined { - return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) - } - - setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - removeWorkspaceSessionStateForWorktree( - worktreeId: string, - hostId?: ExecutionHostId | null, - options: { advanceTerminalTopologyRevision?: boolean } = {} - ): void { - for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, resolved, options) - } - } - - private removeWorkspaceSessionOwnerInPartition( - worktreeId: string, - resolved: ExecutionHostId, - options: { advanceTerminalTopologyRevision?: boolean } - ): void { - if (!this.hasPersistedWorkspaceSession(resolved)) { - return - } - const current = this.getWorkspaceSession(resolved) - const session = removeWorkspaceSessionOwner(current, worktreeId, { - advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true - }) - if (!session) { - return - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = session - } else { - // Host scoping matters because identical repo/path ids may exist on two servers. - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: session - } - } - this.scheduleSave() - } - - /** Whether a partition still holds terminal membership for `worktreeId`. */ - private partitionOwnsWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - return this.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined - } - - /** Whether fencing this partition would rebase a sibling worktree of the same repo. */ - private partitionHasOtherRepoWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const tabsByWorktree = this.getWorkspaceSession(hostId).tabsByWorktree ?? {} - return Object.entries(tabsByWorktree).some( - ([id, tabs]) => - id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 - ) - } - - stageWorkspaceSessionBeforeUnload( - session: PersistedState['workspaceSession'], - hostId?: string | null - ): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session, true) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - /** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */ - private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { - // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. - session = sanitizeWorkspaceSessionTerminalRetirements( - session, - this.state.workspaceSessionsByHostId?.[hostId] - ) - const pruned = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: pruned - } - this.scheduleSave() - } - - private setLocalWorkspaceSession( - session: PersistedState['workspaceSession'], - deferSnapshotFiles = false - ): void { - const prior = this.state.workspaceSession - session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) - session = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - - // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. - const normalized = normalizeWorkspaceSessionPaneIdentities( - session, - prior?.terminalLayoutsByTabId - ) - for (const entry of normalized.migrationUnsupportedEntries) { - setMigrationUnsupportedPty(entry) - } - const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( - this.state.ui?.acknowledgedAgentsByPaneKey, - normalized.leafIdByInputLeafIdByTabId - ) - if (remappedAcknowledgements.changed) { - this.state.ui = { - ...this.state.ui, - acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements - } - } - for (const entry of normalized.legacyPaneKeyAliasEntries) { - registerPersistedPaneKeyAlias(entry) - } - session = normalized.session - const remapsByHostId = new Map([ - [LOCAL_EXECUTION_HOST_ID, normalized] - ]) - const remappedLeases = remapSshRemotePtyLeaseLeafIds( - this.state.sshRemotePtyLeases ?? [], - remapsByHostId, - new Set(this.getWorkspaceSessionHostIds()) - ) - if (remappedLeases.changed) { - this.state.sshRemotePtyLeases = remappedLeases.leases - } - if (session && prior) { - const priorTabs = prior.tabsByWorktree ?? {} - const nextTabs = session.tabsByWorktree ?? {} - const worktreeIdByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - for (const [worktreeId, tabs] of Object.entries(nextTabs)) { - const priorList = priorTabs[worktreeId] - if (!priorList) { - continue - } - for (const tab of tabs) { - if (tab.ptyId) { - continue - } - const priorTab = priorList.find((t) => t.id === tab.id) - if ( - priorTab?.ptyId && - this.isRestorablePtyBinding({ - ptyId: priorTab.ptyId, - worktreeId, - targetId: this.getConnectionIdForWorktree(worktreeId), - tabId: tab.id - }) - ) { - tab.ptyId = priorTab.ptyId - } - } - } - const priorLayouts = prior.terminalLayoutsByTabId ?? {} - const nextLayouts = session.terminalLayoutsByTabId ?? {} - for (const [tabId, layout] of Object.entries(nextLayouts)) { - const priorLayout = priorLayouts[tabId] - if (!priorLayout?.ptyIdsByLeafId) { - continue - } - const incoming = layout.ptyIdsByLeafId ?? {} - const incomingHasAnyBinding = Object.keys(incoming).length > 0 - const liveLeafIds = this.getTerminalLayoutLeafIds(layout.root) - const worktreeId = worktreeIdByTabId.get(tabId) - const targetId = worktreeId ? this.getConnectionIdForWorktree(worktreeId) : null - const restorableBindings = Object.fromEntries( - Object.entries(priorLayout.ptyIdsByLeafId).filter( - ([leafId, ptyId]) => - liveLeafIds.has(leafId) && - incoming[leafId] === undefined && - // Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it. - (incomingHasAnyBinding - ? this.hasRestorableSshRemotePtyLease({ - ptyId, - targetId, - worktreeId, - tabId, - leafId - }) - : this.isRestorablePtyBinding({ ptyId, targetId, worktreeId, tabId, leafId })) - ) - ) - if (Object.keys(restorableBindings).length > 0) { - layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } - // Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. - const buffersByLeafId = preserveMissingLeafRecordEntries( - priorLayout.buffersByLeafId, - layout.buffersByLeafId, - liveLeafIds - ) - const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( - priorLayout.scrollbackRefsByLeafId, - layout.scrollbackRefsByLeafId, - liveLeafIds - ) - const titlesByLeafId = preserveMissingLeafRecordEntries( - priorLayout.titlesByLeafId, - layout.titlesByLeafId, - liveLeafIds - ) - if (buffersByLeafId) { - layout.buffersByLeafId = buffersByLeafId - } - if (scrollbackRefsByLeafId) { - layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId - } - if (titlesByLeafId) { - layout.titlesByLeafId = titlesByLeafId - } - } - } - } - session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - if (!deferSnapshotFiles) { - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - session, - this.terminalScrollbackSnapshotStorage - ) - session = migratedScrollback.session - deleteRemovedTerminalScrollbackSnapshots( - prior, - session, - this.terminalScrollbackSnapshotStorage - ) - } - this.state.workspaceSession = session - if (deferSnapshotFiles) { - this.enqueueTerminalScrollbackSnapshotWork(prior, session) - } - this.scheduleSave() - } - - private enqueueTerminalScrollbackSnapshotWork( - prior: WorkspaceSessionState | undefined, - staged: WorkspaceSessionState - ): void { - const previous = this.pendingSnapshotFileWork ?? Promise.resolve() - const work = previous - .then(async () => { - if (this.state.workspaceSession !== staged) { - if (this.state.workspaceSession) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - this.state.workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - } - return - } - const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( - staged, - this.terminalScrollbackSnapshotStorage - ) - const current = - this.state.workspaceSession === staged ? migrated : this.state.workspaceSession - if (this.state.workspaceSession === staged) { - this.state.workspaceSession = migrated - } else if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - migrated, - current, - this.terminalScrollbackSnapshotStorage - ) - } - if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - current, - this.terminalScrollbackSnapshotStorage - ) - } - }) - .catch((error) => { - console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) - }) - .finally(() => { - if (this.pendingSnapshotFileWork === work) { - this.pendingSnapshotFileWork = null - } - }) - this.pendingSnapshotFileWork = work - } - - patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. - let next: WorkspaceSessionState = { - ...this.getWorkspaceSession(resolved), - ...patch - } - if (workspaceSessionPatchNeedsFullNormalization(patch)) { - this.setWorkspaceSession(next, resolved) - return - } - if (Object.hasOwn(patch, 'browserUrlHistory')) { - next = pruneWorkspaceSessionBrowserHistory(next) - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = next - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: next - } - } - this.scheduleSave() - } - - private getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { - const leafIds = new Set() - const visit = (node: TerminalPaneLayoutNode | null): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - if (isTerminalLeafId(node.leafId)) { - leafIds.add(node.leafId) - } - return - } - visit(node.first) - visit(node.second) - } - visit(root) - return leafIds - } - - private isRestorablePtyBinding(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - const leases = this.state.sshRemotePtyLeases?.filter((entry) => - this.sshRemotePtyLeaseMatchesBinding(entry, binding) - ) - return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired') - } - - private getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { - try { - return toRelaySshPtyId(targetId, ptyId) - } catch { - return ptyId - } - } - - private getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { - return toRelaySshPtyId(targetId, ptyId) - } - - private sshRemotePtyLeaseMatchesBinding( - lease: SshRemotePtyLease, - binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - } - ): boolean { - const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) - if (lease.ptyId !== bindingPtyId) { - return false - } - // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. - return ( - (binding.targetId === undefined || - binding.targetId === null || - lease.targetId === binding.targetId) && - (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && - (binding.tabId === undefined || lease.tabId === binding.tabId) && - (binding.leafId === undefined || lease.leafId === binding.leafId) - ) - } - - private hasRestorableSshRemotePtyLease(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - return ( - this.state.sshRemotePtyLeases?.some( - (lease) => - this.sshRemotePtyLeaseMatchesBinding(lease, binding) && - lease.state !== 'terminated' && - lease.state !== 'expired' - ) ?? false - ) - } - - private getConnectionIdForWorktree(worktreeId: string): string | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return this.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null - } - - // Why: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217). - persistPtyBinding( - args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - startupCwd?: string - expectedBinding?: { ptyId: string; incarnationId?: string } - expectedSourceBinding?: PtyBindingSourceExpectation - /** Set by host-initiated creates, which have no renderer session writer behind them. */ - hostAdmittedMembership?: boolean - }, - hostId?: string | null - ): boolean { - const resolvedHostId = this.resolveHostId(hostId) - const session = this.getWorkspaceSession(resolvedHostId) - const paneKey = `${args.tabId}:${args.leafId}` - const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId - if (args.expectedSourceBinding) { - const expected = args.expectedSourceBinding - if (expected.tabId !== args.tabId) { - return false - } - const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId - ) - const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] - const sourcePaneKey = `${expected.tabId}:${expected.leafId}` - if ( - !sourceTab || - sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || - !layoutContainsLeafId(sourceLayout.root, expected.leafId) || - (expected.incarnationId !== undefined && - session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) - ) { - return false - } - } - if (args.expectedBinding) { - const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId - ) - const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] - if ( - !tab || - boundPtyId !== args.expectedBinding.ptyId || - session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId - ) { - return false - } - } - if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: session - } - } - const sessionBeforeBinding = cloneWorkspaceSessionState(session) - const reconciledIncarnation = - args.expectedBinding !== undefined && - args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - let hostAdmittedTabCreated = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } - } - const restoreSession = (): void => { - if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = sessionBeforeBinding - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding - } - } - } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey - } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] - } - } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) - if (tab) { - tab.ptyId = args.ptyId - } else { - terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId - } - } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] - if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } - } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null - } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } - } - } - } - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - - // ── SSH Targets ──────────────────────────────────────────────────── - - private getSshTargetStateOperations(): SshTargetStateOperations { - return { - state: this.state, - protectedSecrets: this.protectedSecrets, - scheduleSave: () => this.scheduleSave(), - flush: () => this.flush() - } - } - - getSshTargets(): SshTarget[] { - return getSshTargetsOperation(this.state) - } - - getSshTarget(id: string): SshTarget | undefined { - return getSshTargetOperation(this.state, id) - } - - addSshTarget(target: SshTarget): void { - addSshTargetOperation(this.getSshTargetStateOperations(), target) - } - - updateSshTarget(id: string, updates: Partial>): SshTarget | null { - return updateSshTargetOperation(this.getSshTargetStateOperations(), id, updates) - } - - removeSshTarget(id: string): void { - removeSshTargetOperation(this.getSshTargetStateOperations(), id) - } - - // ── Live Claude PTY sessions ─────────────────────────────────────── - - getClaudeLivePtySessionIds(): string[] { - return getClaudeLivePtySessionIdsOperation(this.state) - } - - addClaudeLivePtySessionId(sessionId: string): void { - addClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) - } - - removeClaudeLivePtySessionId(sessionId: string): void { - removeClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) - } - - getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByRepo?.[repoId] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByNamespace?.[namespaceKey] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - addRetiredWorktreeName(repoId: string, name: string): void { - const normalized = normalizeRetirableGeneratedName(name) - if (!repoId || !normalized) { - return - } - this.applyRetiredWorktreeNames(repoId, [normalized]) - } - - mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - if (!repoId) { - return false - } - const incoming = new Set() - for (const name of names) { - const normalized = normalizeRetirableGeneratedName(name) - if (normalized) { - incoming.add(normalized) - } - } - return incoming.size > 0 && this.applyRetiredWorktreeNames(repoId, incoming) - } - - mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { - if (!namespaceKey) { - return false - } - const normalized = new Set() - for (const name of names) { - const candidate = normalizeRetirableGeneratedName(name) - if (candidate) { - normalized.add(candidate) - } - } - const next = addRetiredNames( - this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), - normalized - ) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByNamespace ??= {} - recordRetirementNamespaceRegistry( - this.state.retiredWorktreeNamesByNamespace, - namespaceKey, - next - ) - this.scheduleSave() - return true - } - - private applyRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - const next = addRetiredNames(this.getRetiredWorktreeNameRegistry(repoId), names) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByRepo ??= {} - this.state.retiredWorktreeNamesByRepo[repoId] = next - this.scheduleSave() - return true - } - - getDeletedSshConfigAliases(): string[] { - return getDeletedSshConfigAliasesOperation(this.state) - } - - addDeletedSshConfigAlias(alias: string): void { - addDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) - } - - removeDeletedSshConfigAlias(alias: string): void { - removeDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) - } - - clearDeletedSshConfigAliases(): void { - clearDeletedSshConfigAliasesOperation(this.getSshTargetStateOperations()) - } - - getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { - return getRemovedSshTargetTombstonesOperation(this.state) - } - - addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { - addRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), tombstone) - } - - removeRemovedSshTargetTombstone(oldTargetId: string): void { - removeRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), oldTargetId) - } - - /** - * Re-point every repo and worktree meta pinned to a removed SSH target id onto - * a re-added target's id so orphaned workspaces reattach. Returns re-pointed repo ids. - */ - reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { - const operations: SshTargetReassignmentOperations = { - state: this.state, - protectedSecrets: this.protectedSecrets, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave() - } - return reassignSshTargetIdOperation(operations, oldTargetId, newTargetId) - } - - // ── SSH PTY Consumer Recovery ────────────────────────────────────── - - private getSshPtyConsumerRecoveryOperations(): SshPtyConsumerRecoveryOperations { - return { - state: this.state, - protectedSecrets: this.protectedSecrets, - flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() - } - } - - getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { - return getSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), targetId) - } - - async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { - await upsertSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), record) - } - - async removeSshPtyConsumerRecovery(targetId: string): Promise { - await removeSshPtyConsumerRecoveryOperation( - this.getSshPtyConsumerRecoveryOperations(), - targetId - ) - } - - // ── SSH Remote PTY Leases ────────────────────────────────────────── - - private getSshPtyBindingCleanupOperations(): SshPtyBindingCleanupOperations { - return { - state: this.state, - toComparablePtyId: (targetId, ptyId) => - this.getRelayPtyIdForSshLeaseComparison(targetId, ptyId), - scheduleSave: () => this.scheduleSave() - } - } - - private getSshPtyLeaseOperations(): SshPtyLeaseOperations { - return { - state: this.state, - toStoredPtyId: (targetId, ptyId) => this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId), - clearBindingsForTarget: (targetId) => - clearSshRemotePtyBindingsForTargetOperation( - this.getSshPtyBindingCleanupOperations(), - targetId - ), - clearBindingsForLeases: (targetId, leases) => - clearSshRemotePtyBindingsForLeasesOperation( - this.getSshPtyBindingCleanupOperations(), - targetId, - leases - ), - flush: () => this.flush(), - flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() - } - } - - getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { - return getSshRemotePtyLeasesOperation(this.state, targetId) - } - - upsertSshRemotePtyLease( - lease: Omit & - Partial> - ): void { - upsertSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), lease) - } - - markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - // Why no write of its own: the committed quit path calls this immediately before the final store - // flush, and that flush is what persists it. A durable write here would race the flush and be - // rejected the moment it latches, which is exactly how an attached lease used to survive quit. - markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeasesForShutdownOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - async markSshRemotePtyLeasesAsync( - targetId: string, - state: SshRemotePtyLease['state'] - ): Promise { - await markSshRemotePtyLeasesAsyncOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - async markSshRemotePtyLeasesAttachedAsync( - targetId: string, - ptyIds: readonly string[] - ): Promise { - await markSshRemotePtyLeasesAttachedAsyncOperation( - this.getSshPtyLeaseOperations(), - targetId, - ptyIds - ) - } - - markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId, state) - } - - removeSshRemotePtyLease(targetId: string, ptyId: string): void { - removeSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId) - } - - removeSshRemotePtyLeases(targetId: string): void { - removeSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId) - } - - // ── Flush (for shutdown) ─────────────────────────────────────────── - - flush(): void { - if (this.quitFlushStarted) { - return - } - try { - this.flushOrThrow() - } catch (err) { - console.error('[persistence] Failed to flush state:', err) - } - try { - this.flushActiveViewPreferenceOrThrow() - } catch (err) { - console.error('[active-view] Failed to flush preference:', err) - } - this.writeGithubCacheSnapshotSync() - } - - /** - * Async twin of flush() for the quit path. - * - * Why the quit path needs one: writeToDiskSync fsyncs a multi-MB file from the Electron - * main thread. On a stalled network profile mount that syscall is uninterruptible, so the - * app stops repainting and Force Quit stops working — and no main-thread deadline can - * bound it, because the deadline's own timer is stuck behind the same block. - * - * Never throws — it joins the quit teardown barrier, where a rejection is noise. - */ - flushAsync(): Promise { - if (this.quitFlushPromise) { - return this.quitFlushPromise - } - this.quitFlushStarted = true - this.quitFlushPromise = this.flushCurrentStateAsync(true).catch(() => {}) - return this.quitFlushPromise - } - - flushPendingAsync(): Promise { - // Best-effort callers must not livelock while the live app keeps mutating state. - return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) - } - - flushPendingOrThrowAsync( - options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} - ): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - return Promise.reject(new Error('Cannot flush while persistence is finalized')) - } - return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true) - } - - // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are - // quit/startup work and must not be snapshotted on the live SSH establish/reconnect path. - private async flushDurableStateOrThrowAsync(): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - throw new Error('Cannot flush while persistence is finalized') - } - for (;;) { - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - await this.enqueueWrite() - if (generation === this.writeGeneration) { - break - } - } - } - - private async flushCurrentStateAsync( - final: boolean, - signal?: AbortSignal, - drainToStableGeneration = true, - requireInitialGenerationDurable = false - ): Promise { - const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null - for (;;) { - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - try { - await this.enqueueWrite() - } catch (error) { - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - throw error - } - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (!drainToStableGeneration) { - if ( - requiredDurableGeneration === null || - this.lastDurableWriteGeneration >= requiredDurableGeneration - ) { - break - } - continue - } - if (generation === this.writeGeneration) { - break - } - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private async writeGithubCacheSnapshotAsync( - drainToStableGeneration = true, - signal?: AbortSignal - ): Promise { - if (!this.githubCacheDirty) { - return - } - const previousWrite = this.pendingGithubCacheWrite ?? this.staleGithubCacheTempCleanup - const nextWrite = previousWrite - .then(async () => { - while (this.githubCacheDirty) { - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - const generation = this.githubCacheGeneration - const cacheFile = getGithubCacheFile(this.dataFile) - const tmpFile = durableWriteTempPath(cacheFile) - let renamed = false - try { - await writeFile(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - if (generation === this.githubCacheGeneration) { - await rename(tmpFile, cacheFile) - renamed = true - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - if (!drainToStableGeneration) { - break - } - } - }) - .catch((err) => { - console.warn('[persistence] Failed to write github cache snapshot:', err) - }) - .finally(() => { - if (this.pendingGithubCacheWrite === nextWrite) { - this.pendingGithubCacheWrite = null - } - }) - this.pendingGithubCacheWrite = nextWrite - await nextWrite - } - - // Why: a project move rewrote the data file directly; in-memory state is now stale and any write would undo the transfer. freezeWrites(): void { - this.writesFrozen = true - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private writeGithubCacheSnapshotSync(): void { - if (!this.githubCacheDirty) { - return - } - if (this.pendingGithubCacheWrite) { - void this.writeGithubCacheSnapshotAsync() - return - } - const cacheFile = getGithubCacheFile(this.dataFile) - const generation = this.githubCacheGeneration - const tmpFile = durableWriteTempPath(cacheFile) - try { - writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - renameSync(tmpFile, cacheFile) - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } catch (err) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup. - } - console.warn('[persistence] Failed to write github cache snapshot:', err) + this.runtime.writesFrozen = true + if (this.runtime.writeTimer) { + clearTimeout(this.runtime.writeTimer) + this.runtime.writeTimer = null } } } -function getDefaultWorktreeMeta(): WorktreeMeta { - return { - instanceId: randomUUID(), - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - linkedBitbucketPR: null, - linkedAzureDevOpsPR: null, - linkedGiteaPR: null, - linkedWorkItem: null, - linkedTaskSourceContext: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: Date.now(), - lastActivityAt: 0, - workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID +// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging derives Store's prototype API directly from the exact concrete domain classes installed below +export interface Store + extends + WriteSchedulingOperations, + PrimaryStateWriteOperations, + ProjectCollectionOperations, + RepoLifecycleOperations, + MobileTabSelectionPersistence, + SparsePresetPersistence, + AutomationPersistence, + MetadataLineageOperations, + ProfilePreferences, + SessionHostPartitionOperations, + SessionSnapshotOperations, + PtyBindingPersistenceOperations, + SshProfileOperations, + RetiredWorktreeNamePersistence, + SshLeaseRecoveryOperations, + WriteFlushBarrierOperations {} + +for (const OperationClass of STORE_DOMAIN_OPERATION_CLASSES) { + const descriptors = Object.getOwnPropertyDescriptors(OperationClass.prototype) + for (const [name, descriptor] of Object.entries(descriptors)) { + if (name !== 'constructor') { + Object.defineProperty(Store.prototype, name, descriptor) + } } } diff --git a/src/main/persistence/loading-store/terminal-binding-recovery.ts b/src/main/persistence/loading-store/terminal-binding-recovery.ts new file mode 100644 index 00000000000..a3e03098b70 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-binding-recovery.ts @@ -0,0 +1,104 @@ +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { toRelaySshPtyId } from '../../providers/ssh-pty-id' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +import type { StoreRuntimeState } from './store-runtime-state' + +type TerminalBindingRecoveryOperationsRuntime = Pick + +export class TerminalBindingRecoveryOperations { + constructor(private readonly runtime: TerminalBindingRecoveryOperationsRuntime) {} + + getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { + const leafIds = new Set() + const visit = (node: TerminalPaneLayoutNode | null): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + if (isTerminalLeafId(node.leafId)) { + leafIds.add(node.leafId) + } + return + } + visit(node.first) + visit(node.second) + } + visit(root) + return leafIds + } + + isRestorablePtyBinding(binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + }): boolean { + const leases = this.runtime.state.sshRemotePtyLeases?.filter((entry) => + this.sshRemotePtyLeaseMatchesBinding(entry, binding) + ) + return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired') + } + + getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { + try { + return toRelaySshPtyId(targetId, ptyId) + } catch { + return ptyId + } + } + + getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { + return toRelaySshPtyId(targetId, ptyId) + } + + sshRemotePtyLeaseMatchesBinding( + lease: SshRemotePtyLease, + binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + } + ): boolean { + const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) + if (lease.ptyId !== bindingPtyId) { + return false + } + // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. + return ( + (binding.targetId === undefined || + binding.targetId === null || + lease.targetId === binding.targetId) && + (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && + (binding.tabId === undefined || lease.tabId === binding.tabId) && + (binding.leafId === undefined || lease.leafId === binding.leafId) + ) + } + + hasRestorableSshRemotePtyLease(binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + }): boolean { + return ( + this.runtime.state.sshRemotePtyLeases?.some( + (lease) => + this.sshRemotePtyLeaseMatchesBinding(lease, binding) && + lease.state !== 'terminated' && + lease.state !== 'expired' + ) ?? false + ) + } + + getConnectionIdForWorktree(worktreeId: string): string | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return this.runtime.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null + } +} diff --git a/src/main/persistence/loading-store/terminal-session-cleanup.ts b/src/main/persistence/loading-store/terminal-session-cleanup.ts new file mode 100644 index 00000000000..2981cabc114 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-session-cleanup.ts @@ -0,0 +1,36 @@ +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { + collectTerminalScrollbackSnapshotRefs, + deleteTerminalScrollbackSnapshotSync, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' + +const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ + 'tabsByWorktree', + 'terminalLayoutsByTabId' +]) + +export function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { + return Object.keys(patch).some((key) => + WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) + ) +} + +export 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) + } + } +} diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts new file mode 100644 index 00000000000..50e9d4ae8cc --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-partitions.ts @@ -0,0 +1,56 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' + +export function workspaceSessionSalvageLogDetails(result: { + droppedCount: number + droppedPaths: string[] +}): { count: number; fields: string[]; detailsTruncated: boolean } { + return { + count: result.droppedCount, + fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], + detailsTruncated: result.droppedCount > result.droppedPaths.length + } +} + +/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. + * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ +export function parseWorkspaceSessionsByHostId( + raw: unknown, + defaults: WorkspaceSessionState +): { partitions: Partial>; repaired: boolean } { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { partitions: {}, repaired: raw !== undefined } + } + let repaired = false + const partitions: Partial> = {} + for (const [key, value] of Object.entries(raw as Record)) { + const hostId = normalizeExecutionHostId(key) + // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. + if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { + continue + } + const result = parseWorkspaceSessionSalvaging(value) + if (!result.ok) { + repaired = true + console.error( + `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, + result.error + ) + continue + } + if (result.droppedCount > 0) { + console.warn( + `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, + workspaceSessionSalvageLogDetails(result) + ) + repaired = true + } + partitions[hostId] = { ...defaults, ...result.value } + } + return { partitions, repaired } +} diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts new file mode 100644 index 00000000000..6f52fdc77ca --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -0,0 +1,242 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import { setMigrationUnsupportedPty } from '../../agent-hooks/migration-unsupported-pty-state' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' +import { + deleteRemovedTerminalScrollbackSnapshotsAsync, + migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync +} from '../../terminal-scrollback-snapshot-async-migration' +import { preserveMissingLeafRecordEntries } from '../restoring-sessions/terminal-layout-normalization' +import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' +import { + normalizeWorkspaceSessionPaneIdentities, + remapAcknowledgedAgentPaneKeys, + remapSshRemotePtyLeaseLeafIds, + type WorkspaceSessionPaneIdentityRemap +} from '../restoring-sessions/workspace-pane-normalization' +import { deleteRemovedTerminalScrollbackSnapshots } from './terminal-session-cleanup' +import { + getSessionSnapshotOperationsContext, + type SessionSnapshotOperations +} from './session-snapshot-operations' +import { scheduleSave } from './write-scheduling' + +export function setLocalWorkspaceSession( + owner: SessionSnapshotOperations, + session: PersistedState['workspaceSession'], + deferSnapshotFiles = false +): void { + const context = getSessionSnapshotOperationsContext(owner) + const prior = context.runtime.state.workspaceSession + session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) + session = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + ) + + // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. + const normalized = normalizeWorkspaceSessionPaneIdentities(session, prior?.terminalLayoutsByTabId) + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( + context.runtime.state.ui?.acknowledgedAgentsByPaneKey, + normalized.leafIdByInputLeafIdByTabId + ) + if (remappedAcknowledgements.changed) { + context.runtime.state.ui = { + ...context.runtime.state.ui, + acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + } + } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + registerPersistedPaneKeyAlias(entry) + } + session = normalized.session + const remapsByHostId = new Map([ + [LOCAL_EXECUTION_HOST_ID, normalized] + ]) + const remappedLeases = remapSshRemotePtyLeaseLeafIds( + context.runtime.state.sshRemotePtyLeases ?? [], + remapsByHostId, + new Set(context.sessions.getWorkspaceSessionHostIds()) + ) + if (remappedLeases.changed) { + context.runtime.state.sshRemotePtyLeases = remappedLeases.leases + } + if (session && prior) { + const priorTabs = prior.tabsByWorktree ?? {} + const nextTabs = session.tabsByWorktree ?? {} + const worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + for (const [worktreeId, tabs] of Object.entries(nextTabs)) { + const priorList = priorTabs[worktreeId] + if (!priorList) { + continue + } + for (const tab of tabs) { + if (tab.ptyId) { + continue + } + const priorTab = priorList.find((t) => t.id === tab.id) + if ( + priorTab?.ptyId && + context.bindingRecovery.isRestorablePtyBinding({ + ptyId: priorTab.ptyId, + worktreeId, + targetId: context.bindingRecovery.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 = context.bindingRecovery.getTerminalLayoutLeafIds(layout.root) + const worktreeId = worktreeIdByTabId.get(tabId) + const targetId = worktreeId + ? context.bindingRecovery.getConnectionIdForWorktree(worktreeId) + : null + const restorableBindings = Object.fromEntries( + Object.entries(priorLayout.ptyIdsByLeafId).filter( + ([leafId, ptyId]) => + liveLeafIds.has(leafId) && + incoming[leafId] === undefined && + // Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it. + (incomingHasAnyBinding + ? context.bindingRecovery.hasRestorableSshRemotePtyLease({ + ptyId, + targetId, + worktreeId, + tabId, + leafId + }) + : context.bindingRecovery.isRestorablePtyBinding({ + ptyId, + targetId, + worktreeId, + tabId, + leafId + })) + ) + ) + if (Object.keys(restorableBindings).length > 0) { + layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } + // Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. + const buffersByLeafId = preserveMissingLeafRecordEntries( + priorLayout.buffersByLeafId, + layout.buffersByLeafId, + liveLeafIds + ) + const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( + priorLayout.scrollbackRefsByLeafId, + layout.scrollbackRefsByLeafId, + liveLeafIds + ) + const titlesByLeafId = preserveMissingLeafRecordEntries( + priorLayout.titlesByLeafId, + layout.titlesByLeafId, + liveLeafIds + ) + if (buffersByLeafId) { + layout.buffersByLeafId = buffersByLeafId + } + if (scrollbackRefsByLeafId) { + layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId + } + if (titlesByLeafId) { + layout.titlesByLeafId = titlesByLeafId + } + } + } + } + session = pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + if (!deferSnapshotFiles) { + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + session, + context.runtime.terminalScrollbackSnapshotStorage + ) + session = migratedScrollback.session + deleteRemovedTerminalScrollbackSnapshots( + prior, + session, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + context.runtime.state.workspaceSession = session + if (deferSnapshotFiles) { + enqueueTerminalScrollbackSnapshotWork(owner, prior, session) + } + scheduleSave(context.scheduling) +} + +export function enqueueTerminalScrollbackSnapshotWork( + owner: SessionSnapshotOperations, + prior: WorkspaceSessionState | undefined, + staged: WorkspaceSessionState +): void { + const context = getSessionSnapshotOperationsContext(owner) + const previous = context.runtime.pendingSnapshotFileWork ?? Promise.resolve() + const work = previous + .then(async () => { + if (context.runtime.state.workspaceSession !== staged) { + if (context.runtime.state.workspaceSession) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + context.runtime.state.workspaceSession, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + return + } + const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( + staged, + context.runtime.terminalScrollbackSnapshotStorage + ) + const current = + context.runtime.state.workspaceSession === staged + ? migrated + : context.runtime.state.workspaceSession + if (context.runtime.state.workspaceSession === staged) { + context.runtime.state.workspaceSession = migrated + } else if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + migrated, + current, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + current, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + }) + .catch((error) => { + console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) + }) + .finally(() => { + if (context.runtime.pendingSnapshotFileWork === work) { + context.runtime.pendingSnapshotFileWork = null + } + }) + context.runtime.pendingSnapshotFileWork = work +} diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts new file mode 100644 index 00000000000..81424dc931b --- /dev/null +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -0,0 +1,276 @@ +import { writeFileSync, renameSync, unlinkSync } from 'node:fs' +import { rename, rm, writeFile } from 'node:fs/promises' +import { durableWriteTempPath } from '../../durable-file-write' +import { getGithubCacheFile } from './user-data-path' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import { enqueueWrite } from './primary-state-writes' + +type WriteFlushBarrierOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'dataFile' + | 'firstPendingSaveAt' + | 'githubCacheDirty' + | 'githubCacheGeneration' + | 'lastDurableWriteGeneration' + | 'pendingGithubCacheWrite' + | 'quitFlushPromise' + | 'quitFlushStarted' + | 'staleGithubCacheTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +const writeFlushBarrierOperationsContext = Symbol('WriteFlushBarrierOperations') +type WriteFlushBarrierOperationsContext = { + runtime: WriteFlushBarrierOperationsRuntime + writes: PrimaryStateWriteOperations +} + +export class WriteFlushBarrierOperations { + readonly [writeFlushBarrierOperationsContext]: WriteFlushBarrierOperationsContext + + constructor(runtime: WriteFlushBarrierOperationsRuntime, writes: PrimaryStateWriteOperations) { + this[writeFlushBarrierOperationsContext] = { runtime, writes } + } + + flush(): void { + if (this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted) { + return + } + try { + this[writeFlushBarrierOperationsContext].writes.flushOrThrow() + } catch (err) { + console.error('[persistence] Failed to flush state:', err) + } + try { + this[writeFlushBarrierOperationsContext].writes.flushActiveViewPreferenceOrThrow() + } catch (err) { + console.error('[active-view] Failed to flush preference:', err) + } + writeGithubCacheSnapshotSync(this) + } + + flushAsync(): Promise { + if (this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise) { + return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + } + this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted = true + this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise = flushCurrentStateAsync( + this, + true + ).catch(() => {}) + return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + } + + flushPendingAsync(): Promise { + // Best-effort callers must not livelock while the live app keeps mutating state. + return flushCurrentStateAsync(this, false, undefined, false).catch(() => {}) + } + + flushPendingOrThrowAsync( + options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} + ): Promise { + if ( + this[writeFlushBarrierOperationsContext].runtime.writesFrozen || + this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted + ) { + return Promise.reject(new Error('Cannot flush while persistence is finalized')) + } + return flushCurrentStateAsync( + this, + false, + options.signal, + options.drainToStableGeneration, + true + ) + } +} + +export async function flushDurableStateOrThrowAsync( + owner: WriteFlushBarrierOperations +): Promise { + if ( + owner[writeFlushBarrierOperationsContext].runtime.writesFrozen || + owner[writeFlushBarrierOperationsContext].runtime.quitFlushStarted + ) { + throw new Error('Cannot flush while persistence is finalized') + } + for (;;) { + if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) + owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + } + owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null + const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) + if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { + break + } + } +} + +export async function flushCurrentStateAsync( + owner: WriteFlushBarrierOperations, + final: boolean, + signal?: AbortSignal, + drainToStableGeneration = true, + requireInitialGenerationDurable = false +): Promise { + const requiredDurableGeneration = requireInitialGenerationDurable + ? owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + : null + for (;;) { + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) + owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + } + owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null + const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + try { + await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) + } catch (error) { + await (final + ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() + : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + signal + )) + await writeGithubCacheSnapshotAsync(owner, final, signal) + throw error + } + await (final + ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() + : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + signal + )) + await writeGithubCacheSnapshotAsync(owner, final, signal) + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + owner[writeFlushBarrierOperationsContext].runtime.lastDurableWriteGeneration >= + requiredDurableGeneration + ) { + break + } + continue + } + if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { + break + } + } +} + +export async function writeGithubCacheSnapshotAsync( + owner: WriteFlushBarrierOperations, + drainToStableGeneration = true, + signal?: AbortSignal +): Promise { + if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + return + } + const previousWrite = + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite ?? + owner[writeFlushBarrierOperationsContext].runtime.staleGithubCacheTempCleanup + const nextWrite = previousWrite + .then(async () => { + while (owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile( + owner[writeFlushBarrierOperationsContext].runtime.dataFile + ) + const tmpFile = durableWriteTempPath(cacheFile) + let renamed = false + try { + await writeFile( + tmpFile, + JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), + 'utf-8' + ) + if ( + generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + ) { + await rename(tmpFile, cacheFile) + renamed = true + if ( + generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + ) { + owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + } + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + if (!drainToStableGeneration) { + break + } + } + }) + .catch((err) => { + console.warn('[persistence] Failed to write github cache snapshot:', err) + }) + .finally(() => { + if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite === nextWrite) { + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = null + } + }) + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = nextWrite + await nextWrite +} + +export function writeGithubCacheSnapshotSync(owner: WriteFlushBarrierOperations): void { + if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + return + } + if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite) { + void writeGithubCacheSnapshotAsync(owner) + return + } + const cacheFile = getGithubCacheFile(owner[writeFlushBarrierOperationsContext].runtime.dataFile) + const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const tmpFile = durableWriteTempPath(cacheFile) + try { + writeFileSync( + tmpFile, + JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), + 'utf-8' + ) + renameSync(tmpFile, cacheFile) + if (generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration) { + owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + } + } catch (err) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup. + } + console.warn('[persistence] Failed to write github cache snapshot:', err) + } +} + +export function installWriteFlushBarrierOperationsContext( + target: object, + source: WriteFlushBarrierOperations +): void { + Object.defineProperty(target, writeFlushBarrierOperationsContext, { + value: source[writeFlushBarrierOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts new file mode 100644 index 00000000000..bf9fad874c4 --- /dev/null +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -0,0 +1,71 @@ +import type { StoreRuntimeState } from './store-runtime-state' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import { enqueueWrite } from './primary-state-writes' + +const SAVE_DEBOUNCE_MS = 1_000 +const SAVE_MAX_WAIT_MS = 5_000 + +type WriteSchedulingOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'firstPendingSaveAt' + | 'pendingWrite' + | 'quitFlushStarted' + | 'writeGeneration' + | 'writeTimer' +> + +const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations') +type WriteSchedulingOperationsContext = { + runtime: WriteSchedulingOperationsRuntime + writes: PrimaryStateWriteOperations +} + +export class WriteSchedulingOperations { + readonly [writeSchedulingOperationsContext]: WriteSchedulingOperationsContext + + constructor(runtime: WriteSchedulingOperationsRuntime, writes: PrimaryStateWriteOperations) { + this[writeSchedulingOperationsContext] = { runtime, writes } + } + + async waitForPendingWrite(): Promise { + await Promise.all([ + this[writeSchedulingOperationsContext].runtime.pendingWrite, + this[writeSchedulingOperationsContext].runtime.activeViewPreference.waitForPendingWrite() + ]) + } +} + +export function scheduleSave(owner: WriteSchedulingOperations): void { + // Why: once the quit flush has snapshotted, a newly debounced write would fire during + // teardown with nothing awaiting it, and the process can exit mid-rename. The quit + // flush is the last write by construction. + if (owner[writeSchedulingOperationsContext].runtime.quitFlushStarted) { + return + } + owner[writeSchedulingOperationsContext].runtime.writeGeneration += 1 + const now = Date.now() + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt ??= now + if (owner[writeSchedulingOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeSchedulingOperationsContext].runtime.writeTimer) + } + const untilMaxWait = Math.max( + 0, + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now + ) + const delay = Math.min(SAVE_DEBOUNCE_MS, untilMaxWait) + owner[writeSchedulingOperationsContext].runtime.writeTimer = setTimeout(() => { + owner[writeSchedulingOperationsContext].runtime.writeTimer = null + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt = null + void enqueueWrite(owner[writeSchedulingOperationsContext].writes) + }, delay) +} + +export function installWriteSchedulingOperationsContext( + target: object, + source: WriteSchedulingOperations +): void { + Object.defineProperty(target, writeSchedulingOperationsContext, { + value: source[writeSchedulingOperationsContext] + }) +} diff --git a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts index 77c6547d000..1d3c2cbbcaf 100644 --- a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts +++ b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts @@ -8,11 +8,10 @@ import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-co import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwner } from './session-owner-removal' export type FolderWorkspaceMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index 677655c575e..801746f91ff 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -7,7 +7,6 @@ import type { import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck' import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { getAutomationContextsForRepo, getAutomationSchedulerOwner, @@ -16,7 +15,7 @@ import { } from './automation-context-migration' export type AutomationDefinitionOperations = { - state: StoreOwnedPersistedState + state: PersistedState flush: () => void recordCreated: () => void } diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index f83117edf8b..d1ebfe31c66 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -10,7 +10,6 @@ import { nextAutomationRunNumber, pruneAutomationRuns } from '../../../shared/automation-run-retention' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { normalizeAutomationPrecheckResult, normalizeAutomationRunOutputSnapshot, @@ -20,7 +19,7 @@ import { } from './automation-context-migration' export type AutomationRunOperations = { - state: StoreOwnedPersistedState + state: PersistedState flush: () => void recordManualRun: () => void getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null diff --git a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts index b52de27baa0..25788a158cb 100644 --- a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts @@ -1,12 +1,12 @@ import type { Automation } from '../../../shared/automations-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' export function advanceAutomationNextRun( - state: StoreOwnedPersistedState, + state: PersistedState, flush: () => void, id: string, now = Date.now() diff --git a/src/main/persistence/tracking-repos/project-group-operations.ts b/src/main/persistence/tracking-repos/project-group-operations.ts index 95a5f2f1627..b3918a9a98f 100644 --- a/src/main/persistence/tracking-repos/project-group-operations.ts +++ b/src/main/persistence/tracking-repos/project-group-operations.ts @@ -6,11 +6,10 @@ import { normalizeProjectGroupName } from '../../../shared/project-groups' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwner } from '../restoring-sessions/session-owner-removal' export type ProjectGroupMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts index baaae4777f6..c6a8441f58a 100644 --- a/src/main/persistence/tracking-repos/project-host-operations.ts +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -13,12 +13,11 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../shared/execution-host' import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { makeProjectHostSetupId } from './project-host-compatibility' import { repoGitUsernameCacheKey } from './repo-hydration' export type ProjectHostMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState gitUsernameCache: Map hydrateRepo: (repo: Repo) => Repo updateRepoBackedProjectHostSetup: ( diff --git a/src/main/persistence/tracking-repos/project-host-setup-update.ts b/src/main/persistence/tracking-repos/project-host-setup-update.ts index 2a72ab055c8..05d291bd171 100644 --- a/src/main/persistence/tracking-repos/project-host-setup-update.ts +++ b/src/main/persistence/tracking-repos/project-host-setup-update.ts @@ -1,11 +1,10 @@ -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import type { RepoUpdatePersistenceOperations } from './repo-update-operations' export type ProjectHostSetupUpdateOperations = { - state: StoreOwnedPersistedState + state: PersistedState updateRepo: RepoUpdatePersistenceOperations['updateRepo'] scheduleSave: () => void } diff --git a/src/main/persistence/tracking-repos/repo-order-operations.ts b/src/main/persistence/tracking-repos/repo-order-operations.ts index d578b20dcf7..55cec62226e 100644 --- a/src/main/persistence/tracking-repos/repo-order-operations.ts +++ b/src/main/persistence/tracking-repos/repo-order-operations.ts @@ -1,10 +1,9 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' export type RepoOrderMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void } diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 669dce1a2cc..f39e0e1e26f 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -5,11 +5,10 @@ import { getRepoExecutionHostId } from '../../../shared/execution-host' import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/external-worktree-visibility' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' export type RepoUpdateMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void hydrateRepo: (repo: Repo) => Repo diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index 50c4fe5f745..7032ef2ed60 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -1,7 +1,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' import { getWorktreeIdFromHostIdentity, @@ -9,7 +9,7 @@ import { } from '../../../shared/worktree/host-qualified-identity' export function pruneWorktreeStateForRepo( - state: StoreOwnedPersistedState, + state: PersistedState, id: string, hostId: ExecutionHostId | null, pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index a9d3397aa7c..d9451258634 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -1,11 +1,11 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { PersistedState } from '../../../shared/persisted-state-types' import { getWorktreeIdFromHostIdentity, isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' /** * Re-keys every worktreeId-keyed record in `state` from `oldWorktreeId` to `newWorktreeId`. Mutates `state` in place; @@ -13,7 +13,7 @@ import type { StoreOwnedPersistedState } from '../loading-store/store-owned-stat * See `Store.migrateWorktreeIdentity` for why the rename happens. */ export function migrateWorktreeIdentity( - state: StoreOwnedPersistedState, + state: PersistedState, oldWorktreeId: string, newWorktreeId: string ): boolean { diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts index 0ddba9dc730..5886ebb7196 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeWorktreeLinkedItemMetadata } from './worktree-metadata-normalization' // Only the presence of an entry matters here; the normalizer never reads its linked-item fields. @@ -8,20 +8,20 @@ function makeMeta(): WorktreeMeta { return { createdAt: 1 } as WorktreeMeta } -function makeState(overrides: Partial): StoreOwnedPersistedState { +function makeState(overrides: Partial): PersistedState { return { worktreeMeta: {}, worktreeLineageById: {}, workspaceLineageByChildKey: {}, ...overrides - } as StoreOwnedPersistedState + } as PersistedState } describe('normalizeWorktreeLinkedItemMetadata', () => { it('reports a null lineage map repair as changed so the load path re-saves it', () => { const state = makeState({ worktreeMeta: { 'r1::/tmp/wt': makeMeta() }, - worktreeLineageById: null as unknown as StoreOwnedPersistedState['worktreeLineageById'] + worktreeLineageById: null as unknown as PersistedState['worktreeLineageById'] }) // Without this the map stays null on disk and is repaired again on every reload. @@ -32,8 +32,7 @@ describe('normalizeWorktreeLinkedItemMetadata', () => { it('reports a null child-key lineage map repair as changed', () => { const state = makeState({ worktreeMeta: {}, - workspaceLineageByChildKey: - null as unknown as StoreOwnedPersistedState['workspaceLineageByChildKey'] + workspaceLineageByChildKey: null as unknown as PersistedState['workspaceLineageByChildKey'] }) expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts index 72f64b82906..81c6e94843e 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts @@ -14,14 +14,14 @@ import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' // Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). // GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. export const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 export const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 -export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { +export function gcStaleWorktreeMeta(state: PersistedState): number { // Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing. // Companion lineage maps get the same guard because the deletes below index them directly. state.worktreeMeta ??= {} @@ -80,7 +80,7 @@ export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { return removed } -export function normalizeWorktreeLinkedItemMetadata(state: StoreOwnedPersistedState): boolean { +export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { // Why: a hand-corrupted null lineage map would throw on the companion deletes below. The repair // itself is dirty state — without it the map stays null on disk and is repaired again every load. let changed = From b6a24ecac12f196979a9253c8617a5bd7182429f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:46:07 -0700 Subject: [PATCH 07/10] refactor: split GitHub backend modules (#16194) --- config/max-lines-baseline.txt | 3 - src/main/github/issue-timeline.ts | 189 +++ src/main/github/issue-work-item-details.ts | 260 ++++ .../github/pr-refresh-candidate-policy.ts | 183 +++ ...resh-coordinator-rate-limit-budget.test.ts | 54 + ...refresh-coordinator-refresh-events.test.ts | 77 + src/main/github/pr-refresh-coordinator.ts | 963 +----------- src/main/github/pr-refresh-event-publisher.ts | 69 + src/main/github/pr-refresh-pacing.ts | 114 ++ src/main/github/pr-refresh-queue-drainer.ts | 242 +++ src/main/github/pr-refresh-queue.ts | 225 +++ src/main/github/pr-refresh-rate-limit-gate.ts | 27 + src/main/github/pr-refresh-retry-state.ts | 46 + src/main/github/pr-refresh-visibility.ts | 44 + src/main/github/pull-request-file-contents.ts | 132 ++ src/main/github/pull-request-file-data.ts | 247 +++ .../work-item-details-api-parity.test.ts | 37 + .../github/work-item-details-pr-files.test.ts | 30 + src/main/github/work-item-details.ts | 1143 +------------- src/main/github/work-item-participants.ts | 241 +++ src/main/ipc/github-account-handlers.ts | 31 + .../ipc/github-ipc-channel-parity.test.ts | 100 ++ .../ipc/github-issue-mutation-handlers.ts | 106 ++ src/main/ipc/github-pr-mutation-handlers.ts | 212 +++ src/main/ipc/github-pr-read-handlers.ts | 178 +++ src/main/ipc/github-pr-refresh-handlers.ts | 161 ++ src/main/ipc/github-pr-review-handlers.ts | 180 +++ src/main/ipc/github-project-view-handlers.ts | 89 ++ src/main/ipc/github-repo-routing.ts | 130 ++ src/main/ipc/github-work-item-handlers.ts | 214 +++ .../ipc/github-work-item-mutation-events.ts | 15 + src/main/ipc/github.ts | 1323 +---------------- 32 files changed, 3745 insertions(+), 3320 deletions(-) create mode 100644 src/main/github/issue-timeline.ts create mode 100644 src/main/github/issue-work-item-details.ts create mode 100644 src/main/github/pr-refresh-candidate-policy.ts create mode 100644 src/main/github/pr-refresh-event-publisher.ts create mode 100644 src/main/github/pr-refresh-pacing.ts create mode 100644 src/main/github/pr-refresh-queue-drainer.ts create mode 100644 src/main/github/pr-refresh-queue.ts create mode 100644 src/main/github/pr-refresh-rate-limit-gate.ts create mode 100644 src/main/github/pr-refresh-retry-state.ts create mode 100644 src/main/github/pr-refresh-visibility.ts create mode 100644 src/main/github/pull-request-file-contents.ts create mode 100644 src/main/github/pull-request-file-data.ts create mode 100644 src/main/github/work-item-details-api-parity.test.ts create mode 100644 src/main/github/work-item-participants.ts create mode 100644 src/main/ipc/github-account-handlers.ts create mode 100644 src/main/ipc/github-ipc-channel-parity.test.ts create mode 100644 src/main/ipc/github-issue-mutation-handlers.ts create mode 100644 src/main/ipc/github-pr-mutation-handlers.ts create mode 100644 src/main/ipc/github-pr-read-handlers.ts create mode 100644 src/main/ipc/github-pr-refresh-handlers.ts create mode 100644 src/main/ipc/github-pr-review-handlers.ts create mode 100644 src/main/ipc/github-project-view-handlers.ts create mode 100644 src/main/ipc/github-repo-routing.ts create mode 100644 src/main/ipc/github-work-item-handlers.ts create mode 100644 src/main/ipc/github-work-item-mutation-events.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 9e24dc1b39f..f2a9bebb747 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -27,14 +27,11 @@ inline src/main/daemon/pty-subprocess.ts inline src/main/git/runner.ts inline src/main/git/status.ts inline src/main/git/worktree.ts -inline src/main/github/pr-refresh-coordinator.ts inline src/main/github/project-view.ts inline src/main/github/project-view/mutations.ts -inline src/main/github/work-item-details.ts inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts -inline src/main/ipc/github.ts inline src/main/ipc/repos.ts inline src/main/ipc/ssh.ts inline src/main/ipc/worktree-remote.ts diff --git a/src/main/github/issue-timeline.ts b/src/main/github/issue-timeline.ts new file mode 100644 index 00000000000..3cc1f36eaac --- /dev/null +++ b/src/main/github/issue-timeline.ts @@ -0,0 +1,189 @@ +import type { + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget +} from '../../shared/github/comment-types' +import { ghExecFileAsync } from './gh-utils' +import type { GitHubApiRepository, GitHubRepoExecOptions } from './github-api-repository' +import { githubHostExecOptions } from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const MAX_ISSUE_TIMELINE_ITEMS = 300 +const GITHUB_REST_PAGE_SIZE = 100 + +type RestTimelineUser = { + login?: string | null + avatar_url?: string | null +} + +type RestTimelineIssue = { + number?: number | null + title?: string | null + html_url?: string | null + repository?: { + name?: string | null + owner?: { login?: string | null } | null + } | null + pull_request?: unknown +} + +type RestTimelineEvent = { + id?: number | string | null + node_id?: string | null + event?: string | null + actor?: RestTimelineUser | null + user?: RestTimelineUser | null + assignee?: RestTimelineUser | null + created_at?: string | null + source?: { issue?: RestTimelineIssue | null } | null + closer?: RestTimelineIssue | null + state_reason?: string | null + project_card?: { + column_name?: string | null + previous_column_name?: string | null + project_url?: string | null + } | null + project?: { name?: string | null } | null + project_column_name?: string | null + previous_column_name?: string | null +} + +function isSupportedTimelineEvent( + eventName: string | null | undefined +): eventName is GitHubIssueTimelineItem['event'] { + return ( + eventName === 'assigned' || + eventName === 'unassigned' || + eventName === 'mentioned' || + eventName === 'cross-referenced' || + eventName === 'closed' || + eventName === 'reopened' || + eventName === 'moved_columns_in_project' + ) +} + +function mapTimelineTarget( + issue: RestTimelineIssue | null | undefined +): GitHubIssueTimelineTarget | undefined { + if (!issue || typeof issue.number !== 'number' || !issue.html_url) { + return undefined + } + const owner = issue.repository?.owner?.login + const repo = issue.repository?.name + return { + type: issue.pull_request ? 'pr' : 'issue', + number: issue.number, + title: issue.title ?? '', + url: issue.html_url, + repository: owner && repo ? `${owner}/${repo}` : undefined + } +} + +function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } { + const actor = event.actor ?? event.user + return { + login: actor?.login ?? 'ghost', + avatarUrl: actor?.avatar_url ?? '' + } +} + +function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null { + const eventName = event.event + if (!isSupportedTimelineEvent(eventName) || !event.created_at) { + return null + } + const actor = getTimelineActor(event) + const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`) + const base = { + id, + event: eventName, + actor: actor.login, + actorAvatarUrl: actor.avatarUrl, + createdAt: event.created_at + } + if (eventName === 'assigned' || eventName === 'unassigned') { + return { ...base, assignee: event.assignee?.login ?? undefined } + } + if (eventName === 'mentioned' || eventName === 'cross-referenced') { + return { ...base, source: mapTimelineTarget(event.source?.issue) } + } + if (eventName === 'closed') { + return { + ...base, + stateReason: event.state_reason ?? null, + closer: mapTimelineTarget(event.closer ?? event.source?.issue) + } + } + if (eventName === 'moved_columns_in_project') { + return { + ...base, + previousColumnName: + event.previous_column_name ?? event.project_card?.previous_column_name ?? null, + columnName: event.project_column_name ?? event.project_card?.column_name ?? null, + projectName: event.project?.name ?? null + } + } + return base +} + +function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { + const events: RestTimelineEvent[] = [] + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed) { + continue + } + try { + const parsed = JSON.parse(trimmed) as unknown + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + events.push(parsed) + } + } catch { + // Timeline activity is auxiliary to issue details. + } + } + return events +} + +export async function getIssueTimelineItems( + repository: GitHubApiRepository, + issueNumber: number, + ghOptions: GitHubRepoExecOptions +): Promise { + try { + const items: GitHubIssueTimelineItem[] = [] + for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return items + } + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`, + '--jq', + '.[] | @json' + ], + { ...ghOptions, ...githubHostExecOptions(repository) } + ) + const pageEvents = parseRestTimelineEventLines(stdout) + for (const event of pageEvents) { + const item = mapRestTimelineEvent(event) + if (!item) { + continue + } + items.push(item) + if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { + break + } + } + if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { + break + } + } + return items + } catch { + return [] + } +} diff --git a/src/main/github/issue-work-item-details.ts b/src/main/github/issue-work-item-details.ts new file mode 100644 index 00000000000..d58491abd87 --- /dev/null +++ b/src/main/github/issue-work-item-details.ts @@ -0,0 +1,260 @@ +import type { GitHubIssueTimelineItem, PRComment } from '../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { getIssueTimelineItems } from './issue-timeline' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + body + assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + comments(first: 100) { + nodes { + databaseId + body + createdAt + url + author { + login + avatarUrl(size: 48) + ... on Bot { __typename } + } + } + } + } + } +}` + +type GraphQLIssueDetailsResponse = { + data?: { + repository?: { + issue?: { + body?: string | null + assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } + participants?: { nodes?: GitHubAssignableUser[] } + comments?: { + nodes?: { + databaseId?: number | null + body?: string | null + createdAt?: string | null + url?: string | null + author?: { + login?: string | null + avatarUrl?: string | null + __typename?: string + } | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] +} + +export type CollapsedIssueDetails = { + body: string + comments: PRComment[] + assignees: string[] + assigneeUsers: GitHubAssignableUser[] + participants: GitHubAssignableUser[] + timelineItems: GitHubIssueTimelineItem[] +} + +export async function getIssueDetailsViaGraphQL( + repoPath: string, + issueNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (!repository) { + return null + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${ISSUE_DETAILS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${issueNumber}` + ], + ghOptions + ) + const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse + if (parsed.errors && parsed.errors.length > 0) { + return null + } + const issue = parsed.data?.repository?.issue + if (!issue) { + return null + } + const comments: PRComment[] = (issue.comments?.nodes ?? []) + .filter((comment) => typeof comment.databaseId === 'number') + .map((comment) => ({ + id: comment.databaseId as number, + author: comment.author?.login ?? 'ghost', + authorAvatarUrl: comment.author?.avatarUrl ?? '', + body: comment.body ?? '', + createdAt: comment.createdAt ?? '', + url: comment.url ?? '', + isBot: comment.author?.__typename === 'Bot' + })) + const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) + .filter((assignee): assignee is { login: string; avatarUrl?: string; name?: string | null } => + Boolean(assignee.login) + ) + .map((assignee) => ({ + login: assignee.login, + name: assignee.name ?? null, + avatarUrl: assignee.avatarUrl ?? '' + })) + const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) + .filter((user) => Boolean(user.login)) + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + return { + body: issue.body ?? '', + comments, + assignees: assigneeUsers.map((assignee) => assignee.login), + assigneeUsers, + participants, + timelineItems: await getIssueTimelineItems(repository, issueNumber, ghOptions) + } + } catch { + return null + } +} + +export async function getIssueBodyAndComments( + repoPath: string, + issueNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ + body: string + comments: PRComment[] + assignees: string[] + timelineItems: GitHubIssueTimelineItem[] +}> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + try { + if (repository) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + noteRepositoryRateLimitSpend(repository, 'core', 2, ghOptions) + const [issueResult, commentsResult, timelineItems] = await Promise.all([ + ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}` + ], + ghOptions + ), + ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}/comments?per_page=100` + ], + ghOptions + ), + getIssueTimelineItems(repository, issueNumber, ghOptions) + ]) + const issue = JSON.parse(issueResult.stdout) as { + body?: string | null + assignees?: { login: string }[] + } + type RESTComment = { + id: number + user: { login: string; avatar_url: string; type?: string } | null + body: string + created_at: string + html_url: string + } + const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map( + (comment): PRComment => ({ + id: comment.id, + author: comment.user?.login ?? 'ghost', + authorAvatarUrl: comment.user?.avatar_url ?? '', + body: comment.body ?? '', + createdAt: comment.created_at, + url: comment.html_url, + isBot: comment.user?.type === 'Bot' + }) + ) + return { + body: issue.body ?? '', + comments, + assignees: (issue.assignees ?? []).map((assignee) => assignee.login), + timelineItems + } + } + if (connectionId) { + // A connection-backed lookup must not use ambient local GH_REPO/GH_HOST. + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + const { stdout } = await ghExecFileAsync( + ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], + ghOptions + ) + const data = JSON.parse(stdout) as { + body?: string + comments?: { + author: { login: string } + body: string + createdAt: string + url: string + }[] + assignees?: { login: string }[] + } + const comments = (data.comments ?? []).map( + (comment, index): PRComment => ({ + id: index, + author: comment.author?.login ?? 'ghost', + authorAvatarUrl: '', + body: comment.body ?? '', + createdAt: comment.createdAt, + url: comment.url ?? '' + }) + ) + return { + body: data.body ?? '', + comments, + assignees: (data.assignees ?? []).map((assignee) => assignee.login), + timelineItems: [] + } + } catch { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } +} diff --git a/src/main/github/pr-refresh-candidate-policy.ts b/src/main/github/pr-refresh-candidate-policy.ts new file mode 100644 index 00000000000..e2868edf051 --- /dev/null +++ b/src/main/github/pr-refresh-candidate-policy.ts @@ -0,0 +1,183 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import type { GitHubPRBranchLookupOptions } from './client' +import { NO_REVIEW_REFRESH_INTERVAL_MS } from '../source-control/hosted-review-refresh-pacing' + +export const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 +export const POST_PUSH_DELAY_MS = 2_500 + +type PRBranchLookupCandidate = Pick< + GitHubPRRefreshCandidate, + 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' +> + +function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { + return ( + candidate.linkedPRNumber == null && + candidate.fallbackPRNumber != null && + candidate.fallbackPRSource != null + ) +} + +export function hostedReviewOptionArgs( + candidate: PRBranchLookupCandidate +): [] | [GitHubPRBranchLookupOptions] { + const options: GitHubPRBranchLookupOptions = {} + if (candidate.localGitOptions?.wslDistro) { + options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } + } + if (shouldAcceptMergedFallbackPR(candidate)) { + options.acceptMergedFallbackPR = true + } + if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { + options.currentHeadOid = candidate.currentHeadOid.trim() + } + return Object.keys(options).length > 0 ? [options] : [] +} + +export function refreshKey(candidate: GitHubPRRefreshCandidate): string { + const connectionScope = candidate.connectionId ?? 'local' + const runtimeScope = candidate.connectionId + ? 'remote' + : `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}` + if (typeof candidate.linkedPRNumber === 'number') { + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` + } + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` +} + +export function validateCandidate( + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshSkippedReason | null { + if (candidate.repoKind !== 'git') { + return 'not-git' + } + if (candidate.isBare) { + return 'bare' + } + if (candidate.isArchived) { + return 'archived' + } + if (candidate.connectionId && candidate.connectionState === 'disconnected') { + return 'disconnected' + } + if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { + return 'fresh' + } + return null +} + +export function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { + return reason === 'manual' || reason === 'active' || reason === 'post-push' +} + +export function isBackground(reason: GitHubPRRefreshReason): boolean { + return reason !== 'manual' +} + +export function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { + return reason === 'visible' || reason === 'swr' +} + +export function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { + if (isBudgetedBackground(reason)) { + return false + } + const delay = dueAt - Date.now() + return delay > 0 && delay <= 5_000 +} + +export function shouldSkipFresh( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason +): boolean { + if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { + return false + } + return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) +} + +export function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { + return candidate.cachedFetchedAt == null + ? null + : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) +} + +export function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { + return { + cacheKey: candidate.cacheKey, + repoId: candidate.repoId, + repoPath: candidate.repoPath, + branch: candidate.branch, + worktreeId: candidate.worktreeId, + connectionId: candidate.connectionId ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: + candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, + fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null + } +} + +export function visibleCandidateAfterOutcome( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +): GitHubPRRefreshCandidate { + if (outcome.kind === 'upstream-error') { + return candidate + } + return { + ...candidate, + cachedFetchedAt: outcome.fetchedAt, + cachedHasPR: outcome.kind === 'found', + cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, + cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, + cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, + cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null + } +} + +function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { + if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { + return 30 * 60_000 + } + if (candidate.cachedHasPR === false) { + return NO_REVIEW_REFRESH_INTERVAL_MS + } + if ( + candidate.cachedHasPR === true && + candidate.cachedPRState === 'open' && + candidate.cachedMergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) + ) { + return 10_000 + } + if (candidate.cachedChecksStatus === 'success') { + return 10 * 60_000 + } + if (candidate.cachedChecksStatus === 'failure') { + return 3 * 60_000 + } + if (candidate.cachedChecksStatus === 'pending') { + return 90_000 + } + return 60_000 +} + +function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { + return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' +} + +export function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { + return ( + outcome.kind === 'found' && + outcome.pr.state === 'open' && + outcome.pr.mergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) + ) +} diff --git a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts index 8d81faeb0f7..29b5309d26b 100644 --- a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts +++ b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts @@ -164,4 +164,58 @@ describe('pr-refresh-coordinator', () => { 'feature/0' ]) }) + + it.each([ + { primaryResetAt: 4, retryDisabledUntil: 5_000, expectedGateUntil: 5_000 }, + { primaryResetAt: 6, retryDisabledUntil: 5_000, expectedGateUntil: 6_000 } + ])( + 'uses the later manual gate when the primary reset is $primaryResetAt seconds', + async ({ primaryResetAt, retryDisabledUntil, expectedGateUntil }) => { + const { refreshPRNow, reportVisiblePRRefreshCandidates } = + await import('./pr-refresh-coordinator') + const candidate = makeCandidate() + getPRForBranchOutcomeMock.mockResolvedValueOnce({ + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'limited', + fetchedAt: Date.now(), + retryDisabledUntil + }) + + reportVisiblePRRefreshCandidates([candidate], 1, 1) + await refreshPRNow(candidate) + getPRForBranchOutcomeMock.mockClear() + repositoryRateLimitGuardMock.mockImplementation((_repository, bucket) => + bucket === 'core' + ? { blocked: true, remaining: 0, limit: 5_000, resetAt: primaryResetAt } + : { blocked: false } + ) + + const outcome = await refreshPRNow(candidate) + + expect(outcome).toEqual({ + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', + fetchedAt: 1_000, + nextAutoRetryAt: expectedGateUntil, + retryDisabledUntil: expectedGateUntil + }) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + expect(sendMock.mock.calls.map(([, event]) => event).at(-1)).toEqual( + expect.objectContaining({ + reason: 'manual', + status: 'paused', + pausedUntil: expectedGateUntil, + skippedReason: 'rate-limit' + }) + ) + + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: expectedGateUntil }) + await vi.advanceTimersByTimeAsync(expectedGateUntil - Date.now() - 1) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts index 9d8d0745645..45a026dabbd 100644 --- a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts +++ b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts @@ -27,6 +27,43 @@ describe('pr-refresh-coordinator', () => { vi.useRealTimers() }) + it('preserves the coordinator public module API', async () => { + const coordinator = await import('./pr-refresh-coordinator') + + expect(Object.keys(coordinator).sort()).toEqual([ + '_getPRRefreshAliasCountForTests', + '_getPRRefreshErrorBackoffCountForTests', + '_getPRRefreshQueueSizeForTests', + '_getVisiblePRRefreshWindowCountForTests', + 'clearVisiblePRRefreshWindow', + 'enqueuePRRefresh', + 'pruneWorktreePRRefreshAliases', + 'refreshPRNow', + 'reportVisiblePRRefreshCandidates', + 'setPRRefreshOutcomeObserver' + ]) + }) + + it('ignores a stale visibility generation before it can replace queued work', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: Date.now() }) + + reportVisiblePRRefreshCandidates( + [makeCandidate({ branch: 'feature/current', cacheKey: '/repo::feature/current' })], + 2, + 1 + ) + reportVisiblePRRefreshCandidates( + [makeCandidate({ branch: 'feature/stale', cacheKey: '/repo::feature/stale' })], + 1, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledOnce() + expect(getPRForBranchOutcomeMock.mock.calls[0]?.[1]).toBe('feature/current') + }) + it('sends each refresh event once without broadcasting to 100 browser guests', async () => { const guestSends = Array.from({ length: 100 }, () => vi.fn()) getAllWebContentsMock.mockReturnValue( @@ -109,6 +146,46 @@ describe('pr-refresh-coordinator', () => { expect(outcome?.sequence).toBe(inFlight?.sequence) }) + it('keeps each manual outcome on its request sequence when completions race', async () => { + const { refreshPRNow } = await import('./pr-refresh-coordinator') + let resolveFirst!: (outcome: ReturnType) => void + let resolveSecond!: (outcome: ReturnType) => void + const first = new Promise>((resolve) => { + resolveFirst = resolve + }) + const second = new Promise>((resolve) => { + resolveSecond = resolve + }) + getPRForBranchOutcomeMock + .mockImplementationOnce(async () => ({ + kind: 'found', + pr: await first, + fetchedAt: Date.now() + })) + .mockImplementationOnce(async () => ({ + kind: 'found', + pr: await second, + fetchedAt: Date.now() + })) + + const firstRefresh = refreshPRNow(makeCandidate()) + const secondRefresh = refreshPRNow(makeCandidate()) + await vi.advanceTimersByTimeAsync(0) + resolveSecond(makePR({ number: 22 })) + await secondRefresh + resolveFirst(makePR({ number: 11 })) + await firstRefresh + + const events = sendMock.mock.calls.map(([, event]) => event) + const inFlight = events.filter((event) => event.status === 'in-flight') + const outcomes = events.filter((event) => event.outcome) + const firstOutcome = outcomes.find((event) => event.outcome.pr?.number === 11) + const secondOutcome = outcomes.find((event) => event.outcome.pr?.number === 22) + expect(firstOutcome?.sequence).toBe(inFlight[0]?.sequence) + expect(secondOutcome?.sequence).toBe(inFlight[1]?.sequence) + expect(firstOutcome?.sequence).toBeLessThan(secondOutcome?.sequence) + }) + it('accepts merged fallback PRs for visible fallback refreshes', async () => { const { refreshPRNow } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 2ff9d0e410b..22846cbe17c 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -1,817 +1,61 @@ -/* eslint-disable max-lines -- Why: queueing, pacing, and broadcast rules stay together so freshness and rate-limit invariants are reviewable in one place. */ -import { webContents } from 'electron' import type { - GitHubPRRefreshAlias, GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, GitHubPRRefreshReason, - GitHubPRRefreshSkippedReason, PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' -import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' -import { getOriginGitHubApiRepository } from './github-api-repository' -import { ghRepoExecOptions, githubRepoContext } from './gh-utils' -import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' +import { getPRForBranchOutcome } from './client' import { - lookupBackoffDelayMs, - NO_REVIEW_REFRESH_INTERVAL_MS -} from '../source-control/hosted-review-refresh-pacing' -import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { sendToTrustedUIRenderer } from '../ipc/ui' + aliasFromCandidate, + hostedReviewOptionArgs, + MANUAL_MERGEABILITY_PENDING_REFRESH_MS, + refreshKey, + shouldBroadcastQueued, + validateCandidate +} from './pr-refresh-candidate-policy' +import { + PRRefreshEventPublisher, + type PRRefreshOutcomeObserver +} from './pr-refresh-event-publisher' +import { PRRefreshPacing } from './pr-refresh-pacing' +import { PRRefreshQueue } from './pr-refresh-queue' +import { PRRefreshQueueDrainer } from './pr-refresh-queue-drainer' +import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate' +import { PRRefreshRetryState } from './pr-refresh-retry-state' +import { PRRefreshVisibility } from './pr-refresh-visibility' -type QueueEntry = { - key: string - candidate: GitHubPRRefreshCandidate - aliases: Map - reason: GitHubPRRefreshReason - priority: number - dueAt: number - queuedAt: number - bypassBackgroundBudget?: boolean - activeDelayNotified?: boolean - windowId?: number -} - -type PRRefreshOutcomeObserver = ( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -) => void - -type PRBranchLookupCandidate = Pick< - GitHubPRRefreshCandidate, - 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' -> - -function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { - return ( - candidate.linkedPRNumber == null && - candidate.fallbackPRNumber != null && - candidate.fallbackPRSource != null - ) -} - -function hostedReviewOptionArgs( - candidate: PRBranchLookupCandidate -): [] | [GitHubPRBranchLookupOptions] { - const options: GitHubPRBranchLookupOptions = {} - if (candidate.localGitOptions?.wslDistro) { - options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } - } - if (shouldAcceptMergedFallbackPR(candidate)) { - options.acceptMergedFallbackPR = true - } - if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { - options.currentHeadOid = candidate.currentHeadOid.trim() - } - return Object.keys(options).length > 0 ? [options] : [] -} - -const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 -const MERGEABILITY_PENDING_REFRESH_MS = 10_000 -const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 -const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 -const MIN_BACKGROUND_SPACING_MS = 10_000 -const BACKGROUND_BUDGET_MAX = 20 -const POST_PUSH_DELAY_MS = 2_500 -const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 -const ACTIVE_BURST_WINDOW_MS = 30_000 -const ACTIVE_BURST_MAX = 3 - -let sequence = 0 -let queueOrder = 0 -let draining = false -let drainTimer: ReturnType | null = null -const queue = new Map() -const backgroundStarts: number[] = [] -const activeStartsByScope = new Map() -const errorBackoff = new Map() -// Per-key manual-retry cooldown (secondary rate-limit Retry-After); refreshPRNow refuses manual retry until this time so a stale renderer can't bypass it. -const manualRetryGates = new Map() -let lastBackgroundStartAt = 0 - -/** - * Track (or clear) the manual-retry cooldown for a key from a broadcast outcome. - * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other settled outcome clears it. - */ -function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { - if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { - manualRetryGates.set(key, outcome.retryDisabledUntil) - } else { - manualRetryGates.delete(key) - } -} - -/** Reset all per-key retry state (backoff + manual cooldown) when a key resets. */ -function resetKeyRetryState(key: string): void { - errorBackoff.delete(key) - manualRetryGates.delete(key) -} -const visibleByWindow = new Map }>() -let outcomeObserver: PRRefreshOutcomeObserver | null = null -const diagnosticsCounters = { - enqueued: 0, - coalesced: 0, - skipped: 0, - backgroundPauses: 0 -} +const retry = new PRRefreshRetryState() +const queue = new PRRefreshQueue((key) => retry.reset(key)) +const pacing = new PRRefreshPacing() +const visibility = new PRRefreshVisibility() +const events = new PRRefreshEventPublisher() +const drainer = new PRRefreshQueueDrainer(queue, pacing, visibility, retry, events) export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { - outcomeObserver = observer + events.setOutcomeObserver(observer) } function removeInvisibleVisibleRefreshes(): void { - for (const [key, entry] of queue) { - if (entry.reason === 'visible' && !isVisibleKey(key)) { - queue.delete(key) - resetKeyRetryState(key) - broadcast({ - aliases: Array.from(entry.aliases.values()), - reason: 'visible', - status: 'skipped', - skippedReason: 'fresh' - }) - } - } -} - -function recordPRRefreshQueueDiagnostic( - event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', - reason: GitHubPRRefreshReason, - skippedReason?: GitHubPRRefreshSkippedReason -): void { - recordCoalescedCrashBreadcrumb({ - name: 'pr_refresh_queue', - coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, - minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, - data: { - event, - reason, - ...(skippedReason ? { skippedReason } : {}), - enqueued: diagnosticsCounters.enqueued, - coalesced: diagnosticsCounters.coalesced, - skipped: diagnosticsCounters.skipped, - backgroundPauses: diagnosticsCounters.backgroundPauses - } - }) -} - -function clearActiveBurstWindow(windowId: number): void { - const windowPrefix = `${windowId}::` - for (const scope of Array.from(activeStartsByScope.keys())) { - if (scope.startsWith(windowPrefix)) { - activeStartsByScope.delete(scope) - } + const removed = queue.removeInvisibleVisibleEntries((key) => visibility.has(key)) + for (const entry of removed) { + events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: 'visible', + status: 'skipped', + skippedReason: 'fresh' + }) } } export function clearVisiblePRRefreshWindow(windowId: number): void { - const hadVisibleRefreshes = visibleByWindow.delete(windowId) - clearActiveBurstWindow(windowId) + const hadVisibleRefreshes = visibility.clearWindow(windowId) + pacing.clearActiveBurstWindow(windowId) if (hadVisibleRefreshes) { - // Why: visible follow-ups are owned by the reporting renderer; if its WebContents is destroyed, no later visibility report arrives. removeInvisibleVisibleRefreshes() } } -/** - * Drop a removed worktree's aliases from every queue entry. - * - * Why: aliases are otherwise only pruned when a candidate is re-enqueued as invalid, so churning worktrees grow these maps unbounded (OOM creep). - */ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { - for (const [key, entry] of queue) { - let removed = false - for (const [cacheKey, alias] of entry.aliases) { - if (alias.worktreeId === worktreeId) { - entry.aliases.delete(cacheKey) - removed = true - } - } - if (!removed) { - continue - } - // No aliases left means no worktree still cares about this refresh. - if (entry.aliases.size === 0) { - queue.delete(key) - resetKeyRetryState(key) - continue - } - // Keep the entry alive but replace its representative so it isn't a dangling reference to the removed worktree. - if (entry.candidate.worktreeId === worktreeId) { - const replacementAlias = entry.aliases.values().next().value - if (replacementAlias) { - entry.candidate = { - ...entry.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the removed worktree's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null - } - } - } - } -} - -function nextSequence(): number { - sequence += 1 - return sequence -} - -function nextQueueOrder(): number { - queueOrder += 1 - return queueOrder -} - -function broadcast(event: Omit, sequenceOverride?: number): void { - const payload = { ...event, sequence: sequenceOverride ?? nextSequence() } as GitHubPRRefreshEvent - sendToTrustedUIRenderer('gh:prRefreshEvent', payload) -} - -function refreshKey(candidate: GitHubPRRefreshCandidate): string { - const connectionScope = candidate.connectionId ?? 'local' - const runtimeScope = candidate.connectionId - ? 'remote' - : `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}` - if (typeof candidate.linkedPRNumber === 'number') { - return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` - } - return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` -} - -function isVisibleKey(key: string): boolean { - const liveWindowIds = new Set( - webContents - .getAllWebContents() - .filter((wc) => !wc.isDestroyed()) - .map((wc) => wc.id) - ) - for (const windowId of Array.from(visibleByWindow.keys())) { - if (!liveWindowIds.has(windowId)) { - visibleByWindow.delete(windowId) - } - } - for (const visible of visibleByWindow.values()) { - if (visible.keys.has(key)) { - return true - } - } - return false -} - -function isManual(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' -} - -function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' || reason === 'active' || reason === 'post-push' -} - -function isBackground(reason: GitHubPRRefreshReason): boolean { - return reason !== 'manual' -} - -function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { - return reason === 'visible' || reason === 'swr' -} - -function isBudgetedQueueEntry(entry: QueueEntry): boolean { - return isBudgetedBackground(entry.reason) && entry.bypassBackgroundBudget !== true -} - -function validateCandidate( - candidate: GitHubPRRefreshCandidate -): GitHubPRRefreshSkippedReason | null { - if (candidate.repoKind !== 'git') { - return 'not-git' - } - if (candidate.isBare) { - return 'bare' - } - if (candidate.isArchived) { - return 'archived' - } - if (candidate.connectionId && candidate.connectionState === 'disconnected') { - return 'disconnected' - } - if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { - return 'fresh' - } - return null -} - -function shouldSkipFresh( - candidate: GitHubPRRefreshCandidate, - reason: GitHubPRRefreshReason -): boolean { - if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { - return false - } - return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) -} - -function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { - if (isBudgetedBackground(reason)) { - return false - } - const delay = dueAt - Date.now() - if (delay <= 0) { - return false - } - return delay <= 5_000 -} - -function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { - return candidate.cachedFetchedAt == null - ? null - : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) -} - -function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { - return { - cacheKey: candidate.cacheKey, - repoId: candidate.repoId, - repoPath: candidate.repoPath, - branch: candidate.branch, - worktreeId: candidate.worktreeId, - connectionId: candidate.connectionId ?? null, - currentHeadOid: candidate.currentHeadOid ?? null, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: - candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, - fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null - } -} - -function visibleCandidateAfterOutcome( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -): GitHubPRRefreshCandidate { - if (outcome.kind === 'upstream-error') { - return candidate - } - return { - ...candidate, - cachedFetchedAt: outcome.fetchedAt, - cachedHasPR: outcome.kind === 'found', - cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, - cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, - cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, - cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null - } -} - -function setVisibleFollowUp(entry: QueueEntry): void { - const existing = queue.get(entry.key) - if (!existing) { - queue.set(entry.key, entry) - return - } - - for (const alias of entry.aliases.values()) { - existing.aliases.set(alias.cacheKey, alias) - } - - // Why: a user activation can arrive while a background refresh awaits gh; the follow-up must not overwrite that pending active/manual work. - if ( - bypassesFreshnessDelay(existing.reason) || - existing.priority > entry.priority || - existing.dueAt <= entry.dueAt - ) { - return - } - - queue.set(entry.key, { - ...entry, - aliases: existing.aliases - }) -} - -function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefreshAlias): void { - const existing = queue.get(key) - if (!existing) { - return - } - - existing.aliases.delete(alias.cacheKey) - const replacementAlias = existing.aliases.values().next().value - if (!replacementAlias) { - queue.delete(key) - resetKeyRetryState(key) - return - } - - if (existing.candidate.cacheKey === alias.cacheKey) { - existing.candidate = { - ...existing.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the survivor's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null, - isArchived: false, - isBare: false - } - } -} - -/** - * Advances the visible-key error backoff and returns the earliest retry time. - * Why: only visible keys auto-retry, so callers must gate on `isVisibleKey` first. - */ -function nextVisibleErrorRetryAt(key: string): number { - const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 - const retryAt = Date.now() + lookupBackoffDelayMs(failures) - errorBackoff.set(key, { failures, retryAt }) - return retryAt -} - -/** - * Stamps the auto-retry time onto an error outcome before broadcast. - * Why: sets only `nextAutoRetryAt`, never `retryDisabledUntil` — that disables *manual* Retry and is reserved for a real rate-limit gate. - */ -function withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { - if (outcome.kind !== 'upstream-error') { - return outcome - } - // Why: honor a real Retry-After cooldown the client stamped (`retryDisabledUntil`) so we don't auto-retry into an active secondary rate limit. - const cooldownUntil = outcome.retryDisabledUntil - return { - ...outcome, - nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt - } -} - -function scheduleVisibleFollowUp( - key: string, - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome, - priority: number, - aliases: GitHubPRRefreshAlias[], - windowId?: number, - options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } -): void { - if (!isVisibleKey(key)) { - // Why: manual/active refreshes can remove the queued visible retry after its owner window is gone, orphaning the backoff. - resetKeyRetryState(key) - return - } - if (outcome.kind === 'upstream-error') { - // Why: reuse the retry time already computed for the broadcast so the same failure isn't counted twice against the backoff. - const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) - setVisibleFollowUp({ - key, - candidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt: retryAt, - queuedAt: nextQueueOrder(), - windowId - }) - // Why: this is a delayed retry, not active work; a spinner would make visible worktrees look stuck until backoff expires. - scheduleDrain(retryAt - Date.now()) - return - } - resetKeyRetryState(key) - const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) - const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() - const pendingMergeabilityDueAt = - options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) - ? outcome.fetchedAt + options.pendingMergeabilityDelayMs - : null - const dueAt = - pendingMergeabilityDueAt === null - ? regularDueAt - : Math.min(regularDueAt, pendingMergeabilityDueAt) - // Why: a coalesced linked-PR refresh may represent several branches; preserve every alias so all cache entries keep getting updates. - setVisibleFollowUp({ - key, - candidate: followUpCandidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt, - queuedAt: nextQueueOrder(), - // Why: this manual one-shot fixes GitHub's transient UNKNOWN state; visible spacing would delay it past the prompt retry window. - bypassBackgroundBudget: pendingMergeabilityDueAt !== null, - windowId - }) - scheduleDrain(Math.max(0, dueAt - Date.now())) -} - -function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { - if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { - return 30 * 60_000 - } - if (candidate.cachedHasPR === false) { - return NO_REVIEW_REFRESH_INTERVAL_MS - } - if ( - candidate.cachedHasPR === true && - candidate.cachedPRState === 'open' && - candidate.cachedMergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) - ) { - // Why: GitHub returns transient UNKNOWN mergeability while computing the test merge; visible merge buttons need a prompt follow-up. - return MERGEABILITY_PENDING_REFRESH_MS - } - if (candidate.cachedChecksStatus === 'success') { - return 10 * 60_000 - } - if (candidate.cachedChecksStatus === 'failure') { - return 3 * 60_000 - } - if (candidate.cachedChecksStatus === 'pending') { - return 90_000 - } - return MIN_BACKGROUND_REFRESH_AGE_MS -} - -function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { - return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' -} - -function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { - return ( - outcome.kind === 'found' && - outcome.pr.state === 'open' && - outcome.pr.mergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) - ) -} - -function backgroundRefreshBuckets(): ('core' | 'graphql')[] { - // Why: branch refreshes prefer REST but can fall back to `gh pr list`, so guard both buckets until the client exposes a per-lookup cost plan. - return ['core', 'graphql'] -} - -function noteBackgroundStart(): void { - const now = Date.now() - lastBackgroundStartAt = now - backgroundStarts.push(now) - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } -} - -function nextBudgetDelay(): number { - const now = Date.now() - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } - const spacingDelay = - lastBackgroundStartAt > 0 - ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - lastBackgroundStartAt)) - : 0 - const windowDelay = - backgroundStarts.length < BACKGROUND_BUDGET_MAX - ? 0 - : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - backgroundStarts[0])) - return Math.max(spacingDelay, windowDelay) -} - -function activeBurstScope(entry: QueueEntry): string { - const runtimeScope = entry.candidate.connectionId - ? `ssh:${entry.candidate.connectionId}` - : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` - return `${entry.windowId ?? 'global'}::${runtimeScope}` -} - -function pruneActiveStarts(scope: string, now: number): number[] { - const activeStarts = activeStartsByScope.get(scope) ?? [] - while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { - activeStarts.shift() - } - if (activeStarts.length === 0) { - activeStartsByScope.delete(scope) - } else { - activeStartsByScope.set(scope, activeStarts) - } - return activeStarts -} - -function nextActiveBurstDelay(entry: QueueEntry): number { - const now = Date.now() - const activeStarts = pruneActiveStarts(activeBurstScope(entry), now) - if (activeStarts.length < ACTIVE_BURST_MAX) { - return 0 - } - return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) -} - -function noteActiveStart(entry: QueueEntry): void { - const now = Date.now() - const scope = activeBurstScope(entry) - const activeStarts = pruneActiveStarts(scope, now) - activeStarts.push(now) - activeStartsByScope.set(scope, activeStarts) -} - -function activeOrder(a: QueueEntry, b: QueueEntry): number { - if (a.reason !== 'active' || b.reason !== 'active') { - return 0 - } - if (activeBurstScope(a) !== activeBurstScope(b)) { - return 0 - } - // Why: refresh the worktree the user lands on before stale transient selections in the same window/runtime scope. - return b.queuedAt - a.queuedAt -} - -function entryDelay(entry: QueueEntry): number { - const activeBurstDelay = entry.reason === 'active' ? nextActiveBurstDelay(entry) : 0 - if (activeBurstDelay > 0) { - return activeBurstDelay - } - return isBudgetedQueueEntry(entry) ? nextBudgetDelay() : 0 -} - -function isActiveBurstDelayed(entry: QueueEntry): boolean { - return entry.reason === 'active' && nextActiveBurstDelay(entry) > 0 -} - -function nextQueuedWakeDelay(excludedKey: string): number | null { - const now = Date.now() - let nextDelay = Number.POSITIVE_INFINITY - for (const entry of queue.values()) { - if (entry.key === excludedKey) { - continue - } - const delay = entry.dueAt > now ? entry.dueAt - now : entryDelay(entry) - nextDelay = Math.min(nextDelay, delay) - } - return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null -} - -function scheduleDrain(delay = 0): void { - if (drainTimer) { - clearTimeout(drainTimer) - } - drainTimer = setTimeout(() => { - drainTimer = null - void drainQueue() - }, delay) -} - -function queuedEntriesByPriority(): QueueEntry[] { - const now = Date.now() - return Array.from(queue.values()).sort((a, b) => { - const aReady = a.dueAt <= now - const bReady = b.dueAt <= now - if (aReady && bReady) { - return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt - } - if (aReady !== bReady) { - return aReady ? -1 : 1 - } - return a.dueAt - b.dueAt || b.priority - a.priority - }) -} - -async function drainQueue(): Promise { - if (draining) { - return - } - draining = true - try { - while (queue.size > 0) { - let next = queuedEntriesByPriority()[0] - const waitMs = next.dueAt - Date.now() - if (waitMs > 0) { - scheduleDrain(waitMs) - return - } - - let delay = entryDelay(next) - if (delay > 0) { - const runnable = queuedEntriesByPriority().find( - (entry) => entry.dueAt <= Date.now() && entryDelay(entry) === 0 - ) - if (runnable && runnable.key !== next.key) { - next = runnable - delay = 0 - } else { - if (isActiveBurstDelayed(next) && !next.activeDelayNotified) { - next.activeDelayNotified = true - broadcast({ - aliases: Array.from(next.aliases.values()), - reason: next.reason, - status: 'queued' - }) - } - if (isBudgetedQueueEntry(next) && nextBudgetDelay() > 0) { - diagnosticsCounters.backgroundPauses += 1 - recordPRRefreshQueueDiagnostic('background-pause', next.reason) - } - scheduleDrain(Math.min(delay, nextQueuedWakeDelay(next.key) ?? delay)) - return - } - } - - queue.delete(next.key) - const aliases = Array.from(next.aliases.values()) - const skippedReason = validateCandidate(next.candidate) - if (skippedReason) { - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) - continue - } - if (next.reason === 'visible' && !isVisibleKey(next.key)) { - resetKeyRetryState(next.key) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }) - continue - } - const requestSequence = nextSequence() - const requestStartedAt = Date.now() - broadcast( - { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, - requestSequence - ) - - if (isBackground(next.reason)) { - const executionOptions = ghRepoExecOptions( - githubRepoContext( - next.candidate.repoPath, - next.candidate.connectionId, - next.candidate.localGitOptions - ) - ) - const repository = await getOriginGitHubApiRepository( - next.candidate.repoPath, - next.candidate.connectionId, - executionOptions - ) - // Why: only native github.com uses the singleton snapshot; scoped breakers protect GHES and WSL. - if (spendsSharedGitHubComQuota(repository, executionOptions)) { - // Why: the probe only warms the cache, so failures must fail open (#7553). - await getRateLimit() - } - const buckets = backgroundRefreshBuckets() - const blockedGuard = buckets - .map((bucket) => repositoryRateLimitGuard(repository, bucket, executionOptions)) - .find((guard) => guard.blocked) - if (blockedGuard?.blocked) { - const retryAt = blockedGuard.resetAt * 1000 - queue.set(next.key, { ...next, dueAt: retryAt }) - broadcast({ - aliases, - reason: next.reason, - status: 'paused', - pausedUntil: retryAt, - skippedReason: 'rate-limit' - }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) - continue - } - if (isBudgetedQueueEntry(next)) { - noteBackgroundStart() - } - if (next.reason === 'active') { - // Why: tab/worktree churn can enqueue many distinct active refreshes that each probe local Git. - noteActiveStart(next) - } - // Why (#11532): the lookup itself now debits the snapshot, so every - // caller is accounted for; debiting here too would double-count. - } - - const outcome = await getPRForBranchOutcome( - next.candidate.repoPath, - next.candidate.branch, - next.candidate.linkedPRNumber ?? null, - next.candidate.connectionId ?? null, - next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, - ...hostedReviewOptionArgs(next.candidate) - ) - // Why: compute the retry schedule before broadcasting so the outcome and its follow-up share one timing; only visible keys auto-retry. - let plannedRetryAt: number | undefined - let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(next.key)) { - plannedRetryAt = nextVisibleErrorRetryAt(next.key) - broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) - } - outcomeObserver?.(next.candidate, outcome) - noteManualRetryGate(next.key, broadcastOutcome) - broadcast( - { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, - requestSequence - ) - scheduleVisibleFollowUp( - next.key, - next.candidate, - outcome, - next.priority, - aliases, - next.windowId, - { - plannedRetryAt - } - ) - } - } finally { - draining = false - } + queue.pruneWorktreeAliases(worktreeId) } export function enqueuePRRefresh( @@ -824,66 +68,18 @@ export function enqueuePRRefresh( const key = refreshKey(candidate) const skippedReason = validateCandidate(candidate) if (skippedReason) { - removeQueuedAliasForInvalidCandidate(key, alias) - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason) - broadcast({ - aliases: [alias], - reason, - status: 'skipped', - skippedReason - }) + queue.removeInvalidAlias(key, alias) + events.record('skipped', reason, skippedReason) + events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason }) return } - const existing = queue.get(key) - const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null - const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) - if (existing) { - existing.aliases.set(alias.cacheKey, alias) - diagnosticsCounters.coalesced += 1 - recordPRRefreshQueueDiagnostic('coalesced', reason) - const shouldPromoteExisting = - priority > existing.priority || - isManual(reason) || - (reason === 'active' && existing.reason === 'active') || - (priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason)) - if (shouldPromoteExisting) { - existing.priority = priority - existing.reason = reason - existing.dueAt = Math.min(existing.dueAt, dueAt) - existing.queuedAt = nextQueueOrder() - existing.activeDelayNotified = false - existing.candidate = candidate - existing.windowId = windowId ?? existing.windowId - } else if (existing.candidate.worktreeId === candidate.worktreeId) { - // Why: the representative drives the probe head; refresh probe inputs when its worktree moved head/branch, else a merged linked PR lingers after a switch. - existing.candidate = { - ...existing.candidate, - cacheKey: candidate.cacheKey, - branch: candidate.branch, - currentHeadOid: candidate.currentHeadOid ?? null - } - } - } else { - diagnosticsCounters.enqueued += 1 - recordPRRefreshQueueDiagnostic('enqueued', reason) - queue.set(key, { - key, - candidate, - aliases: new Map([[alias.cacheKey, alias]]), - reason, - priority, - dueAt, - queuedAt: nextQueueOrder(), - windowId - }) + const enqueued = queue.enqueue(candidate, reason, priority, windowId) + events.record(enqueued.coalesced ? 'coalesced' : 'enqueued', reason) + if (shouldBroadcastQueued(reason, enqueued.dueAt)) { + events.broadcast({ aliases: [enqueued.alias], reason, status: 'queued' }) } - // Why: visible/SWR are background maintenance behind the budget queue; only user/action-driven queueing should surface in UI. - if (shouldBroadcastQueued(reason, dueAt)) { - broadcast({ aliases: [alias], reason, status: 'queued' }) - } - scheduleDrain() + drainer.schedule() } export function reportVisiblePRRefreshCandidates( @@ -891,11 +87,9 @@ export function reportVisiblePRRefreshCandidates( generation: number, windowId: number ): void { - const existingVisible = visibleByWindow.get(windowId) - if (existingVisible && generation < existingVisible.generation) { + if (!visibility.report(candidates, generation, windowId)) { return } - visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) removeInvisibleVisibleRefreshes() for (const candidate of candidates) { enqueuePRRefresh(candidate, 'visible', 40, windowId) @@ -903,11 +97,11 @@ export function reportVisiblePRRefreshCandidates( } export function _getVisiblePRRefreshWindowCountForTests(): number { - return visibleByWindow.size + return visibility.windowCount } export function _getPRRefreshErrorBackoffCountForTests(): number { - return errorBackoff.size + return retry.errorBackoffCount } export function _getPRRefreshQueueSizeForTests(): number { @@ -915,7 +109,7 @@ export function _getPRRefreshQueueSizeForTests(): number { } export function _getPRRefreshAliasCountForTests(key: string): number { - return queue.get(key)?.aliases.size ?? 0 + return queue.aliasCount(key) } export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise { @@ -927,68 +121,54 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise const aliases = Array.from(aliasMap.values()) const skippedReason = validateCandidate(candidate) if (skippedReason) { - removeQueuedAliasForInvalidCandidate(key, alias) + queue.removeInvalidAlias(key, alias) const outcome: PRRefreshOutcome = { kind: 'upstream-error', errorType: 'unknown', message: `Cannot refresh PR for this worktree: ${skippedReason}`, fetchedAt: Date.now() } - broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) + events.broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) return outcome } - // Why: enforce the rate-limit gate so a stale renderer can't bypass it; refuse without spending quota until the later of the two cooldowns. - const manualExecutionOptions = ghRepoExecOptions( - githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) - ) - const manualRepository = await getOriginGitHubApiRepository( - candidate.repoPath, - candidate.connectionId, - manualExecutionOptions - ) - const manualBlockedGuard = backgroundRefreshBuckets() - .map((bucket) => repositoryRateLimitGuard(manualRepository, bucket, manualExecutionOptions)) - .find((guard) => guard.blocked) - const secondaryGateUntil = manualRetryGates.get(key) - const gateUntil = Math.max( - manualBlockedGuard?.blocked ? manualBlockedGuard.resetAt * 1000 : 0, - secondaryGateUntil !== undefined ? secondaryGateUntil : 0 - ) + const primaryGateUntil = await prRefreshRateLimitPausedUntil(candidate, false) + const gateUntil = Math.max(primaryGateUntil ?? 0, retry.manualGateUntil(key)) if (gateUntil > Date.now()) { - const retryAt = gateUntil - // Why: paused maps `pausedUntil` into the renderer's auto-retry, so requeue at reset (finding 12) — don't advertise an unscheduled retry. queue.set(key, { key, candidate, aliases: aliasMap, reason: 'manual', priority: 40, - dueAt: retryAt, - queuedAt: nextQueueOrder() + dueAt: gateUntil, + queuedAt: queue.nextOrder() }) - broadcast({ + events.broadcast({ aliases, reason: 'manual', status: 'paused', - pausedUntil: retryAt, + pausedUntil: gateUntil, skippedReason: 'rate-limit' }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) + drainer.schedule(Math.max(1_000, gateUntil - Date.now())) return { kind: 'upstream-error', errorType: 'rate_limited', message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', fetchedAt: Date.now(), - nextAutoRetryAt: retryAt, - retryDisabledUntil: retryAt + nextAutoRetryAt: gateUntil, + retryDisabledUntil: gateUntil } } queue.delete(key) - const requestSequence = nextSequence() + const requestSequence = events.nextSequence() const requestStartedAt = Date.now() - broadcast({ aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, requestSequence) + events.broadcast( + { aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, + requestSequence + ) const outcome = await getPRForBranchOutcome( candidate.repoPath, candidate.branch, @@ -999,19 +179,18 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise ) let plannedRetryAt: number | undefined let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(key)) { - plannedRetryAt = nextVisibleErrorRetryAt(key) - broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) + if (outcome.kind === 'upstream-error' && visibility.has(key)) { + plannedRetryAt = retry.nextVisibleErrorRetryAt(key) + broadcastOutcome = retry.withErrorSchedule(outcome, plannedRetryAt) } - outcomeObserver?.(candidate, outcome) - noteManualRetryGate(key, broadcastOutcome) - broadcast( + events.observe(candidate, outcome) + retry.noteManualGate(key, broadcastOutcome) + events.broadcast( { aliases, reason: 'manual', outcome: broadcastOutcome, requestStartedAt }, requestSequence ) - scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { + drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { plannedRetryAt, - // Why: GitHub reports UNKNOWN right after `gh pr reopen`; one prompt visible retry replaces the transient label. pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }) return broadcastOutcome diff --git a/src/main/github/pr-refresh-event-publisher.ts b/src/main/github/pr-refresh-event-publisher.ts new file mode 100644 index 00000000000..b028729f1f5 --- /dev/null +++ b/src/main/github/pr-refresh-event-publisher.ts @@ -0,0 +1,69 @@ +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { sendToTrustedUIRenderer } from '../ipc/ui' + +export type PRRefreshOutcomeObserver = ( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +) => void + +const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export class PRRefreshEventPublisher { + private sequence = 0 + private observer: PRRefreshOutcomeObserver | null = null + private readonly counters = { enqueued: 0, coalesced: 0, skipped: 0, backgroundPauses: 0 } + + setOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { + this.observer = observer + } + + observe(candidate: GitHubPRRefreshCandidate, outcome: PRRefreshOutcome): void { + this.observer?.(candidate, outcome) + } + + nextSequence(): number { + this.sequence += 1 + return this.sequence + } + + broadcast(event: Omit, sequenceOverride?: number): void { + const payload = { + ...event, + sequence: sequenceOverride ?? this.nextSequence() + } as GitHubPRRefreshEvent + sendToTrustedUIRenderer('gh:prRefreshEvent', payload) + } + + record( + event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', + reason: GitHubPRRefreshReason, + skippedReason?: GitHubPRRefreshSkippedReason + ): void { + if (event === 'background-pause') { + this.counters.backgroundPauses += 1 + } else { + this.counters[event] += 1 + } + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_queue', + coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, + minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, + data: { + event, + reason, + ...(skippedReason ? { skippedReason } : {}), + enqueued: this.counters.enqueued, + coalesced: this.counters.coalesced, + skipped: this.counters.skipped, + backgroundPauses: this.counters.backgroundPauses + } + }) + } +} diff --git a/src/main/github/pr-refresh-pacing.ts b/src/main/github/pr-refresh-pacing.ts new file mode 100644 index 00000000000..7a88bd74a0a --- /dev/null +++ b/src/main/github/pr-refresh-pacing.ts @@ -0,0 +1,114 @@ +import type { PRRefreshQueueEntry } from './pr-refresh-queue' + +const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 +const MIN_BACKGROUND_SPACING_MS = 10_000 +const BACKGROUND_BUDGET_MAX = 20 +const ACTIVE_BURST_WINDOW_MS = 30_000 +const ACTIVE_BURST_MAX = 3 + +export class PRRefreshPacing { + private readonly backgroundStarts: number[] = [] + private readonly activeStartsByScope = new Map() + private lastBackgroundStartAt = 0 + + clearActiveBurstWindow(windowId: number): void { + const windowPrefix = `${windowId}::` + for (const scope of Array.from(this.activeStartsByScope.keys())) { + if (scope.startsWith(windowPrefix)) { + this.activeStartsByScope.delete(scope) + } + } + } + + noteBackgroundStart(): void { + const now = Date.now() + this.lastBackgroundStartAt = now + this.backgroundStarts.push(now) + this.pruneBackgroundStarts(now) + } + + nextBudgetDelay(): number { + const now = Date.now() + this.pruneBackgroundStarts(now) + const spacingDelay = + this.lastBackgroundStartAt > 0 + ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - this.lastBackgroundStartAt)) + : 0 + const windowDelay = + this.backgroundStarts.length < BACKGROUND_BUDGET_MAX + ? 0 + : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - this.backgroundStarts[0])) + return Math.max(spacingDelay, windowDelay) + } + + activeOrder(a: PRRefreshQueueEntry, b: PRRefreshQueueEntry): number { + if (a.reason !== 'active' || b.reason !== 'active') { + return 0 + } + if (this.activeBurstScope(a) !== this.activeBurstScope(b)) { + return 0 + } + return b.queuedAt - a.queuedAt + } + + entryDelay(entry: PRRefreshQueueEntry): number { + const activeDelay = entry.reason === 'active' ? this.nextActiveBurstDelay(entry) : 0 + if (activeDelay > 0) { + return activeDelay + } + return entry.bypassBackgroundBudget !== true && + (entry.reason === 'visible' || entry.reason === 'swr') + ? this.nextBudgetDelay() + : 0 + } + + isActiveBurstDelayed(entry: PRRefreshQueueEntry): boolean { + return entry.reason === 'active' && this.nextActiveBurstDelay(entry) > 0 + } + + noteActiveStart(entry: PRRefreshQueueEntry): void { + const now = Date.now() + const scope = this.activeBurstScope(entry) + const activeStarts = this.pruneActiveStarts(scope, now) + activeStarts.push(now) + this.activeStartsByScope.set(scope, activeStarts) + } + + private pruneBackgroundStarts(now: number): void { + while ( + this.backgroundStarts.length > 0 && + now - this.backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS + ) { + this.backgroundStarts.shift() + } + } + + private activeBurstScope(entry: PRRefreshQueueEntry): string { + const runtimeScope = entry.candidate.connectionId + ? `ssh:${entry.candidate.connectionId}` + : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` + return `${entry.windowId ?? 'global'}::${runtimeScope}` + } + + private pruneActiveStarts(scope: string, now: number): number[] { + const activeStarts = this.activeStartsByScope.get(scope) ?? [] + while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { + activeStarts.shift() + } + if (activeStarts.length === 0) { + this.activeStartsByScope.delete(scope) + } else { + this.activeStartsByScope.set(scope, activeStarts) + } + return activeStarts + } + + private nextActiveBurstDelay(entry: PRRefreshQueueEntry): number { + const now = Date.now() + const activeStarts = this.pruneActiveStarts(this.activeBurstScope(entry), now) + if (activeStarts.length < ACTIVE_BURST_MAX) { + return 0 + } + return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) + } +} diff --git a/src/main/github/pr-refresh-queue-drainer.ts b/src/main/github/pr-refresh-queue-drainer.ts new file mode 100644 index 00000000000..d1e37492e32 --- /dev/null +++ b/src/main/github/pr-refresh-queue-drainer.ts @@ -0,0 +1,242 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { getPRForBranchOutcome } from './client' +import { + freshRetryAt, + hostedReviewOptionArgs, + isBackground, + isMergeabilityPendingOutcome, + validateCandidate, + visibleCandidateAfterOutcome +} from './pr-refresh-candidate-policy' +import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher' +import type { PRRefreshPacing } from './pr-refresh-pacing' +import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue' +import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate' +import type { PRRefreshRetryState } from './pr-refresh-retry-state' +import type { PRRefreshVisibility } from './pr-refresh-visibility' + +export class PRRefreshQueueDrainer { + private draining = false + private timer: ReturnType | null = null + + constructor( + private readonly queue: PRRefreshQueue, + private readonly pacing: PRRefreshPacing, + private readonly visibility: PRRefreshVisibility, + private readonly retry: PRRefreshRetryState, + private readonly events: PRRefreshEventPublisher + ) {} + + schedule(delay = 0): void { + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = setTimeout(() => { + this.timer = null + void this.drain() + }, delay) + } + + scheduleVisibleFollowUp( + key: string, + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome, + priority: number, + aliases: GitHubPRRefreshAlias[], + windowId?: number, + options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } + ): void { + if (!this.visibility.has(key)) { + this.retry.reset(key) + return + } + if (outcome.kind === 'upstream-error') { + const retryAt = options?.plannedRetryAt ?? this.retry.nextVisibleErrorRetryAt(key) + this.queue.setVisibleFollowUp({ + key, + candidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt: retryAt, + queuedAt: this.queue.nextOrder(), + windowId + }) + this.schedule(retryAt - Date.now()) + return + } + this.retry.reset(key) + const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) + const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() + const pendingDueAt = + options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) + ? outcome.fetchedAt + options.pendingMergeabilityDelayMs + : null + const dueAt = pendingDueAt === null ? regularDueAt : Math.min(regularDueAt, pendingDueAt) + this.queue.setVisibleFollowUp({ + key, + candidate: followUpCandidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt, + queuedAt: this.queue.nextOrder(), + bypassBackgroundBudget: pendingDueAt !== null, + windowId + }) + this.schedule(Math.max(0, dueAt - Date.now())) + } + + private ordered(): PRRefreshQueueEntry[] { + return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b)) + } + + private nextQueuedWakeDelay(excludedKey: string): number | null { + const now = Date.now() + let nextDelay = Number.POSITIVE_INFINITY + for (const entry of this.queue.values()) { + if (entry.key === excludedKey) { + continue + } + const delay = entry.dueAt > now ? entry.dueAt - now : this.pacing.entryDelay(entry) + nextDelay = Math.min(nextDelay, delay) + } + return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null + } + + private async drain(): Promise { + if (this.draining) { + return + } + this.draining = true + try { + while (this.queue.size > 0) { + let next = this.ordered()[0] + const waitMs = next.dueAt - Date.now() + if (waitMs > 0) { + this.schedule(waitMs) + return + } + + let delay = this.pacing.entryDelay(next) + if (delay > 0) { + const runnable = this.ordered().find( + (entry) => entry.dueAt <= Date.now() && this.pacing.entryDelay(entry) === 0 + ) + if (runnable && runnable.key !== next.key) { + next = runnable + delay = 0 + } else { + this.notePacingDelay(next) + this.schedule(Math.min(delay, this.nextQueuedWakeDelay(next.key) ?? delay)) + return + } + } + + this.queue.delete(next.key) + const aliases = Array.from(next.aliases.values()) + const skippedReason = validateCandidate(next.candidate) + if (skippedReason) { + this.events.record('skipped', next.reason, skippedReason) + this.events.broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) + continue + } + if (next.reason === 'visible' && !this.visibility.has(next.key)) { + this.retry.reset(next.key) + this.events.broadcast({ + aliases, + reason: next.reason, + status: 'skipped', + skippedReason: 'fresh' + }) + continue + } + const requestSequence = this.events.nextSequence() + const requestStartedAt = Date.now() + this.events.broadcast( + { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, + requestSequence + ) + + if (isBackground(next.reason)) { + const pausedUntil = await prRefreshRateLimitPausedUntil(next.candidate, true) + if (pausedUntil !== null) { + this.queue.set(next.key, { ...next, dueAt: pausedUntil }) + this.events.broadcast({ + aliases, + reason: next.reason, + status: 'paused', + pausedUntil, + skippedReason: 'rate-limit' + }) + this.schedule(Math.max(1_000, pausedUntil - Date.now())) + continue + } + if ( + next.bypassBackgroundBudget !== true && + (next.reason === 'visible' || next.reason === 'swr') + ) { + this.pacing.noteBackgroundStart() + } + if (next.reason === 'active') { + this.pacing.noteActiveStart(next) + } + } + + const outcome = await getPRForBranchOutcome( + next.candidate.repoPath, + next.candidate.branch, + next.candidate.linkedPRNumber ?? null, + next.candidate.connectionId ?? null, + next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, + ...hostedReviewOptionArgs(next.candidate) + ) + let plannedRetryAt: number | undefined + let broadcastOutcome = outcome + if (outcome.kind === 'upstream-error' && this.visibility.has(next.key)) { + plannedRetryAt = this.retry.nextVisibleErrorRetryAt(next.key) + broadcastOutcome = this.retry.withErrorSchedule(outcome, plannedRetryAt) + } + this.events.observe(next.candidate, outcome) + this.retry.noteManualGate(next.key, broadcastOutcome) + this.events.broadcast( + { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, + requestSequence + ) + this.scheduleVisibleFollowUp( + next.key, + next.candidate, + outcome, + next.priority, + aliases, + next.windowId, + { plannedRetryAt } + ) + } + } finally { + this.draining = false + } + } + + private notePacingDelay(entry: PRRefreshQueueEntry): void { + if (this.pacing.isActiveBurstDelayed(entry) && !entry.activeDelayNotified) { + entry.activeDelayNotified = true + this.events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: entry.reason, + status: 'queued' + }) + } + if ( + entry.bypassBackgroundBudget !== true && + (entry.reason === 'visible' || entry.reason === 'swr') && + this.pacing.nextBudgetDelay() > 0 + ) { + this.events.record('background-pause', entry.reason) + } + } +} diff --git a/src/main/github/pr-refresh-queue.ts b/src/main/github/pr-refresh-queue.ts new file mode 100644 index 00000000000..2cc185535fa --- /dev/null +++ b/src/main/github/pr-refresh-queue.ts @@ -0,0 +1,225 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason +} from '../../shared/github/pull-request-refresh-types' +import { + aliasFromCandidate, + bypassesFreshnessDelay, + freshRetryAt, + POST_PUSH_DELAY_MS, + refreshKey, + shouldSkipFresh +} from './pr-refresh-candidate-policy' + +export type PRRefreshQueueEntry = { + key: string + candidate: GitHubPRRefreshCandidate + aliases: Map + reason: GitHubPRRefreshReason + priority: number + dueAt: number + queuedAt: number + bypassBackgroundBudget?: boolean + activeDelayNotified?: boolean + windowId?: number +} + +export type PRRefreshEnqueue = { + alias: GitHubPRRefreshAlias + key: string + dueAt: number + coalesced: boolean +} + +export class PRRefreshQueue { + private readonly entries = new Map() + private order = 0 + + constructor(private readonly resetRetryState: (key: string) => void) {} + + get size(): number { + return this.entries.size + } + + get(key: string): PRRefreshQueueEntry | undefined { + return this.entries.get(key) + } + + set(key: string, entry: PRRefreshQueueEntry): void { + this.entries.set(key, entry) + } + + delete(key: string): void { + this.entries.delete(key) + } + + values(): IterableIterator { + return this.entries.values() + } + + nextOrder(): number { + this.order += 1 + return this.order + } + + aliasCount(key: string): number { + return this.entries.get(key)?.aliases.size ?? 0 + } + + enqueue( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason, + priority: number, + windowId?: number + ): PRRefreshEnqueue { + const alias = aliasFromCandidate(candidate) + const key = refreshKey(candidate) + const existing = this.entries.get(key) + const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null + const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) + if (!existing) { + this.entries.set(key, { + key, + candidate, + aliases: new Map([[alias.cacheKey, alias]]), + reason, + priority, + dueAt, + queuedAt: this.nextOrder(), + windowId + }) + return { alias, key, dueAt, coalesced: false } + } + + existing.aliases.set(alias.cacheKey, alias) + const shouldPromote = + priority > existing.priority || + reason === 'manual' || + (reason === 'active' && existing.reason === 'active') || + (priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason)) + if (shouldPromote) { + existing.priority = priority + existing.reason = reason + existing.dueAt = Math.min(existing.dueAt, dueAt) + existing.queuedAt = this.nextOrder() + existing.activeDelayNotified = false + existing.candidate = candidate + existing.windowId = windowId ?? existing.windowId + } else if (existing.candidate.worktreeId === candidate.worktreeId) { + existing.candidate = { + ...existing.candidate, + cacheKey: candidate.cacheKey, + branch: candidate.branch, + currentHeadOid: candidate.currentHeadOid ?? null + } + } + return { alias, key, dueAt, coalesced: true } + } + + removeInvalidAlias(key: string, alias: GitHubPRRefreshAlias): void { + const existing = this.entries.get(key) + if (!existing) { + return + } + existing.aliases.delete(alias.cacheKey) + const replacement = existing.aliases.values().next().value + if (!replacement) { + this.entries.delete(key) + this.resetRetryState(key) + return + } + if (existing.candidate.cacheKey === alias.cacheKey) { + existing.candidate = { + ...existing.candidate, + cacheKey: replacement.cacheKey, + branch: replacement.branch, + worktreeId: replacement.worktreeId, + currentHeadOid: replacement.currentHeadOid ?? null, + isArchived: false, + isBare: false + } + } + } + + pruneWorktreeAliases(worktreeId: string): void { + for (const [key, entry] of this.entries) { + let removed = false + for (const [cacheKey, alias] of entry.aliases) { + if (alias.worktreeId === worktreeId) { + entry.aliases.delete(cacheKey) + removed = true + } + } + if (!removed) { + continue + } + if (entry.aliases.size === 0) { + this.entries.delete(key) + this.resetRetryState(key) + continue + } + if (entry.candidate.worktreeId === worktreeId) { + const replacement = entry.aliases.values().next().value + if (replacement) { + entry.candidate = { + ...entry.candidate, + cacheKey: replacement.cacheKey, + branch: replacement.branch, + worktreeId: replacement.worktreeId, + currentHeadOid: replacement.currentHeadOid ?? null + } + } + } + } + } + + removeInvisibleVisibleEntries(isVisible: (key: string) => boolean): PRRefreshQueueEntry[] { + const removed: PRRefreshQueueEntry[] = [] + for (const [key, entry] of this.entries) { + if (entry.reason !== 'visible' || isVisible(key)) { + continue + } + this.entries.delete(key) + this.resetRetryState(key) + removed.push(entry) + } + return removed + } + + setVisibleFollowUp(entry: PRRefreshQueueEntry): void { + const existing = this.entries.get(entry.key) + if (!existing) { + this.entries.set(entry.key, entry) + return + } + for (const alias of entry.aliases.values()) { + existing.aliases.set(alias.cacheKey, alias) + } + if ( + bypassesFreshnessDelay(existing.reason) || + existing.priority > entry.priority || + existing.dueAt <= entry.dueAt + ) { + return + } + this.entries.set(entry.key, { ...entry, aliases: existing.aliases }) + } + + ordered( + activeOrder: (a: PRRefreshQueueEntry, b: PRRefreshQueueEntry) => number + ): PRRefreshQueueEntry[] { + const now = Date.now() + return Array.from(this.entries.values()).sort((a, b) => { + const aReady = a.dueAt <= now + const bReady = b.dueAt <= now + if (aReady && bReady) { + return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt + } + if (aReady !== bReady) { + return aReady ? -1 : 1 + } + return a.dueAt - b.dueAt || b.priority - a.priority + }) + } +} diff --git a/src/main/github/pr-refresh-rate-limit-gate.ts b/src/main/github/pr-refresh-rate-limit-gate.ts new file mode 100644 index 00000000000..19cee2e215d --- /dev/null +++ b/src/main/github/pr-refresh-rate-limit-gate.ts @@ -0,0 +1,27 @@ +import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' +import { getOriginGitHubApiRepository } from './github-api-repository' +import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' + +const BUCKETS = ['core', 'graphql'] as const + +export async function prRefreshRateLimitPausedUntil( + candidate: GitHubPRRefreshCandidate, + warmSharedSnapshot: boolean +): Promise { + const executionOptions = ghRepoExecOptions( + githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) + ) + const repository = await getOriginGitHubApiRepository( + candidate.repoPath, + candidate.connectionId, + executionOptions + ) + if (warmSharedSnapshot && spendsSharedGitHubComQuota(repository, executionOptions)) { + await getRateLimit() + } + const blockedGuard = BUCKETS.map((bucket) => + repositoryRateLimitGuard(repository, bucket, executionOptions) + ).find((guard) => guard.blocked) + return blockedGuard?.blocked ? blockedGuard.resetAt * 1000 : null +} diff --git a/src/main/github/pr-refresh-retry-state.ts b/src/main/github/pr-refresh-retry-state.ts new file mode 100644 index 00000000000..fa66bf021a4 --- /dev/null +++ b/src/main/github/pr-refresh-retry-state.ts @@ -0,0 +1,46 @@ +import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' +import { lookupBackoffDelayMs } from '../source-control/hosted-review-refresh-pacing' + +export class PRRefreshRetryState { + private readonly errorBackoff = new Map() + private readonly manualRetryGates = new Map() + + get errorBackoffCount(): number { + return this.errorBackoff.size + } + + reset(key: string): void { + this.errorBackoff.delete(key) + this.manualRetryGates.delete(key) + } + + noteManualGate(key: string, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { + this.manualRetryGates.set(key, outcome.retryDisabledUntil) + } else { + this.manualRetryGates.delete(key) + } + } + + manualGateUntil(key: string): number { + return this.manualRetryGates.get(key) ?? 0 + } + + nextVisibleErrorRetryAt(key: string): number { + const failures = (this.errorBackoff.get(key)?.failures ?? 0) + 1 + const retryAt = Date.now() + lookupBackoffDelayMs(failures) + this.errorBackoff.set(key, { failures, retryAt }) + return retryAt + } + + withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { + if (outcome.kind !== 'upstream-error') { + return outcome + } + const cooldownUntil = outcome.retryDisabledUntil + return { + ...outcome, + nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt + } + } +} diff --git a/src/main/github/pr-refresh-visibility.ts b/src/main/github/pr-refresh-visibility.ts new file mode 100644 index 00000000000..9673252b6fe --- /dev/null +++ b/src/main/github/pr-refresh-visibility.ts @@ -0,0 +1,44 @@ +import { webContents } from 'electron' +import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' +import { refreshKey } from './pr-refresh-candidate-policy' + +export class PRRefreshVisibility { + private readonly visibleByWindow = new Map }>() + + get windowCount(): number { + return this.visibleByWindow.size + } + + clearWindow(windowId: number): boolean { + return this.visibleByWindow.delete(windowId) + } + + report(candidates: GitHubPRRefreshCandidate[], generation: number, windowId: number): boolean { + const existing = this.visibleByWindow.get(windowId) + if (existing && generation < existing.generation) { + return false + } + this.visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) + return true + } + + has(key: string): boolean { + const liveWindowIds = new Set( + webContents + .getAllWebContents() + .filter((contents) => !contents.isDestroyed()) + .map((contents) => contents.id) + ) + for (const windowId of Array.from(this.visibleByWindow.keys())) { + if (!liveWindowIds.has(windowId)) { + this.visibleByWindow.delete(windowId) + } + } + for (const visible of this.visibleByWindow.values()) { + if (visible.keys.has(key)) { + return true + } + } + return false + } +} diff --git a/src/main/github/pull-request-file-contents.ts b/src/main/github/pull-request-file-contents.ts new file mode 100644 index 00000000000..fa367e1c68b --- /dev/null +++ b/src/main/github/pull-request-file-contents.ts @@ -0,0 +1,132 @@ +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' +import { acquire, ghExecFileAsync, ghRepoExecOptions, githubRepoContext, release } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { + githubHostExecOptions, + resolveGitHubRepoExecution, + type GitHubApiRepository +} from './github-api-repository' +import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 + +function encodeGitHubContentPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +async function fetchContentAtRef(args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + ownerRepo: GitHubApiRepository + path: string + ref: string +}): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), + ...githubHostExecOptions(args.ownerRepo), + maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES + } + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { + return { content: '', isBinary: false } + } + try { + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '300s', + '-H', + 'Accept: application/vnd.github.raw', + `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` + ], + ghOptions + ) + const sample = stdout.slice(0, 2048) + if (sample.includes('\u0000')) { + return { content: '', isBinary: true } + } + return { content: stdout, isBinary: false } + } catch (error) { + if (isMaxBufferOverflowError(error)) { + return { content: '', isBinary: false, tooLarge: true } + } + return { content: '', isBinary: false } + } +} + +export async function getPRFileContents(args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string +}): Promise { + const { ownerRepo } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions + ) + if (!ownerRepo) { + return { + original: '', + modified: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } + + await acquire() + try { + // Added and removed files have no content on one side of the comparison. + const needsOriginal = args.status !== 'added' + const needsModified = args.status !== 'removed' + const originalPath = args.oldPath ?? args.path + const [original, modified] = await Promise.all([ + needsOriginal + ? fetchContentAtRef({ + repoPath: args.repoPath, + connectionId: args.connectionId, + localGitOptions: args.localGitOptions, + ownerRepo, + path: originalPath, + ref: args.baseSha + }) + : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ + content: '', + isBinary: false + }), + needsModified + ? fetchContentAtRef({ + repoPath: args.repoPath, + connectionId: args.connectionId, + localGitOptions: args.localGitOptions, + ownerRepo, + path: args.path, + ref: args.headSha + }) + : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ + content: '', + isBinary: false + }) + ]) + return { + original: original.content, + modified: modified.content, + originalIsBinary: original.isBinary, + modifiedIsBinary: modified.isBinary, + originalTooLarge: original.tooLarge, + modifiedTooLarge: modified.tooLarge + } + } finally { + release() + } +} diff --git a/src/main/github/pull-request-file-data.ts b/src/main/github/pull-request-file-data.ts new file mode 100644 index 00000000000..62622e4583f --- /dev/null +++ b/src/main/github/pull-request-file-data.ts @@ -0,0 +1,247 @@ +import type { GitHubPRFile, GitHubPRFileViewedState } from '../../shared/github/pull-request-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const MAX_PR_FILES = 300 + +const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + files(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + path + viewerViewedState + } + } + } + } +}` + +type RESTPRFile = { + filename: string + previous_filename?: string + status: string + additions: number + deletions: number + changes: number + patch?: string +} + +export type PRFileViewedStatesResult = { + pullRequestId: string + viewedStates: Map +} + +function mapFileStatus(raw: string): GitHubPRFile['status'] { + switch (raw) { + case 'added': + return 'added' + case 'removed': + return 'removed' + case 'modified': + return 'modified' + case 'renamed': + return 'renamed' + case 'copied': + return 'copied' + case 'changed': + return 'changed' + case 'unchanged': + return 'unchanged' + default: + return 'modified' + } +} + +function isBinaryHint(file: RESTPRFile): boolean { + return file.patch === undefined && file.changes > 0 +} + +export async function getPRMetadata( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ body: string; headSha?: string; baseSha?: string }> { + if (!repository) { + return { body: '' } + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return { body: '' } + } + try { + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + ['api', '--cache', '60s', `repos/${repository.owner}/${repository.repo}/pulls/${prNumber}`], + ghOptions + ) + const data = JSON.parse(stdout) as { + body?: string | null + head?: { sha?: string } + base?: { sha?: string } + } + return { + body: data.body ?? '', + ...(data.head?.sha ? { headSha: data.head.sha } : {}), + ...(data.base?.sha ? { baseSha: data.base.sha } : {}) + } + } catch { + return { body: '' } + } +} + +// null means unavailable; [] means a successfully fetched empty PR. +export async function getPRFiles( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return null + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + try { + const data: RESTPRFile[] = [] + for (let page = 1; data.length < MAX_PR_FILES; page += 1) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return null + } + const pageSuffix = page === 1 ? '' : `&page=${page}` + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` + ], + ghOptions + ) + const pageData = JSON.parse(stdout) as RESTPRFile[] + data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) + if (pageData.length < 100) { + break + } + } + return data.map((file) => ({ + path: file.filename, + oldPath: file.previous_filename, + status: mapFileStatus(file.status), + additions: file.additions, + deletions: file.deletions, + isBinary: isBinaryHint(file), + reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch) + })) + } catch { + return null + } +} + +export async function getPRFileViewedStates( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return null + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + const viewedStates = new Map() + let pullRequestId: string | null = null + let after: string | null = null + + try { + for (let fetched = 0; fetched < MAX_PR_FILES; fetched += 100) { + const args = [ + 'api', + 'graphql', + '-f', + `query=${PR_FILE_VIEWED_STATES_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${prNumber}` + ] + if (after) { + args.push('-f', `after=${after}`) + } + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync(args, ghOptions) + const parsed = JSON.parse(stdout) as { + data?: { + repository?: { + pullRequest?: { + id?: string + files?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: { + path?: string | null + viewerViewedState?: GitHubPRFileViewedState | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] + } + if (parsed.errors && parsed.errors.length > 0) { + return null + } + const pullRequest = parsed.data?.repository?.pullRequest + if (!pullRequest?.id) { + return null + } + pullRequestId = pullRequest.id + for (const file of pullRequest.files?.nodes ?? []) { + if (file.path && file.viewerViewedState) { + viewedStates.set(file.path, file.viewerViewedState) + } + } + if (!pullRequest.files?.pageInfo?.hasNextPage || !pullRequest.files.pageInfo.endCursor) { + break + } + after = pullRequest.files.pageInfo.endCursor + } + } catch { + return null + } + return pullRequestId ? { pullRequestId, viewedStates } : null +} + +export function mergePRFileViewedStates( + files: GitHubPRFile[], + viewedStates: PRFileViewedStatesResult | null +): GitHubPRFile[] { + if (!viewedStates) { + return files + } + return files.map((file) => ({ + ...file, + viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED' + })) +} diff --git a/src/main/github/work-item-details-api-parity.test.ts b/src/main/github/work-item-details-api-parity.test.ts new file mode 100644 index 00000000000..187680f64cd --- /dev/null +++ b/src/main/github/work-item-details-api-parity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import type { LocalGitExecOptions } from './gh-utils' +import type { GitHubApiRepository } from './github-api-repository' +import * as workItemDetails from './work-item-details' + +type GetWorkItemDetails = ( + repoPath: string, + number: number, + type?: 'issue' | 'pr', + connectionId?: string | null, + localGitOptions?: LocalGitExecOptions, + preference?: IssueSourcePreference +) => Promise + +type GetPRFileContents = (args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string +}) => Promise + +describe('work-item-details public API parity', () => { + it('retains only the established runtime exports and call signatures', () => { + expect(Object.keys(workItemDetails).sort()).toEqual(['getPRFileContents', 'getWorkItemDetails']) + expectTypeOf(workItemDetails.getWorkItemDetails).toEqualTypeOf() + expectTypeOf(workItemDetails.getPRFileContents).toEqualTypeOf() + }) +}) diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts index cac3c33eaf4..d0205332bd5 100644 --- a/src/main/github/work-item-details-pr-files.test.ts +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -333,6 +333,36 @@ describe('getWorkItemDetails PR file listing', () => { ).toHaveLength(2) }) + it('skips an added file base read and preserves a head max-buffer overflow', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' }) + ) + + const contents = await getPRFileContents({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + prNumber: 7, + path: 'src/large.ts', + status: 'added', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + + expect(contents).toEqual({ + original: '', + modified: '', + originalIsBinary: false, + modifiedIsBinary: false, + originalTooLarge: undefined, + modifiedTooLarge: true + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( + 'repos/team/orca/contents/src/large.ts?ref=head-sha' + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledOnce() + }) + it('does not fetch raw PR file contents while the repository core budget is blocked', async () => { repositoryRateLimitGuardMock.mockReturnValue({ blocked: true, diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 276ee9d33eb..dafc4f514d6 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -1,1012 +1,41 @@ -/* eslint-disable max-lines -- Why: groups the PR/Issue fetch paths and file-contents resolver so caching/rate-limit strategy lives in one place. */ import type { PRCheckDetail } from '../../shared/github/check-types' -import type { - GitHubIssueTimelineItem, - GitHubIssueTimelineTarget, - PRComment -} from '../../shared/github/comment-types' -import type { - GitHubAssignableUser, - GitHubPRFile, - GitHubPRFileContents, - GitHubPRFileViewedState -} from '../../shared/github/pull-request-types' +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types' import type { IssueSourcePreference } from '../../shared/repo-types' -import { - ghExecFileAsync, - acquire, - release, - ghRepoExecOptions, - githubRepoContext, - type LocalGitExecOptions -} from './gh-utils' -import { getWorkItem, getPRChecks, getPRComments } from './client' +import { getPRChecks, getPRComments, getWorkItem } from './client' +import { acquire, release, type LocalGitExecOptions } from './gh-utils' import { getIssueGitHubApiRepository, - githubHostExecOptions, resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository' -import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' -import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' -import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' - -// Why: cap total PR files so a massive PR can't starve the gh semaphore while paging (100/page). -const MAX_PR_FILES = 300 -// Why: bound noisy issue timelines so one huge issue can't monopolize gh/API time. -const MAX_ISSUE_TIMELINE_ITEMS = 300 -const GITHUB_REST_PAGE_SIZE = 100 -// Why: raw-fetch buffer must exceed the renderer's large-diff threshold, else the UI shows an empty diff instead of the fallback. -const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 +import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details' +import { + getPRFiles, + getPRFileViewedStates, + getPRMetadata, + mergePRFileViewedStates +} from './pull-request-file-data' +import { getPRFileContents as loadPRFileContents } from './pull-request-file-contents' +import { + enrichItemDisplayAvatars, + getMentionParticipants, + getWorkItemParticipants +} from './work-item-participants' function localGitOptionArgs(options: LocalGitExecOptions = {}): [] | [LocalGitExecOptions] { return Object.keys(options).length > 0 ? [options] : [] } -function encodeGitHubContentPath(path: string): string { - return path.split('/').map(encodeURIComponent).join('/') -} - -const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - id - files(first: 100, after: $after) { - pageInfo { hasNextPage endCursor } - nodes { - path - viewerViewedState - } - } - } - } -}` - -const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $isPr: Boolean!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) @include(if: $isPr) { - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - } - issue(number: $number) @skip(if: $isPr) { - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - } - } -}` - -// Why: one GraphQL round-trip replaces the 3 serial gh subprocesses (REST issue + comments + participants); falls back to the legacy path on failure. -const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - body - assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - comments(first: 100) { - nodes { - databaseId - body - createdAt - url - author { - login - avatarUrl(size: 48) - ... on Bot { __typename } - } - } - } - } - } -}` - -type GraphQLIssueDetailsResponse = { - data?: { - repository?: { - issue?: { - body?: string | null - assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } - participants?: { nodes?: GitHubAssignableUser[] } - comments?: { - nodes?: { - databaseId?: number | null - body?: string | null - createdAt?: string | null - url?: string | null - author?: { - login?: string | null - avatarUrl?: string | null - __typename?: string - } | null - }[] - } - } | null - } | null - } - errors?: { message?: string }[] -} - -type RestTimelineUser = { - login?: string | null - avatar_url?: string | null -} - -type RestTimelineIssue = { - number?: number | null - title?: string | null - html_url?: string | null - repository?: { - name?: string | null - owner?: { login?: string | null } | null - } | null - pull_request?: unknown -} - -type RestTimelineEvent = { - id?: number | string | null - node_id?: string | null - event?: string | null - actor?: RestTimelineUser | null - user?: RestTimelineUser | null - assignee?: RestTimelineUser | null - created_at?: string | null - source?: { - issue?: RestTimelineIssue | null - } | null - closer?: RestTimelineIssue | null - state_reason?: string | null - project_card?: { - column_name?: string | null - previous_column_name?: string | null - project_url?: string | null - } | null - project?: { - name?: string | null - } | null - project_column_name?: string | null - previous_column_name?: string | null -} - -function isSupportedTimelineEvent( - eventName: string | null | undefined -): eventName is GitHubIssueTimelineItem['event'] { - return ( - eventName === 'assigned' || - eventName === 'unassigned' || - eventName === 'mentioned' || - eventName === 'cross-referenced' || - eventName === 'closed' || - eventName === 'reopened' || - eventName === 'moved_columns_in_project' - ) -} - -function mapTimelineTarget( - issue: RestTimelineIssue | null | undefined -): GitHubIssueTimelineTarget | undefined { - if (!issue || typeof issue.number !== 'number' || !issue.html_url) { - return undefined - } - const owner = issue.repository?.owner?.login - const repo = issue.repository?.name - return { - type: issue.pull_request ? 'pr' : 'issue', - number: issue.number, - title: issue.title ?? '', - url: issue.html_url, - repository: owner && repo ? `${owner}/${repo}` : undefined - } -} - -function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } { - const actor = event.actor ?? event.user - return { - login: actor?.login ?? 'ghost', - avatarUrl: actor?.avatar_url ?? '' - } -} - -function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null { - const eventName = event.event - if (!isSupportedTimelineEvent(eventName)) { - return null - } - if (!event.created_at) { - return null - } - const actor = getTimelineActor(event) - const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`) - const base = { - id, - event: eventName, - actor: actor.login, - actorAvatarUrl: actor.avatarUrl, - createdAt: event.created_at - } - if (eventName === 'assigned' || eventName === 'unassigned') { - return { - ...base, - assignee: event.assignee?.login ?? undefined - } - } - if (eventName === 'mentioned' || eventName === 'cross-referenced') { - return { - ...base, - source: mapTimelineTarget(event.source?.issue) - } - } - if (eventName === 'closed') { - return { - ...base, - stateReason: event.state_reason ?? null, - closer: mapTimelineTarget(event.closer ?? event.source?.issue) - } - } - if (eventName === 'moved_columns_in_project') { - return { - ...base, - previousColumnName: - event.previous_column_name ?? event.project_card?.previous_column_name ?? null, - columnName: event.project_column_name ?? event.project_card?.column_name ?? null, - projectName: event.project?.name ?? null - } - } - return base -} - -function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { - const events: RestTimelineEvent[] = [] - for (const line of stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed) { - continue - } - try { - const parsed = JSON.parse(trimmed) as unknown - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - events.push(parsed) - } - } catch { - // Skip malformed jq lines; timeline activity is auxiliary to issue details. - } - } - return events -} - -async function getIssueTimelineItems( - ownerRepo: GitHubApiRepository, - issueNumber: number, - ghOptions: ReturnType -): Promise { +async function withWorkItemDetailsPermit(operation: () => Promise): Promise { + await acquire() try { - const items: GitHubIssueTimelineItem[] = [] - for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return items - } - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`, - '--jq', - '.[] | @json' - ], - { ...ghOptions, ...githubHostExecOptions(ownerRepo) } - ) - // Why: --jq emits NDJSON and explicit paging lets us stop once we hit the drawer cap. - const pageEvents = parseRestTimelineEventLines(stdout) - for (const event of pageEvents) { - const item = mapRestTimelineEvent(event) - if (!item) { - continue - } - items.push(item) - if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { - break - } - } - if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { - break - } - } - return items - } catch { - return [] + return await operation() + } finally { + release() } } -/** - * Fetch an issue's body, comments, assignees, participants, and timeline in one GraphQL round-trip. - * Returns null on any partial error so the caller falls back to the strict REST path. - * Avatars are resolved here so GHE users don't render blank. - */ -async function getIssueDetailsViaGraphQL( - repoPath: string, - issueNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ - body: string - comments: PRComment[] - assignees: string[] - // Avatar-bearing assignees, kept separate from the login-only `assignees`; enriches avatars `gh` leaves blank (GHE). - assigneeUsers: GitHubAssignableUser[] - participants: GitHubAssignableUser[] - timelineItems: GitHubIssueTimelineItem[] -} | null> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (!ownerRepo) { - return null - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return null - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${ISSUE_DETAILS_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${issueNumber}` - ], - ghOptions - ) - const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse - if (parsed.errors && parsed.errors.length > 0) { - // Why: any partial GraphQL error forces the strict REST fallback so the drawer never paints a half-built shell. - return null - } - const issue = parsed.data?.repository?.issue - if (!issue) { - return null - } - const comments: PRComment[] = (issue.comments?.nodes ?? []) - .filter((c) => typeof c.databaseId === 'number') - .map((c) => ({ - id: c.databaseId as number, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: c.author?.avatarUrl ?? '', - body: c.body ?? '', - createdAt: c.createdAt ?? '', - url: c.url ?? '', - isBot: c.author?.__typename === 'Bot' - })) - const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) - .filter((a): a is { login: string; avatarUrl?: string; name?: string | null } => - Boolean(a.login) - ) - .map((a) => ({ - login: a.login, - name: a.name ?? null, - avatarUrl: a.avatarUrl ?? '' - })) - const assignees = assigneeUsers.map((a) => a.login) - const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) - .filter((u) => Boolean(u.login)) - .map((u) => ({ - login: u.login, - name: u.name ?? null, - avatarUrl: u.avatarUrl ?? '' - })) - const timelineItems = await getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) - return { - body: issue.body ?? '', - comments, - assignees, - assigneeUsers, - participants, - timelineItems - } - } catch { - return null - } -} - -function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] { - const byLogin = new Map() - for (const user of users) { - if (!user.login) { - continue - } - const key = user.login.toLowerCase() - const existing = byLogin.get(key) - if (existing) { - // Why: return a new merged record instead of mutating caller-provided objects. - byLogin.set(key, { - login: existing.login, - name: existing.name ?? user.name ?? null, - avatarUrl: existing.avatarUrl || user.avatarUrl || '' - }) - continue - } - byLogin.set(key, { - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - }) - } - return Array.from(byLogin.values()) -} - -type RESTPRFile = { - filename: string - previous_filename?: string - status: string - additions: number - deletions: number - changes: number - /** Raw patch text when available; absent for binary files or patches over GitHub's size cap. */ - patch?: string -} - -function mapFileStatus(raw: string): GitHubPRFile['status'] { - switch (raw) { - case 'added': - return 'added' - case 'removed': - return 'removed' - case 'modified': - return 'modified' - case 'renamed': - return 'renamed' - case 'copied': - return 'copied' - case 'changed': - return 'changed' - case 'unchanged': - return 'unchanged' - default: - return 'modified' - } -} - -// Why: REST doesn't flag binaries but omits `patch` for them; treat "changes but no patch" as binary so the diff tab shows a placeholder. -function isBinaryHint(file: RESTPRFile): boolean { - if (file.status === 'removed' || file.status === 'added') { - // Added/removed file with changes but no patch is almost always binary (images, oversized lockfiles). - return file.patch === undefined && file.changes > 0 - } - return file.patch === undefined && file.changes > 0 -} - -async function getPRMetadata( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ body: string; headSha?: string; baseSha?: string }> { - if (!ownerRepo) { - // Why: a bare `gh pr view` can honor ambient GH_HOST/GH_REPO after hosted - // repository resolution fails, returning metadata for the wrong PR. - return { body: '' } - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return { body: '' } - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { - body?: string | null - head?: { sha?: string } - base?: { sha?: string } - } - return { - body: data.body ?? '', - ...(data.head?.sha ? { headSha: data.head.sha } : {}), - ...(data.base?.sha ? { baseSha: data.base.sha } : {}) - } - } catch { - return { body: '' } - } -} - -// Why: null = failed/blocked fetch (Files tab shows retry, not "No files changed."); [] = genuinely empty PR. -async function getPRFiles( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!ownerRepo) { - return null - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - try { - const data: RESTPRFile[] = [] - for (let page = 1; data.length < MAX_PR_FILES; page += 1) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return null - } - const pageSuffix = page === 1 ? '' : `&page=${page}` - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` - ], - ghOptions - ) - const pageData = JSON.parse(stdout) as RESTPRFile[] - data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) - if (pageData.length < 100) { - break - } - } - return data.map((file) => ({ - path: file.filename, - oldPath: file.previous_filename, - status: mapFileStatus(file.status), - additions: file.additions, - deletions: file.deletions, - isBinary: isBinaryHint(file), - reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch) - })) - } catch { - return null - } -} - -type PRFileViewedStatesResult = { - pullRequestId: string - viewedStates: Map -} - -async function getPRFileViewedStates( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!ownerRepo) { - return null - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return null - } - const viewedStates = new Map() - let pullRequestId: string | null = null - let after: string | null = null - - try { - for (let fetched = 0; fetched < MAX_PR_FILES; fetched += 100) { - const args = [ - 'api', - 'graphql', - '-f', - `query=${PR_FILE_VIEWED_STATES_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${prNumber}` - ] - if (after) { - args.push('-f', `after=${after}`) - } - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync(args, ghOptions) - const parsed = JSON.parse(stdout) as { - data?: { - repository?: { - pullRequest?: { - id?: string - files?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: { - path?: string | null - viewerViewedState?: GitHubPRFileViewedState | null - }[] - } - } | null - } | null - } - errors?: { message?: string }[] - } - if (parsed.errors && parsed.errors.length > 0) { - return null - } - const pullRequest = parsed.data?.repository?.pullRequest - if (!pullRequest?.id) { - return null - } - pullRequestId = pullRequest.id - for (const file of pullRequest.files?.nodes ?? []) { - if (file.path && file.viewerViewedState) { - viewedStates.set(file.path, file.viewerViewedState) - } - } - if (!pullRequest.files?.pageInfo?.hasNextPage || !pullRequest.files.pageInfo.endCursor) { - break - } - after = pullRequest.files.pageInfo.endCursor - } - } catch { - return null - } - - return pullRequestId ? { pullRequestId, viewedStates } : null -} - -function mergePRFileViewedStates( - files: GitHubPRFile[], - viewedStates: PRFileViewedStatesResult | null -): GitHubPRFile[] { - if (!viewedStates) { - return files - } - return files.map((file) => ({ - ...file, - viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED' - })) -} - -async function getIssueBodyAndComments( - repoPath: string, - issueNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ - body: string - comments: PRComment[] - assignees: string[] - timelineItems: GitHubIssueTimelineItem[] -}> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - try { - if (ownerRepo) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return { body: '', comments: [], assignees: [], timelineItems: [] } - } - // Why: the fallback starts the issue and comments reads together; debit - // both before spawning so a failed response cannot leave quota overstated. - noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) - const [issueResult, commentsResult, timelineItems] = await Promise.all([ - ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}` - ], - ghOptions - ), - ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100` - ], - ghOptions - ), - getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) - ]) - const issue = JSON.parse(issueResult.stdout) as { - body?: string | null - assignees?: { login: string }[] - } - type RESTComment = { - id: number - user: { login: string; avatar_url: string; type?: string } | null - body: string - created_at: string - html_url: string - } - const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map( - (c): PRComment => ({ - id: c.id, - author: c.user?.login ?? 'ghost', - authorAvatarUrl: c.user?.avatar_url ?? '', - body: c.body ?? '', - createdAt: c.created_at, - url: c.html_url, - isBot: c.user?.type === 'Bot' - }) - ) - const assignees = (issue.assignees ?? []).map((a) => a.login) - return { body: issue.body ?? '', comments, assignees, timelineItems } - } - if (connectionId) { - // Why: connection-backed gh has no cwd. A bare issue lookup could honor - // process GH_REPO/GH_HOST and return an unrelated repository's issue. - return { body: '', comments: [], assignees: [], timelineItems: [] } - } - // Fallback: non-GitHub remote - const { stdout } = await ghExecFileAsync( - ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], - ghOptions - ) - const data = JSON.parse(stdout) as { - body?: string - comments?: { - author: { login: string } - body: string - createdAt: string - url: string - }[] - assignees?: { login: string }[] - } - const comments = (data.comments ?? []).map( - (c, i): PRComment => ({ - id: i, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: '', - body: c.body ?? '', - createdAt: c.createdAt, - url: c.url ?? '' - }) - ) - const fallbackAssignees = (data.assignees ?? []).map((a) => a.login) - return { body: data.body ?? '', comments, assignees: fallbackAssignees, timelineItems: [] } - } catch { - return { body: '', comments: [], assignees: [], timelineItems: [] } - } -} - -async function getWorkItemParticipants( - repoPath: string, - item: Pick, - resolvedRepository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - // Why: reuse the fan-out's repository identity so details cannot drift across hosts. - const ownerRepo = resolvedRepository - if (!ownerRepo) { - return [] - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return [] - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${WORK_ITEM_PARTICIPANTS_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${item.number}`, - '-F', - `isPr=${item.type === 'pr'}` - ], - ghOptions - ) - const data = JSON.parse(stdout) as { - data?: { - repository?: { - pullRequest?: { - participants?: { nodes?: GitHubAssignableUser[] } - } | null - issue?: { - participants?: { nodes?: GitHubAssignableUser[] } - } | null - } - } - } - const nodes = - data.data?.repository?.pullRequest?.participants?.nodes ?? - data.data?.repository?.issue?.participants?.nodes ?? - [] - return nodes - .map((user) => ({ - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - })) - .filter((user) => user.login) - } catch { - return [] - } -} - -async function getGitHubUsersByLogin( - repoPath: string, - logins: string[], - repository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!repository) { - return [] - } - const uniqueLogins = Array.from( - new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) - ).slice(0, 40) - if (uniqueLogins.length === 0) { - return [] - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(repository) - } - const guard = repositoryRateLimitGuard(repository, 'graphql', ghOptions) - if (guard.blocked) { - // Why: log the skip — callers degrade silently to blank GHE avatars, so it's otherwise untraceable. - console.warn( - `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` - ) - return [] - } - const fields = uniqueLogins - .map( - (login, index) => - `u${index}: user(login: ${JSON.stringify(login)}) { login name avatarUrl(size: 48) }` - ) - .join('\n') - try { - noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - ['api', 'graphql', '-f', `query=query { ${fields} }`], - ghOptions - ) - const data = JSON.parse(stdout) as { - data?: Record< - string, - { - login?: string - name?: string | null - avatarUrl?: string | null - } | null - > - } - return Object.values(data.data ?? {}) - .filter( - ( - user - ): user is { - login: string - name?: string | null - avatarUrl?: string | null - } => Boolean(user?.login) - ) - .map((user) => ({ - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - })) - } catch { - return [] - } -} - -/** - * Stamp GraphQL-resolved avatars onto a work item's author, reviewers, review requests, latest reviews, and assignees. - * - * Why: `gh pr view` omits avatar_url, so login-based avatars 404 on GHE; knownUsers carries the GraphQL-resolved ones. See #8784. - */ -function enrichItemDisplayAvatars( - item: Omit, - knownUsers: GitHubAssignableUser[] -): Omit { - const avatarByLogin = new Map() - for (const user of knownUsers) { - if (user.login && user.avatarUrl) { - avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) - } - } - if (avatarByLogin.size === 0) { - return item - } - // Why: prefer the GraphQL-resolved avatar — `gh pr view` returns empty/`u/0` placeholders for enterprise users; fall back to the original only when the lookup is empty. - const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) - const resolvedAvatar = (login: string, existing?: string | null): string | undefined => - avatarFor(login) || existing || undefined - // Callers coalesce a missing result to each field's "no avatar" sentinel ('' or null); GitHubUserAvatar falls back to login URL then initials. - const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl - return { - ...item, - ...(authorAvatarUrl ? { authorAvatarUrl } : {}), - ...(item.reviewRequests - ? { - reviewRequests: item.reviewRequests.map((user) => ({ - ...user, - avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' - })) - } - : {}), - ...(item.latestReviews - ? { - latestReviews: item.latestReviews.map((review) => ({ - ...review, - avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null - })) - } - : {}), - ...(item.assignees - ? { - assignees: item.assignees.map((user) => ({ - ...user, - avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' - })) - } - : {}) - } -} - -async function getMentionParticipants( - repoPath: string, - item: Pick< - GitHubWorkItem, - 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' - >, - comments: PRComment[], - participants: GitHubAssignableUser[], - repository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - // Why: resolve mention authors + display users (reviewers/assignees) in one aliased trip so avatars reuse it without a second rate-limited lookup (#8784). - // Why: order display users before comment authors so the 40-login cap in getGitHubUsersByLogin can't drop a reviewer/assignee avatar. - const visibleLogins = [ - item.author ?? '', - ...(item.reviewRequests ?? []).map((user) => user.login), - ...(item.latestReviews ?? []).map((review) => review.login), - ...(item.assignees ?? []).map((user) => user.login), - ...comments.map((comment) => comment.author) - ] - const graphQlUsers = await getGitHubUsersByLogin( - repoPath, - visibleLogins, - repository, - connectionId, - localGitOptions - ) - return mergeGitHubUsers([...participants, ...graphQlUsers]) -} - async function getPRChecksForDetails( repoPath: string, prNumber: number, @@ -1028,22 +57,12 @@ async function getPRChecksForDetails( connectionId, ...localGitOptionArgs(localGitOptions) ) - } catch (err) { - // Why: checks are auxiliary — a gh failure must not block opening the PR drawer. - console.warn('getWorkItemDetails PR checks failed:', err) + } catch (error) { + console.warn('getWorkItemDetails PR checks failed:', error) return [] } } -async function withWorkItemDetailsPermit(operation: () => Promise): Promise { - await acquire() - try { - return await operation() - } finally { - release() - } -} - export async function getWorkItemDetails( repoPath: string, number: number, @@ -1072,7 +91,6 @@ export async function getWorkItemDetails( if (item.type === 'issue') { return withWorkItemDetailsPermit(async () => { - // Why: one GraphQL trip returns all issue details; fall back to the legacy fan-out on failure. const collapsed = await getIssueDetailsViaGraphQL( repoPath, item.number, @@ -1082,7 +100,6 @@ export async function getWorkItemDetails( ) if (collapsed) { return { - // Include assigneeUsers: non-participating assignees are absent from participants and keep a blank avatar (GHE). item: enrichItemDisplayAvatars(item, [ ...collapsed.participants, ...collapsed.assigneeUsers @@ -1094,7 +111,6 @@ export async function getWorkItemDetails( timelineItems: collapsed.timelineItems } } - // Fallback: fetch body/comments and participants in parallel. const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([ getIssueBodyAndComments( repoPath, @@ -1125,8 +141,7 @@ export async function getWorkItemDetails( }) } - // Why: getPRComments and getPRChecks own their semaphore permits. Keeping an - // outer permit while awaiting either can deadlock four concurrent detail loads. + // Nested client reads own their semaphore permits, so they stay outside these permits. const [[metadata, files, viewedStates, participants], comments] = await Promise.all([ Promise.all([ withWorkItemDetailsPermit(() => @@ -1159,7 +174,6 @@ export async function getWorkItemDetails( : Promise.resolve([]) ]) - // Why: mention hydration spawns gh directly while checks owns a permit; bound them without nesting. const [mentionParticipants, checks] = await Promise.all([ withWorkItemDetailsPermit(() => getMentionParticipants( @@ -1193,57 +207,12 @@ export async function getWorkItemDetails( baseSha: metadata.baseSha, pullRequestId: viewedStates?.pullRequestId, checks, - // Why: null (failed fetch) vs empty PR — Files tab shows retry, not "No files changed." files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), filesUnavailable: files === null, participants: mentionParticipants } } -// Why: Monaco DiffViewer needs original/modified text (not patches); --cache bounds rate-limit spend on rapid file-expands. -async function fetchContentAtRef(args: { - repoPath: string - connectionId?: string | null - localGitOptions?: LocalGitExecOptions - ownerRepo: GitHubApiRepository - path: string - ref: string -}): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), - ...githubHostExecOptions(args.ownerRepo), - maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES - } - if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { - return { content: '', isBinary: false } - } - try { - noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '300s', - '-H', - 'Accept: application/vnd.github.raw', - `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` - ], - ghOptions - ) - // Heuristic: a NUL byte in the first 2KB means binary (execFile decodes as lossy utf-8). - const sample = stdout.slice(0, 2048) - if (sample.includes('\u0000')) { - return { content: '', isBinary: true } - } - return { content: stdout, isBinary: false } - } catch (error) { - if (isMaxBufferOverflowError(error)) { - return { content: '', isBinary: false, tooLarge: true } - } - return { content: '', isBinary: false } - } -} - export async function getPRFileContents(args: { repoPath: string connectionId?: string | null @@ -1256,67 +225,5 @@ export async function getPRFileContents(args: { headSha: string baseSha: string }): Promise { - const { ownerRepo } = await resolveGitHubRepoExecution( - args.repoPath, - args.prRepo, - args.connectionId, - args.localGitOptions - ) - if (!ownerRepo) { - return { - original: '', - modified: '', - originalIsBinary: false, - modifiedIsBinary: false - } - } - - await acquire() - try { - // Why: added files have no base-ref original, removed files no head-ref modified; skip those to avoid spurious 404s. - const needsOriginal = args.status !== 'added' - const needsModified = args.status !== 'removed' - const originalRef = args.baseSha - const originalPath = args.oldPath ?? args.path - - const [original, modified] = await Promise.all([ - needsOriginal - ? fetchContentAtRef({ - repoPath: args.repoPath, - connectionId: args.connectionId, - localGitOptions: args.localGitOptions, - ownerRepo, - path: originalPath, - ref: originalRef - }) - : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ - content: '', - isBinary: false - }), - needsModified - ? fetchContentAtRef({ - repoPath: args.repoPath, - connectionId: args.connectionId, - localGitOptions: args.localGitOptions, - ownerRepo, - path: args.path, - ref: args.headSha - }) - : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ - content: '', - isBinary: false - }) - ]) - - return { - original: original.content, - modified: modified.content, - originalIsBinary: original.isBinary, - modifiedIsBinary: modified.isBinary, - originalTooLarge: original.tooLarge, - modifiedTooLarge: modified.tooLarge - } - } finally { - release() - } + return loadPRFileContents(args) } diff --git a/src/main/github/work-item-participants.ts b/src/main/github/work-item-participants.ts new file mode 100644 index 00000000000..0ac793b4812 --- /dev/null +++ b/src/main/github/work-item-participants.ts @@ -0,0 +1,241 @@ +import type { PRComment } from '../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../shared/github/work-item-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $isPr: Boolean!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) @include(if: $isPr) { + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + } + issue(number: $number) @skip(if: $isPr) { + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + } + } +}` + +function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of users) { + if (!user.login) { + continue + } + const key = user.login.toLowerCase() + const existing = byLogin.get(key) + if (existing) { + byLogin.set(key, { + login: existing.login, + name: existing.name ?? user.name ?? null, + avatarUrl: existing.avatarUrl || user.avatarUrl || '' + }) + continue + } + byLogin.set(key, { + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + }) + } + return Array.from(byLogin.values()) +} + +export async function getWorkItemParticipants( + repoPath: string, + item: Pick, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return [] + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return [] + } + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${WORK_ITEM_PARTICIPANTS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${item.number}`, + '-F', + `isPr=${item.type === 'pr'}` + ], + ghOptions + ) + const data = JSON.parse(stdout) as { + data?: { + repository?: { + pullRequest?: { participants?: { nodes?: GitHubAssignableUser[] } } | null + issue?: { participants?: { nodes?: GitHubAssignableUser[] } } | null + } + } + } + const nodes = + data.data?.repository?.pullRequest?.participants?.nodes ?? + data.data?.repository?.issue?.participants?.nodes ?? + [] + return nodes + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + .filter((user) => user.login) + } catch { + return [] + } +} + +async function getGitHubUsersByLogin( + repoPath: string, + logins: string[], + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return [] + } + const uniqueLogins = Array.from( + new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) + ).slice(0, 40) + if (uniqueLogins.length === 0) { + return [] + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + console.warn( + `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` + ) + return [] + } + const fields = uniqueLogins + .map( + (login, index) => + `u${index}: user(login: ${JSON.stringify(login)}) { login name avatarUrl(size: 48) }` + ) + .join('\n') + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + ['api', 'graphql', '-f', `query=query { ${fields} }`], + ghOptions + ) + const data = JSON.parse(stdout) as { + data?: Record< + string, + { login?: string; name?: string | null; avatarUrl?: string | null } | null + > + } + return Object.values(data.data ?? {}) + .filter((user): user is { login: string; name?: string | null; avatarUrl?: string | null } => + Boolean(user?.login) + ) + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + } catch { + return [] + } +} + +export async function getMentionParticipants( + repoPath: string, + item: Pick< + GitHubWorkItem, + 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' + >, + comments: PRComment[], + participants: GitHubAssignableUser[], + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const visibleLogins = [ + item.author ?? '', + ...(item.reviewRequests ?? []).map((user) => user.login), + ...(item.latestReviews ?? []).map((review) => review.login), + ...(item.assignees ?? []).map((user) => user.login), + ...comments.map((comment) => comment.author) + ] + const graphQlUsers = await getGitHubUsersByLogin( + repoPath, + visibleLogins, + repository, + connectionId, + localGitOptions + ) + return mergeGitHubUsers([...participants, ...graphQlUsers]) +} + +export function enrichItemDisplayAvatars( + item: Omit, + knownUsers: GitHubAssignableUser[] +): Omit { + const avatarByLogin = new Map() + for (const user of knownUsers) { + if (user.login && user.avatarUrl) { + avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) + } + } + if (avatarByLogin.size === 0) { + return item + } + const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) + const resolvedAvatar = (login: string, existing?: string | null): string | undefined => + avatarFor(login) || existing || undefined + const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl + return { + ...item, + ...(authorAvatarUrl ? { authorAvatarUrl } : {}), + ...(item.reviewRequests + ? { + reviewRequests: item.reviewRequests.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}), + ...(item.latestReviews + ? { + latestReviews: item.latestReviews.map((review) => ({ + ...review, + avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null + })) + } + : {}), + ...(item.assignees + ? { + assignees: item.assignees.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}) + } +} diff --git a/src/main/ipc/github-account-handlers.ts b/src/main/ipc/github-account-handlers.ts new file mode 100644 index 00000000000..b5d91b6757a --- /dev/null +++ b/src/main/ipc/github-account-handlers.ts @@ -0,0 +1,31 @@ +import { ipcMain } from 'electron' +import { appStarSourceSchema } from '../../shared/gh-star-source' +import { diagnoseGhAuth } from '../github/auth-diagnose' +import { checkOrcaStarred, getAuthenticatedViewer, starOrca } from '../github/client' +import { getRateLimit } from '../github/rate-limit' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { track } from '../telemetry/client' + +export function registerGitHubAccountHandlers(): void { + ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) + ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) + ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { + const sourceParse = appStarSourceSchema.safeParse(source) + const starred = await starOrca() + if (starred && sourceParse.success) { + track('app_starred_orca', { + source: sourceParse.data, + ...getCohortAtEmit() + }) + } + return starred + }) + + ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => + getRateLimit(args?.force ? { force: true } : undefined) + ) + + ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => + diagnoseGhAuth(args?.host) + ) +} diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts new file mode 100644 index 00000000000..a54b7cfbf19 --- /dev/null +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -0,0 +1,100 @@ +import { readFileSync } from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = await vi.hoisted(async () => { + const { createGitHubIpcMocks } = await import('./github-ipc-module-mocks') + return createGitHubIpcMocks() +}) + +vi.mock('electron', () => mocks.electron) +vi.mock('../github/client', () => mocks.client) +vi.mock('../github/work-item-details', () => mocks.workItemDetails) +vi.mock('../github/pr-refresh-coordinator', () => mocks.prRefresh) +vi.mock('../telemetry/client', () => mocks.telemetry) +vi.mock('../telemetry/cohort-classifier', () => mocks.cohort) +vi.mock('./ui', () => mocks.ui) + +import * as github from './github' +import { createGitHubIpcHarness } from './github-ipc-test-harness' + +const EXPECTED_GITHUB_IPC_CHANNELS = [ + 'gh:prForBranch', + 'gh:refreshPRNow', + 'gh:enqueuePRRefresh', + 'gh:reportVisiblePRRefreshCandidates', + 'gh:issue', + 'gh:listIssues', + 'gh:createIssue', + 'gh:listWorkItems', + 'gh:countWorkItems', + 'gh:workItem', + 'gh:workItemByOwnerRepo', + 'gh:workItemDetails', + 'gh:notifyWorkItemMutated', + 'gh:prFileContents', + 'gh:repoSlug', + 'gh:repoUpstream', + 'gh:prChecks', + 'gh:prCheckDetails', + 'gh:prComments', + 'gh:setPRCommentReaction', + 'gh:resolveReviewThread', + 'gh:setPRFileViewed', + 'gh:addPRReviewCommentReply', + 'gh:addPRReviewComment', + 'gh:updatePRTitle', + 'gh:mergePR', + 'gh:setPRAutoMerge', + 'gh:updatePRState', + 'gh:rerunPRChecks', + 'gh:requestPRReviewers', + 'gh:removePRReviewers', + 'gh:updateIssue', + 'gh:addIssueComment', + 'gh:listLabels', + 'gh:listAssignableUsers', + 'gh:viewer', + 'gh:checkOrcaStarred', + 'gh:starOrca', + 'gh:rateLimit', + 'gh:diagnoseAuth', + 'gh:listAccessibleProjects', + 'gh:resolveProjectRef', + 'gh:listProjectViews', + 'gh:getProjectViewTable', + 'gh:projectWorkItemDetailsBySlug', + 'gh:updateProjectItemField', + 'gh:clearProjectItemField', + 'gh:updateIssueBySlug', + 'gh:updatePullRequestBySlug', + 'gh:addIssueCommentBySlug', + 'gh:updateIssueCommentBySlug', + 'gh:deleteIssueCommentBySlug', + 'gh:listLabelsBySlug', + 'gh:listAssignableUsersBySlug', + 'gh:listIssueTypesBySlug', + 'gh:updateIssueTypeBySlug' +] as const + +describe('GitHub IPC channel parity', () => { + const harness = createGitHubIpcHarness(mocks) + + beforeEach(harness.reset) + + it('preserves the facade and fixed IPC channel contract', () => { + github.registerGitHubHandlers(harness.store as never, harness.stats as never) + + const preloadSource = readFileSync(new URL('../../preload/index.ts', import.meta.url), 'utf8') + const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( + (match) => match[1] + ) + const registeredChannels = mocks.electron.ipcMain.handle.mock.calls.map( + ([channel]) => channel as string + ) + + expect(Object.keys(github)).toEqual(['registerGitHubHandlers']) + expect(new Set(registeredChannels).size).toBe(registeredChannels.length) + expect(registeredChannels).toEqual(EXPECTED_GITHUB_IPC_CHANNELS) + expect(registeredChannels.toSorted()).toEqual([...new Set(exposedChannels)].toSorted()) + }) +}) diff --git a/src/main/ipc/github-issue-mutation-handlers.ts b/src/main/ipc/github-issue-mutation-handlers.ts new file mode 100644 index 00000000000..992f0c6e11f --- /dev/null +++ b/src/main/ipc/github-issue-mutation-handlers.ts @@ -0,0 +1,106 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { addIssueComment, listAssignableUsers, listLabels, updateIssue } from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubIssueMutationHandlers(store: Store): void { + ipcMain.handle( + 'gh:updateIssue', + async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { + return { ok: false, error: 'Invalid issue number' } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: 'Updates object is required' } + } + const result = await updateIssue( + repo.path, + args.number, + args.updates, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle( + 'gh:addIssueComment', + async ( + event, + args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + body: string + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { + return { ok: false, error: 'Invalid issue number' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addIssueComment( + repo.path, + args.number, + args.body.trim(), + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { + repoPath: repo.path, + repoId: repo.id, + type: args.type ?? 'issue', + number: args.number + }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listLabels( + repo.path, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) + + ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) +} diff --git a/src/main/ipc/github-pr-mutation-handlers.ts b/src/main/ipc/github-pr-mutation-handlers.ts new file mode 100644 index 00000000000..1d3aecc38e3 --- /dev/null +++ b/src/main/ipc/github-pr-mutation-handlers.ts @@ -0,0 +1,212 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types' +import { + mergePR, + removePRReviewers, + requestPRReviewers, + rerunPRChecks, + setPRAutoMerge, + updatePRState, + updatePRTitle +} from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +function broadcastSuccessfulPRMutation( + ok: boolean, + repoPath: string, + repoId: string, + prNumber: number, + senderId: number +): void { + if (ok) { + broadcastGitHubWorkItemMutation({ repoPath, repoId, type: 'pr', number: prNumber }, senderId) + } +} + +export function registerGitHubPRMutationHandlers(store: Store): void { + ipcMain.handle( + 'gh:updatePRTitle', + async ( + event, + args: { repoPath: string; prNumber: number; title: string; prRepo?: GitHubOwnerRepo | null } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const ok = await updatePRTitle( + repo.path, + args.prNumber, + args.title, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(ok, repo.path, repo.id, args.prNumber, event.sender.id) + return ok + } + ) + + ipcMain.handle( + 'gh:mergePR', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await mergePR( + repo.path, + args.prNumber, + args.method, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:setPRAutoMerge', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + enabled: boolean + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await setPRAutoMerge( + repo.path, + args.prNumber, + args.enabled, + args.method, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:updatePRState', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + updates: GitHubPullRequestStateUpdate + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + const result = await updatePRState( + repo.path, + args.prNumber, + args.updates, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:rerunPRChecks', + async ( + _event, + args: GitHubRepoScopedArgs & { + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + return rerunPRChecks( + repo.path, + args.prNumber, + { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:requestPRReviewers', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await requestPRReviewers( + repo.path, + args.prNumber, + args.reviewers, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:removePRReviewers', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await removePRReviewers( + repo.path, + args.prNumber, + args.reviewers, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) +} diff --git a/src/main/ipc/github-pr-read-handlers.ts b/src/main/ipc/github-pr-read-handlers.ts new file mode 100644 index 00000000000..1c200218f91 --- /dev/null +++ b/src/main/ipc/github-pr-read-handlers.ts @@ -0,0 +1,178 @@ +import { ipcMain } from 'electron' +import type { GitHubReactionContent } from '../../shared/github/comment-types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + getPRCheckDetails, + getPRChecks, + getPRComments, + getRepoSlug, + getRepoUpstream, + resolveReviewThread, + setPRCommentReaction +} from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId +} from './github-repo-routing' + +export function registerGitHubPRReadHandlers(store: Store): void { + ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoSlug(repo.path, getGitHubRepoConnectionId(repo), { + localGitExecOptions: localGitOptions + }) + : getRepoSlug(repo.path, getGitHubRepoConnectionId(repo)) + }) + + ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoUpstream(repo.path, getGitHubRepoConnectionId(repo), { + localGitExecOptions: localGitOptions + }) + : getRepoUpstream(repo.path, getGitHubRepoConnectionId(repo)) + }) + + ipcMain.handle( + 'gh:prChecks', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRChecks( + repo.path, + args.prNumber, + args.headSha, + args.prRepo ?? null, + { noCache: args.noCache }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:prCheckDetails', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRCheckDetails( + repo.path, + { + checkRunId: args.checkRunId, + workflowRunId: args.workflowRunId, + checkName: args.checkName, + url: args.url, + prRepo: args.prRepo ?? null + }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:prComments', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRComments( + repo.path, + args.prNumber, + { noCache: args.noCache, prRepo: args.prRepo ?? null }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:setPRCommentReaction', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (!args.reactionSubjectId?.trim()) { + return false + } + return setPRCommentReaction( + repo.path, + args.reactionSubjectId.trim(), + args.content, + args.reacted, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:resolveReviewThread', + async ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return resolveReviewThread( + repo.path, + args.threadId, + args.resolve, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) +} diff --git a/src/main/ipc/github-pr-refresh-handlers.ts b/src/main/ipc/github-pr-refresh-handlers.ts new file mode 100644 index 00000000000..ab4dc74ec7d --- /dev/null +++ b/src/main/ipc/github-pr-refresh-handlers.ts @@ -0,0 +1,161 @@ +import { ipcMain } from 'electron' +import { resolve } from 'node:path' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../shared/repo-types' +import { getPRForBranch, type GitHubPRBranchLookupOptions } from '../github/client' +import { + clearVisiblePRRefreshWindow, + enqueuePRRefresh, + refreshPRNow, + reportVisiblePRRefreshCandidates, + setPRRefreshOutcomeObserver +} from '../github/pr-refresh-coordinator' +import type { Store } from '../persistence' +import type { StatsCollector } from '../stats/collector' +import { + applyRegisteredRepoToPRRefreshCandidate, + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + validateAutomaticPRRefreshCandidate +} from './github-repo-routing' + +const visibilityCleanupRegistered = new Set() + +export function registerGitHubPRRefreshHandlers(store: Store, stats: StatsCollector): void { + function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url } + }) + } + } + + setPRRefreshOutcomeObserver((candidate, outcome) => { + const repo = + store.getRepos().find((entry) => entry.id === candidate.repoId) ?? + store.getRepos().find((entry) => resolve(entry.path) === resolve(candidate.repoPath)) + if (repo) { + recordPRIfNeeded(repo, outcome) + } + }) + + ipcMain.handle( + 'gh:prForBranch', + async ( + _event, + args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + const hostedReviewOptions = localGitOptions + ? { localGitExecOptions: localGitOptions } + : undefined + const currentHeadOid = + typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0 + ? args.currentHeadOid.trim() + : null + const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptions + ? { ...hostedReviewOptions } + : args.acceptMergedFallbackPR === true || currentHeadOid !== null + ? {} + : undefined + if (lookupOptions && args.acceptMergedFallbackPR === true) { + lookupOptions.acceptMergedFallbackPR = true + } + if (lookupOptions && currentHeadOid !== null) { + lookupOptions.currentHeadOid = currentHeadOid + } + const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions + ? [lookupOptions] + : [] + const pr = await getPRForBranch( + repo.path, + args.branch, + args.linkedPRNumber ?? null, + getGitHubRepoConnectionId(repo), + args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null, + ...lookupOptionArgs + ) + if (pr && !stats.hasCountedPR(pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: pr.number, prUrl: pr.url } + }) + } + return pr + } + ) + + ipcMain.handle( + 'gh:refreshPRNow', + async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { + const repo = assertRegisteredGitHubRepo(args.candidate, store) + const outcome = await refreshPRNow( + applyRegisteredRepoToPRRefreshCandidate(store, repo, args.candidate) + ) + recordPRIfNeeded(repo, outcome) + return outcome + } + ) + + ipcMain.handle( + 'gh:enqueuePRRefresh', + ( + event, + args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + } + ): GitHubPRRefreshEnqueueResult => { + const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) + if (validation.kind === 'skipped') { + return validation.result + } + enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, event?.sender?.id) + return { kind: 'queued' } + } + ) + + ipcMain.handle( + 'gh:reportVisiblePRRefreshCandidates', + (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { + const senderId = event.sender.id + if (!visibilityCleanupRegistered.has(senderId)) { + visibilityCleanupRegistered.add(senderId) + event.sender.once('destroyed', () => { + visibilityCleanupRegistered.delete(senderId) + clearVisiblePRRefreshWindow(senderId) + }) + } + const candidates: GitHubPRRefreshCandidate[] = [] + const repos = store.getRepos() + for (const candidate of args.candidates) { + const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) + if (validation.kind === 'ok') { + candidates.push(validation.candidate) + } + } + reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) + return true + } + ) +} diff --git a/src/main/ipc/github-pr-review-handlers.ts b/src/main/ipc/github-pr-review-handlers.ts new file mode 100644 index 00000000000..637eb74c1de --- /dev/null +++ b/src/main/ipc/github-pr-review-handlers.ts @@ -0,0 +1,180 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { addPRReviewComment, addPRReviewCommentReply, setPRFileViewed } from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubPRReviewHandlers(store: Store): void { + ipcMain.handle( + 'gh:setPRFileViewed', + async ( + event, + args: { + repoPath: string + repoId?: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return false + } + if (!args.pullRequestId?.trim() || !args.path?.trim()) { + return false + } + const ok = await setPRFileViewed({ + repoPath: repo.path, + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, + pullRequestId: args.pullRequestId.trim(), + path: args.path, + viewed: Boolean(args.viewed) + }) + if (ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return ok + } + ) + + ipcMain.handle( + 'gh:addPRReviewCommentReply', + async ( + event, + args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid PR number' } + } + if ( + typeof args.commentId !== 'number' || + !Number.isInteger(args.commentId) || + args.commentId < 1 + ) { + return { ok: false, error: 'Invalid comment ID' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addPRReviewCommentReply( + repo.path, + args.prNumber, + args.commentId, + args.body.trim(), + args.threadId, + args.path, + args.line, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle( + 'gh:addPRReviewComment', + async ( + event, + args: { + repoPath: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + commitId: string + path: string + line: number + startLine?: number + body: string + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid PR number' } + } + if (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1) { + return { ok: false, error: 'Invalid line number' } + } + if ( + args.startLine !== undefined && + (typeof args.startLine !== 'number' || + !Number.isInteger(args.startLine) || + args.startLine < 1 || + args.startLine > args.line) + ) { + return { ok: false, error: 'Invalid start line' } + } + if (!args.commitId?.trim()) { + return { ok: false, error: 'Missing PR head SHA' } + } + if (!args.path?.trim()) { + return { ok: false, error: 'File path required' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addPRReviewComment({ + repoPath: repo.path, + prRepo: args.prRepo ?? null, + prNumber: args.prNumber, + commitId: args.commitId.trim(), + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body.trim(), + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0] + }) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) +} diff --git a/src/main/ipc/github-project-view-handlers.ts b/src/main/ipc/github-project-view-handlers.ts new file mode 100644 index 00000000000..0106bd80bc7 --- /dev/null +++ b/src/main/ipc/github-project-view-handlers.ts @@ -0,0 +1,89 @@ +import { ipcMain } from 'electron' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAccessibleProjectsArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github/project-request-types' +import { + addIssueCommentBySlug, + clearProjectItemFieldValue, + deleteIssueCommentBySlug, + getProjectViewTable, + getWorkItemDetailsBySlug, + listAccessibleProjects, + listAssignableUsersBySlug, + listIssueTypesBySlug, + listLabelsBySlug, + listProjectViews, + resolveProjectRef, + updateIssueBySlug, + updateIssueCommentBySlug, + updateIssueTypeBySlug, + updateProjectItemFieldValue, + updatePullRequestBySlug +} from '../github/project-view' + +export function registerGitHubProjectViewHandlers(): void { + ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => + listAccessibleProjects(args) + ) + ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => + resolveProjectRef(args) + ) + ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => + listProjectViews(args) + ) + ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => + getProjectViewTable(args) + ) + ipcMain.handle( + 'gh:projectWorkItemDetailsBySlug', + (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) + ) + ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => + updateProjectItemFieldValue(args) + ) + ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => + clearProjectItemFieldValue(args) + ) + ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => + updateIssueBySlug(args) + ) + ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => + updatePullRequestBySlug(args) + ) + ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => + addIssueCommentBySlug(args) + ) + ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => + updateIssueCommentBySlug(args) + ) + ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => + deleteIssueCommentBySlug(args) + ) + ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => + listLabelsBySlug(args) + ) + ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => + listAssignableUsersBySlug(args) + ) + ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => + listIssueTypesBySlug(args) + ) + ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => + updateIssueTypeBySlug(args) + ) +} diff --git a/src/main/ipc/github-repo-routing.ts b/src/main/ipc/github-repo-routing.ts new file mode 100644 index 00000000000..a7f93c2b44f --- /dev/null +++ b/src/main/ipc/github-repo-routing.ts @@ -0,0 +1,130 @@ +import { resolve } from 'node:path' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult +} from '../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../shared/repo-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + notePRRefreshValidationDenial, + type PRRefreshValidationDenialReason +} from '../github/pr-refresh-validation-backoff' +import type { Store } from '../persistence' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' + +export type GitHubRepoScopedArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +type RegisteredRepoValidationResult = + | { kind: 'ok'; repo: Repo } + | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } + +function validateRegisteredRepo( + args: string | GitHubRepoScopedArgs, + store: Store, + repos = store.getRepos() +): RegisteredRepoValidationResult { + const repoPath = typeof args === 'string' ? args : args.repoPath + const repoId = typeof args === 'string' ? undefined : args.repoId + const resolvedRepoPath = resolve(repoPath) + const repo = repos.find((candidate) => + repoId ? candidate.id === repoId : resolve(candidate.path) === resolvedRepoPath + ) + if (!repo) { + return { + kind: 'denied', + reason: 'unknown-repo', + message: 'Access denied: unknown repository path' + } + } + if (repoId && resolve(repo.path) !== resolvedRepoPath) { + return { + kind: 'denied', + reason: 'repo-path-mismatch', + message: 'Access denied: repository path does not match repo id' + } + } + if ( + typeof args !== 'string' && + args.sourceContext?.provider === 'github' && + args.sourceContext.hostId !== getRepoExecutionHostId(repo) + ) { + return { + kind: 'denied', + reason: 'host-mismatch', + message: 'Access denied: GitHub source host does not match repository host' + } + } + return { kind: 'ok', repo } +} + +export function assertRegisteredGitHubRepo( + args: string | GitHubRepoScopedArgs, + store: Store +): Repo { + const result = validateRegisteredRepo(args, store) + if (result.kind === 'denied') { + throw new Error(result.message) + } + return result.repo +} + +export function getGitHubRepoConnectionId(repo: Repo): string | null { + return repo.connectionId ?? null +} + +export function getGitHubLocalGitOptionArgs( + store: Store, + repo: Repo +): [] | [{ wslDistro?: string }] { + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] +} + +export function applyRegisteredRepoToPRRefreshCandidate( + store: Store, + repo: Repo, + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshCandidate { + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + const appliedCandidate = { ...candidate } + delete appliedCandidate.localGitOptions + delete appliedCandidate.connectionId + delete appliedCandidate.executionHostId + delete appliedCandidate.connectionState + return { + ...appliedCandidate, + repoPath: repo.path, + repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), + connectionId: getGitHubRepoConnectionId(repo), + executionHostId: repo.executionHostId ?? null, + connectionState: repo.connectionId ? 'connected' : 'unknown' + } +} + +export function validateAutomaticPRRefreshCandidate( + candidate: GitHubPRRefreshCandidate, + store: Store, + repos = store.getRepos() +): + | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } + | { kind: 'skipped'; result: Extract } { + const result = validateRegisteredRepo(candidate, store, repos) + if (result.kind === 'denied') { + const skippedReason = notePRRefreshValidationDenial({ + repoId: candidate.repoId, + repoPath: candidate.repoPath, + reason: result.reason + }) + return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } + } + return { + kind: 'ok', + candidate: applyRegisteredRepoToPRRefreshCandidate(store, result.repo, candidate) + } +} diff --git a/src/main/ipc/github-work-item-handlers.ts b/src/main/ipc/github-work-item-handlers.ts new file mode 100644 index 00000000000..26c21b3db81 --- /dev/null +++ b/src/main/ipc/github-work-item-handlers.ts @@ -0,0 +1,214 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' +import type { GitHubCreateIssueFields } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + countWorkItems, + createIssue, + getIssue, + getWorkItem, + getWorkItemByOwnerRepo, + listIssues, + listWorkItems +} from '../github/client' +import { getPRFileContents, getWorkItemDetails } from '../github/work-item-details' +import type { Store } from '../persistence' +import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubWorkItemHandlers(store: Store): void { + ipcMain.handle( + 'gh:issue', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getIssue( + repo.path, + args.number, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listIssues( + repo.path, + args.limit, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ).then((result) => result.items) + }) + + ipcMain.handle( + 'gh:createIssue', + ( + _event, + args: GitHubRepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const fields = + args.labels !== undefined || args.assignees !== undefined + ? { labels: args.labels, assignees: args.assignees } + : undefined + return createIssue( + repo.path, + args.title, + args.body, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + fields, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:listWorkItems', + ( + _event, + args: { + repoPath: string + repoId?: string + limit?: number + query?: string + page?: number + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listWorkItems( + repo.path, + args.limit, + args.query, + args.page, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + args.noCache, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return countWorkItems( + repo.path, + args.query, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) + + ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem(args, repo, getWorkItem, getGitHubLocalGitOptionArgs(store, repo)[0]) + }) + + ipcMain.handle( + 'gh:workItemByOwnerRepo', + ( + _event, + args: { + repoPath: string + owner: string + repo: string + host?: string + number: number + type: 'issue' | 'pr' + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getWorkItemByOwnerRepo( + repo.path, + { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, + args.number, + args.type, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem( + args, + repo, + getWorkItemDetails, + getGitHubLocalGitOptionArgs(store, repo)[0] + ) + }) + + ipcMain.handle( + 'gh:notifyWorkItemMutated', + (event, args: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number }) => { + const repo = args.repoId + ? store.getRepos().find((candidate) => candidate.id === args.repoId) + : assertRegisteredGitHubRepo(args, store) + if (!repo) { + return false + } + if ( + (args.type !== 'issue' && args.type !== 'pr') || + typeof args.number !== 'number' || + !Number.isInteger(args.number) || + args.number < 1 + ) { + return false + } + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: args.type, number: args.number }, + event.sender.id + ) + return true + } + ) + + ipcMain.handle( + 'gh:prFileContents', + ( + _event, + args: { + repoPath: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRFileContents({ + repoPath: repo.path, + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, + prNumber: args.prNumber, + path: args.path, + oldPath: args.oldPath, + status: args.status, + headSha: args.headSha, + baseSha: args.baseSha + }) + } + ) +} diff --git a/src/main/ipc/github-work-item-mutation-events.ts b/src/main/ipc/github-work-item-mutation-events.ts new file mode 100644 index 00000000000..5466dd8bdc4 --- /dev/null +++ b/src/main/ipc/github-work-item-mutation-events.ts @@ -0,0 +1,15 @@ +import { sendToTrustedUIRenderer } from './ui' + +export type GitHubWorkItemMutation = { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number +} + +export function broadcastGitHubWorkItemMutation( + payload: GitHubWorkItemMutation, + senderId?: number +): void { + sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) +} diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 7673aff06ce..668d9fdca79 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -1,1312 +1,21 @@ -/* eslint-disable max-lines -- Why: all GitHub IPC handlers stay co-located so -the repo-path validation, preference-threading, and stats wiring patterns are -reviewable as one surface. Splitting by feature area would risk drifting -validation/gate conventions across handler files. */ -import { ipcMain } from 'electron' -import { resolve } from 'node:path' -import type { GitHubReactionContent } from '../../shared/github/comment-types' -import type { - GitHubPRRefreshCandidate, - GitHubPRRefreshEnqueueResult, - GitHubPRRefreshReason, - PRRefreshOutcome -} from '../../shared/github/pull-request-refresh-types' -import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' -import type { - GitHubCreateIssueFields, - GitHubIssueUpdate, - GitHubPullRequestStateUpdate -} from '../../shared/issue-mutation-types' -import type { Repo } from '../../shared/repo-types' -import { getRepoExecutionHostId } from '../../shared/execution-host' -import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' -import { - getPRForBranch, - getIssue, - getRepoSlug, - getRepoUpstream, - listIssues, - listWorkItems, - countWorkItems, - getWorkItem, - getWorkItemByOwnerRepo, - createIssue, - updateIssue, - addIssueComment, - listLabels, - listAssignableUsers, - getAuthenticatedViewer, - getPRChecks, - getPRCheckDetails, - getPRComments, - setPRCommentReaction, - resolveReviewThread, - setPRFileViewed, - addPRReviewComment, - addPRReviewCommentReply, - updatePRTitle, - mergePR, - setPRAutoMerge, - updatePRState, - rerunPRChecks, - requestPRReviewers, - removePRReviewers, - checkOrcaStarred, - starOrca -} from '../github/client' -import type { GitHubPRBranchLookupOptions } from '../github/client' -import { - clearVisiblePRRefreshWindow, - enqueuePRRefresh, - refreshPRNow, - reportVisiblePRRefreshCandidates, - setPRRefreshOutcomeObserver -} from '../github/pr-refresh-coordinator' -import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' -import { getRateLimit } from '../github/rate-limit' -import { diagnoseGhAuth } from '../github/auth-diagnose' -import { - notePRRefreshValidationDenial, - type PRRefreshValidationDenialReason -} from '../github/pr-refresh-validation-backoff' -import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' -import { - getProjectViewTable, - listAccessibleProjects, - resolveProjectRef, - listProjectViews, - getWorkItemDetailsBySlug, - updateProjectItemFieldValue, - clearProjectItemFieldValue, - updateIssueBySlug, - updatePullRequestBySlug, - addIssueCommentBySlug, - updateIssueCommentBySlug, - deleteIssueCommentBySlug, - listLabelsBySlug, - listAssignableUsersBySlug, - listIssueTypesBySlug, - updateIssueTypeBySlug -} from '../github/project-view' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ListProjectViewsArgs, - ProjectWorkItemDetailsBySlugArgs, - ResolveProjectRefArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdateProjectItemFieldArgs, - UpdatePullRequestBySlugArgs -} from '../../shared/github/project-request-types' -import { appStarSourceSchema } from '../../shared/gh-star-source' -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { sendToTrustedUIRenderer } from './ui' - -const prRefreshVisibilityCleanupRegistered = new Set() - -// Why: the app renderer owns the SWR cache; browser guests cannot consume this -// event. Skip the origin because it already updated its cache optimistically. -function broadcastWorkItemMutated( - payload: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }, - senderId?: number -): void { - sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) -} - -// Why: returns the full Repo object instead of just the path string so that -// callers have access to repo.id for stat tracking and other context. -type RepoScopedArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} - -type RegisteredRepoValidationResult = - | { kind: 'ok'; repo: Repo } - | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } - -function validateRegisteredRepo( - args: string | RepoScopedArgs, - store: Store, - repos = store.getRepos() -): RegisteredRepoValidationResult { - const repoPath = typeof args === 'string' ? args : args.repoPath - const repoId = typeof args === 'string' ? undefined : args.repoId - const resolvedRepoPath = resolve(repoPath) - const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) - if (!repo) { - return { - kind: 'denied', - reason: 'unknown-repo', - message: 'Access denied: unknown repository path' - } - } - if (repoId && resolve(repo.path) !== resolvedRepoPath) { - return { - kind: 'denied', - reason: 'repo-path-mismatch', - message: 'Access denied: repository path does not match repo id' - } - } - if ( - typeof args !== 'string' && - args.sourceContext?.provider === 'github' && - args.sourceContext.hostId !== getRepoExecutionHostId(repo) - ) { - return { - kind: 'denied', - reason: 'host-mismatch', - message: 'Access denied: GitHub source host does not match repository host' - } - } - return { kind: 'ok', repo } -} - -function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { - const result = validateRegisteredRepo(args, store) - if (result.kind === 'denied') { - throw new Error(result.message) - } - return result.repo -} - -function repoConnectionId(repo: Repo): string | null { - return repo.connectionId ?? null -} - -function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: string }] { - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] -} - -function applyRepoToPRRefreshCandidate( - store: Store, - repo: Repo, - candidate: GitHubPRRefreshCandidate -): GitHubPRRefreshCandidate { - const localGitOptions = localGitOptionArgs(store, repo)[0] - const appliedCandidate = { ...candidate } - delete appliedCandidate.localGitOptions - delete appliedCandidate.connectionId - delete appliedCandidate.executionHostId - delete appliedCandidate.connectionState - return { - ...appliedCandidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repoConnectionId(repo), - executionHostId: repo.executionHostId ?? null, - connectionState: repo.connectionId ? 'connected' : 'unknown' - } -} - -function validateAutomaticPRRefreshCandidate( - candidate: GitHubPRRefreshCandidate, - store: Store, - repos = store.getRepos() -): - | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } - | { - kind: 'skipped' - result: Extract - } { - const result = validateRegisteredRepo(candidate, store, repos) - if (result.kind === 'denied') { - const skippedReason = notePRRefreshValidationDenial({ - repoId: candidate.repoId, - repoPath: candidate.repoPath, - reason: result.reason - }) - return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } - } - return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) } -} +import { registerGitHubAccountHandlers } from './github-account-handlers' +import { registerGitHubIssueMutationHandlers } from './github-issue-mutation-handlers' +import { registerGitHubPRMutationHandlers } from './github-pr-mutation-handlers' +import { registerGitHubPRReadHandlers } from './github-pr-read-handlers' +import { registerGitHubPRRefreshHandlers } from './github-pr-refresh-handlers' +import { registerGitHubPRReviewHandlers } from './github-pr-review-handlers' +import { registerGitHubProjectViewHandlers } from './github-project-view-handlers' +import { registerGitHubWorkItemHandlers } from './github-work-item-handlers' export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { - function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { - if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url } - }) - } - } - - setPRRefreshOutcomeObserver((candidate, outcome) => { - const repo = - store.getRepos().find((r) => r.id === candidate.repoId) ?? - store.getRepos().find((r) => resolve(r.path) === resolve(candidate.repoPath)) - if (repo) { - recordPRIfNeeded(repo, outcome) - } - }) - - ipcMain.handle( - 'gh:prForBranch', - async ( - _event, - args: { - repoPath: string - branch: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - acceptMergedFallbackPR?: boolean - currentHeadOid?: string | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - const hostedReviewOptionArgs: [] | [{ localGitExecOptions: { wslDistro?: string } }] = - localGitOptions ? [{ localGitExecOptions: localGitOptions }] : [] - const currentHeadOid = - typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0 - ? args.currentHeadOid.trim() - : null - const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptionArgs[0] - ? { ...hostedReviewOptionArgs[0] } - : args.acceptMergedFallbackPR === true || currentHeadOid !== null - ? {} - : undefined - if (lookupOptions && args.acceptMergedFallbackPR === true) { - lookupOptions.acceptMergedFallbackPR = true - } - if (lookupOptions && currentHeadOid !== null) { - lookupOptions.currentHeadOid = currentHeadOid - } - const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions - ? [lookupOptions] - : [] - const pr = await getPRForBranch( - repo.path, - args.branch, - args.linkedPRNumber ?? null, - repoConnectionId(repo), - args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null, - ...lookupOptionArgs - ) - // Emit pr_created when a PR is first detected for a branch. - // Why here: the renderer polls gh:prForBranch to check PR status per worktree. - // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). - if (pr && !stats.hasCountedPR(pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: pr.number, prUrl: pr.url } - }) - } - return pr - } - ) - - ipcMain.handle( - 'gh:refreshPRNow', - async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { - const repo = assertRegisteredRepo(args.candidate, store) - const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate)) - recordPRIfNeeded(repo, outcome) - return outcome - } - ) - - ipcMain.handle( - 'gh:enqueuePRRefresh', - ( - event, - args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority?: number - } - ): GitHubPRRefreshEnqueueResult => { - const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) - if (validation.kind === 'skipped') { - return validation.result - } - const senderWindowId = event?.sender?.id - enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, senderWindowId) - return { kind: 'queued' } - } - ) - - ipcMain.handle( - 'gh:reportVisiblePRRefreshCandidates', - (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { - const senderId = event.sender.id - if (!prRefreshVisibilityCleanupRegistered.has(senderId)) { - prRefreshVisibilityCleanupRegistered.add(senderId) - event.sender.once('destroyed', () => { - prRefreshVisibilityCleanupRegistered.delete(senderId) - clearVisiblePRRefreshWindow(senderId) - }) - } - const candidates: GitHubPRRefreshCandidate[] = [] - const repos = store.getRepos() - for (const candidate of args.candidates) { - const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) - if (validation.kind === 'ok') { - candidates.push(validation.candidate) - } - } - reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) - return true - } - ) - - ipcMain.handle( - 'gh:issue', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - number: number - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getIssue( - repo.path, - args.number, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { - const repo = assertRegisteredRepo(args, store) - // Why: listIssues now returns { items, error? }. The IPC handler unwraps to - // the items array for the existing contract; feature 1's UI consumes the - // richer envelope through `gh:listWorkItems` instead. - return listIssues( - repo.path, - args.limit, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ).then((r) => r.items) - }) - - ipcMain.handle( - 'gh:createIssue', - (_event, args: RepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields) => { - const repo = assertRegisteredRepo(args, store) - const fields = - args.labels !== undefined || args.assignees !== undefined - ? { labels: args.labels, assignees: args.assignees } - : undefined - return createIssue( - repo.path, - args.title, - args.body, - repo.issueSourcePreference, - repoConnectionId(repo), - fields, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:listWorkItems', - ( - _event, - args: { - repoPath: string - repoId?: string - limit?: number - query?: string - page?: number - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return listWorkItems( - repo.path, - args.limit, - args.query, - args.page, - repo.issueSourcePreference, - repoConnectionId(repo), - args.noCache, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { - const repo = assertRegisteredRepo(args, store) - return countWorkItems( - repo.path, - args.query, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredRepo(args, store) - return dispatchWorkItem(args, repo, getWorkItem, localGitOptionArgs(store, repo)[0]) - }) - ipcMain.handle( - 'gh:workItemByOwnerRepo', - ( - _event, - args: { - repoPath: string - owner: string - repo: string - host?: string - number: number - type: 'issue' | 'pr' - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getWorkItemByOwnerRepo( - repo.path, - // Why: Enterprise host identity must survive the IPC boundary or the - // lookup falls back to gh's default host for a same-named repo. - { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, - args.number, - args.type, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredRepo(args, store) - return dispatchWorkItem(args, repo, getWorkItemDetails, localGitOptionArgs(store, repo)[0]) - }) - - ipcMain.handle( - 'gh:notifyWorkItemMutated', - ( - event, - args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - } - ) => { - const repo = args.repoId - ? store.getRepos().find((candidate) => candidate.id === args.repoId) - : assertRegisteredRepo(args, store) - if (!repo) { - return false - } - if ( - (args.type !== 'issue' && args.type !== 'pr') || - typeof args.number !== 'number' || - !Number.isInteger(args.number) || - args.number < 1 - ) { - return false - } - broadcastWorkItemMutated( - { - repoPath: repo.path, - repoId: repo.id, - type: args.type, - number: args.number - }, - event.sender.id - ) - return true - } - ) - - ipcMain.handle( - 'gh:prFileContents', - ( - _event, - args: { - repoPath: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRFileContents({ - repoPath: repo.path, - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0], - prRepo: args.prRepo ?? null, - prNumber: args.prNumber, - path: args.path, - oldPath: args.oldPath, - status: args.status, - headSha: args.headSha, - baseSha: args.baseSha - }) - } - ) - - ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return localGitOptions - ? getRepoSlug(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) - : getRepoSlug(repo.path, repoConnectionId(repo)) - }) - - ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return localGitOptions - ? getRepoUpstream(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) - : getRepoUpstream(repo.path, repoConnectionId(repo)) - }) - - ipcMain.handle( - 'gh:prChecks', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRChecks( - repo.path, - args.prNumber, - args.headSha, - args.prRepo ?? null, - { - noCache: args.noCache - }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:prCheckDetails', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRCheckDetails( - repo.path, - { - checkRunId: args.checkRunId, - workflowRunId: args.workflowRunId, - checkName: args.checkName, - url: args.url, - prRepo: args.prRepo ?? null - }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:prComments', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRComments( - repo.path, - args.prNumber, - { noCache: args.noCache, prRepo: args.prRepo ?? null }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:setPRCommentReaction', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - reactionSubjectId: string - content: GitHubReactionContent - reacted: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if (!args.reactionSubjectId?.trim()) { - return false - } - return setPRCommentReaction( - repo.path, - args.reactionSubjectId.trim(), - args.content, - args.reacted, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:resolveReviewThread', - async ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - threadId: string - resolve: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - // Why: thread resolve doesn't carry the PR number, so we cannot target - // a specific cache entry. The renderer cache stores per-(repo, type, number) - // entries — emitting a path-wide invalidation here would require a new - // event shape; instead, the drawer's existing thread-resolve UI updates - // its local state immediately and the next reopen pays one fresh fetch. - return resolveReviewThread( - repo.path, - args.threadId, - args.resolve, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:setPRFileViewed', - async ( - event, - args: { - repoPath: string - repoId?: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return false - } - if (!args.pullRequestId?.trim() || !args.path?.trim()) { - return false - } - const ok = await setPRFileViewed({ - repoPath: repo.path, - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0], - prRepo: args.prRepo ?? null, - pullRequestId: args.pullRequestId.trim(), - path: args.path, - viewed: Boolean(args.viewed) - }) - if (ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return ok - } - ) - - ipcMain.handle( - 'gh:addPRReviewCommentReply', - async ( - event, - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid PR number' } - } - if ( - typeof args.commentId !== 'number' || - !Number.isInteger(args.commentId) || - args.commentId < 1 - ) { - return { ok: false, error: 'Invalid comment ID' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addPRReviewCommentReply( - repo.path, - args.prNumber, - args.commentId, - args.body.trim(), - args.threadId, - args.path, - args.line, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:addPRReviewComment', - async ( - event, - args: { - repoPath: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - commitId: string - path: string - line: number - startLine?: number - body: string - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid PR number' } - } - if (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1) { - return { ok: false, error: 'Invalid line number' } - } - if ( - args.startLine !== undefined && - (typeof args.startLine !== 'number' || - !Number.isInteger(args.startLine) || - args.startLine < 1 || - args.startLine > args.line) - ) { - return { ok: false, error: 'Invalid start line' } - } - if (!args.commitId?.trim()) { - return { ok: false, error: 'Missing PR head SHA' } - } - if (!args.path?.trim()) { - return { ok: false, error: 'File path required' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addPRReviewComment({ - repoPath: repo.path, - prRepo: args.prRepo ?? null, - prNumber: args.prNumber, - commitId: args.commitId.trim(), - path: args.path, - line: args.line, - startLine: args.startLine, - body: args.body.trim(), - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0] - }) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updatePRTitle', - async ( - event, - args: { repoPath: string; prNumber: number; title: string; prRepo?: GitHubOwnerRepo | null } - ) => { - const repo = assertRegisteredRepo(args, store) - const ok = await updatePRTitle( - repo.path, - args.prNumber, - args.title, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return ok - } - ) - - ipcMain.handle( - 'gh:mergePR', - async ( - event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await mergePR( - repo.path, - args.prNumber, - args.method, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:setPRAutoMerge', - async ( - event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - enabled: boolean - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await setPRAutoMerge( - repo.path, - args.prNumber, - args.enabled, - args.method, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updatePRState', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - updates: GitHubPullRequestStateUpdate - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid pull request number' } - } - const result = await updatePRState( - repo.path, - args.prNumber, - args.updates, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:rerunPRChecks', - async ( - _event, - args: RepoScopedArgs & { - prNumber: number - headSha?: string - failedOnly?: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid pull request number' } - } - return rerunPRChecks( - repo.path, - args.prNumber, - { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:requestPRReviewers', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await requestPRReviewers( - repo.path, - args.prNumber, - args.reviewers, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:removePRReviewers', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await removePRReviewers( - repo.path, - args.prNumber, - args.reviewers, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updateIssue', - async (event, args: RepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { - const repo = assertRegisteredRepo(args, store) - if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { - return { ok: false, error: 'Invalid issue number' } - } - if (!args.updates || typeof args.updates !== 'object') { - return { ok: false, error: 'Updates object is required' } - } - const result = await updateIssue( - repo.path, - args.number, - args.updates, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:addIssueComment', - async ( - event, - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { - return { ok: false, error: 'Invalid issue number' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addIssueComment( - repo.path, - args.number, - args.body.trim(), - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - // Why: PR conversation comments hit `/issues/N/comments` too, but the - // drawer's cache key uses type='pr'. The caller passes through which - // drawer they're posting from so we only invalidate the matching key - // — broadcasting both would evict an unrelated PR/issue that happens - // to share the number. - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: args.type ?? 'issue', number: args.number }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle('gh:listLabels', (_event, args: RepoScopedArgs) => { - const repo = assertRegisteredRepo(args, store) - return listLabels( - repo.path, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - ipcMain.handle('gh:listAssignableUsers', (_event, args: RepoScopedArgs) => { - const repo = assertRegisteredRepo(args, store) - return listAssignableUsers( - repo.path, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - // Star operations target the Orca repo itself — no repoPath validation needed - ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) - ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) - ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { - const sourceParse = appStarSourceSchema.safeParse(source) - const starred = await starOrca() - if (starred && sourceParse.success) { - // Why: this main-owned event bypasses renderer telemetry IPC, so cohort - // context must be attached here on the successful star path. - track('app_starred_orca', { - source: sourceParse.data, - ...getCohortAtEmit() - }) - } - return starred - }) - - // Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so - // polling is cheap. A 30s in-process cache still avoids the gh subprocess - // cost on every render — see getRateLimit for the ttl rationale. Force - // parameter lets the renderer bust the cache after a known-expensive op - // (e.g. post-ProjectPicker discovery) without waiting out the ttl. - ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => - getRateLimit(args?.force ? { force: true } : undefined) - ) - - ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => - diagnoseGhAuth(args?.host) - ) - - // ── GitHub ProjectV2 view handlers ───────────────────────────────── - // Why: registered unconditionally so enabling the experimental flag at - // runtime takes effect without a restart. The renderer gates entry points. - // Handlers never throw across IPC — every failure mode resolves through the - // GitHubProjectViewError envelope. - - ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => - listAccessibleProjects(args) - ) - - ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => - resolveProjectRef(args) - ) - - ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => - listProjectViews(args) - ) - - ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => - getProjectViewTable(args) - ) - - ipcMain.handle( - 'gh:projectWorkItemDetailsBySlug', - (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) - ) - - ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => - updateProjectItemFieldValue(args) - ) - - ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => - clearProjectItemFieldValue(args) - ) - - ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => - updateIssueBySlug(args) - ) - - ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => - updatePullRequestBySlug(args) - ) - - ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => - addIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => - updateIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => - deleteIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => - listLabelsBySlug(args) - ) - - ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => - listAssignableUsersBySlug(args) - ) - - ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => - listIssueTypesBySlug(args) - ) - - ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => - updateIssueTypeBySlug(args) - ) - - // Why: issue-source preference writes go through the generic `repos:update` - // IPC (extended in this PR to accept `issueSourcePreference`). Routing - // through the same channel keeps a single write path, guarantees the - // `repos:changed` broadcast is emitted, and avoids two channels racing to - // persist the same field with different validation and eviction semantics. - // Reads piggyback on the `Repo` record already delivered by `repos:list`. + registerGitHubPRRefreshHandlers(store, stats) + registerGitHubWorkItemHandlers(store) + registerGitHubPRReadHandlers(store) + registerGitHubPRReviewHandlers(store) + registerGitHubPRMutationHandlers(store) + registerGitHubIssueMutationHandlers(store) + registerGitHubAccountHandlers() + registerGitHubProjectViewHandlers() } From d74f4693795c48a2c3a94ca8305703825bdc4dbd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:57:21 -0700 Subject: [PATCH 08/10] refactor(feature-wall): split animated browser and terminal stories (#16142) * refactor feature wall animated visuals * fix(feature-wall): restore merge-base render behavior in split visuals - Hoist workbench reduced-motion state to module constants so cursorTarget identity is stable and the cursor layout effect stops re-firing per render. - Render one frame component and branch on the state source so toggling reducedMotion re-renders the storyboard instead of remounting its DOM. --- config/max-lines-baseline.txt | 2 - .../BrowserAgentVerificationPane.tsx | 160 +++ .../feature-wall/BrowserAnimatedVisual.tsx | 1048 +--------------- .../feature-wall/BrowserPricingStoryboard.tsx | 87 ++ .../feature-wall/BrowserSignupStoryboard.tsx | 17 + .../feature-wall/BrowserStoryboardWindow.tsx | 386 ++++++ .../feature-wall/WorkbenchAnimatedVisual.tsx | 1099 +++-------------- .../feature-wall/WorkbenchTerminalPanes.tsx | 349 ++++++ .../WorkbenchTerminalSplitMenu.tsx | 113 ++ .../browser-animated-visual-phase.ts | 115 ++ .../browser-animated-visual-sequence.test.ts | 104 ++ .../browser-animated-visual-sequence.ts | 203 +++ .../use-browser-animated-visual-storyboard.ts | 168 +++ .../use-workbench-terminal-cursor.ts | 55 + .../use-workbench-terminal-storyboard.ts | 89 ++ ...bench-terminal-storyboard-sequence.test.ts | 106 ++ .../workbench-terminal-storyboard-sequence.ts | 227 ++++ .../workbench-terminal-storyboard-state.ts | 46 + 18 files changed, 2453 insertions(+), 1921 deletions(-) create mode 100644 src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx create mode 100644 src/renderer/src/components/feature-wall/BrowserPricingStoryboard.tsx create mode 100644 src/renderer/src/components/feature-wall/BrowserSignupStoryboard.tsx create mode 100644 src/renderer/src/components/feature-wall/BrowserStoryboardWindow.tsx create mode 100644 src/renderer/src/components/feature-wall/WorkbenchTerminalPanes.tsx create mode 100644 src/renderer/src/components/feature-wall/WorkbenchTerminalSplitMenu.tsx create mode 100644 src/renderer/src/components/feature-wall/browser-animated-visual-phase.ts create mode 100644 src/renderer/src/components/feature-wall/browser-animated-visual-sequence.test.ts create mode 100644 src/renderer/src/components/feature-wall/browser-animated-visual-sequence.ts create mode 100644 src/renderer/src/components/feature-wall/use-browser-animated-visual-storyboard.ts create mode 100644 src/renderer/src/components/feature-wall/use-workbench-terminal-cursor.ts create mode 100644 src/renderer/src/components/feature-wall/use-workbench-terminal-storyboard.ts create mode 100644 src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.test.ts create mode 100644 src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.ts create mode 100644 src/renderer/src/components/feature-wall/workbench-terminal-storyboard-state.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index f2a9bebb747..e732ac529ba 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -87,9 +87,7 @@ inline src/renderer/src/components/editor/CombinedDiffViewer.tsx inline src/renderer/src/components/editor/EditorContent.tsx inline src/renderer/src/components/editor/IpynbViewer.tsx inline src/renderer/src/components/editor/MarkdownPreview.tsx -inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx -inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx inline src/renderer/src/components/github-project/ProjectCell.tsx inline src/renderer/src/components/github-project/ProjectPicker.tsx diff --git a/src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx b/src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx new file mode 100644 index 00000000000..f8efab939af --- /dev/null +++ b/src/renderer/src/components/feature-wall/BrowserAgentVerificationPane.tsx @@ -0,0 +1,160 @@ +import type { JSX, ReactNode } from 'react' +import { ClaudeIcon } from '@/components/status-bar/icons' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { + BROWSER_PROMPT_TEXT, + browserPhaseAtLeast, + type BrowserVisualPhase +} from './browser-animated-visual-phase' + +type TerminalEntry = + | { kind: 'prompt'; text: string } + | { kind: 'working' } + | { kind: 'ok'; content: ReactNode } + | { kind: 'tool'; tool: string; arg: string } + | { kind: 'tool-muted'; tool: string; muted: string } + +type PhasedTerminalEntry = { + id: string + entry: TerminalEntry + minPhase: BrowserVisualPhase +} + +function terminalEntries(): readonly PhasedTerminalEntry[] { + return [ + { + id: 'change-prompt', + entry: { kind: 'prompt', text: BROWSER_PROMPT_TEXT }, + minPhase: 'working' + }, + { id: 'working', entry: { kind: 'working' }, minPhase: 'working' }, + { + id: 'updated', + entry: { + kind: 'ok', + content: ( + <> + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', + '✓ Updated' + )}{' '} + + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', + '.pp-card[data-card="starter"] .pp-cta' + )} + + + ) + }, + minPhase: 'updated' + }, + { + id: 'verify-prompt', + entry: { + kind: 'prompt', + text: 'Let me click Try free to verify it still works.' + }, + minPhase: 'verify-intent' + }, + { + id: 'click', + entry: { kind: 'tool', tool: 'click', arg: '"Try free"' }, + minPhase: 'click-press' + }, + { + id: 'screenshot', + entry: { kind: 'tool-muted', tool: 'screenshot', muted: '(capturing page)' }, + minPhase: 'screenshot-line' + }, + { + id: 'verified', + entry: { + kind: 'ok', + content: translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', + '✓ Verified — Try free still works.' + ) + }, + minPhase: 'verified' + } + ] +} + +export function BrowserAgentVerificationPane(props: { + phase: BrowserVisualPhase + splitVisible: boolean +}): JSX.Element { + return ( +
+
+ + + {translate('auto.components.feature.wall.BrowserAnimatedVisual.6e4616d039', 'Claude')} + +
+
+ {terminalEntries().map(({ id, entry, minPhase }) => ( + + + + ))} +
+
+ ) +} + +function TerminalEntryView(props: { entry: TerminalEntry }): JSX.Element { + const { entry } = props + if (entry.kind === 'prompt') { + return ( + + + {translate('auto.components.feature.wall.BrowserAnimatedVisual.f2034c4930', '>')} + {' '} + {entry.text} + + ) + } + if (entry.kind === 'working') { + return ( + + + {translate('auto.components.feature.wall.BrowserAnimatedVisual.0ce7c24b4d', 'Working…')} + + ) + } + if (entry.kind === 'ok') { + return {entry.content} + } + if (entry.kind === 'tool') { + return ( + + {entry.tool}{' '} + {entry.arg} + + ) + } + return ( + + {entry.tool}{' '} + {entry.muted} + + ) +} + +function TerminalLine(props: { visible: boolean; children: ReactNode }): JSX.Element { + return ( + + {props.children} + + ) +} diff --git a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx index 3d229ddaa91..2966f05a60b 100644 --- a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx @@ -1,499 +1,49 @@ -/* eslint-disable max-lines -- Why: this animation is a self-contained storyboard; splitting the phase markup from its timing constants would make the sequence harder to verify. */ -import { useEffect, useRef, useState, type JSX, type ReactNode } from 'react' -import { cn } from '@/lib/utils' -import { ClaudeIcon } from '@/components/status-bar/icons' +import type { JSX } from 'react' import { useShortcutLabel } from '@/hooks/useShortcutLabel' -import { FeatureWallClickRing } from './FeatureWallClickRing' import { translate } from '@/i18n/i18n' - -// Why: this animation tells the full Orca story end-to-end — the user opens a -// new browser tab, annotates a target on the pricing page, types a change, -// hands off to Claude in a split pane, and Claude edits + verifies the page. -// The DOM and timing track docs/feature-wall-workbench-tile-mock.html so the -// modal stays in lockstep with the design source. - -const PROMPT_TEXT = 'Make Starter card stand out' - -// Why: these hand-rolled tour popovers need the same dark-mode separation as -// Orca's dropdown/popover primitives while staying inside the storyboard DOM. -const TOUR_FLOATING_SURFACE_CLASS = - 'border border-black/14 bg-[rgba(255,255,255,0.82)] text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)]' - -const PRE_INTRO_MS = 600 -const NEWTAB_APPROACH_MS = 700 -const NEWTAB_CLICK_MS = 180 -const NEWTAB_DWELL_MS = 700 -const NEWTAB_ROW_HOVER_MS = 1050 -const NEWTAB_ROW_CLICK_MS = 220 -const TAB_REVEAL_MS = 500 -const APPROACH_CARD_MS = 900 -const INSPECT_MS = 700 -const ANNOTATE_OPEN_MS = 360 -const ANNOTATE_TYPE_INTERVAL_MS = 58 -const ANNOTATE_HOLD_MS = 900 -const SEND_APPROACH_MS = 500 -const SEND_CLICK_MS = 250 -const HANDOFF_MS = 200 -const WORKING_LINE_STAGGER_MS = 260 -const WORKING_HOLD_MS = 1400 -const UPDATED_HOLD_MS = 900 -const VERIFY_INTENT_MS = 1100 -const CLICK_APPROACH_MS = 620 -const CLICK_PRESS_MS = 280 -const NAVIGATED_HOLD_MS = 700 -const SCREENSHOT_LINE_HOLD_MS = 420 -const SCREENSHOT_FLASH_HOLD_MS = 700 -const VERIFIED_HOLD_MS = 2400 -const RESET_HOLD_MS = 300 -const CLICK_RING_MS = 460 - -type Phase = - | 'idle' - | 'newtab-approach' - | 'newtab-click' - | 'newtab-row-approach' - | 'newtab-row-click' - | 'tab-revealed' - | 'approach-card' - | 'inspect' - | 'annotate' - | 'send-approach' - | 'send-click' - | 'handoff' - | 'working' - | 'updated' - | 'verify-intent' - | 'click-approach' - | 'click-press' - | 'navigated' - | 'screenshot-line' - | 'screenshot-flash' - | 'verified' - -const PHASE_ORDER: readonly Phase[] = [ - 'idle', - 'newtab-approach', - 'newtab-click', - 'newtab-row-approach', - 'newtab-row-click', - 'tab-revealed', - 'approach-card', - 'inspect', - 'annotate', - 'send-approach', - 'send-click', - 'handoff', - 'working', - 'updated', - 'verify-intent', - 'click-approach', - 'click-press', - 'navigated', - 'screenshot-line', - 'screenshot-flash', - 'verified' -] - -function phaseAtLeast(current: Phase, target: Phase): boolean { - return PHASE_ORDER.indexOf(current) >= PHASE_ORDER.indexOf(target) -} - -const SPLIT_PHASES: readonly Phase[] = [ - 'working', - 'updated', - 'verify-intent', - 'click-approach', - 'click-press', - 'navigated', - 'screenshot-line', - 'screenshot-flash', - 'verified' -] - -function isSplitPhase(phase: Phase): boolean { - return SPLIT_PHASES.includes(phase) -} - -type TermEntry = - | { kind: 'prompt'; text: string } - | { kind: 'working' } - | { kind: 'ok'; html: ReactNode } - | { kind: 'tool'; tool: string; arg: string } - | { kind: 'tool-muted'; tool: string; muted: string } - -const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [ - { entry: { kind: 'prompt', text: PROMPT_TEXT }, minPhase: 'working' }, - { entry: { kind: 'working' }, minPhase: 'working' }, - { - entry: { - kind: 'ok', - get html() { - return ( - <> - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', - '✓ Updated' - )}{' '} - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', - '.pp-card[data-card="starter"] .pp-cta' - )} - - - ) - } - }, - minPhase: 'updated' - }, - { - entry: { - kind: 'prompt', - text: 'Let me click Try free to verify it still works.' - }, - minPhase: 'verify-intent' - }, - { entry: { kind: 'tool', tool: 'click', arg: '"Try free"' }, minPhase: 'click-press' }, - { - entry: { kind: 'tool-muted', tool: 'screenshot', muted: '(capturing page)' }, - minPhase: 'screenshot-line' - }, - { - entry: { - kind: 'ok', - get html() { - return ( - <> - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', - '✓ Verified — Try free still works.' - )} - - ) - } - }, - minPhase: 'verified' - } -] +import { BrowserAgentVerificationPane } from './BrowserAgentVerificationPane' +import { BrowserStoryboardWindow } from './BrowserStoryboardWindow' +import { + BROWSER_REDUCED_MOTION_STATE, + isBrowserSplitPhase, + type BrowserVisualState, + type BrowserVisualTargetRefs +} from './browser-animated-visual-phase' +import { + useBrowserAnimatedVisualStoryboard, + useBrowserVisualTargetRefs +} from './use-browser-animated-visual-storyboard' export function BrowserAnimatedVisual(props: { reducedMotion: boolean onCycleComplete?: () => void }): JSX.Element { - const { reducedMotion, onCycleComplete } = props + const targets = useBrowserVisualTargetRefs() const newBrowserShortcutLabel = useShortcutLabel('tab.newBrowser') - const [phase, setPhase] = useState('idle') - const [typedChars, setTypedChars] = useState(0) - const [flashKey, setFlashKey] = useState(0) - const [clickRingKey, setClickRingKey] = useState(0) - const [clickRingVisible, setClickRingVisible] = useState(false) - const [menuOffsetX, setMenuOffsetX] = useState(0) - const [annotateAnchor, setAnnotateAnchor] = useState<{ left: number; top: number }>({ - left: 116, - top: 70 - }) + // Why: branch on the state source, not the element type, so a reducedMotion + // toggle re-renders the storyboard in place instead of remounting it. + const animatedState = useBrowserAnimatedVisualStoryboard( + targets, + props.reducedMotion, + props.onCycleComplete + ) - const browserPageRef = useRef(null) - const titlebarRef = useRef(null) - const newtabBtnRef = useRef(null) - const newtabRowRef = useRef(null) - const starterCardRef = useRef(null) - const ctaRef = useRef(null) - const sendBtnRef = useRef(null) - const cursorPosRef = useRef<{ x: number; y: number }>({ x: 40, y: 18 }) - const [cursorPos, setCursorPos] = useState<{ x: number; y: number }>({ x: 40, y: 18 }) - - // Why: cursor coordinates are computed against .browser-page so they survive - // grid reflow when the split lands. We stash both a ref (synchronous read - // for hover-then-click sequences) and state (for transition animation). - function setCursorTo(x: number, y: number): void { - cursorPosRef.current = { x, y } - setCursorPos({ x, y }) - } - - function transformForElement( - el: HTMLElement | null, - offsetX = 0, - offsetY = 0 - ): { x: number; y: number } { - const page = browserPageRef.current - if (!page || !el) { - return cursorPosRef.current - } - const pageRect = page.getBoundingClientRect() - const elRect = el.getBoundingClientRect() - return { - x: elRect.left - pageRect.left + elRect.width / 2 - 8 + offsetX, - y: elRect.top - pageRect.top + elRect.height / 2 - 8 + offsetY - } - } - - /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: this - storyboard effect owns timed local animation state; deriving every phase - during render would break measured cursor sequencing. */ - useEffect(() => { - if (reducedMotion) { - setPhase('verified') - setTypedChars(PROMPT_TEXT.length) - setClickRingVisible(false) - return - } - let cancelled = false - const timeouts: number[] = [] - const wait = (ms: number): Promise => - new Promise((resolve) => { - const id = window.setTimeout(() => resolve(), ms) - timeouts.push(id) - }) - function pulseClickRing(): void { - setClickRingKey((key) => key + 1) - setClickRingVisible(true) - const id = window.setTimeout(() => { - if (!cancelled) { - setClickRingVisible(false) - } - }, CLICK_RING_MS) - timeouts.push(id) - } - - async function loop(): Promise { - while (!cancelled) { - setPhase('idle') - setTypedChars(0) - setClickRingVisible(false) - setCursorTo(40, 18) - await wait(PRE_INTRO_MS) - if (cancelled) { - return - } - - // 1. Cursor approaches the "+" in the tab strip. - const newtabPos = transformForElement(newtabBtnRef.current) - setCursorTo(newtabPos.x, newtabPos.y) - setPhase('newtab-approach') - await wait(NEWTAB_APPROACH_MS) - if (cancelled) { - return - } - - // 2. Click "+". Capture the dropdown's left offset relative to the - // titlebar so the menu lines up with the button. - setPhase('newtab-click') - pulseClickRing() - if (titlebarRef.current && newtabBtnRef.current) { - const tbRect = titlebarRef.current.getBoundingClientRect() - const btnRect = newtabBtnRef.current.getBoundingClientRect() - setMenuOffsetX(btnRect.left - tbRect.left) - } - await wait(NEWTAB_CLICK_MS) - if (cancelled) { - return - } - await wait(NEWTAB_DWELL_MS) - if (cancelled) { - return - } - - // 3. Move to "New Browser Tab" row. - const rowPos = transformForElement(newtabRowRef.current, 6, 0) - setCursorTo(rowPos.x, rowPos.y) - setPhase('newtab-row-approach') - await wait(NEWTAB_ROW_HOVER_MS) - if (cancelled) { - return - } - - // 4. Click → dropdown closes, browser tab reveals, page comes alive. - setPhase('newtab-row-click') - pulseClickRing() - await wait(NEWTAB_ROW_CLICK_MS) - if (cancelled) { - return - } - setPhase('tab-revealed') - await wait(TAB_REVEAL_MS) - if (cancelled) { - return - } - - // Cursor approaches the Starter card. - const starterPos = transformForElement(starterCardRef.current, 0, -8) - setCursorTo(starterPos.x, starterPos.y) - setPhase('approach-card') - await wait(APPROACH_CARD_MS) - if (cancelled) { - return - } - - setPhase('inspect') - pulseClickRing() - await wait(INSPECT_MS) - if (cancelled) { - return - } - - // Anchor the annotate popover to the Starter card's actual position so - // it lines up regardless of how the parent grid lays out. - if (browserPageRef.current && starterCardRef.current) { - const pageRect = browserPageRef.current.getBoundingClientRect() - const cardRect = starterCardRef.current.getBoundingClientRect() - setAnnotateAnchor({ - left: cardRect.right - pageRect.left + 6, - top: cardRect.top - pageRect.top - }) - } - setPhase('annotate') - await wait(ANNOTATE_OPEN_MS) - if (cancelled) { - return - } - for (let i = 1; i <= PROMPT_TEXT.length; i += 1) { - if (cancelled) { - return - } - setTypedChars(i) - await wait(ANNOTATE_TYPE_INTERVAL_MS) - } - await wait(ANNOTATE_HOLD_MS) - if (cancelled) { - return - } - - const sendPos = transformForElement(sendBtnRef.current) - setCursorTo(sendPos.x, sendPos.y) - setPhase('send-approach') - await wait(SEND_APPROACH_MS) - if (cancelled) { - return - } - - setPhase('send-click') - pulseClickRing() - await wait(SEND_CLICK_MS) - if (cancelled) { - return - } - - setPhase('handoff') - await wait(HANDOFF_MS) - if (cancelled) { - return - } - - // Split lands. Working line + prompt are gated on phase >= 'working'; - // small stagger keeps them from popping in simultaneously. - setPhase('working') - await wait(WORKING_LINE_STAGGER_MS * 2) - if (cancelled) { - return - } - await wait(WORKING_HOLD_MS) - if (cancelled) { - return - } - - setPhase('updated') - await wait(UPDATED_HOLD_MS) - if (cancelled) { - return - } - - setPhase('verify-intent') - await wait(VERIFY_INTENT_MS) - if (cancelled) { - return - } - - const ctaPos = transformForElement(ctaRef.current) - setCursorTo(ctaPos.x, ctaPos.y) - setPhase('click-approach') - await wait(CLICK_APPROACH_MS) - if (cancelled) { - return - } - - setPhase('click-press') - pulseClickRing() - await wait(CLICK_PRESS_MS) - if (cancelled) { - return - } - - setPhase('navigated') - await wait(NAVIGATED_HOLD_MS) - if (cancelled) { - return - } - - setPhase('screenshot-line') - await wait(SCREENSHOT_LINE_HOLD_MS) - if (cancelled) { - return - } - - setPhase('screenshot-flash') - setFlashKey((k) => k + 1) - await wait(SCREENSHOT_FLASH_HOLD_MS) - if (cancelled) { - return - } - - setPhase('verified') - await wait(VERIFIED_HOLD_MS) - if (cancelled) { - return - } - onCycleComplete?.() - - await wait(RESET_HOLD_MS) - } - } - - loop() - return () => { - cancelled = true - timeouts.forEach((id) => window.clearTimeout(id)) - } - }, [onCycleComplete, reducedMotion]) - /* oxlint-enable react-doctor/no-adjust-state-on-prop-change */ - - const isIntroPhase = - phase === 'idle' || - phase === 'newtab-approach' || - phase === 'newtab-click' || - phase === 'newtab-row-approach' || - phase === 'newtab-row-click' - const browserChromeVisible = !isIntroPhase - const browserTabVisible = !isIntroPhase - const terminalTabMinimized = !isIntroPhase - const newtabActive = phase === 'newtab-click' || phase === 'newtab-row-approach' - const newtabRowActive = phase === 'newtab-row-approach' - const dropdownVisible = - phase === 'newtab-click' || phase === 'newtab-row-approach' || phase === 'newtab-row-click' - const cursorVisible = (phase !== 'idle' && phase !== 'navigated') || clickRingVisible - const ringStarter = - phase === 'inspect' || - phase === 'annotate' || - phase === 'send-approach' || - phase === 'send-click' || - phase === 'handoff' - const annotateOpen = phase === 'annotate' || phase === 'send-approach' || phase === 'send-click' - const sendPressed = phase === 'send-click' - const isSplit = isSplitPhase(phase) - const ctaHighlighted = phaseAtLeast(phase, 'updated') - const ctaPressing = phase === 'click-press' - const showSignup = - phase === 'navigated' || - phase === 'screenshot-line' || - phase === 'screenshot-flash' || - phase === 'verified' - const flashing = phase === 'screenshot-flash' - // While the cursor is acting on the titlebar / dropdown, let the cursor - // overflow the body's clipping bounds. Once the page is live we re-clip so - // the cursor never escapes the browser body. - const bodyOverflowVisible = isIntroPhase + return ( + + ) +} +function BrowserVisualFrame(props: { + state: BrowserVisualState + targets: BrowserVisualTargetRefs + newBrowserShortcutLabel: string +}): JSX.Element { + const isSplit = isBrowserSplitPhase(props.state.phase) return (
@@ -504,246 +54,12 @@ export function BrowserAnimatedVisual(props: { gap: isSplit ? 10 : 0 }} > - {/* Browser app-window — column 1 */} -
-
-
- } - title={translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', - 'Terminal 1' - )} - /> - {browserTabVisible ? ( - } - title={translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.7da6eed7bf', - 'localhost:3000' - )} - /> - ) : null} - - - -
- {/* New-tab dropdown menu */} -
- -
- - - - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.0a2bd01c02', - 'New Browser Tab' - )} - - - {newBrowserShortcutLabel} - -
- -
-
- - {/* URL toolbar — hidden until tab reveals so the panel reads as - "tab created → page came alive" instead of a static frame. */} -
- - ‹ - › - ↻ - -
- {isSplit ? ( - - {`...${showSignup ? '/signup' : '/pricing'}`} - - ) : ( - <> - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.7da6eed7bf', - 'localhost:3000' - )} - - - {showSignup - ? translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.f39be6ca14', - '/signup' - ) - : translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.73bbb46073', - '/pricing' - )} - - - )} -
-
- - {/* Browser body — relative + overflow-{hidden|visible} so the - cursor can escape during the new-tab intro to reach the - titlebar's "+" / dropdown rows. */} -
-
- {showSignup ? ( - - ) : ( - - )} - -
- - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.d8856b604a', - 'div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta' - )} - - -
- {typedChars > 0 ? ( - <> - {PROMPT_TEXT.slice(0, typedChars)} - - - ) : ( - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.3d2352f94b', - 'Describe the change…' - )} - - )} -
-
- - - -
-
- - -
-
-
- - {clickRingVisible ? : null} -
-
-
-
- -
-
- - - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.6e4616d039', - 'Claude' - )} - -
-
- {TERM_ENTRIES.map(({ entry, minPhase }, i) => ( - - - - ))} -
-
+ +