diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index bf2ce235464..39cb1465852 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -73,7 +73,8 @@ runs: shell: bash run: | pnpm install --frozen-lockfile --ignore-scripts - git diff --exit-code package.json pnpm-lock.yaml + # Job containers can run composite steps from a source mirror without .git. + git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml # Why cached: `--ignore-scripts` leaves node-pty without build/Release, so # ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime. diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 85ea75132d2..940129be633 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -128,7 +128,6 @@ inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx inline src/renderer/src/components/terminal-pane/TerminalPane.tsx inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts -inline src/renderer/src/components/terminal-pane/pty-connection.ts inline src/renderer/src/components/terminal-pane/pty-transport.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts diff --git a/config/scripts/install-node-dependencies-action.test.mjs b/config/scripts/install-node-dependencies-action.test.mjs new file mode 100644 index 00000000000..d989221c11a --- /dev/null +++ b/config/scripts/install-node-dependencies-action.test.mjs @@ -0,0 +1,69 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) +const installScript = action.runs.steps.find((step) => step.name === 'Install dependencies').run + +function run(command, args, options = {}) { + return spawnSync(command, args, { encoding: 'utf8', ...options }) +} + +function createFixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-install-node-action-')) + const workspace = join(root, 'checkout') + const detachedCwd = join(root, 'action cwd') + const bin = join(root, 'bin') + mkdirSync(workspace) + mkdirSync(detachedCwd) + mkdirSync(bin) + + for (const directory of [workspace, detachedCwd]) { + writeFileSync(join(directory, 'package.json'), '{"name":"fixture"}\n') + writeFileSync(join(directory, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') + } + + expect(run('git', ['init', '-q'], { cwd: workspace }).status).toBe(0) + expect(run('git', ['add', 'package.json', 'pnpm-lock.yaml'], { cwd: workspace }).status).toBe(0) + + const pnpm = join(bin, 'pnpm') + writeFileSync(pnpm, '#!/bin/sh\nexit 0\n') + chmodSync(pnpm, 0o755) + return { bin, detachedCwd, root, workspace } +} + +function executeInstallScript(fixture) { + return run('bash', ['-c', installScript], { + cwd: fixture.detachedCwd, + env: { + ...process.env, + GITHUB_WORKSPACE: fixture.workspace, + PATH: `${fixture.bin}${delimiter}${process.env.PATH}` + } + }) +} + +describe('install-node-dependencies action', () => { + it.each([ + ['package.json', '{"name":"changed"}\n'], + ['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n'] + ])('rejects a changed %s when the composite step cwd is detached', (file, contents) => { + const fixture = createFixture() + try { + const clean = executeInstallScript(fixture) + expect(clean.status, clean.stderr || clean.stdout).toBe(0) + + writeFileSync(join(fixture.workspace, file), contents) + const dirty = executeInstallScript(fixture) + const output = `${dirty.stdout}\n${dirty.stderr}` + expect(dirty.status).toBe(1) + expect(output).toContain(`diff --git a/${file} b/${file}`) + expect(output).not.toContain('diff --git a/package.json b/pnpm-lock.yaml') + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 4b3e0917906..f529849447b 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -256,7 +256,9 @@ describe('PR workflow parallelism', () => { // resolution can never legitimately change anything. expect(dependencyInstall.run).toContain('--frozen-lockfile') expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile') - expect(dependencyInstall.run).toContain('git diff --exit-code package.json pnpm-lock.yaml') + expect(dependencyInstall.run).toContain( + 'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml' + ) expect(dependencyInstall.run).toContain('--ignore-scripts') expect(dependencyInstall.run).not.toContain('--os=') expect(dependencyInstall.run).not.toContain('--cpu=') diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index b05127389f2..8c6f96f4d6c 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -21,6 +21,7 @@ import { PickerListDrawer } from './PickerListDrawer' import { MobileAgentIcon } from './MobileAgentIcon' import { getSuggestedCreatureName } from './worktree-name-suggestion' import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names' +import { repoColor } from '../worktree/repo-color' import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' import { isSetupHookTrusted, @@ -120,15 +121,6 @@ type CreateOptions = { approvedSetupContentHash?: string } -function repoColor(name: string): string { - const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] - let hash = 0 - for (let i = 0; i < name.length; i += 1) { - hash = (hash * 31 + name.charCodeAt(i)) | 0 - } - return palette[Math.abs(hash) % palette.length]! -} - function repoBadgeColor(repo: Repo | null): string { return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository') } diff --git a/src/main/agent-hooks/hooks-json-read.ts b/src/main/agent-hooks/hooks-json-read.ts index e2c8743cab0..97ca220c354 100644 --- a/src/main/agent-hooks/hooks-json-read.ts +++ b/src/main/agent-hooks/hooks-json-read.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import type { HooksConfig } from './installer-utils' export function isPlainObject(value: unknown): value is Record { @@ -26,16 +27,17 @@ export function parseHooksJsonText(raw: string): HooksConfig | null { // bytes it was derived from; the raw snapshot and the parse must come from one // read or a concurrent save can slip between them unnoticed. export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot { - if (!existsSync(configPath)) { - return { raw: null, config: {} } - } - let raw: string + // Why: the read arm below already separates "no hooks configured" from "could + // not read", but the `existsSync` arm in front of it did not — it returned a + // VALID EMPTY config for a file that merely could not be opened, and the + // installer then wrote generated hooks over it. One read classifies both, and + // closes the TOCTOU window between the two calls. try { - raw = readFileSync(configPath, 'utf-8') - } catch { - return { raw: null, config: null } + const raw = readFileSync(configPath, 'utf-8') + return { raw, config: parseHooksJsonText(raw) } + } catch (error) { + return isDefinitiveAbsence(error) ? { raw: null, config: {} } : { raw: null, config: null } } - return { raw, config: parseHooksJsonText(raw) } } export function readHooksJson(configPath: string): HooksConfig | null { diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index dcb51c814da..f3d96a1064c 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -160,10 +160,10 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe( ): CodexConfigMirrorResult { const systemConfigPath = join(systemHomePath, 'config.toml') const runtimeConfigPath = join(runtimeHomePath, 'config.toml') - // Why: `existsSync` answered `false` for a locked file exactly as for an absent - // one, so a held handle on the RUNTIME config read as "no runtime config yet" - // and the fresh-mirror branch below overwrote it wholesale. Neither side may - // be acted on unless it was actually observed. + // Why: `existsSync` collapses an indeterminate probe into the same `false` as + // absence, so a transiently unavailable RUNTIME config could reach the fresh + // mirror after the path recovered. Neither side may be acted on unless it was + // actually observed. const systemConfigObservation = observeAgentStateFile(systemConfigPath) if (systemConfigObservation.kind === 'indeterminate') { return { status: 'refused-indeterminate', error: systemConfigObservation.error } diff --git a/src/main/codex/codex-pane-account-registry-mutations.test.ts b/src/main/codex/codex-pane-account-registry-mutations.test.ts new file mode 100644 index 00000000000..e7a09ad7a82 --- /dev/null +++ b/src/main/codex/codex-pane-account-registry-mutations.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CodexPaneAccountRegistryMutations } from './codex-pane-account-registry-mutations' +import type { CodexPaneAccountRegistryFile } from './codex-pane-account-registry-types' + +const EXISTING = { + selectionKey: 'host', + accountId: 'account-1', + homeRoute: 'account-home' +} as const + +const SPAWNED = { + selectionKey: 'host', + accountId: 'account-2', + homeRoute: 'account-home' +} as const + +function parseRegistry(serialized: string): CodexPaneAccountRegistryFile { + return JSON.parse(serialized) as CodexPaneAccountRegistryFile +} + +describe('CodexPaneAccountRegistryMutations', () => { + beforeEach(() => vi.useFakeTimers()) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('retries a one-shot spawn record and merges it with the recovered file', async () => { + let readable = false + let persisted = JSON.stringify({ version: 2, panes: { existing: EXISTING } }) + const write = vi.fn((registry: CodexPaneAccountRegistryFile) => { + persisted = JSON.stringify(registry) + return true + }) + const mutations = new CodexPaneAccountRegistryMutations({ + read: () => (readable ? parseRegistry(persisted) : null), + write + }) + + mutations.record('spawned', SPAWNED) + expect(write).not.toHaveBeenCalled() + + readable = true + await vi.advanceTimersByTimeAsync(100) + + expect(parseRegistry(persisted).panes).toEqual({ existing: EXISTING, spawned: SPAWNED }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('bounds automatic retries but keeps the pending mutation for a later read', async () => { + let readable = false + let persisted = JSON.stringify({ version: 2, panes: { existing: EXISTING } }) + const read = vi.fn(() => (readable ? parseRegistry(persisted) : null)) + const write = vi.fn((registry: CodexPaneAccountRegistryFile) => { + persisted = JSON.stringify(registry) + return true + }) + const mutations = new CodexPaneAccountRegistryMutations({ read, write }) + + mutations.record('spawned', SPAWNED) + await vi.advanceTimersByTimeAsync(120_000) + + expect(read).toHaveBeenCalledTimes(5) + expect(vi.getTimerCount()).toBe(0) + + readable = true + mutations.flush() + expect(parseRegistry(persisted).panes.spawned).toEqual(SPAWNED) + }) + + it('retries durability after a write failure even when the cached value now matches', async () => { + const registry = parseRegistry(JSON.stringify({ version: 2, panes: { existing: EXISTING } })) + const write = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + const mutations = new CodexPaneAccountRegistryMutations({ read: () => registry, write }) + + mutations.record('spawned', SPAWNED) + await vi.advanceTimersByTimeAsync(100) + + expect(write).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) + + it('retries a failed reconciliation write without a pending pane mutation', async () => { + const registry = parseRegistry(JSON.stringify({ version: 2, panes: { existing: EXISTING } })) + const write = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + const mutations = new CodexPaneAccountRegistryMutations({ read: () => registry, write }) + + mutations.persistReconciliation(registry, true) + await vi.advanceTimersByTimeAsync(100) + + expect(write).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/codex/codex-pane-account-registry-mutations.ts b/src/main/codex/codex-pane-account-registry-mutations.ts new file mode 100644 index 00000000000..f946453c0bf --- /dev/null +++ b/src/main/codex/codex-pane-account-registry-mutations.ts @@ -0,0 +1,157 @@ +import type { + CodexPaneAccountRecord, + CodexPaneAccountRegistryFile +} from './codex-pane-account-registry-types' + +// Why: bounds both crash-leaked disk records and mutations retained while the file is unavailable. +const MAX_TRACKED_PANES = 2000 +const RETRY_DELAYS_MS = [100, 1_000, 5_000, 30_000] as const + +type MutationStoreDependencies = { + read: () => CodexPaneAccountRegistryFile | null + write: (registry: CodexPaneAccountRegistryFile) => boolean +} + +export class CodexPaneAccountRegistryMutations { + private readonly pending = new Map() + private retryTimer: ReturnType | null = null + private retryAttempt = 0 + private needsPersistence = false + + constructor(private readonly dependencies: MutationStoreDependencies) {} + + record(ptyId: string, record: CodexPaneAccountRecord | null): void { + this.pending.delete(ptyId) + this.pending.set(ptyId, record) + if (this.pending.size > MAX_TRACKED_PANES) { + const oldestPtyId = this.pending.keys().next().value + if (typeof oldestPtyId === 'string') { + this.pending.delete(oldestPtyId) + console.warn('[codex-pane-accounts] Dropped oldest pending registry mutation at capacity') + } + } + this.flush() + } + + flush(): void { + if (this.pending.size === 0 && !this.needsPersistence) { + this.clearRetryState() + return + } + const registry = this.dependencies.read() + if (!registry) { + this.scheduleRetry() + return + } + let changed = false + for (const [ptyId, record] of this.pending) { + changed = applyMutation(registry, ptyId, record) || changed + } + trimRegistry(registry) + if ((changed || this.needsPersistence) && !this.dependencies.write(registry)) { + this.needsPersistence = true + this.scheduleRetry() + return + } + this.needsPersistence = false + this.pending.clear() + this.clearRetryState() + } + + getPendingRegistry(): CodexPaneAccountRegistryFile | null { + if (this.pending.size === 0) { + return null + } + const registry: CodexPaneAccountRegistryFile = { version: 2, panes: {} } + for (const [ptyId, record] of this.pending) { + applyMutation(registry, ptyId, record) + } + return registry + } + + persistReconciliation(registry: CodexPaneAccountRegistryFile, changed: boolean): void { + if (!changed) { + return + } + if (!this.dependencies.write(registry)) { + this.needsPersistence = true + this.scheduleRetry() + return + } + this.needsPersistence = false + this.pending.clear() + this.clearRetryState() + } + + reset(): void { + this.needsPersistence = false + this.pending.clear() + this.clearRetryState() + } + + private scheduleRetry(): void { + if (this.retryTimer || this.retryAttempt >= RETRY_DELAYS_MS.length) { + return + } + this.retryTimer = setTimeout(() => { + this.retryTimer = null + this.flush() + }, RETRY_DELAYS_MS[this.retryAttempt]) + this.retryAttempt += 1 + this.retryTimer.unref?.() + } + + private clearRetryState(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = null + } + this.retryAttempt = 0 + } +} + +function applyMutation( + registry: CodexPaneAccountRegistryFile, + ptyId: string, + record: CodexPaneAccountRecord | null +): boolean { + if (!record) { + if (!(ptyId in registry.panes)) { + return false + } + delete registry.panes[ptyId] + return true + } + const existing = registry.panes[ptyId] + if (recordsEqual(existing, record)) { + return false + } + registry.panes[ptyId] = record + return true +} + +function recordsEqual( + left: CodexPaneAccountRecord | undefined, + right: CodexPaneAccountRecord +): boolean { + return ( + left?.selectionKey === right.selectionKey && + left.accountId === right.accountId && + left.homeRoute === right.homeRoute && + left.shellStartupHomeOverride?.home === right.shellStartupHomeOverride?.home && + left.shellStartupHomeOverride?.shell === right.shellStartupHomeOverride?.shell && + left.shellStartupHomeOverride?.configHome === right.shellStartupHomeOverride?.configHome && + left.shellStartupHomeOverride?.codexHome === right.shellStartupHomeOverride?.codexHome && + left.environmentHomeOverride?.codexHome === right.environmentHomeOverride?.codexHome + ) +} + +function trimRegistry(registry: CodexPaneAccountRegistryFile): void { + const trackedPtyIds = Object.keys(registry.panes) + if (trackedPtyIds.length <= MAX_TRACKED_PANES) { + return + } + for (const staleId of trackedPtyIds.slice(0, trackedPtyIds.length - MAX_TRACKED_PANES)) { + delete registry.panes[staleId] + } +} diff --git a/src/main/codex/codex-pane-account-registry-types.ts b/src/main/codex/codex-pane-account-registry-types.ts new file mode 100644 index 00000000000..1c93de135b2 --- /dev/null +++ b/src/main/codex/codex-pane-account-registry-types.ts @@ -0,0 +1,29 @@ +import type { + CodexEnvironmentHomeOverride, + CodexShellStartupHomeOverride +} from './codex-real-home-path' + +export type CodexPaneHomeRoute = + | 'real-home' + | 'shared-home' + | 'account-home' + | 'custom-home' + | 'wsl-home' + +export type CodexPaneAccountRecord = { + /** 'host' or 'wsl:' — the selection lane this pane launched from. */ + selectionKey: string + /** Managed account id, or null for the system-default account. */ + accountId: string | null + /** Absent only on records written before route provenance was introduced. */ + homeRoute?: CodexPaneHomeRoute + /** Rechecked when CODEX_HOME came from process-global shell startup. */ + shellStartupHomeOverride?: CodexShellStartupHomeOverride + /** Rechecked after restart when CODEX_HOME came from the process environment. */ + environmentHomeOverride?: CodexEnvironmentHomeOverride +} + +export type CodexPaneAccountRegistryFile = { + version: 2 + panes: Record +} diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 39853471683..449c11ba711 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -1,11 +1,23 @@ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { dirname, join } from 'node:path' import { getOrcaUserDataPath } from './codex-home-paths' +import { CodexPaneAccountRegistryMutations } from './codex-pane-account-registry-mutations' +import type { + CodexPaneAccountRecord, + CodexPaneAccountRegistryFile, + CodexPaneHomeRoute +} from './codex-pane-account-registry-types' import type { CodexEnvironmentHomeOverride, CodexShellStartupHomeOverride } from './codex-real-home-path' +export type { + CodexPaneAccountRecord, + CodexPaneHomeRoute +} from './codex-pane-account-registry-types' + /** * Remembers which Codex account each live PTY was launched under. * @@ -16,63 +28,75 @@ import type { * the old account and the user is stuck there with no prompt to escape it. */ -export type CodexPaneHomeRoute = - | 'real-home' - | 'shared-home' - | 'account-home' - | 'custom-home' - | 'wsl-home' - -export type CodexPaneAccountRecord = { - /** 'host' or 'wsl:' — the selection lane this pane launched from. */ - selectionKey: string - /** Managed account id, or null for the system-default account. */ - accountId: string | null - /** Absent only on records written before route provenance was introduced. */ - homeRoute?: CodexPaneHomeRoute - /** Rechecked when CODEX_HOME came from process-global shell startup. */ - shellStartupHomeOverride?: CodexShellStartupHomeOverride - /** Rechecked after restart when CODEX_HOME came from the process environment. */ - environmentHomeOverride?: CodexEnvironmentHomeOverride -} - -type RegistryFile = { - version: 2 - panes: Record -} - -// Why: bounds a file that only shrinks when Orca observes a PTY exit; a crash -// mid-session would otherwise leak an entry per terminal, forever. -const MAX_TRACKED_PANES = 2000 - -let cachedRegistry: RegistryFile | null = null +let cachedRegistry: CodexPaneAccountRegistryFile | null = null function getRegistryPath(): string { return join(getOrcaUserDataPath(), 'codex-pane-accounts.json') } -function readRegistry(): RegistryFile { +/** + * `null` means the registry could not be READ. That is not the same as "no + * panes are attributed", and it must never be cached: the old `catch { return + * null }` collapsed both into an empty registry, so one unreadable read erased + * every pane's account attribution AND pinned that erasure in `cachedRegistry` + * for the rest of the process, surviving the file becoming readable again. + */ +function readRegistryOrNull(): CodexPaneAccountRegistryFile | null { if (cachedRegistry) { return cachedRegistry } - cachedRegistry = parseRegistry(readRegistryFile()) + let rawRegistry: string + try { + rawRegistry = readFileSync(getRegistryPath(), 'utf-8') + } catch (error) { + if (!isDefinitiveAbsence(error)) { + return null + } + cachedRegistry = parseRegistry(null) + return cachedRegistry + } + // Why: a corrupt registry still degrades to empty and IS cached — rebuilding + // unparseable state is the intent, and re-reading it every call would only + // repeat the parse failure. + cachedRegistry = parseRegistry(parseRegistryJson(rawRegistry)) return cachedRegistry } -function readRegistryFile(): unknown { +function parseRegistryJson(rawRegistry: string): unknown { try { - return JSON.parse(readFileSync(getRegistryPath(), 'utf-8')) + return JSON.parse(rawRegistry) } catch { return null } } -function parseRegistry(parsed: unknown): RegistryFile { - const empty: RegistryFile = { version: 2, panes: {} } +/** Read-only callers: an unreadable registry reports no attribution, uncached. */ +function readRegistry(): CodexPaneAccountRegistryFile { + mutations.flush() + if (!cachedRegistry) { + const pendingRegistry = mutations.getPendingRegistry() + if (pendingRegistry) { + return pendingRegistry + } + } + return readRegistryOrNull() ?? { version: 2, panes: {} } +} + +function readRegistryOrThrow(): CodexPaneAccountRegistryFile { + mutations.flush() + const registry = readRegistryOrNull() + if (!registry) { + throw new Error('Codex pane account registry could not be read') + } + return registry +} + +function parseRegistry(parsed: unknown): CodexPaneAccountRegistryFile { + const empty: CodexPaneAccountRegistryFile = { version: 2, panes: {} } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return empty } - const panes = (parsed as Partial).panes + const panes = (parsed as Partial).panes if (!panes || typeof panes !== 'object' || Array.isArray(panes)) { return empty } @@ -138,7 +162,7 @@ function isPaneHomeRoute(value: unknown): value is CodexPaneHomeRoute { ) } -function writeRegistry(registry: RegistryFile): void { +function writeRegistry(registry: CodexPaneAccountRegistryFile): boolean { const registryPath = getRegistryPath() const temporaryPath = `${registryPath}.${process.pid}.tmp` try { @@ -148,6 +172,7 @@ function writeRegistry(registry: RegistryFile): void { mode: 0o600 }) renameSync(temporaryPath, registryPath) + return true } catch (error) { // Why: this record only powers a restart hint; losing it must never break a // terminal spawn or a PTY teardown — including when the cleanup itself fails. @@ -155,62 +180,23 @@ function writeRegistry(registry: RegistryFile): void { try { rmSync(temporaryPath, { force: true }) } catch {} + return false } } +const mutations = new CodexPaneAccountRegistryMutations({ + read: readRegistryOrNull, + write: writeRegistry +}) + /** * Records the account a PTY launched under. Pass null to forget a pinned pane. */ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRecord | null): void { - const registry = readRegistry() - if (!record) { - if (!(ptyId in registry.panes)) { - return - } - delete registry.panes[ptyId] - writeRegistry(registry) - return - } - const existing = registry.panes[ptyId] - if ( - existing?.selectionKey === record.selectionKey && - existing.accountId === record.accountId && - existing.homeRoute === record.homeRoute && - shellStartupHomeOverridesEqual( - existing.shellStartupHomeOverride, - record.shellStartupHomeOverride - ) && - environmentHomeOverridesEqual(existing.environmentHomeOverride, record.environmentHomeOverride) - ) { - return - } - registry.panes[ptyId] = record - const trackedPtyIds = Object.keys(registry.panes) - if (trackedPtyIds.length > MAX_TRACKED_PANES) { - for (const staleId of trackedPtyIds.slice(0, trackedPtyIds.length - MAX_TRACKED_PANES)) { - delete registry.panes[staleId] - } - } - writeRegistry(registry) -} - -function environmentHomeOverridesEqual( - left: CodexEnvironmentHomeOverride | undefined, - right: CodexEnvironmentHomeOverride | undefined -): boolean { - return left?.codexHome === right?.codexHome -} - -function shellStartupHomeOverridesEqual( - left: CodexShellStartupHomeOverride | undefined, - right: CodexShellStartupHomeOverride | undefined -): boolean { - return ( - left?.home === right?.home && - left?.shell === right?.shell && - left?.configHome === right?.configHome && - left?.codexHome === right?.codexHome - ) + // Why: the spawn attribution is one-shot. Retain it while the registry is + // unreadable, then merge it with the recovered file instead of dropping it + // or rebuilding from an empty stand-in. + mutations.record(ptyId, record) } /** @@ -243,7 +229,7 @@ export function isCodexPaneHomeRouteProvenAwayFromSharedHome( * answers for a launch that never happened once the user edits those settings. */ export function listRecordedCodexPaneLanes(ptyIds: readonly string[]): Record { - const registry = readRegistry() + const registry = readRegistryOrThrow() const lanesByPtyId: Record = {} for (const ptyId of ptyIds) { const record = registry.panes[ptyId] @@ -254,6 +240,21 @@ export function listRecordedCodexPaneLanes(ptyIds: readonly string[]): Record { + const registry = readRegistryOrThrow() + const records = new Map() + for (const ptyId of ptyIds) { + const record = registry.panes[ptyId] + if (record) { + records.set(ptyId, record) + } + } + return records +} + /** True when a retained host pane may still read the retired shared CODEX_HOME. */ export function hasRecordedLegacySharedCodexPane(): boolean { return Object.values(readRegistry().panes).some( @@ -279,7 +280,14 @@ export function hasRecordedManagedHostCodexPane(): boolean { /** Drops records whose daemon PTYs are authoritatively absent. */ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly string[]): void { - const registry = readRegistry() + // Why: this deletes every pane not in the live list. Against an empty stand-in + // it is a no-op, but the write it guards would still persist that stand-in + // over the real file, so refuse rather than reconcile a registry nobody read. + mutations.flush() + const registry = readRegistryOrNull() + if (!registry) { + return + } const livePtyIdSet = new Set(livePtyIds) let changed = false for (const ptyId of Object.keys(registry.panes)) { @@ -288,13 +296,12 @@ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly stri changed = true } } - if (changed) { - writeRegistry(registry) - } + mutations.persistReconciliation(registry, changed) } export const _internals = { resetCache: (): void => { cachedRegistry = null + mutations.reset() } } diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts index b242a24cc0b..b108a0b5250 100644 --- a/src/main/codex/codex-real-home-hook-install.test.ts +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -372,6 +372,23 @@ describe('ensureRealHomeCodexHookState (install)', () => { }) describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { + it('keeps the managed lane when hooks.json cannot be read', () => { + mkdirSync(getRealHooksJsonPath()) + + expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe( + 'unavailable' + ) + }) + + it('keeps the managed lane when hooks.json is malformed', () => { + writeFileSync(getRealHooksJsonPath(), '{ not json', 'utf-8') + + expect(ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir })).toBe( + 'unavailable' + ) + expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{ not json') + }) + it('rebases trust when a user appended hooks after Orca installed', () => { grantSucceeds() const before = { type: 'command', command: 'before.sh' } diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 1d59c17ac14..678526e4e60 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -274,7 +274,12 @@ function sweepRealHomeCodexHook(): RealHomeCodexHookLane { // Why: single read — the pre-write generation guard must compare against // the exact bytes this sweep's parse came from. const { raw: previousRaw, config } = readHooksJsonWithRaw(hooksJsonPath) - if (!config?.hooks || previousRaw === null) { + if (!config) { + // Why: a failed or malformed read proves no cleanup; keep the managed lane + // until a later pass can inspect and remove the real-home entry. + return 'unavailable' + } + if (!config.hooks || previousRaw === null) { return 'removed' } const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index e3c63697cb9..c7e789b3b9b 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -2,7 +2,7 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection' import { forgetCodexPaneAccount, - getCodexPaneAccount, + listRecordedCodexPaneAccounts, type CodexPaneHomeRoute } from './codex-pane-account-registry' @@ -23,8 +23,9 @@ export function listStaleCodexPanes(args: { activeHostHomeRoute?: CodexPaneHomeRoute }): StaleCodexPane[] { const stalePanes: StaleCodexPane[] = [] + const records = listRecordedCodexPaneAccounts(args.ptyIds) for (const ptyId of args.ptyIds) { - const record = getCodexPaneAccount(ptyId) + const record = records.get(ptyId) if (!record) { continue } diff --git a/src/main/codex/codex-trust-grant-ledger.ts b/src/main/codex/codex-trust-grant-ledger.ts index b1d20421bce..75a289db22f 100644 --- a/src/main/codex/codex-trust-grant-ledger.ts +++ b/src/main/codex/codex-trust-grant-ledger.ts @@ -1,4 +1,5 @@ -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { dirname, join } from 'node:path' import { getOrcaManagedCodexHomePath } from './codex-home-paths' import { normalizeCodexProjectPathForLookup } from './config-toml-trust' @@ -40,13 +41,22 @@ export function getCodexTrustGrantHomeKey(runtimeHomePath: string): string { return normalizeCodexProjectPathForLookup(runtimeHomePath) } -function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile { +/** + * `null` means the ledger could not be READ, which is different from an absent + * or corrupt one. A write derived from `empty` persists a file containing only + * the home being written, destroying every other home's grants — so the write + * paths refuse on `null` while the read path keeps degrading. + */ +function readLedgerFileOrNull(ledgerPath: string): CodexTrustGrantLedgerFile | null { const empty: CodexTrustGrantLedgerFile = { version: 1, homes: {} } - if (!existsSync(ledgerPath)) { - return empty + let rawLedger: string + try { + rawLedger = readFileSync(ledgerPath, 'utf-8') + } catch (error) { + return isDefinitiveAbsence(error) ? empty : null } try { - const parsed: unknown = JSON.parse(readFileSync(ledgerPath, 'utf-8')) + const parsed: unknown = JSON.parse(rawLedger) if ( !parsed || typeof parsed !== 'object' || @@ -67,6 +77,11 @@ function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile { } } +/** Corrupt or absent still degrades to empty — rebuilding those IS the intent. */ +function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile { + return readLedgerFileOrNull(ledgerPath) ?? { version: 1, homes: {} } +} + function persistLedgerFile(ledgerPath: string, file: CodexTrustGrantLedgerFile): void { mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 }) writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, { @@ -94,7 +109,13 @@ export function writeCodexTrustGrantLedgerHome( home: CodexTrustGrantLedgerHome, ledgerPath = getCodexTrustGrantLedgerPath() ): void { - const file = readLedgerFile(ledgerPath) + const file = readLedgerFileOrNull(ledgerPath) + if (!file) { + // Why: writing a file derived from `empty` would drop every other home's + // grants, and those homes would then be re-prompted for trust they had + // already granted. Skip this write; the next grant retries. + return + } file.homes[getCodexTrustGrantHomeKey(runtimeHomePath)] = home persistLedgerFile(ledgerPath, file) } diff --git a/src/main/codex/config-settings-baseline.ts b/src/main/codex/config-settings-baseline.ts index 6959219c2d2..c771f3f7d37 100644 --- a/src/main/codex/config-settings-baseline.ts +++ b/src/main/codex/config-settings-baseline.ts @@ -1,4 +1,7 @@ -import { existsSync, writeFileSync } from 'node:fs' +import { writeFileSync } from 'node:fs' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { JsonTextStructureCapacityError } from '../../shared/json-text-structure-limit' +import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' import { join } from 'node:path' import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader' @@ -20,11 +23,37 @@ type StoredSettingsBaseline = { conflicts?: Record } -export function readCodexSettingsBaseline(runtimeHomePath: string): CodexSettingsBaseline | null { +/** + * Why callers need three answers, not two: without a readable baseline, + * promotion cannot distinguish an in-Codex edit from Orca's last mirror. An + * unreadable baseline must stall that mirror; absent and unparseable still map + * to `absent` because rebuilding those is the intent. + */ +export type CodexSettingsBaselineObservation = + | { kind: 'present'; baseline: CodexSettingsBaseline } + | { kind: 'absent' } + | { kind: 'indeterminate' } + +export function observeCodexSettingsBaseline( + runtimeHomePath: string +): CodexSettingsBaselineObservation { const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath) - if (!existsSync(baselinePath)) { - return null + const baseline = readParsedCodexSettingsBaseline(baselinePath) + if (baseline === 'unreadable') { + return { kind: 'indeterminate' } } + return baseline ? { kind: 'present', baseline } : { kind: 'absent' } +} + +/** Absent and unreadable both collapse to `null`; use the observation to tell them apart. */ +export function readCodexSettingsBaseline(runtimeHomePath: string): CodexSettingsBaseline | null { + const observation = observeCodexSettingsBaseline(runtimeHomePath) + return observation.kind === 'present' ? observation.baseline : null +} + +function readParsedCodexSettingsBaseline( + baselinePath: string +): CodexSettingsBaseline | null | 'unreadable' { try { const parsed: unknown = readAgentStateJsonFileSync(baselinePath) if (!isStoredSettingsBaseline(parsed)) { @@ -46,11 +75,22 @@ export function readCodexSettingsBaseline(runtimeHomePath: string): CodexSetting } } return { settings, conflicts } - } catch { - return null + } catch (error) { + // Why: invalid baseline state is still `null` — resetting it is the intent, + // and only a read that FAILED must be preserved. + return isDefinitiveAbsence(error) || isRebuildableBaselineError(error) ? null : 'unreadable' } } +/** Why: known-present baseline state outside its parse/capacity contract is rebuildable, not unreadable. */ +function isRebuildableBaselineError(error: unknown): boolean { + return ( + error instanceof SyntaxError || + error instanceof JsonTextStructureCapacityError || + error instanceof NodeFileReadTooLargeError + ) +} + export function writeCodexSettingsBaseline( runtimeHomePath: string, baseline: CodexSettingsBaseline @@ -64,14 +104,23 @@ export function writeCodexSettingsBaseline( } const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath) const serialized = `${JSON.stringify(file, null, 2)}\n` + let existing: string | null = null + try { + existing = readAgentStateFileSync(baselinePath) + } catch (error) { + // Why: only absence or known-invalid derived state may authorize replacement. + if (!isDefinitiveAbsence(error) && !isRebuildableBaselineError(error)) { + throw error + } + } // Why: launch prep runs repeatedly; byte-identical baselines should not churn disk metadata. - if (existsSync(baselinePath) && readAgentStateFileSync(baselinePath) === serialized) { + if (existing === serialized) { return } writeFileSync(baselinePath, serialized, { encoding: 'utf-8', mode: 0o600 }) } -function getCodexSettingsBaselinePath(runtimeHomePath: string): string { +export function getCodexSettingsBaselinePath(runtimeHomePath: string): string { return join(runtimeHomePath, SETTINGS_BASELINE_FILE) } diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index cd0b703960a..9ff7383291d 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -14,7 +14,7 @@ import { import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert' import { - readCodexSettingsBaseline, + observeCodexSettingsBaseline, writeCodexSettingsBaseline, type CodexSettingsBaseline, type CodexSettingsConflict @@ -235,10 +235,17 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( throw runtimeTomlObservation.error } // Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one. - const baseline = readCodexSettingsBaseline(runtimeHomePath) - if (!baseline) { + const baselineObservation = observeCodexSettingsBaseline(runtimeHomePath) + if (baselineObservation.kind === 'indeterminate') { + // Why: an empty plan here lets the mirror proceed and write the system value + // back over an in-Codex edit this baseline would have identified. The caller + // turns a throw into the existing stall-and-retry null. + throw new Error('Codex settings baseline could not be read') + } + if (baselineObservation.kind === 'absent') { return emptyPromotionPlan() } + const baseline = baselineObservation.baseline const runtimeValues = readPromotedSettingValues(runtimeTomlPath) const systemValues = readPromotedSettingValues(systemTomlPath) const updates = new Map() @@ -260,10 +267,10 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( const writeTarget = resolvePromotionWriteTarget(systemTomlPath) // Why: a dangling symlink may target an unmade dir tree; create its real parent so the atomic temp write has a home. mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 }) - // Why: this is the user's real ~/.codex/config.toml, and `existsSync` reading - // `false` for a locked file sent it down the reconstruct branch below, which - // replaces the canonical config with settings derived from Orca's runtime - // copy. One read replaces the old existsSync + read pair and its TOCTOU gap. + // Why: this is the user's real ~/.codex/config.toml, and an indeterminate + // existence probe sent it down the reconstruct branch below, which replaces + // the canonical config with settings derived from Orca's runtime copy. One + // read replaces the old existsSync + read pair and its TOCTOU gap. // The indeterminate arm is a backstop rather than the live guard: an // unreadable system config already refused in readPromotedSettingValues, // because `writeTarget.path` always resolves to the same file as diff --git a/src/main/codex/config-sync-stall.test.ts b/src/main/codex/config-sync-stall.test.ts index 6cdd25613b2..37c62c105a0 100644 --- a/src/main/codex/config-sync-stall.test.ts +++ b/src/main/codex/config-sync-stall.test.ts @@ -4,9 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { getCodexConfigSyncStatus, + reportCodexConfigSyncOutcome, resetCodexConfigSyncStallLatchForTests } from './config-sync-stall' import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { getCodexSettingsBaselinePath } from './config-settings-baseline' let root: string let homes: { runtimeHomePath: string; systemHomePath: string } @@ -68,6 +70,20 @@ describe('getCodexConfigSyncStatus', () => { expect(getCodexConfigSyncStatus(homes).reason).toBe('unreadable-source') }) + it('reports a stall when the settings baseline cannot be read', () => { + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\n', 'utf-8') + const baselinePath = getCodexSettingsBaselinePath(homes.runtimeHomePath) + mkdirSync(baselinePath) + + expect(getCodexConfigSyncStatus(homes)).toEqual({ + state: 'stalled', + reason: 'managed-home-unavailable', + systemConfigPath: systemConfigPath(), + managedStatePath: baselinePath + }) + }) + it('stays synced before the runtime config exists, since nothing can fall behind yet', () => { expect(getCodexConfigSyncStatus(homes)).toEqual({ state: 'synced', @@ -163,6 +179,19 @@ describe('reportCodexConfigSyncOutcome', () => { expect(String(stallLogs[0]?.[0])).toContain('unreadable-source') }) + it('names the managed config when that is the unreadable file', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + mkdirSync(runtimeConfigPath()) + + reportCodexConfigSyncOutcome(homes.runtimeHomePath, getCodexConfigSyncStatus(homes)) + + const message = String(warn.mock.calls[0]?.[0]) + expect(message).toContain(runtimeConfigPath()) + expect(message).not.toContain(systemConfigPath()) + expect(message).toContain('The managed state must be readable before settings can sync.') + }) + it('clears a stall without claiming the source became readable', () => { // Why: a removed runtime config also reads as synced, so "readable again" // would be a false claim about a source that is still gone. diff --git a/src/main/codex/config-sync-stall.ts b/src/main/codex/config-sync-stall.ts index c03598d4a28..c209b4d28b8 100644 --- a/src/main/codex/config-sync-stall.ts +++ b/src/main/codex/config-sync-stall.ts @@ -1,7 +1,10 @@ -import { existsSync } from 'node:fs' import { join } from 'node:path' -import { readAgentStateFileSync } from '../agent-state-file-reader' +import { observeAgentStateFile } from './codex-path-observation' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { + getCodexSettingsBaselinePath, + observeCodexSettingsBaseline +} from './config-settings-baseline' import type { CodexSettingsPromotionHomes } from './config-settings-promotion' import type { CodexConfigSyncStallReason, @@ -28,20 +31,42 @@ export function getCodexConfigSyncStatus( const runtimeConfigPath = join(homes.runtimeHomePath, 'config.toml') // Why: a stall only withholds settings once a managed runtime config exists; // without one the mirror seeds it and there is nothing yet to fall behind. - if (!existsSync(runtimeConfigPath)) { + // But `existsSync` never proved the config readable, so this function could + // report `synced` while the mirror refused its content read — telling the + // user their edits had been applied when nothing had run. + const runtimeConfigObservation = observeAgentStateFile(runtimeConfigPath) + if (runtimeConfigObservation.kind === 'absent') { return { state: 'synced', reason: null, systemConfigPath } } - if (!existsSync(systemConfigPath)) { + if (runtimeConfigObservation.kind === 'indeterminate') { + // Why: the managed home is what could not be read, so say that rather than + // borrowing a source-side reason and blaming the wrong path. + return { + state: 'stalled', + reason: 'managed-home-unavailable', + systemConfigPath, + managedStatePath: runtimeConfigPath + } + } + const baselineObservation = observeCodexSettingsBaseline(homes.runtimeHomePath) + if (baselineObservation.kind === 'indeterminate') { + return { + state: 'stalled', + reason: 'managed-home-unavailable', + systemConfigPath, + managedStatePath: getCodexSettingsBaselinePath(homes.runtimeHomePath) + } + } + const systemConfigObservation = observeAgentStateFile(systemConfigPath) + if (systemConfigObservation.kind === 'absent') { return { state: 'stalled', reason: 'missing-source', systemConfigPath } } - let rawSystemConfig: string - try { - rawSystemConfig = readAgentStateFileSync(systemConfigPath) - } catch { + if (systemConfigObservation.kind === 'indeterminate') { // Why: the mirror aborts on an unreadable source too, so report the stall // rather than claiming a sync that cannot happen. return { state: 'stalled', reason: 'unreadable-source', systemConfigPath } } + const rawSystemConfig = systemConfigObservation.value if (rawSystemConfig.trim() === '') { return { state: 'stalled', reason: 'blank-source', systemConfigPath } } @@ -87,6 +112,13 @@ export function reportCodexConfigSyncOutcome( return } stalledHomes.set(runtimeHomePath, status.reason) + if (status.reason === 'managed-home-unavailable') { + const managedStatePath = status.managedStatePath ?? join(runtimeHomePath, 'config.toml') + console.warn( + `[codex-config] Config sync stalled (${status.reason}): ${managedStatePath} is unusable, so ${runtimeHomePath} keeps its last synced settings. The managed state must be readable before settings can sync.` + ) + return + } console.warn( `[codex-config] Config sync stalled (${status.reason}): ${status.systemConfigPath} is unusable, so ${runtimeHomePath} keeps its last synced settings. Edits to the source will not apply until it is readable.` ) diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index a4768730c5b..d641c664f24 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -12,6 +12,7 @@ import { import { basename, dirname, join, posix as pathPosix, win32 as pathWin32 } from 'node:path' import { createHash, randomUUID } from 'node:crypto' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { observe } from './codex-path-observation' import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths' import { writeRollingFileBackup } from '../rolling-file-backup' import { @@ -337,7 +338,15 @@ export function upsertHookTrustEntries( configPath: string, entries: readonly CodexTrustEntry[] ): void { - const existing = existsSync(configPath) ? readTomlFile(configPath) : '' + // Why: `existsSync` collapses an indeterminate probe into the same `false` as + // absence. If the path recovers before the write, rebuilding from '' replaces + // the user's model, provider, MCP servers, approvals and comments with a + // trust-only stub. Only a definitive absence may seed from empty. + const observation = observe(() => readTomlFile(configPath)) + if (observation.kind === 'indeterminate') { + throw observation.error + } + const existing = observation.kind === 'present' ? observation.value : '' const updated = upsertHookTrustEntriesInContent(existing, entries) if (updated === existing) { return @@ -372,7 +381,11 @@ export function upsertProjectTrustLevel( projectPath: string, trustLevel: CodexProjectTrustLevel ): void { - const existing = existsSync(configPath) ? readTomlFile(configPath) : '' + const observation = observe(() => readTomlFile(configPath)) + if (observation.kind === 'indeterminate') { + throw observation.error + } + const existing = observation.kind === 'present' ? observation.value : '' const updated = upsertProjectTrustLevelInContent(existing, projectPath, trustLevel) if (updated === existing) { return diff --git a/src/main/codex/hook-service-user-hook-mirroring.test.ts b/src/main/codex/hook-service-user-hook-mirroring.test.ts index e25d8e709f7..d06fec0b3a7 100644 --- a/src/main/codex/hook-service-user-hook-mirroring.test.ts +++ b/src/main/codex/hook-service-user-hook-mirroring.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import type * as Os from 'node:os' import { join } from 'node:path' import { upsertHookTrustEntriesInContent } from './config-toml-trust' @@ -32,6 +32,36 @@ import { CodexHookService } from './hook-service' const homes = setupCodexHookHomes(homedirMock, getPathMock) +function seedSystemUserHook(command: string): { + systemHooksPath: string + managedHooksPath: string +} { + const systemCodexHome = join(homes.tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] } })}\n`, + 'utf-8' + ) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + return { + systemHooksPath, + managedHooksPath: join(homes.userDataDir, 'codex-runtime-home', 'home', 'hooks.json') + } +} + +function readRuntimeHookCommands(managedHooksPath: string): string[] { + const runtime = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + return Object.values(runtime.hooks).flatMap((definitions) => + definitions.flatMap( + (definition) => definition.hooks?.flatMap((hook) => hook.command ?? []) ?? [] + ) + ) +} + function markHookTrustDisabled(toml: string, header: string): string { const headerIndex = toml.indexOf(header) expect(headerIndex).not.toBe(-1) @@ -43,6 +73,48 @@ function markHookTrustDisabled(toml: string, header: string): string { } describe('CodexHookService', () => { + it('preserves mirrored user hooks when the system hooks file cannot be read', () => { + const service = new CodexHookService() + const { systemHooksPath, managedHooksPath } = seedSystemUserHook('user-hook') + expect(service.install().state).toBe('installed') + const systemBefore = readFileSync(systemHooksPath, 'utf-8') + const before = readFileSync(managedHooksPath, 'utf-8') + + rmSync(systemHooksPath) + mkdirSync(systemHooksPath) + + for (const retry of [() => service.install(), () => service.refreshRuntimeUserHooks()]) { + expect(retry()).toMatchObject({ + state: 'error', + detail: 'Could not read system Codex hooks.json' + }) + expect(readFileSync(managedHooksPath, 'utf-8')).toBe(before) + } + + rmSync(systemHooksPath, { recursive: true }) + writeFileSync(systemHooksPath, systemBefore, 'utf-8') + expect(service.install().state).toBe('installed') + expect(readRuntimeHookCommands(managedHooksPath)).toContain('user-hook') + }) + + it.each(['absent', 'malformed'] as const)( + 'rebuilds mirrored user hooks when the system source is %s', + (sourceState) => { + const service = new CodexHookService() + const { systemHooksPath, managedHooksPath } = seedSystemUserHook('stale-user-hook') + expect(service.install().state).toBe('installed') + + if (sourceState === 'absent') { + rmSync(systemHooksPath) + } else { + writeFileSync(systemHooksPath, '{ not json', 'utf-8') + } + + expect(service.install().state).toBe('installed') + expect(readRuntimeHookCommands(managedHooksPath)).not.toContain('stale-user-hook') + } + ) + it('mirrors trusted system user hook approvals into the runtime CODEX_HOME', () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 3bf087b14d9..a0e30c5941c 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -299,13 +299,18 @@ function getRuntimeHooksWithSystemUserHooks( ): { hooks: Record trustEntries: MirroredRuntimeUserHookTrustEntry[] -} { +} | null { const systemConfigPath = getSystemConfigPath() if (systemConfigPath === runtimeConfigPath) { return { hooks: { ...runtimeHooks }, trustEntries: [] } } - const systemConfig = readHooksJson(systemConfigPath) + const { raw: systemRaw, config: systemConfig } = readHooksJsonWithRaw(systemConfigPath) + if (!systemConfig && systemRaw === null) { + // Why: rebuilding from an unreadable source drops the last-known user hooks + // from the managed runtime; let launch prep retry without changing it. + return null + } if (!systemConfig?.hooks) { return { hooks: {}, trustEntries: [] } } @@ -1284,6 +1289,15 @@ export class CodexHookService { const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const command = getManagedCommand(scriptPath) const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand, configPath) + if (!hookPlan) { + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read system Codex hooks.json' + } + } const nextHooks = hookPlan.hooks const managedEvents = new Set(CODEX_EVENTS) @@ -1534,6 +1548,15 @@ export class CodexHookService { const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand, configPath) + if (!hookPlan) { + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read system Codex hooks.json' + } + } config.hooks = hookPlan.hooks writeCodexHooksJson(configPath, hookPlan.hooks) diff --git a/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts b/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts new file mode 100644 index 00000000000..adab285b592 --- /dev/null +++ b/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts @@ -0,0 +1,506 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { listStaleCodexPanes } from './codex-stale-pane-accounts' +import type { CodexTrustEntry } from './config-toml-trust' + +// STA-4823: six shared Codex state files were rebuilt, erased or reported as +// healthy after a read that had merely failed. Host permission failures drive +// four; the other guards cover transient transport and observation races. + +const denials = vi.hoisted(() => { + const state = { + paths: new Set(), + readOnlyPaths: new Set(), + existenceDeniedPaths: new Set(), + deny(path: string): void { + state.paths.add(path) + }, + /** + * Content reads fail but `existsSync` and metadata reads still succeed — + * the measured host permission/sharing-denial shape. + */ + denyReads(path: string): void { + state.readOnlyPaths.add(path) + }, + /** + * `existsSync` reports false while the path is still there, and the write + * still lands. + * + * MEASURED, and not what I first assumed: a file-permission denial does NOT + * produce this. `chmod 000` on macOS and `icacls /deny (R)` on Windows both + * leave `existsSync` TRUE, leave `stat`/`lstat` succeeding, and fail only + * the content read (EACCES / EPERM errno -4048). The shape modelled here is + * a transient transport failure: a UNC / `\\wsl$` probe reports errno + * `UNKNOWN`, `existsSync` folds it to false, and the transport recovers + * before the following write. + * + * The distinction decides what the guard is worth: under a permission denial + * the pre-fix code already failed safe, because `existsSync` was true so it + * took the read branch and threw. Only a transport that lies about existence + * reaches the rebuild-from-empty path. + */ + denyExistence(path: string): void { + state.existenceDeniedPaths.add(path) + }, + release(path: string): void { + state.paths.delete(path) + state.readOnlyPaths.delete(path) + state.existenceDeniedPaths.delete(path) + }, + reset(): void { + state.paths.clear() + state.readOnlyPaths.clear() + state.existenceDeniedPaths.clear() + }, + check(target: unknown, syscall: string, readOnly = false): void { + if (typeof target !== 'string') { + return + } + const readDenied = + readOnly && (state.readOnlyPaths.has(target) || state.existenceDeniedPaths.has(target)) + if (!state.paths.has(target) && !readDenied) { + return + } + const error: NodeJS.ErrnoException = new Error( + `EPERM: operation not permitted, ${syscall} '${target}'` + ) + error.code = 'EPERM' + error.errno = -4048 + error.syscall = syscall + error.path = target + throw error + } + } + return state +}) + +// One denial modelled coherently: a denied path throws from every read AND +// reports false from existsSync. Faulting one but not another lets a test pass +// against code that still consults the one left healthy. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const guard = (fn: unknown, syscall: string): unknown => { + const original = fn as (...args: unknown[]) => unknown + const wrapped = (...args: unknown[]): unknown => { + denials.check(args[0], syscall) + return original(...args) + } + return Object.assign(wrapped, original) + } + const patched: Record = { + ...actual, + readFileSync: Object.assign((...args: unknown[]): unknown => { + denials.check(args[0], 'read', true) + return (actual.readFileSync as (...a: unknown[]) => unknown)(...args) + }, actual.readFileSync), + // Why: the size-capped agent-state reader opens with `openSync`, so leaving + // it unguarded makes a denial invisible to every config/baseline read. Guard + // it for READ intent only — a write opens 'w'/'wx'/'a', and the atomic + // replace those writes perform must stay healthy (see `deny` above). + openSync: Object.assign((...args: unknown[]): unknown => { + const flags = args[1] + const isRead = flags === undefined || (typeof flags === 'string' && flags.startsWith('r')) + if (isRead) { + denials.check(args[0], 'open', true) + } + return (actual.openSync as (...a: unknown[]) => unknown)(...args) + }, actual.openSync), + statSync: guard(actual.statSync, 'stat'), + lstatSync: guard(actual.lstatSync, 'lstat'), + existsSync: Object.assign( + (...args: unknown[]): boolean => + typeof args[0] === 'string' && + (denials.paths.has(args[0]) || denials.existenceDeniedPaths.has(args[0])) + ? false + : actual.existsSync(args[0] as string), + actual.existsSync + ) + } + return { ...patched, default: patched } +}) + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: homedirMock } +}) + +const realFs = await vi.importActual('node:fs') + +const { upsertHookTrustEntries, upsertProjectTrustLevel, readHookTrustEntries } = + await import('./config-toml-trust') +const { + writeCodexTrustGrantLedgerHome, + readCodexTrustGrantLedgerHome, + getCodexTrustGrantLedgerPath +} = await import('./codex-trust-grant-ledger') +const { readHooksJson } = await import('../agent-hooks/hooks-json-read') +const { MAX_AGENT_STATE_FILE_BYTES } = await import('../agent-state-file-reader') +const { observeCodexSettingsBaseline } = await import('./config-settings-baseline') +const { snapshotCodexRuntimeSettingsBaseline } = await import('./config-settings-promotion') +const { getCodexConfigSyncStatus } = await import('./config-sync-stall') +const paneRegistry = await import('./codex-pane-account-registry') + +let fakeHomeDir: string +let userDataDir: string +let runtimeHomePath: string +let previousUserDataPath: string | undefined + +const systemHome = (): string => join(fakeHomeDir, '.codex') +const baselinePath = (): string => join(runtimeHomePath, '.orca-config-settings-baseline.json') + +beforeEach(() => { + denials.reset() + fakeHomeDir = realFs.mkdtempSync(join(tmpdir(), 'orca-sta4823-home-')) + userDataDir = realFs.mkdtempSync(join(tmpdir(), 'orca-sta4823-data-')) + runtimeHomePath = join(userDataDir, 'codex-runtime-home', 'home') + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(fakeHomeDir) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) + realFs.mkdirSync(runtimeHomePath, { recursive: true }) + realFs.mkdirSync(systemHome(), { recursive: true }) + paneRegistry._internals.resetCache() +}) + +afterEach(() => { + denials.reset() + paneRegistry._internals.resetCache() + realFs.rmSync(fakeHomeDir, { recursive: true, force: true }) + realFs.rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +describe('STA-4823 D29 — an unreadable config.toml must not become a trust-only stub', () => { + const USER_CONFIG = [ + 'model = "gpt-5.1-codex-max"', + 'model_provider = "openai"', + '', + '# my notes', + '[mcp_servers.local]', + 'command = "run-me"', + '' + ].join('\n') + const ENTRY = [ + { + sourcePath: '/tmp/hooks.json', + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'orca hook', + trustedHash: 'sha256:x', + enabled: true + } + ] satisfies readonly CodexTrustEntry[] + + it('refuses the write rather than rebuilding the file from the trust entries alone', () => { + const configPath = join(runtimeHomePath, 'config.toml') + realFs.writeFileSync(configPath, USER_CONFIG, 'utf-8') + denials.denyExistence(configPath) + + // Every hook-service caller already turns this throw into "trust entries + // could not be written. Run /hooks in Codex to approve." + expect(() => upsertHookTrustEntries(configPath, ENTRY)).toThrow('EPERM') + + // Before the fix, existsSync said the file was gone, `existing` became '', + // and the upsert wrote a config containing ONLY the trust table — the + // user's model, provider, MCP servers and comments all discarded. + expect(realFs.readFileSync(configPath, 'utf-8')).toBe(USER_CONFIG) + }) + + it('still seeds a config.toml that does not exist yet', () => { + const configPath = join(runtimeHomePath, 'config.toml') + + // Why: seeding from empty is correct for a genuine absence, and is how a + // fresh managed home gets its trust entries at all. + upsertHookTrustEntries(configPath, ENTRY) + + expect(readHookTrustEntries(configPath).size).toBeGreaterThan(0) + }) + + it('also refuses a project-trust write when the existing config cannot be read', () => { + const configPath = join(runtimeHomePath, 'config.toml') + realFs.writeFileSync(configPath, USER_CONFIG, 'utf-8') + denials.denyExistence(configPath) + + expect(() => upsertProjectTrustLevel(configPath, '/tmp/project', 'trusted')).toThrow('EPERM') + expect(realFs.readFileSync(configPath, 'utf-8')).toBe(USER_CONFIG) + }) + + it('still seeds project trust when config.toml is genuinely absent', () => { + const configPath = join(runtimeHomePath, 'config.toml') + + upsertProjectTrustLevel(configPath, '/tmp/project', 'trusted') + + expect(realFs.readFileSync(configPath, 'utf-8')).toContain('trust_level = "trusted"') + }) +}) + +describe('STA-4823 D30 — an unreadable trust-grant ledger must not be rewritten from empty', () => { + const OTHER_HOME = '/some/other/managed/home' + + function seedLedgerWithAnotherHome(): string { + const ledgerPath = getCodexTrustGrantLedgerPath() + writeCodexTrustGrantLedgerHome( + OTHER_HOME, + { entries: { 'a:b': { trustedHash: 'sha256:other' } } } as never, + ledgerPath + ) + return ledgerPath + } + + it('skips the write instead of dropping every other home', () => { + const ledgerPath = seedLedgerWithAnotherHome() + const before = realFs.readFileSync(ledgerPath, 'utf-8') + denials.denyReads(ledgerPath) + + writeCodexTrustGrantLedgerHome( + runtimeHomePath, + { entries: { 'c:d': { trustedHash: 'sha256:mine' } } } as never, + ledgerPath + ) + + // Before the fix the read degraded to an empty ledger and the write + // persisted a file holding only this home, so every other home lost its + // grants and would be re-prompted for trust it had already given. + expect(realFs.readFileSync(ledgerPath, 'utf-8')).toBe(before) + }) + + it('still records a home when the ledger is readable', () => { + const ledgerPath = seedLedgerWithAnotherHome() + + // Why: the anchor. Refusing whenever a read fails would stop the ledger + // ever recording a grant. + writeCodexTrustGrantLedgerHome( + runtimeHomePath, + { entries: { 'c:d': { trustedHash: 'sha256:mine' } } } as never, + ledgerPath + ) + + expect(readCodexTrustGrantLedgerHome(runtimeHomePath, ledgerPath)).not.toBeNull() + expect(readCodexTrustGrantLedgerHome(OTHER_HOME, ledgerPath)).not.toBeNull() + }) +}) + +describe('STA-4823 D33 — an unreadable hooks.json is not an empty hooks.json', () => { + it('reports a failed read as unknown rather than as no hooks configured', () => { + const hooksPath = join(runtimeHomePath, 'hooks.json') + realFs.writeFileSync(hooksPath, JSON.stringify({ hooks: { Stop: [] } }), 'utf-8') + denials.deny(hooksPath) + + // The read arm already returned null for a failed read; the existsSync arm + // in front of it returned a VALID EMPTY config, and the installer then + // wrote generated hooks over the user's file. + expect(readHooksJson(hooksPath)).toBeNull() + }) + + it('still reports a genuinely absent hooks.json as an empty config', () => { + // Why: absence really does mean "no hooks configured", and the installer + // depends on that to seed one. + expect(readHooksJson(join(runtimeHomePath, 'hooks.json'))).toEqual({}) + }) +}) + +describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror', () => { + function seedBaseline(): string { + realFs.writeFileSync( + baselinePath(), + `${JSON.stringify({ version: 2, settings: { model: 'old-model' } })}\n`, + 'utf-8' + ) + return realFs.readFileSync(baselinePath(), 'utf-8') + } + + it('preserves the pending runtime edit until the baseline can be read', () => { + const before = seedBaseline() + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + realFs.writeFileSync(runtimeConfigPath, 'model = "edited-in-codex"\n', 'utf-8') + realFs.writeFileSync(join(systemHome(), 'config.toml'), 'model = "old-model"\n', 'utf-8') + denials.denyReads(baselinePath()) + + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath: systemHome() }) + + // Before the fix the failed baseline read became "no baseline", promotion + // returned an empty plan, and the mirror wrote the old system value over + // the pending in-Codex edit. + expect(realFs.readFileSync(runtimeConfigPath, 'utf-8')).toContain('edited-in-codex') + expect(realFs.readFileSync(baselinePath(), 'utf-8')).toBe(before) + }) + + it('does not replace a baseline after an indeterminate existence probe', () => { + const before = seedBaseline() + realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "edited"\n', 'utf-8') + denials.denyExistence(baselinePath()) + + snapshotCodexRuntimeSettingsBaseline(runtimeHomePath) + + expect(realFs.readFileSync(baselinePath(), 'utf-8')).toBe(before) + }) + + it('still replaces a baseline that is present but malformed', () => { + realFs.writeFileSync(baselinePath(), '{ not json', 'utf-8') + realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "m"\n', 'utf-8') + + // Why: a corrupt baseline carries no information and resetting it IS the + // intent. Only the unreadable case may be preserved, or a user is wedged + // on a broken file forever. + snapshotCodexRuntimeSettingsBaseline(runtimeHomePath) + + // Asserted against the file rather than the new observation API, so this + // anchor still means something when the fix is reverted. + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + }) + + it('still replaces a fully-read baseline rejected by the JSON structure limit', () => { + realFs.writeFileSync(baselinePath(), '['.repeat(129), 'utf-8') + realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "m"\n', 'utf-8') + + snapshotCodexRuntimeSettingsBaseline(runtimeHomePath) + + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + }) + + it('rebuilds an oversized baseline previously produced from a bounded runtime config', () => { + const escapedValue = '\\'.repeat(MAX_AGENT_STATE_FILE_BYTES / 2 + 64) + const oversizedRuntimeConfig = `model = "${escapedValue}"\n` + expect(Buffer.byteLength(oversizedRuntimeConfig)).toBeLessThan(MAX_AGENT_STATE_FILE_BYTES) + realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), oversizedRuntimeConfig, 'utf-8') + + snapshotCodexRuntimeSettingsBaseline(runtimeHomePath) + expect(realFs.statSync(baselinePath()).size).toBeGreaterThan(MAX_AGENT_STATE_FILE_BYTES) + expect(observeCodexSettingsBaseline(runtimeHomePath)).toEqual({ kind: 'absent' }) + + realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "recovered"\n', 'utf-8') + snapshotCodexRuntimeSettingsBaseline(runtimeHomePath) + + expect(realFs.statSync(baselinePath()).size).toBeLessThan(MAX_AGENT_STATE_FILE_BYTES) + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ + settings: { model: '"recovered"' } + }) + }) +}) + +describe('STA-4823 D15 — the sync status must not report synced while the mirror refuses', () => { + it('reports the managed home as unavailable when its config cannot be read', () => { + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + realFs.writeFileSync(runtimeConfigPath, 'model = "m"\n', 'utf-8') + realFs.writeFileSync(join(systemHome(), 'config.toml'), 'model = "s"\n', 'utf-8') + denials.deny(runtimeConfigPath) + + const status = getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath: systemHome() }) + + // Before the fix existsSync collapsed this failed observation into absence + // and returned `synced` — telling the user their edits had been applied + // while nothing had run. + expect(status).toMatchObject({ state: 'stalled', reason: 'managed-home-unavailable' }) + }) + + it('reports the managed home as unavailable when only its content read is denied', () => { + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + realFs.writeFileSync(runtimeConfigPath, 'model = "m"\n', 'utf-8') + realFs.writeFileSync(join(systemHome(), 'config.toml'), 'model = "s"\n', 'utf-8') + denials.denyReads(runtimeConfigPath) + + expect( + getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath: systemHome() }) + ).toMatchObject({ state: 'stalled', reason: 'managed-home-unavailable' }) + }) + + it('still reports synced when no runtime config exists yet', () => { + realFs.writeFileSync(join(systemHome(), 'config.toml'), 'model = "s"\n', 'utf-8') + + // Why: with no managed config the mirror seeds one; there is nothing to + // have fallen behind, and warning here would fire on every fresh install. + expect( + getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath: systemHome() }) + ).toMatchObject({ state: 'synced', reason: null }) + }) +}) + +describe('STA-4823 D31 — an unreadable pane registry must not erase every attribution', () => { + const PANE = 'pty-1' + const OTHER_PANE = 'pty-2' + const registryPath = (): string => join(userDataDir, 'codex-pane-accounts.json') + + function seedTwoAttributedPanes(): void { + paneRegistry.recordCodexPaneAccount(PANE, { + selectionKey: 'host', + accountId: 'account-1', + homeRoute: 'account-home' + } as never) + paneRegistry.recordCodexPaneAccount(OTHER_PANE, { + selectionKey: 'host', + accountId: 'account-2', + homeRoute: 'account-home' + } as never) + paneRegistry._internals.resetCache() + } + + it('preserves existing records and retries the one-shot spawn mutation', () => { + seedTwoAttributedPanes() + const before = realFs.readFileSync(registryPath(), 'utf-8') + denials.denyReads(registryPath()) + + paneRegistry.recordCodexPaneAccount('pty-3', { + selectionKey: 'host', + accountId: 'account-3', + homeRoute: 'account-home' + } as never) + + // Before the fix the read degraded to an empty registry and this write + // persisted it, dropping every other pane's account attribution on disk. + expect(realFs.readFileSync(registryPath(), 'utf-8')).toBe(before) + + denials.release(registryPath()) + expect(paneRegistry.getCodexPaneAccount('pty-3')).toMatchObject({ accountId: 'account-3' }) + paneRegistry._internals.resetCache() + expect(paneRegistry.getCodexPaneAccount(PANE)).toMatchObject({ accountId: 'account-1' }) + expect(paneRegistry.getCodexPaneAccount('pty-3')).toMatchObject({ accountId: 'account-3' }) + }) + + it('does not cache the erasure, so attribution returns when the file does', () => { + seedTwoAttributedPanes() + denials.denyReads(registryPath()) + + expect(paneRegistry.getCodexPaneAccount(PANE)).toBeNull() + expect(() => + listStaleCodexPanes({ + ptyIds: [PANE], + settings: { activeCodexManagedAccountId: 'account-2' } as never + }) + ).toThrow('registry could not be read') + + denials.release(registryPath()) + + // THE CACHE is the second half of this defect: one unreadable read used to + // pin the empty registry in memory for the rest of the process, so the + // attribution never came back even after the file did. + expect(paneRegistry.getCodexPaneAccount(PANE)).toMatchObject({ accountId: 'account-1' }) + }) + + it('still reports no attribution when the registry genuinely does not exist', () => { + // Why: a fresh install has no registry, and that really is "no panes are + // attributed". Refusing here would make every first launch look degraded. + expect(paneRegistry.getCodexPaneAccount(PANE)).toBeNull() + }) +}) diff --git a/src/main/ipc/codex-config-sync.test.ts b/src/main/ipc/codex-config-sync.test.ts index aa64664a75b..f9504a149c8 100644 --- a/src/main/ipc/codex-config-sync.test.ts +++ b/src/main/ipc/codex-config-sync.test.ts @@ -21,6 +21,7 @@ vi.mock('node:os', async (importOriginal) => { import { registerCodexConfigSyncHandlers } from './codex-config-sync' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' +import { getCodexSettingsBaselinePath } from '../codex/config-settings-baseline' let root: string @@ -79,6 +80,22 @@ describe('codexConfigSync:status handler', () => { }) }) + it('reports a stall when the selected home baseline cannot be read', () => { + const perAccountHome = join(root, 'codex-accounts', 'acct-1', 'home') + mkdirSync(perAccountHome, { recursive: true }) + writeFileSync(join(perAccountHome, 'config.toml'), 'model = "runtime-model"\n', 'utf-8') + writeFileSync(join(root, '.codex', 'config.toml'), 'model = "system-model"\n', 'utf-8') + const baselinePath = getCodexSettingsBaselinePath(perAccountHome) + mkdirSync(baselinePath) + + expect(invokeHandler(perAccountHome)).toEqual({ + state: 'stalled', + reason: 'managed-home-unavailable', + systemConfigPath: join(root, '.codex', 'config.toml'), + managedStatePath: baselinePath + }) + }) + it('re-registers cleanly so a reload cannot leak a duplicate handler', () => { invokeHandler(null) expect(removeHandlerMock).toHaveBeenCalledWith('codexConfigSync:status') diff --git a/src/main/skills/skill-bundle-creation.ts b/src/main/skills/skill-bundle-creation.ts index bfd2b29780d..b8a549425a5 100644 --- a/src/main/skills/skill-bundle-creation.ts +++ b/src/main/skills/skill-bundle-creation.ts @@ -20,7 +20,11 @@ import { import { summarizeSkillMarkdown } from '../../shared/skill-metadata' import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry' import { extractSkillBundleArchive } from './skill-bundle-extraction' -import { observeSkillPackage, type ObservedSkillPackage } from './skill-package-identity' +import { + observedSkillPackagesMatch, + observeSkillPackage, + type ObservedSkillPackage +} from './skill-package-identity' import { writeSkillTarGzip, type SkillTarWriteEntry } from './skill-package-tar' import { startSkillPhaseOperation } from './skill-operation-observability' @@ -42,23 +46,6 @@ export type SkillBundleCreationDependencies = { afterSourcesObserved?: () => Pro const SOURCE_OBSERVATION_CONCURRENCY = 4 -function observationsMatch(left: ObservedSkillPackage, right: ObservedSkillPackage): boolean { - return ( - left.files.length === right.files.length && - left.files.every((file, index) => { - const other = right.files[index] - return ( - file.path === other.path && - file.size === other.size && - file.executable === other.executable && - file.classification === other.classification && - file.exactSha256 === other.exactSha256 && - file.identitySha256 === other.identitySha256 - ) - }) - ) -} - function bundleEntry(input: { id: string name: string @@ -104,7 +91,7 @@ async function stageSkill(input: { process.platform, process.platform === 'win32' ) - if (!observationsMatch(input.sourceObservation, stagedObservation)) { + if (!observedSkillPackagesMatch(input.sourceObservation, stagedObservation)) { throw new Error('skill-package-source-changed-during-staging') } const stagedSummary = summarizeSkillMarkdown( diff --git a/src/main/skills/skill-package-creation.ts b/src/main/skills/skill-package-creation.ts index 694313f4812..527f447031a 100644 --- a/src/main/skills/skill-package-creation.ts +++ b/src/main/skills/skill-package-creation.ts @@ -11,7 +11,11 @@ import { import { summarizeSkillMarkdown } from '../../shared/skill-metadata' import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry' import { startSkillPhaseOperation } from './skill-operation-observability' -import { observeSkillPackage, type ObservedSkillPackage } from './skill-package-identity' +import { + observedSkillPackagesMatch, + observeSkillPackage, + type ObservedSkillPackage +} from './skill-package-identity' import { extractSkillPackageArchive } from './skill-package-extraction' import { writeSkillTarGzip, type SkillTarWriteEntry } from './skill-package-tar' @@ -26,23 +30,6 @@ export type SkillPackageCreationDependencies = { afterSourceObserved?: () => Promise } -function observationsMatch(left: ObservedSkillPackage, right: ObservedSkillPackage): boolean { - return ( - left.files.length === right.files.length && - left.files.every((file, index) => { - const other = right.files[index] - return ( - file.path === other.path && - file.size === other.size && - file.executable === other.executable && - file.classification === other.classification && - file.exactSha256 === other.exactSha256 && - file.identitySha256 === other.identitySha256 - ) - }) - ) -} - function packageManifest(input: { packageId: string versionId: string @@ -98,7 +85,7 @@ async function createSkillPackageArchiveUnobserved( errorOnExist: true }) const stagedObservation = await observeSkillPackage(stagedSkill) - if (!observationsMatch(sourceObservation, stagedObservation)) { + if (!observedSkillPackagesMatch(sourceObservation, stagedObservation)) { throw new Error('skill-package-source-changed-during-staging') } const summary = summarizeSkillMarkdown(await readFile(join(stagedSkill, 'SKILL.md'), 'utf8')) diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index 3613e4980a9..8d8ac841a85 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -23,6 +23,26 @@ export type ObservedSkillPackage = { treeEntries: SkillGitTreeFileEntry[] } +export function observedSkillPackagesMatch( + left: ObservedSkillPackage, + right: ObservedSkillPackage +): boolean { + return ( + left.files.length === right.files.length && + left.files.every((file, index) => { + const other = right.files[index] + return ( + file.path === other.path && + file.size === other.size && + file.executable === other.executable && + file.classification === other.classification && + file.exactSha256 === other.exactSha256 && + file.identitySha256 === other.identitySha256 + ) + }) + ) +} + // Why: package identity compares a live user directory against a tree the generator read // from a clean checkout, so anything the OS deposits on its own counts as drift the user // never caused. One Finder visit writes .DS_Store, and that alone made the copy diff --git a/src/main/updater.linux-root-package-install.test.ts b/src/main/updater.linux-root-package-install.test.ts index 62e1a8388ef..8139c5e2102 100644 --- a/src/main/updater.linux-root-package-install.test.ts +++ b/src/main/updater.linux-root-package-install.test.ts @@ -183,7 +183,9 @@ describe('updater', () => { const settleQuitAndInstall = async (): Promise => { await vi.advanceTimersByTimeAsync(100) await revalidation.drain() - for (let turn = 0; turn < 40; turn += 1) { + // Full Node 26 shards can briefly starve the libuv poll phase while other workers transform + // tests; keep the operation alive long enough to avoid leaking it into the next test. + for (let turn = 0; turn < 200; turn += 1) { await new Promise((resolve) => realSetTimeout(resolve, 0)) } await vi.advanceTimersByTimeAsync(0) diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index bf1e4b6e36e..c211a13581b 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -46,11 +46,9 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createBrowserUuid } from '@/lib/browser-uuid' import { buildLinearIssueContextSnapshot } from '@/lib/linear-issue-context-snapshot' -import { - findLinearIssueWorkspaceAttachment, - getLinearIssueWorkspaceAttachmentLabel -} from '@/lib/linear-issue-workspace-attachment' +import { findLinearIssueWorkspaceAttachment } from '@/lib/linear-issue-workspace-attachment' import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' +import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label' import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { buildContainedLinkedContextBlock } from '@/lib/linked-work-item-context' import { useMountedRef } from '@/hooks/useMountedRef' @@ -674,7 +672,7 @@ export default function LinearIssueWorkspace({ [attachmentWorkspaces, displayed] ) const attachedWorkspaceLabel = attachedWorkspace - ? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace) + ? getWorktreeAttachmentLabel(attachedWorkspace) : null const handleUseIssue = useCallback((): void => { diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 5cbd0cc2a3b..04b4c613c4f 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -36,11 +36,9 @@ import { getCommentBodySubmitState, hasBoundedCommentBodyText } from '@/lib/comment-body-submit-state' -import { - findLinearIssueWorkspaceAttachment, - getLinearIssueWorkspaceAttachmentLabel -} from '@/lib/linear-issue-workspace-attachment' +import { findLinearIssueWorkspaceAttachment } from '@/lib/linear-issue-workspace-attachment' import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' +import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label' import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' @@ -1384,7 +1382,7 @@ export default function LinearItemDrawer({ [attachmentWorkspaces, displayed] ) const attachedWorkspaceLabel = attachedWorkspace - ? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace) + ? getWorktreeAttachmentLabel(attachedWorkspace) : null const handleOpenOrUseIssue = useCallback((): void => { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index b0265713971..087fce68aed 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -136,16 +136,13 @@ import PRFilterDropdowns, { type PRFilterChange } from '@/components/github/PRFi import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { buildGitHubRepoUrl, parseGitHubIssueOrPRLink } from '@/lib/github-links' -import { - findGithubWorkItemWorkspaceAttachment, - getGithubWorkItemWorkspaceAttachmentLabel -} from '@/lib/github-work-item-workspace-attachment' +import { findGithubWorkItemWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment' import { buildLinearIssueWorkspaceAttachmentIndex, - findLinearIssueWorkspaceAttachmentInIndex, - getLinearIssueWorkspaceAttachmentLabel + findLinearIssueWorkspaceAttachmentInIndex } from '@/lib/linear-issue-workspace-attachment' import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' +import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label' import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' @@ -10272,7 +10269,7 @@ export default function TaskPage(): React.JSX.Element { item.number ) const attachedWorkspaceLabel = attachedWorkspace - ? getGithubWorkItemWorkspaceAttachmentLabel(attachedWorkspace) + ? getWorktreeAttachmentLabel(attachedWorkspace) : null const prDelta = item.type === 'pr' ? formatPRDelta(item) : null const githubTaskIdPill = ( @@ -11569,7 +11566,7 @@ export default function TaskPage(): React.JSX.Element { issue ) const attachedWorkspaceLabel = attachedWorkspace - ? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace) + ? getWorktreeAttachmentLabel(attachedWorkspace) : null return (
s.activeView) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const terminalWorktreeParkingInputsKey = useMemo( + () => getTerminalWorktreeParkingInputsKey(tabsByWorktree), + [tabsByWorktree] + ) const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) const terminalSshParkingEnabled = useAppStore((s) => s.settings?.terminalSshViewParking !== false) @@ -913,6 +918,7 @@ function Terminal(): React.JSX.Element | null { // Worktree cold-park policy: hiddenSince bookkeeping, parked-set selection, and one recheck timer per deadline so React re-renders when hysteresis elapses instead of polling. useEffect(() => { const parkingTimers = terminalWorktreeParkingTimersRef.current + const parkingTabsByWorktree = useAppStore.getState().tabsByWorktree for (const timer of parkingTimers.values()) { window.clearTimeout(timer) } @@ -975,7 +981,7 @@ function Terminal(): React.JSX.Element | null { retentionCandidates.push({ worktreeId, - terminalTabs: tabsByWorktree[worktreeId] ?? [], + terminalTabs: parkingTabsByWorktree[worktreeId] ?? [], isVisible, shouldMeasureHiddenWorktree, hasActivityTerminalPortal, @@ -1012,7 +1018,7 @@ function Terminal(): React.JSX.Element | null { }) // Why: a worktree with any watcher-uncoverable tab must never park, or it goes silent for bells/titles/completions (sank the first parking attempt). for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) { - if (!worktreeTabsAreWatcherCovered(worktreeId, tabsByWorktree[worktreeId] ?? [])) { + if (!worktreeTabsAreWatcherCovered(worktreeId, parkingTabsByWorktree[worktreeId] ?? [])) { nextParkedTerminalWorktreeIds.delete(worktreeId) } } @@ -1022,7 +1028,7 @@ function Terminal(): React.JSX.Element | null { // is the accepted cost of bounding retention. const retentionBudgetCandidates: TerminalWorktreeRetentionCandidate[] = retentionCandidates.map( (candidate) => { - const tabs = tabsByWorktree[candidate.worktreeId] ?? [] + const tabs = parkingTabsByWorktree[candidate.worktreeId] ?? [] const parkEligible = canParkTerminalWorktreeRenderers({ ...candidate, // Why nulled: the post-measure cool-down is a timing gate, not a @@ -1079,7 +1085,7 @@ function Terminal(): React.JSX.Element | null { const repos = useAppStore.getState().repos const nextEvictionExemptTabIds = new Set() for (const worktreeId of forceParkedWorktreeIds) { - const forceParkedTabs = tabsByWorktree[worktreeId] ?? [] + const forceParkedTabs = parkingTabsByWorktree[worktreeId] ?? [] const exemptTabIds = selectEvictionExemptTerminalTabIds(worktreeId, forceParkedTabs) for (const tabId of exemptTabIds) { nextEvictionExemptTabIds.add(tabId) @@ -1178,7 +1184,7 @@ function Terminal(): React.JSX.Element | null { pendingStartupByTabId, pairedRuntimeParkingEnvironmentIds, renderedActiveWorktreeId, - tabsByWorktree, + terminalWorktreeParkingInputsKey, terminalParkingEnabled, terminalParkingRevision, terminalProviderSnapshotCapabilityRevision, diff --git a/src/renderer/src/components/editor/monaco-view-state-persistence.test.ts b/src/renderer/src/components/editor/monaco-view-state-persistence.test.ts new file mode 100644 index 00000000000..60dfe48cdeb --- /dev/null +++ b/src/renderer/src/components/editor/monaco-view-state-persistence.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { editor, ISelection } from 'monaco-editor' +import { editorSelectionCache, scrollTopCache } from '@/lib/scroll-cache' +import { + installMonacoViewStateTracking, + restoreMonacoViewState, + snapshotMonacoViewState +} from './monaco-view-state-persistence' + +const selections: readonly ISelection[] = [ + { + selectionStartLineNumber: 2, + selectionStartColumn: 4, + positionLineNumber: 5, + positionColumn: 7 + }, + { + selectionStartLineNumber: 9, + selectionStartColumn: 3, + positionLineNumber: 7, + positionColumn: 2 + } +] + +beforeEach(() => { + editorSelectionCache.clear() + scrollTopCache.clear() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0) + return 1 + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Monaco view state persistence', () => { + it('restores selected ranges and their direction after a tab remount', () => { + const sourceEditor = { + getScrollTop: () => 320, + getSelections: () => selections + } as unknown as editor.IStandaloneCodeEditor + snapshotMonacoViewState({ current: sourceEditor }, 'file.ts::tab-1') + + const setSelections = vi.fn() + const setScrollTop = vi.fn() + const focus = vi.fn() + const remountedEditor = { + setSelections, + setScrollTop, + focus + } as unknown as editor.IStandaloneCodeEditor + restoreMonacoViewState(remountedEditor, 'file.ts::tab-1') + + expect(setSelections).toHaveBeenCalledWith(selections) + expect(setScrollTop).toHaveBeenCalledWith(320) + expect(focus).toHaveBeenCalledOnce() + }) + + it('defers selection caching until the lifecycle snapshot', () => { + let emitCursorPosition: + | ((event: { position: { lineNumber: number; column: number } }) => void) + | undefined + const editorInstance = { + getPosition: () => ({ lineNumber: 1, column: 1 }), + onDidChangeCursorPosition: ( + listener: (event: { position: { lineNumber: number; column: number } }) => void + ) => { + emitCursorPosition = listener + return { dispose: vi.fn() } + }, + onDidScrollChange: () => ({ dispose: vi.fn() }) + } as unknown as editor.IStandaloneCodeEditor + const setEditorCursorLine = vi.fn() + + installMonacoViewStateTracking({ + editorInstance, + filePath: 'file.ts', + viewStateKey: 'file.ts::tab-1', + scrollThrottleTimerRef: { current: null }, + setEditorCursorLine + }) + emitCursorPosition?.({ position: { lineNumber: 12, column: 3 } }) + + expect(setEditorCursorLine).toHaveBeenLastCalledWith('file.ts', 12) + expect(editorSelectionCache.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/editor/monaco-view-state-persistence.ts b/src/renderer/src/components/editor/monaco-view-state-persistence.ts index aeb6c0bb118..46272e9ee39 100644 --- a/src/renderer/src/components/editor/monaco-view-state-persistence.ts +++ b/src/renderer/src/components/editor/monaco-view-state-persistence.ts @@ -1,6 +1,6 @@ import type { MutableRefObject } from 'react' import type { editor } from 'monaco-editor' -import { scrollTopCache, cursorPositionCache, setWithLRU } from '@/lib/scroll-cache' +import { editorSelectionCache, scrollTopCache, setWithLRU } from '@/lib/scroll-cache' type MonacoViewStateTrackingParams = { editorInstance: editor.IStandaloneCodeEditor @@ -24,10 +24,6 @@ export function installMonacoViewStateTracking(params: MonacoViewStateTrackingPa } const cursorPositionSub = editorInstance.onDidChangeCursorPosition((e) => { setEditorCursorLine(filePath, e.position.lineNumber) - setWithLRU(cursorPositionCache, viewStateKey, { - lineNumber: e.position.lineNumber, - column: e.position.column - }) }) // Why: only the resting scroll position matters, so trailing-throttle writes (~150ms) instead of writing every 60fps frame. @@ -48,13 +44,13 @@ export function restoreMonacoViewState( editorInstance: editor.IStandaloneCodeEditor, viewStateKey: string ): void { - const savedCursor = cursorPositionCache.get(viewStateKey) + const savedSelections = editorSelectionCache.get(viewStateKey) const savedScrollTop = scrollTopCache.get(viewStateKey) - if (savedScrollTop !== undefined || savedCursor) { + if (savedScrollTop !== undefined || savedSelections) { // Why: Monaco renders synchronously so one RAF suffices; focus inside it to avoid a scroll-0 flash before restore. requestAnimationFrame(() => { - if (savedCursor) { - editorInstance.setPosition(savedCursor) + if (savedSelections) { + editorInstance.setSelections(savedSelections) } if (savedScrollTop !== undefined) { editorInstance.setScrollTop(savedScrollTop) @@ -74,12 +70,9 @@ export function snapshotMonacoViewState( const ed = editorRef.current if (ed) { setWithLRU(scrollTopCache, viewStateKey, ed.getScrollTop()) - const pos = ed.getPosition() - if (pos) { - setWithLRU(cursorPositionCache, viewStateKey, { - lineNumber: pos.lineNumber, - column: pos.column - }) + const selections = ed.getSelections() + if (selections) { + setWithLRU(editorSelectionCache, viewStateKey, selections) } } } diff --git a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts index cdf0d7f7cd2..56e25045353 100644 --- a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts +++ b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import * as monaco from 'monaco-editor' import type { OpenFile } from '@/store/slices/editor' import { - cursorPositionCache, + editorSelectionCache, diffViewStateCache, pdfViewPositionCache, scrollTopCache @@ -47,8 +47,8 @@ function disposeClosedEditorTab(prevId: string, prevFile: OpenFile): void { scrollTopCache.delete(`${prevFile.filePath}:rich`) scrollTopCache.delete(`${prevFile.filePath}:preview`) scrollTopCache.delete(`${prevFile.filePath}:mermaid-diagram`) - cursorPositionCache.delete(prevFile.filePath) - deleteCacheEntriesByPrefix(cursorPositionCache, `${prevFile.filePath}::`) + editorSelectionCache.delete(prevFile.filePath) + deleteCacheEntriesByPrefix(editorSelectionCache, `${prevFile.filePath}::`) // Why: only 'edit' tabs ever get a PDF scroll key (see EditorContent). sweepClosedPdfViewPositions(pdfViewPositionCache, prevFile.filePath) break diff --git a/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx index b3abb1c9853..71e580a21d5 100644 --- a/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx +++ b/src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx @@ -2,11 +2,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useAllWorktrees } from '@/store/selectors' import { useAppStore } from '@/store' -import { - findGithubIssueWorkspaceAttachment, - getGithubWorkItemWorkspaceAttachmentLabel -} from '@/lib/github-work-item-workspace-attachment' +import { findGithubIssueWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label' import { translate } from '@/i18n/i18n' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { GitHubItemDialogProps } from '../load-item-details/github-item-dialog-types' @@ -45,7 +43,7 @@ export default function GitHubItemDialog({ [allWorktrees, effectiveRepoId, workItem] ) const issueAttachedWorkspaceLabel = issueAttachedWorkspace - ? getGithubWorkItemWorkspaceAttachmentLabel(issueAttachedWorkspace) + ? getWorktreeAttachmentLabel(issueAttachedWorkspace) : null const handleOpenOrUseIssueWorkspace = useCallback( diff --git a/src/renderer/src/components/pull-request-page/page/surface.tsx b/src/renderer/src/components/pull-request-page/page/surface.tsx index 9267badff97..a299a0caa87 100644 --- a/src/renderer/src/components/pull-request-page/page/surface.tsx +++ b/src/renderer/src/components/pull-request-page/page/surface.tsx @@ -10,11 +10,9 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context' -import { - findGithubPrWorkspaceAttachment, - getGithubPrWorkspaceAttachmentLabel -} from '@/lib/github-work-item-workspace-attachment' +import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label' import { clearGitHubLinkCopied, createGitHubLinkCopyState, @@ -72,7 +70,7 @@ export default function PullRequestPage({ [allWorktrees, effectiveRepoId, workItem] ) const attachedWorkspaceLabel = attachedWorkspace - ? getGithubPrWorkspaceAttachmentLabel(attachedWorkspace) + ? getWorktreeAttachmentLabel(attachedWorkspace) : null // Why: key must include issue source preference so origin/upstream toggles for the same issue number don't read back the wrong repo's details. diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 9a0d4d11b1d..e116dedc2b0 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -310,7 +310,6 @@ function Settings(): React.JSX.Element { const settingsProjectHostSelection = useAppStore((s) => s.settingsProjectHostSelection) const settingsProjectSetupSelection = useAppStore((s) => s.settingsProjectSetupSelection) const setSettingsProjectHostSelection = useAppStore((s) => s.setSettingsProjectHostSelection) - const settingsSearchInputQuery = useAppStore((s) => s.settingsSearchInputQuery) const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery) const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery) const modelStates = useAppStore((s) => s.modelStates) @@ -1206,12 +1205,10 @@ function Settings(): React.JSX.Element { generalGroups={generalNavGroups} repoSections={repoNavSections} hasRepos={repos.length > 0} - searchQuery={settingsSearchInputQuery} searchInputRef={searchInputRef} // Why: deep-links open panes/modals that own focus; plain entry lands in search. searchAutoFocus={settingsNavigationTarget == null} onBack={closeSettingsPageWithPromptGuard} - onSearchChange={setSettingsSearchQuery} onSelectSection={scrollToSection} /> diff --git a/src/renderer/src/components/settings/SettingsSidebar.test.tsx b/src/renderer/src/components/settings/SettingsSidebar.test.tsx index c7125b1109e..b852fe6cfe3 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.test.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.test.tsx @@ -111,9 +111,7 @@ function renderSidebar( ]} repoSections={[]} hasRepos={false} - searchQuery="" onBack={vi.fn()} - onSearchChange={vi.fn()} onSelectSection={vi.fn()} /> diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 11f73b3016d..680b2f1ec61 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -18,6 +18,7 @@ import type { SettingsSetupGuideProgress } from './settings-setup-guide-progress import { translate } from '@/i18n/i18n' import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark' +import { useAppStore } from '@/store' type NavSection = { id: string @@ -46,11 +47,9 @@ type SettingsSidebarProps = { generalGroups: NavGroup[] repoSections: RepoNavSection[] hasRepos: boolean - searchQuery: string searchInputRef?: RefObject searchAutoFocus?: boolean onBack: () => void - onSearchChange: (query: string) => void onSelectSection: ( sectionId: string, modifiers: { @@ -62,6 +61,47 @@ type SettingsSidebarProps = { ) => void } +function SettingsSearchField({ + searchInputRef, + searchAutoFocus = false +}: Pick): React.JSX.Element { + const searchQuery = useAppStore((state) => state.settingsSearchInputQuery) + const onSearchChange = useAppStore((state) => state.setSettingsSearchQuery) + const searchShortcutCombos = useShortcutKeyComboDetails('settings.search') + + return ( +
+
+ + onSearchChange(event.target.value)} + placeholder={translate( + 'auto.components.settings.SettingsSidebar.dbceaa8840', + 'Search settings' + )} + className="bg-background/60 pl-9 pr-14 text-[13px]" + /> + {searchQuery === '' ? ( + + {searchShortcutCombos.map((combo) => ( + + ))} + + ) : null} +
+
+ ) +} + type VisibleInstallStatus = Extract< SettingsNavInstallStatus, 'update-available' | 'needs-attention' @@ -134,11 +174,9 @@ export function SettingsSidebar({ generalGroups, repoSections, hasRepos, - searchQuery, searchInputRef, searchAutoFocus = false, onBack, - onSearchChange, onSelectSection }: SettingsSidebarProps): React.JSX.Element { const setupGuideProgress = useSettingsSetupGuideProgress(true) @@ -152,7 +190,6 @@ export function SettingsSidebar({ // Settings should remain a stable place to reopen the checklist. const showSetupGuideTopRow = setupGuideProgress.ready && setupGuideProgress.doneCount < setupGuideProgress.total - const searchShortcutCombos = useShortcutKeyComboDetails('settings.search') const navItemClassName = (isActive: boolean): string => cn( 'flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-[13px] outline-none transition-colors duration-150 focus-visible:ring-[3px] focus-visible:ring-worktree-sidebar-ring/50', @@ -194,35 +231,7 @@ export function SettingsSidebar({
-
-
- - onSearchChange(event.target.value)} - placeholder={translate( - 'auto.components.settings.SettingsSidebar.dbceaa8840', - 'Search settings' - )} - className="bg-background/60 pl-9 pr-14 text-[13px]" - /> - {searchQuery === '' ? ( - - {searchShortcutCombos.map((combo) => ( - - ))} - - ) : null} -
-
+ {showSetupGuideTopRow ? (
diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index fbed90765b0..bf7eb3c758e 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -96,12 +96,14 @@ describe('sleep flow vs slept-workspace activation', () => { expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) }) - it('does not activate a slept worktree when VM resume fails', async () => { + it('keeps the slept worktree selected when VM resume fails', async () => { mocks.resumeWorkspace.mockRejectedValueOnce(new Error('provider unavailable')) await activateWorktreeFromSidebar('wt-parent') - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-parent', { + revealInSidebar: false + }) expect(mocks.toastError).toHaveBeenCalledWith( 'Failed to wake ephemeral VM workspace', expect.objectContaining({ description: 'provider unavailable' }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts index 422585644d9..c93294ba2f9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts @@ -210,7 +210,14 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) await new Promise((resolve) => setTimeout(resolve, 70)) + const reattachArgs = transport.connect.mock.calls.find( + ([args]) => args.sessionId === 'lost-pty' + )?.[0] + const launchToken = (reattachArgs?.env as Record | undefined) + ?.ORCA_AGENT_LAUNCH_TOKEN + expect(transport.sendInput).not.toHaveBeenCalled() + expect(launchToken).toMatch(new RegExp(`^${UUID_RE}$`)) expect(transport.connect).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'lost-pty', @@ -221,13 +228,13 @@ describe('connectPanePty', () => { ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'wt-1', ORCA_WORKSPACE_ID: 'wt-1', - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + ORCA_AGENT_LAUNCH_TOKEN: launchToken }) }) ) expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, { agentType: 'codex', - launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)), + launchToken, tabId: 'tab-1', leafId: LEAF_1 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts index e0abf8054e8..72b51ac3d01 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts @@ -382,6 +382,60 @@ describe('connectPanePty', () => { expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() }) + it('consumes a sole deferred SSH session id after reattach succeeds', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]), + deferredSshSessionIdsByTabId: { 'tab-1': 'saved-session' } + } + const deps = createDeps() + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() + await flushAsyncTicks(12) + + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'saved-session' }) + ) + expect(mockStoreState.removeDeferredSshSessionId).toHaveBeenCalledExactlyOnceWith('tab-1') + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'saved-session') + }) + + it('retains a sole deferred SSH session id when its replay is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('saved-session') + transport.connect.mockResolvedValueOnce({ + id: 'saved-session', + replay: 'restored output' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]), + deferredSshSessionIdsByTabId: { 'tab-1': 'saved-session' } + } + const pane = createPane(1) + const deps = createDeps() + let binding: ReturnType | null = null + pane.terminal.write.mockImplementation((_data, callback) => { + binding?.dispose() + callback?.() + }) + + binding = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(12) + + expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() + expect(mockStoreState.deferredSshSessionIdsByTabId['tab-1']).toBe('saved-session') + }) + it('spawns a fresh PTY when a deferred SSH session expired', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -418,6 +472,7 @@ describe('connectPanePty', () => { expect(transport.connect).toHaveBeenCalledTimes(2) expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'expired-session') expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'expired-session') + expect(mockStoreState.removeDeferredSshSessionId).toHaveBeenCalledWith('tab-1') expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'fresh-ssh-pty') expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-ssh-pty') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts index d08a68fae5d..ff55695c857 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts @@ -282,6 +282,78 @@ describe('connectPanePty', () => { ) }) + it('uses the SSH relay replay for a reconnect even when main has an alt-screen model', async () => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = toAppSshPtyId('target-a', 'pty-restored-relay') + const transport = createMockTransport(restoredPtyId) + transport.connect.mockResolvedValue({ + id: restoredPtyId, + isReattach: true, + replay: '9l\r\n$ real-shell-output' + }) + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-relay-restore', + authority: { + targetId: 'target-a', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ + data: 'MODEL-ALT-FRAME', + cols: 120, + rows: 40, + source: 'headless', + alternateScreen: true, + pendingEscapeTailAnsi: '\x1b[?104' + }) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [restoredPtyId] }, + repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }], + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry: vi.fn() + } + const pane = createPane(1) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId } + }) as never + ) + await flushAsyncTicks(20) + + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + expect(writes.join('')).toContain('real-shell-output') + expect(writes.join('')).not.toContain('MODEL-ALT-FRAME') + expect(transport.resize).not.toHaveBeenCalledWith(119, 40) + }) + it('rejects expired reattach state after its direct SSH retry lease is revoked', async () => { const { connectPanePty } = await import('./pty-connection') const restoredPtyId = toAppSshPtyId('target-a', 'pty-stale-reattach') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts index fb5a0a1142b..31867772ef7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts @@ -458,6 +458,78 @@ describe('connectPanePty', () => { }) }) + it.each([ + ['rejects', 'reject'], + ['returns no PTY', 'empty'] + ] as const)( + 'preserves a direct SSH binding and schedules another retry when reattach %s', + async (_description, outcome) => { + const { connectPanePty } = await import('./pty-connection') + const restoredPtyId = 'ssh:conn-1@@restored-session' + const transport = createMockTransport() + if (outcome === 'reject') { + transport.connect.mockRejectedValueOnce(new Error('relay attach timed out')) + } else { + transport.connect.mockResolvedValueOnce(undefined) + } + transportFactoryQueue.push(transport) + const pendingRetry = { + attemptId: 'attempt-generic-reattach-failure', + authority: { + targetId: 'conn-1', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + const settleDirectSshPaneRetry = vi.fn() + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }] + }, + ptyIdsByTabId: { 'tab-1': [] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([ + [ + 'conn-1', + { + targetId: 'conn-1', + status: 'connected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + deferredSshSessionIdsByTabId: { 'tab-1': restoredPtyId }, + directSshPaneRetryByTabId: { 'tab-1': pendingRetry }, + settleDirectSshPaneRetry + } as StoreState + const deps = createDeps() + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(12) + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.updateTabPtyId).not.toHaveBeenCalled() + expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() + expect(settleDirectSshPaneRetry).toHaveBeenCalledExactlyOnceWith({ + status: 'failed', + tabId: 'tab-1', + attemptId: pendingRetry.attemptId, + authority: pendingRetry.authority, + tabGeneration: pendingRetry.tabGeneration + }) + expect(mockStoreState.tabsByWorktree['wt-1']).toEqual([ + { id: 'tab-1', ptyId: null, generation: 7 } + ]) + expect(mockStoreState.deferredSshSessionIdsByTabId['tab-1']).toBe(restoredPtyId) + } + ) + it('reattaches via the tab-level SSH pty id when deferred bookkeeping missed the tab', async () => { // Why: restore can miss the deferred maps; the tab's SSH pty id must still drive connect-then-reattach, not a fresh spawn into a missing provider. const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8b8fbd0aca5..fbc51fc9c9d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,9761 +1,4 @@ -/* oxlint-disable max-lines */ -import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' -import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' -import type { IBuffer, IDisposable } from '@xterm/xterm' -import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' -import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations' -import { installTerminalImeCompositionRoute } from './terminal-ime-composition-route' -import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status' -import { reportWorkerTerminalUserInput } from '@/lib/worker-terminal-takeover-report' -import { resolvePaneTitleDecision } from './terminal-title-evidence' -import { blocksCodexPaneInput } from '../codex-restart-notice-state' -import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' -import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' -import { useAppStore } from '@/store' -import { getWorktreeMapFromState } from '@/store/selectors' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id' -import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' -import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' -import { restoredSnapshotPaintsPrintableContent } from './restored-snapshot-coverage' -import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' -import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' -import { isWorktreeRemovalFenceError } from '../../../../shared/worktree/removal-fence-error' -import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' -import { - HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS, - containsCsiRendererQuery, - containsStatefulRendererQuery, - extractHiddenStartupRendererQueryData, - findCsiFinalByteIndex, - isStatefulRendererReplyCsiQuery, - isStatelessRendererReplyCsiQuery -} from '../../../../shared/terminal-reply-query-extraction' -import { - deliverTerminalDataWithDeferredCredit, - takeCurrentTerminalDeliveryCredit -} from '@/lib/pane-manager/terminal-delivery-credit' -import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' -import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' -import type { PtyBufferSnapshot, PtyConnectResult, PtyReplayDataMeta } from './pty-transport' -import type { IpcPtyTransportOptions, PtyTransportRecoveryState } from './pty-transport-types' -import { createIpcPtyTransport } from './pty-transport' -import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' -import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation' -import { createUnresolvedOwnerPtyTransport } from './unresolved-owner-pty-transport' -import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { getConnectionId } from '@/lib/connection-context' -import { recordTerminalTabParkedOnUnresolvedHost } from '@/lib/parked-terminal-host-hydration' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { - getCachedWindowsTerminalCapabilities, - hasCachedWindowsTerminalCapabilities -} from '@/lib/windows-terminal-capabilities' -import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' -import { - shouldReconcileDeadSession, - shouldReconcileMissingSession, - type HasPty -} from './terminal-dead-session-reconcile' -import type { PtyConnectionDeps, PtyPaneStartup } from './pty-connection-types' -import { - createGitBashConsoleCapacityDetector, - type GitBashConsoleCapacityDetector -} from './git-bash-console-capacity' -import type { SessionRestoredBannerReason } from './session-restored-banner-pane-state' -import { - consumeCommittedPtyShutdownExit, - deferPtyShutdownExit, - isHostPtySleepPending -} from './pty-shutdown-exit-deferral' -import { - cancelPendingSafeFitContinuations, - safeFit, - safeFitAndThen, - type SafeFitContinuationHandle -} from '@/lib/pane-manager/pane-tree-ops' -import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' -import { - bindPanePtyId, - getFitOverrideForPty, - onOverrideChange -} from '@/lib/pane-manager/mobile-fit-overrides' -import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' -import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' -import { getDeleteStateForWorktreeHost } from '../sidebar/worktree-delete-state-host-match' -import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' -import { getAppliedSizeReadE2eDelayMs } from './pty-applied-size-read-e2e-delay' -import { createPtySizeReassertion } from './pty-size-reassertion' -import { - isPaneReplaying, - replayIntoTerminal, - replayIntoTerminalAsync, - waitForTerminalReplayWritesParsed -} from './replay-guard' -import { - isTerminalWritePipelineCertifiedDead, - registerUndeliverableWriteHandler, - requestTerminalWritePipelineProbe -} from '@/lib/pane-manager/terminal-write-pipeline-health' -import { - captureTerminalPaneRecoveryGeneration, - registerTerminalPaneRecoveryInstance, - requestTerminalPaneRecovery -} from './terminal-pane-recovery' -import { shouldDropQuarantinedTerminalInput } from './terminal-input-quarantine' -import { - isDocumentVisibilityProvenStale, - registerStaleDocumentVisibilityRecovery -} from './stale-document-visibility' -import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' -import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' -import { - nativeWindowsRewriteNeedsFollowupRenderRefresh, - terminalOutputPrefersRenderRefresh, - terminalRewriteOutputRenderRefreshDecision, - terminalRewriteOutputPrefersRenderRefresh, - windowsEastAsianOutputPrefersRenderRefresh -} from '@/lib/pane-manager/terminal-complex-script' -import { - PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, - queuePanePtyResizeIfHeld, - type PanePtyResizeHoldFlushDetail -} from '@/lib/pane-manager/pane-pty-resize-hold' -import { CSI_SEQUENCE_PATTERN } from '../../../../shared/ansi-escape-sequences' -import { - buildPostReplayLiveAgentReattachReset, - POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, - POST_REPLAY_LIVE_SNAPSHOT_RESET, - POST_REPLAY_MODE_RESET, - POST_REPLAY_REATTACH_RESET, - POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, - RESET_AFTER_BYTE_GAP, - RESET_GRAPHIC_RENDITION, - RESET_KITTY_KEYBOARD_PROTOCOL, - RESET_TERMINAL_CURSOR_STYLE -} from '../../../../shared/terminal-mode-reset-profiles' -import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' -import { getSystemPrefersDark } from '@/lib/terminal-theme' -import { - INITIAL_MODE_2031_REPLY_SCAN_STATE, - resolveTerminalColorSchemeMode, - scanMode2031ReplyDecision -} from '../../../../shared/terminal-color-scheme-protocol' -import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' -import { subscribeToTerminalUserInput } from './terminal-user-input-signal' -import { - hasPtySerializer, - registerPtySerializer, - registerPtyTitleSource -} from './pty-buffer-serializer' -import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' -// Why: a restored pane's stale-account prompt can only be raised once a PTY is -// actually attached — nothing is inspectable while the session hydrates. -import { notifyCodexPaneBoundForStaleSweep } from '@/lib/codex-stale-pane-sweep' -import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' -import { isHostAnsweredSnapshotRetryCause } from '@/runtime/remote-runtime-terminal-multiplexer' -import { - discardTerminalOutput, - flushTerminalOutput, - registerTerminalBacklogRecovery, - waitForTerminalOutputParsed, - writeTerminalOutput -} from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity' -import { - isLocalNativeWindowsConpty, - resolveWindowsShellOverride -} from '@/lib/pane-manager/windows-pty-compatibility' -import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' -import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' -import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' -import { - installTerminalLiveScrollbackRestore, - type TerminalLiveScrollbackRestore -} from '@/lib/pane-manager/terminal-live-scrollback-restore' -import { - enforceTerminalCurrentScrollIntent, - getTerminalScrollIntentKind, - markTerminalFollowOutput, - onTerminalScrollIntentFollowOutput -} from '@/lib/pane-manager/terminal-scroll-intent' -import { - cancelTerminalScrollIntentBufferRebuildCompletions, - deferTerminalGeometryMutationDuringRebuild -} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' -import { createTerminalStructuralReplayCoordinator } from '@/lib/pane-manager/terminal-structural-replay-coordinator' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' -import { - getProviderSessionClaimKey, - isPassiveCompletedHibernationEvidence -} from '@/lib/sleeping-agent-pane-ownership' -import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' -import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' -import { resolveSshPaneConnectGate } from './ssh-pane-connect-gate' -import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' -import { e2eConfig } from '@/lib/e2e-config' -import { - isFreshNonDoneAgentStatus, - type AgentStatusEntry, - type AgentType -} from '../../../../shared/agent-status-types' -import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' -import { - createAgentInterruptInference, - isCtrlCKeyEvent, - isPlainEscapeKeyEvent -} from './agent-interrupt-inference' -import { createAgentQuestionAnsweredInference } from './agent-question-answered-inference' -import { - AGENT_INTERRUPT_SETTLE_MS, - type AgentInterruptInputIntent -} from '../../../../shared/agent-interrupt-intent' -import { createAgentCompletionCoordinator } from './agent-completion-coordinator' -import { - dispatchAgentHookTerminalLifecycle, - registerAgentHookTerminalLifecycleHandler -} from './agent-hook-terminal-lifecycle' -import { - createCodexAutoApprovalHookCompletionSuppressor, - shouldSuppressCodexAutoApprovalSyntheticTitle, - shouldSuppressCodexAutoApprovalStatus -} from './codex-auto-approval-notification-suppression' -import type { - AgentCompletionDispatchMeta, - AgentCompletionStatusSnapshot -} from './agent-completion-coordinator-types' -import { - markTerminalBracketedPasteInterrupted, - observeTerminalBracketedPasteModeOutput -} from './terminal-bracketed-paste' -import { executeTerminalStartupCommandPaste } from './terminal-startup-command-paste' -import { - waitForStableStartupGrid, - type TerminalStartupGridSettleHandle -} from './terminal-startup-grid-settle' -import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' -import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' -import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' -import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' -import { createCodexBackfillErrorDetector } from './codex-backfill-error-detector' -import type { PtyDataMeta } from './pty-dispatcher' -import { getEagerPtyBufferHandle } from './pty-dispatcher' -import { - DeferredReattachLiveDataQueue, - MAX_DEFERRED_REATTACH_LIVE_CHARS -} from './deferred-reattach-live-data-queue' -import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' -import { - CONPTY_DA1_RESPONSE, - DEFAULT_DA1_RESPONSE, - createTerminalPixelSizeQueryResponder, - installTerminalCapabilityReplyHandlers, - sendTerminalOscColorQueryReplies -} from './terminal-capability-replies' -import { registerPtyModelRestoreNeededHandler } from './pty-model-restore-channel' -import { - acquireHiddenRendererPtyDeliveryClaim, - declareRendererPtyDeliveryVisible, - releaseRendererPtyVisibilityClaim, - setRendererPtyVisibilityClaim -} from './pty-renderer-delivery-claims' -import { - cancelScheduledHiddenOutputRestore, - scheduleHiddenOutputRestore -} from './hidden-output-restore-scheduler' -import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback' -import { - buildMainModelSnapshotReplayWrites, - hasPositiveTerminalDimensions, - readProposedTerminalCols, - resolvePositiveTerminalDimensions, - shouldSkipAltFrameForWidthMismatch -} from './terminal-snapshot-replay-paint' -import { - decideSshReattachPaintSource, - lastAlternateScreenTransition, - memoizeSshReattachModelSnapshotProbe, - resolveSshReattachModelSnapshotWithTimeout, - shouldFetchSshReattachModelSnapshot, - sshReconnectPaintsFromModel -} from './ssh-reattach-model-restore' -import { readInFlightCommandCodeTurn } from './parked-terminal-command-status' -import { - cancelCommandCodeDoneSettle, - openCommandCodeDoneSettle, - setCommandCodeDoneSettleExecutor -} from './command-code-done-settle' -import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' -import { - getExecutionHostIdForWorktree, - getSettingsForWorktreeRuntimeOwner -} from '@/lib/worktree-runtime-owner' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' -import { resolveAgentResumeLaunchTarget } from '@/lib/agent-resume-launch-target' -import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../../../shared/tui-agent-launch-defaults' -import { - agentProviderSessionsEqual, - isResumableTuiAgent, - normalizeAgentProviderSession, - type AgentProviderSessionMetadata, - type ResumableTuiAgent, - type SleepingAgentSessionRecord -} from '../../../../shared/agent-session-resume' -import { - normalizeCompatibleAgentTitleForOwner, - resolveCompatibleAgentTypeForOwner -} from '../../../../shared/agent-title-owner' -import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' -import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' -import { - isExpectedAgentProcess, - recognizeAgentProcessFromCommandLine -} from '../../../../shared/agent-process-recognition' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' -import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' -import { resolveDraftPasteReadyTimeoutMs } from '../../../../shared/draft-paste-ready-timeout' -import { createDraftPasteReadyScanner } from '../../../../shared/draft-paste-ready-scanner' -import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' -import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer' -import { - beginAgentStartupDeliveryAttempt, - releaseAgentStartupDeliveryAttempt -} from '@/lib/agent-startup-delayed-delivery' -import { - AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, - AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS, - canDispatchAgentNotificationAfterGrace, - isAgentTaskCompleteOsNotificationEnabledFromState, - isAgentTaskCompleteTrackingEnabledFromState -} from './agent-task-complete-policy' -import { - isMainTerminalSideEffectAuthorityForPty, - registerTerminalSideEffectFactConsumer -} from './terminal-side-effect-facts-handler' -import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' -import { - markRendererOwnedAgentStatusWrite, - registerRendererOwnedAgentStatusPane -} from './renderer-owned-agent-status-registry' -import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery' -import { directSshAuthoritiesEqual } from '@/store/slices/direct-ssh-terminal-authority-ledger' +import { connectPanePty } from './pty-connection/connect-pane-pty' +import { STARTUP_CWD_FALLBACK_NOTICE } from './pty-connection/startup-cwd-fallback-notice' -const pendingSpawnByPaneKey = new Map>() -const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' -const PANE_OWNER_UNVERIFIED_ERROR = 'terminal_pane_owner_unverified' -// Why: relay requests expire at 30s; leave one second for their fallback before re-arming locally. -const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000 -const REMOTE_PTY_ID_PREFIX = 'remote:' -const PTY_CONNECT_DIAG_LIMIT = 200 -const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 -const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 -// Why: the notice deliberately omits the rejected path — saved cwds can -// contain private repo/user names; the terminal itself shows where it opened. -export const STARTUP_CWD_FALLBACK_NOTICE = - '\r\n[Orca opened this terminal at the workspace root because its saved start folder no longer exists.]\r\n' -const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 -const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 -const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 -const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750 -// Why (rc.7.perf DSR-timeout feedback loop): under a foreground flood the -// restore pipeline is its own bottleneck — each synchronous snapshot replay -// starves ACK processing, main pins at the in-flight cap, drops at the -// pending cap, and every drop marker re-armed another restore until the -// flood ended. Backpressure evidence opens this suppression window: drop -// markers inside it must not re-arm restores; live bytes write through and -// ONE deferred repaint (when the window closes) heals the visual gap. -const HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS = 2000 -// Backstop for the same loop: a single in-flight restore task may re-iterate -// (fresh-snapshot marks, unmappable slices) only this many times before it -// abandons and lets live bytes flow. -const HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS = 3 -// Why: remote-runtime PTYs have no local main fallback — the host transport is -// the only recovery and legitimately answers null while it resyncs, trims a -// flooded snapshot, or waits out link RTT. Those nulls are not proof of loss, -// so an abandoned restore re-arms one quiet post-suppression repaint instead of -// claiming the bytes are gone. Five cycles (~2.15s each) outlast both the -// multiplexer resync window and the remote snapshot request timeout (10s); -// past that the host really is unreachable and the loss banner is honest. -const HIDDEN_OUTPUT_RESTORE_REMOTE_REARM_MAX = 5 -// Why: the host declined seven separate times; each one cost it a real serialize attempt, so stop asking. -const HIDDEN_OUTPUT_RESTORE_REMOTE_OUTCOME_MAX_ATTEMPTS = 7 -// Why separate and larger: these causes are decided before any frame leaves the -// client (resync gate, occupied request lane, detached stream), so the host -// declined nothing and charging them to its budget would banner a healthy pane — -// the exact elapsed-time guess this change removes. They cost zero host traffic, -// so the only job of this cap is termination. At the ~2s post-abandon re-arm -// cadence, 30 outlasts several full 10s resync watchdog cycles. -const HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS = 30 -const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 -const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' -const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' -const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 -const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' -const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' -const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' -const TERMINAL_FOCUS_OUT_SEQUENCE = '\x1b[O' -const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' -const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 -const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350 -const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 -const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024 -const FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS = 150 -// Why: a submit repaint can take longer than one keystroke echo to fully -// arrive, so a synchronized frame that *began* this close to a keystroke stays -// latency-sensitive even when ConPTY splits its end marker past the redraw -// window — the keystroke is the "user is here, paint now" signal, not the -// late closing chunk. -const FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS = 400 -// Why: OpenTUI can emit many tiny redraws that each look interactive but -// collectively starve timers unless foreground writes have a rolling budget. -const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024 -const FOREGROUND_BUDGET_WINDOW_MS = 500 -const INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS = 32 * 1024 -const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250 -// Why: this is only shown if hidden renderer output was skipped and main-owned -// terminal state is unavailable, so the user has an explicit loss signal. -const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = - '\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n' -type E2eTerminalPtyDataInjectionApi = { - inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean - keys: () => string[] -} - -type TerminalWithFocusMode = { - textarea?: HTMLTextAreaElement | null - modes?: { - sendFocusMode?: boolean - } -} - -type TerminalWithInspectableBuffer = { - cols: number - rows: number - buffer?: { - active?: IBuffer - } -} - -// Why: replay bytes can carry a dead run's screen in scrollback — or still -// painted in the viewport with a shell prompt below it — so once xterm has -// parsed the replay the confirmation needs both the cursor-agent screen shape -// AND the parked cursor. A dead screen leaves the shell cursor after its -// prompt; a live agent that needs the focus-in is by definition parked, and a -// live agent that is not parked only loses focus reporting the way the -// pre-fix reattach always did. Returns null when the buffer is not -// inspectable (e.g. test doubles). -function parsedViewportShowsParkedCursorAgentScreen( - terminal: TerminalWithInspectableBuffer -): boolean | null { - const buffer = terminal.buffer?.active - if ( - !buffer || - typeof buffer.getLine !== 'function' || - typeof buffer.cursorX !== 'number' || - typeof buffer.cursorY !== 'number' - ) { - return null - } - return ( - resolveCursorAgentImeAnchor({ - buffer, - rows: terminal.rows, - cols: terminal.cols, - cursorX: buffer.cursorX, - cursorY: buffer.cursorY - }) !== null - ) -} - -function terminalHasFocusReportingEnabled(terminal: TerminalWithFocusMode): boolean { - return terminal.modes?.sendFocusMode === true -} - -function terminalOwnsDomFocus(terminal: TerminalWithFocusMode): boolean { - if (typeof document === 'undefined' || !terminal.textarea) { - return false - } - return document.activeElement === terminal.textarea -} - -const CURSOR_AGENT_REATTACH_HEADER = 'Cursor Agent' -const CURSOR_AGENT_REATTACH_INPUT_MARKER = '→' -const CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS = 5000 -// Why bounded: reattach payloads reach multiple MB, but every replay puts the current screen last -// and only the header nearest the end matters. 256KB clears even a fully SGR-styled frame by ~2x, -// so the cut only ever drops stale scrollback — which would have been rejected anyway. -const CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS = 256 * 1024 - -function hasCursorAgentReattachPayloadScreenSignal(data: string): boolean { - const tail = - data.length > CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS - ? data.slice(-CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS) - : data - // Why CSI only: OSC-carried titles must keep counting as a header occurrence, as they did when - // this stripped CSI by hand. - const normalized = tail.replace(CSI_SEQUENCE_PATTERN, '') - // Why: anchor on the LAST header occurrence — replay buffers keep scrollback, - // and an earlier finished run must not classify the current screen. - const headerIndex = normalized.lastIndexOf(CURSOR_AGENT_REATTACH_HEADER) - if (headerIndex === -1) { - return false - } - const screenTail = normalized.slice( - headerIndex + CURSOR_AGENT_REATTACH_HEADER.length, - headerIndex + CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS - ) - return screenTail.includes(`${CURSOR_AGENT_REATTACH_INPUT_MARKER} `) -} - -type E2eTerminalPtyDataInjectionWindow = Window & { - __terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi - __terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi -} - -const e2eTerminalPtyDataInjectors = new Map void>() - -type E2eTerminalHiddenSnapshotOverrideApi = { - setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void - resolve: (ptyId: string) => void - clear: (ptyId: string) => void -} - -type E2eTerminalHiddenSnapshotOverride = { - promise: Promise - resolve: () => void -} - -const e2eTerminalHiddenSnapshotOverrides = new Map() - -// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes -// either never reach the renderer (delivery gate) or ride the background -// scheduler queue. -type E2eTerminalPtyOutputDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number -} - -type E2eTerminalPtyOutputDebugApi = { - reset: () => void - snapshot: () => E2eTerminalPtyOutputDebugSnapshot -} - -type E2eTerminalPtyOutputDebugWindow = Window & { - __terminalPtyOutputDebug?: E2eTerminalPtyOutputDebugApi -} - -type PendingStartupCommand = { - command: string - env?: Record -} - -type FreshSpawnOptions = { - forceBlankRestoredViewport?: boolean -} - -type ColdRestoreAgentResumeStartup = PendingStartupCommand & { - agent: ResumableTuiAgent - resumeProviderSession: AgentProviderSessionMetadata - launchConfig: NonNullable>['launchConfig'] - launchToken: string - useLiveEntry: boolean - hasSleepingRecord: boolean - sleepingRecordEntry: { paneKey: string; record: SleepingAgentSessionRecord } | null -} - -const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = { - hiddenRendererSkipCount: 0, - hiddenRendererSkippedChars: 0 -} - -function resetE2eTerminalPtyOutputDebug(): void { - e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount = 0 - e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars = 0 -} - -function exposeE2eTerminalPtyOutputDebug(): void { - if (!e2eConfig.exposeStore || typeof window === 'undefined') { - return - } - const target = window as E2eTerminalPtyOutputDebugWindow - target.__terminalPtyOutputDebug ??= { - reset: resetE2eTerminalPtyOutputDebug, - snapshot: () => ({ ...e2eTerminalPtyOutputDebugState }) - } -} - -function recordHiddenRendererSkip(chars: number): void { - if (!e2eConfig.exposeStore) { - return - } - exposeE2eTerminalPtyOutputDebug() - e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount += 1 - e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars += chars -} - -function exposeE2eTerminalPtyDataInjection(): void { - if (!e2eConfig.exposeStore || typeof window === 'undefined') { - return - } - // Why: a real PTY can coalesce tiny TUI redraws before E2E sees them. This - // e2e-only seam lets tests replay the renderer-side data callback exactly. - const target = window as E2eTerminalPtyDataInjectionWindow - target.__terminalPtyDataInjection ??= { - inject: (paneKey, data, meta) => { - const inject = e2eTerminalPtyDataInjectors.get(paneKey) - if (!inject) { - return false - } - inject(data, meta) - return true - }, - keys: () => [...e2eTerminalPtyDataInjectors.keys()] - } - target.__terminalHiddenSnapshotOverride ??= { - setPending: (ptyId, snapshot) => { - let resolve = (): void => {} - const wait = new Promise((nextResolve) => { - resolve = nextResolve - }) - e2eTerminalHiddenSnapshotOverrides.set(ptyId, { - promise: wait.then(() => snapshot), - resolve - }) - }, - resolve: (ptyId) => { - e2eTerminalHiddenSnapshotOverrides.get(ptyId)?.resolve() - }, - clear: (ptyId) => { - e2eTerminalHiddenSnapshotOverrides.delete(ptyId) - } - } -} - -function registerE2eTerminalPtyDataInjection( - paneKey: string, - inject: (data: string, meta?: PtyDataMeta) => void -): () => void { - if (!e2eConfig.exposeStore) { - return () => {} - } - exposeE2eTerminalPtyDataInjection() - e2eTerminalPtyDataInjectors.set(paneKey, inject) - return () => { - if (e2eTerminalPtyDataInjectors.get(paneKey) === inject) { - e2eTerminalPtyDataInjectors.delete(paneKey) - } - } -} - -function readE2eHiddenSnapshotOverride(ptyId: string): Promise | null { - if (!e2eConfig.exposeStore) { - return null - } - const override = e2eTerminalHiddenSnapshotOverrides.get(ptyId) - if (!override) { - return null - } - // Why: visual E2E needs to hold a hidden restore snapshot in flight so a - // newer live TUI frame can race it deterministically. - return override.promise.finally(() => { - if (e2eTerminalHiddenSnapshotOverrides.get(ptyId) === override) { - e2eTerminalHiddenSnapshotOverrides.delete(ptyId) - } - }) -} - -function shouldKeepHiddenStartupRendererQueriesLive( - startup: PtyConnectionDeps['startup'] -): boolean { - return ( - Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') || - isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '') - ) -} - -function containsHiddenStartupRendererQuery(data: string): boolean { - // Why: hidden Codex startup must not live-render ordinary redraw floods, but - // query chunks still need xterm's built-in terminal replies to unblock TUIs. - return containsCsiRendererQuery(data) || data.includes('\x1b]10;?') || data.includes('\x1b]11;?') -} - -let codexRestartNoticePresenceSource: Record< - string, - { previousAccountLabel: string; nextAccountLabel: string } -> | null = null -let codexRestartNoticePresence = false -let inactiveForegroundImmediateBudgetChars = 0 -let inactiveForegroundImmediateBudgetWindowStart = 0 - -type PanePtyBinding = IDisposable & { - syncProcessTracking: () => void - noteVisibilityResume: () => void - reassertPtySizeAfterWindowWake: () => void - /** Navigation-free hibernation wake: fires the armed cold-restore --resume - * without the size-reassert/foreground-sample side effects of a real reveal. - * Used by the mobile wake fanout so a hidden hibernated pane resumes with no - * desktop hidden→visible transition. Returns the sleeping record's provider - * session claim key when this pane started (or latched) the in-place wake, - * so the follow-up generic resume never launches the same session twice. */ - wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null - /** Re-sample process identity when the pane gains intra-tab focus: the tab - * icon follows the active leaf, and a shell-marked entry on a still-running - * agent pane has no OSC boundary left to correct it. */ - sampleForegroundAgentOnFocus: () => void - /** Reconfirm after direct shortcut input, which bypasses PTY onData. */ - requestWindowsShiftEnterReconfirmation: () => void - /** Refresh interactive redraw scheduling after captured shortcut input. */ - markShortcutTerminalInputSent: () => void - reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void - reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void -} - -function isAgentTaskCompleteNotificationEnabled(): boolean { - return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState()) -} - -function isAgentTaskCompleteTrackingEnabled(): boolean { - return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState()) -} - -const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>() -let agentTaskCompleteTrackingSettingsUnsubscribe: (() => void) | null = null -let agentTaskCompleteTrackingSettingsSnapshot: string | null = null - -function getAgentTaskCompleteTrackingSettingsSnapshot( - state: ReturnType -): string { - return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}` -} - -function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void { - if (agentTaskCompleteTrackingSettingsUnsubscribe === null) { - agentTaskCompleteTrackingSettingsSnapshot = getAgentTaskCompleteTrackingSettingsSnapshot( - useAppStore.getState() - ) - agentTaskCompleteTrackingSettingsUnsubscribe = useAppStore.subscribe((state) => { - const snapshot = getAgentTaskCompleteTrackingSettingsSnapshot(state) - if (snapshot === agentTaskCompleteTrackingSettingsSnapshot) { - return - } - agentTaskCompleteTrackingSettingsSnapshot = snapshot - for (const subscriber of Array.from(agentTaskCompleteTrackingEnabledListeners)) { - subscriber() - } - }) - } - - agentTaskCompleteTrackingEnabledListeners.add(listener) - return () => { - agentTaskCompleteTrackingEnabledListeners.delete(listener) - if ( - agentTaskCompleteTrackingEnabledListeners.size === 0 && - agentTaskCompleteTrackingSettingsUnsubscribe !== null - ) { - agentTaskCompleteTrackingSettingsUnsubscribe() - agentTaskCompleteTrackingSettingsUnsubscribe = null - agentTaskCompleteTrackingSettingsSnapshot = null - } - } -} - -function recordPtyConnectDiagnostic(message: string): void { - if (!e2eConfig.exposeStore) { - return - } - console.log(`[pty-connect] ${message}`) - const target = globalThis as Record - const diag = (target.__ptyConnectDiag ??= [] as string[]) as string[] - diag.push(message) - if (diag.length > PTY_CONNECT_DIAG_LIMIT) { - diag.splice(0, diag.length - PTY_CONNECT_DIAG_LIMIT) - } -} - -// Why: when multiple panes/tabs need the same deferred SSH connection, -// the first one calls ssh.connect() and subsequent ones must wait for it -// rather than returning early (which would leave them disconnected). This -// helper either connects or waits for an in-flight connect to finish. -type SshConnectResult = { connected: true } | { connected: false; error: string } -type UserInitiatedSshConnectOutcome = 'connected' | 'cancelled' | 'failed' - -const sshConnectPromises = new Map>() - -function isSshSessionExpiredError(err: unknown): boolean { - return (err instanceof Error ? err.message : String(err)).includes(SSH_SESSION_EXPIRED_ERROR) -} - -function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { - return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX) -} - -function canRestorePairedParkedTerminal(ptyId: string): boolean { - const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId) - return ( - environmentId !== null && - useAppStore - .getState() - .runtimeStatusByEnvironmentId.get(environmentId) - ?.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) === true - ) -} - -function consumeInactiveForegroundImmediateBudget(dataLength: number): boolean { - const now = performance.now() - if (now - inactiveForegroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) { - inactiveForegroundImmediateBudgetChars = 0 - inactiveForegroundImmediateBudgetWindowStart = now - } - if ( - inactiveForegroundImmediateBudgetChars + dataLength > - INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS - ) { - return false - } - inactiveForegroundImmediateBudgetChars += dataLength - return true -} - -function hasCodexRestartNotices( - noticesByPtyId: Record -): boolean { - if (codexRestartNoticePresenceSource !== noticesByPtyId) { - codexRestartNoticePresenceSource = noticesByPtyId - codexRestartNoticePresence = Object.keys(noticesByPtyId).length > 0 - } - return codexRestartNoticePresence -} - -function sshPromptConnectOutcomeForStatus( - status: string | undefined, - sawNonDisconnected: boolean -): UserInitiatedSshConnectOutcome | null { - if (status === 'connected') { - return 'connected' - } - if (status === 'auth-failed' || status === 'error' || status === 'reconnection-failed') { - return 'failed' - } - // Why: this only counts after a real connect attempt; the entry-time - // disconnected state just means the user still needs to initiate auth. - if (sawNonDisconnected && status === 'disconnected') { - return 'cancelled' - } - return null -} - -async function waitForSshConnection(connectionId: string): Promise { - const state = useAppStore.getState().sshConnectionStates.get(connectionId) - if (state?.status === 'connected') { - return { connected: true } - } - - const existing = sshConnectPromises.get(connectionId) - if (existing) { - return existing - } - - const promise: Promise = (async (): Promise => { - try { - await window.api.ssh.connect({ targetId: connectionId }) - return { connected: true } - } catch (err) { - console.warn(`Deferred SSH reconnect failed for ${connectionId}:`, err) - return { - connected: false, - error: err instanceof Error ? err.message : String(err) - } - } finally { - sshConnectPromises.delete(connectionId) - } - })() - - sshConnectPromises.set(connectionId, promise) - return promise -} - -function isCodexPaneStale(args: { - tabId: string - worktreeId: string - panePtyId: string | null -}): boolean { - const state = useAppStore.getState() - const { codexRestartNoticeByPtyId } = state - if (!hasCodexRestartNotices(codexRestartNoticeByPtyId)) { - return false - } - // Why: a bound pane's own record is the last word — its ptyId is exactly the - // shell its keystrokes reach. `tab.ptyId` holds one sibling's id, not this - // pane's, so in a split tab consulting it would kill this pane's keyboard over - // that sibling's notice, with no prompt on screen once the sibling is answered. - if (args.panePtyId) { - return blocksCodexPaneInput(codexRestartNoticeByPtyId[args.panePtyId]) - } - - // Why: only an unbound pane needs the tab's persisted id. Both transports - // refuse writes while their pty binding is null, so a keystroke here arms - // recovery — a coarser signal than a bound pane's own record, but the tab's id - // is the only evidence available of which shell this pane is about to own. - const tab = (state.tabsByWorktree[args.worktreeId] ?? []).find((entry) => entry.id === args.tabId) - if (tab?.ptyId && blocksCodexPaneInput(codexRestartNoticeByPtyId[tab.ptyId])) { - return true - } - - return false -} - -// Why: daemon session IDs use the format `${worktreeId}@@${shortUuid}`. -// This validates that a session ID actually belongs to the given worktree, -// preventing cross-workspace contamination during restore. -function isSessionOwnedByWorktree(sessionId: string, worktreeId: string): boolean { - const separatorIdx = sessionId.lastIndexOf('@@') - if (separatorIdx === -1) { - return true - } - return sessionId.slice(0, separatorIdx) === worktreeId -} - -function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { - if (!isPaneVisible) { - return false - } - if (typeof document === 'undefined') { - return true - } - // Why: Electron can keep visible panes mounted while the whole app is - // backgrounded. Treat hidden documents like background tabs so Chromium - // timer throttling cannot pin terminal writes on the renderer foreground path. - if (document.visibilityState === 'visible') { - return true - } - // Why: macOS occlusion tracking can wedge visibilityState at 'hidden' after - // display sleep; proven-stale means real user input contradicted it, so the - // hidden-delivery gate must not keep dropping a watched pane's bytes. - return isDocumentVisibilityProvenStale() -} - -type SynchronizedForegroundScan = { - started: boolean - ended: boolean - active: boolean - markerTail: string -} - -// Why the carried tail: ConPTY can split \x1b[?2026l across chunks; scanning the raw -// chunk alone left the foreground DEC 2026 latch stuck open so every later chunk was -// held instead of coalesced, freezing the visible pane (#8754). Mirrors the hidden path. -function scanSynchronizedForegroundOutput( - data: string, - markerTail: string, - wasActive: boolean -): SynchronizedForegroundScan { - const scanData = markerTail ? `${markerTail}${data}` : data - const currentChunkStartIndex = scanData.length - data.length - let active = wasActive - let started = false - let ended = false - let offset = 0 - - while (offset < scanData.length) { - const startIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE, offset) - const endIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE, offset) - if (startIndex === -1 && endIndex === -1) { - break - } - if (endIndex !== -1 && (startIndex === -1 || endIndex < startIndex)) { - active = false - if (endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length > currentChunkStartIndex) { - ended = true - } - offset = endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length - continue - } - active = true - if (startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length > currentChunkStartIndex) { - started = true - } - offset = startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - } - - return { - started, - ended, - active, - // Why length-1: a full marker can never hide in the tail, so no marker is counted twice. - markerTail: scanData.slice(-SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS) - } -} - -function containsCursorPositionSequence(data: string): boolean { - let offset = data.indexOf('\x1b[') - while (offset !== -1) { - let index = offset + 2 - while (index < data.length) { - const char = data[index] - if (char === 'G' || char === 'H' || char === 'f') { - return true - } - if ((char < '0' || char > '9') && char !== ';') { - break - } - index += 1 - } - offset = data.indexOf('\x1b[', offset + 2) - } - return false -} - -function containsCursorRestore(data: string): boolean { - const hideIndex = data.indexOf(CURSOR_HIDE_SEQUENCE) - const showIndex = data.lastIndexOf(CURSOR_SHOW_SEQUENCE) - return hideIndex !== -1 && showIndex > hideIndex && containsCursorPositionSequence(data) -} - -const SPLIT_GEOMETRY_EPSILON_PX = 1 - -function readElementRect(element: HTMLElement | null | undefined): DOMRect | null { - try { - return element?.getBoundingClientRect?.() ?? null - } catch { - return null - } -} - -function hasVisibleRect(rect: DOMRect | null): rect is DOMRect { - return Boolean( - rect && rect.width > SPLIT_GEOMETRY_EPSILON_PX && rect.height > SPLIT_GEOMETRY_EPSILON_PX - ) -} - -function readProposedPaneGrid(pane: ManagedPane): { cols: number; rows: number } | null { - try { - const dimensions = pane.fitAddon.proposeDimensions() - if (!dimensions || dimensions.cols <= 0 || dimensions.rows <= 0) { - return null - } - return dimensions - } catch { - return null - } -} - -function isPaneGridAlignedWithFit(pane: ManagedPane): boolean { - const proposed = readProposedPaneGrid(pane) - return Boolean( - proposed && pane.terminal.cols === proposed.cols && pane.terminal.rows === proposed.rows - ) -} - -function isSetupSplitGeometryReady( - pane: ManagedPane, - manager: PaneManager, - direction: SetupSplitDirection -): boolean { - const splitElement = pane.container.parentElement - const directionClass = direction === 'vertical' ? 'is-vertical' : 'is-horizontal' - if ( - !splitElement?.classList?.contains('pane-split') || - !splitElement.classList.contains(directionClass) - ) { - return false - } - - const sibling = manager - .getPanes() - .find( - (candidate) => candidate.id !== pane.id && candidate.container.parentElement === splitElement - ) - const splitRect = readElementRect(splitElement) - const paneRect = readElementRect(pane.container) - const siblingRect = readElementRect(sibling?.container) - if (!hasVisibleRect(splitRect) || !hasVisibleRect(paneRect) || !hasVisibleRect(siblingRect)) { - return false - } - - const splitAxis = direction === 'vertical' ? splitRect.width : splitRect.height - const paneAxis = direction === 'vertical' ? paneRect.width : paneRect.height - const siblingAxis = direction === 'vertical' ? siblingRect.width : siblingRect.height - return ( - paneAxis > SPLIT_GEOMETRY_EPSILON_PX && - siblingAxis > SPLIT_GEOMETRY_EPSILON_PX && - splitAxis - paneAxis > SPLIT_GEOMETRY_EPSILON_PX && - splitAxis - siblingAxis > SPLIT_GEOMETRY_EPSILON_PX && - isPaneGridAlignedWithFit(pane) - ) -} - -/** Start time of the newest completed turn, counting turns already folded into history. - * Why: batched publications coalesce done→working→done into a single store notification, - * so the done EDGE survives only in stateHistory; comparing `state` alone misses it. */ -function resolveLatestAgentDoneStartedAt(entry: AgentStatusEntry | undefined): number | undefined { - if (!entry) { - return undefined - } - if (entry.state === 'done') { - return entry.stateStartedAt - } - const history = entry.stateHistory ?? [] - for (let index = history.length - 1; index >= 0; index -= 1) { - if (history[index].state === 'done') { - return history[index].startedAt - } - } - return undefined -} - -/** - * Establishes a binding between a terminal pane and its corresponding PTY stream, - * managing input, output, title synchronization, and agent status tracking. - */ -export function connectPanePty( - pane: ManagedPane, - manager: PaneManager, - deps: PtyConnectionDeps -): PanePtyBinding { - const shouldRefreshForegroundSynchronously = (): boolean => !manager.hasWebglRenderer(pane.id) - // Why: recovery ownership belongs to this xterm instance. A request that - // settles after remount must not remount its already-replaced successor. - const terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(deps.tabId) - const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId) - let mountFollowsTerminalPark = deps.mountFollowsTerminalPark - let authoritativeReattachGeneration = 0 - exposeE2eTerminalPtyOutputDebug() - let disposed = false - const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal) - let connectFrame: number | null = null - let connectFallbackTimer: ReturnType | null = null - let startupGridSettleHandle: TerminalStartupGridSettleHandle | null = null - let startupGridSettledForConnect = false - let connectStarted = false - let unregisterBacklogRecovery: (() => void) | null = null - let unregisterDocumentVisibilityRecovery: (() => void) | null = null - let cancelHiddenOutputSnapshotScrollRestore = (): void => {} - let pendingHiddenSnapshotFit: SafeFitContinuationHandle | null = null - let pendingReattachFit: SafeFitContinuationHandle | null = null - let cancelFreshSpawnFollowReset = (): void => {} - let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} - let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} - let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} - let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} - let cleanupStartupDraftPasteTimers = (): void => {} - let unregisterE2ePtyDataInjection = (): void => {} - let startupInjectTimer: ReturnType | null = null - let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null - let agentTaskCompleteNotificationMaxTimer: ReturnType | null = null - let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null - let agentTaskCompleteSettingsUnsubscribe: (() => void) | null = null - let agentTaskCompleteNotificationGeneration = 0 - let wasAgentTaskCompleteTrackingEnabled = isAgentTaskCompleteTrackingEnabled() - let requiresFreshWorkingForAgentTaskCompleteNotification = !wasAgentTaskCompleteTrackingEnabled - let wasAgentTaskCompleteOsNotificationEnabled = isAgentTaskCompleteNotificationEnabled() - let terminalBellNotificationTimer: ReturnType | null = null - let pendingTerminalBellNotification = false - let reattachIdleAgentCursorResetTimer: ReturnType | null = null - let alternateScreenBackgroundRepaintTimer: ReturnType | null = null - let shiftEnterReconfirmTimer: ReturnType | null = null - let synchronizedForegroundOutputActive = false - // Why: carries up to one marker-length-1 of trailing bytes so a ConPTY-split DEC 2026 marker is still detected (#8754). - let synchronizedForegroundMarkerTail = '' - // Why: tracks the keystroke proximity captured when the current synchronized - // foreground frame opened, so a split end marker that lands after the redraw - // window still drains on the fast path instead of the 1s coalesce fallback. - let synchronizedForegroundFrameInteractive = false - let suppressStructuralReplayPtyResize = false - // Why: hidden-delivery gate sync is wired up alongside the deferred PTY - // output plumbing inside the connect frame; lifecycle hooks (visibility - // flips, exit, dispose) run before/after it exists, so start with no-ops. - let syncHiddenRendererPtyDelivery: () => void = () => {} - let releaseHiddenRendererPtyDelivery: () => void = () => {} - let handleRemoteOutputPauseChanged: (paused: boolean, supported: boolean) => void = () => {} - let handleRendererOwnedAgentStatus: NonNullable< - IpcPtyTransportOptions['onAgentStatus'] - > = () => {} - let remoteOutputGatedPtyId: string | null = null - let remoteOutputFactConsumerPtyId: string | null = null - let suppressViewportClaimTerminalResize = false - // Why: idle callbacks are registered before the deferred PTY output plumbing - // exists. Start with the shared scheduler, then switch to the PTY writer - // below so hidden-tab resets keep backlog-recovery callbacks and byte order. - let idleAgentTerminalModeReset = RESET_TERMINAL_CURSOR_STYLE - let suppressNativeWindowsIdleCodexFocusReports = false - const setFocusReportSuppressionForAgentCompletion = ( - title: string | undefined, - agentType: AgentType | undefined - ): void => { - const titleAgentType = resolveCommittedTitleAgentType(title ?? '') - suppressNativeWindowsIdleCodexFocusReports = - agentType && agentType !== 'unknown' ? agentType === 'codex' : titleAgentType === 'codex' - } - let queueAgentIdleTerminalModeReset = (): void => { - if (disposed) { - return - } - writeTerminalOutput(pane.terminal, idleAgentTerminalModeReset, { - foreground: shouldWritePtyOutputForeground(deps.isVisibleRef.current) - }) - } - // Why: passphrase-gate waits register a teardown here so dispose() can - // actively unsubscribe + resolve them. Without this, a pane disposed - // mid-wait leaks its zustand subscriber and the surrounding async IIFE - // forever, since the subscriber's `disposed` check only fires when the - // store next emits — which may never happen after disconnect. - const waitTeardowns: (() => void)[] = [] - // Why: startup commands must only run once — in the pane they were - // targeted at. Capture `deps.startup` into a local and clear the field on - // the (already spread-copied) `deps` so nothing else inside this function - // can accidentally re-read it. The caller is responsible for clearing its - // own outer reference, since `deps` here is a shallow copy and our - // mutation does not propagate back. - const paneStartup = deps.startup ?? null - deps.startup = undefined - - // Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay. - // Use the stable layout leaf UUID, not the renderer-local numeric pane id. - const cacheKey = makePaneKey(deps.tabId, pane.leafId) - // Why: mirrors the kitty keyboard flags the pane's application negotiates. - // Fed only from application output (live PTY bytes + daemon replay - // payloads), never from renderer-generated resets, so it reflects what the - // application expects even after defensive renderer-side kitty wipes. - const kittyKeyboardModes = (() => { - const existing = deps.paneKittyKeyboardModesRef.current.get(pane.id) - if (existing) { - return existing - } - const created = new TerminalKittyKeyboardModeTracker() - deps.paneKittyKeyboardModesRef.current.set(pane.id, created) - return created - })() - const getSleepingRecordForPane = ( - state: ReturnType - ): { paneKey: string; record: SleepingAgentSessionRecord } | null => { - const stableRecord = state.sleepingAgentSessionsByPaneKey[cacheKey] - if (stableRecord) { - return { paneKey: cacheKey, record: stableRecord } - } - const legacyMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter( - ([paneKey, record]) => { - const legacy = parseLegacyNumericPaneKey(paneKey) - return ( - legacy?.tabId === deps.tabId && - record.worktreeId === deps.worktreeId && - (!record.tabId || record.tabId === deps.tabId) - ) - } - ) - const exactLegacyMatch = legacyMatches.find(([paneKey]) => { - const legacy = parseLegacyNumericPaneKey(paneKey) - return legacy?.numericPaneId === String(pane.id) - }) - const providerSessionKeys = new Set( - legacyMatches.map(([, record]) => getProviderSessionClaimKey(record)) - ) - const oldestLegacyMatch = legacyMatches - .slice() - .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] - // Why: duplicate legacy aliases can point at one provider session; consume - // the oldest capture as canonical and clear its aliases after resume. - const selectedLegacyMatch = - exactLegacyMatch ?? - (providerSessionKeys.size === 1 - ? legacyMatches.length === 1 - ? legacyMatches[0] - : oldestLegacyMatch - : null) - if (!selectedLegacyMatch) { - return null - } - const [paneKey, record] = selectedLegacyMatch - return { paneKey, record } - } - const isLegacyWorkerAutomaticResumeBlocked = (): boolean => - getSleepingRecordForPane(useAppStore.getState())?.record.automaticResumeBlockedBy === - 'legacy-orchestration-worker' - const clearSleepingRecordProviderDuplicates = ( - state: ReturnType, - consumed: { paneKey: string; record: SleepingAgentSessionRecord } - ): void => { - state.clearSleepingAgentSession(consumed.paneKey) - for (const [paneKey, record] of Object.entries(state.sleepingAgentSessionsByPaneKey)) { - if ( - paneKey !== consumed.paneKey && - record.worktreeId === consumed.record.worktreeId && - record.agent === consumed.record.agent && - agentProviderSessionsEqual( - record.agent, - record.providerSession, - consumed.record.providerSession - ) - ) { - // Why: legacy pane aliases can leave multiple sleeping rows for one - // provider session; once this pane resumes it, every alias is stale. - state.clearSleepingAgentSession(paneKey) - } - } - } - const launchToken = paneStartup?.launchConfig - ? (paneStartup.launchToken ?? createBrowserUuid()) - : undefined - const startupDraftAgent = paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent - const startupDraftAgentConfig = startupDraftAgent ? TUI_AGENT_CONFIG[startupDraftAgent] : null - const startupDraftPrompt = - typeof paneStartup?.draftPrompt === 'string' && paneStartup.draftPrompt.trim() - ? paneStartup.draftPrompt - : null - const startupDraftPromptNeedsPaste = - startupDraftPrompt !== null && - !startupDraftAgentConfig?.draftPromptFlag && - !startupDraftAgentConfig?.draftPromptEnvVar - let startupDraftDeliveryClaimed = false - let startupDraftPasteAttempted = false - const claimStartupDraftPasteDelivery = (): boolean => { - if (!startupDraftPromptNeedsPaste || launchToken === undefined) { - return false - } - if (startupDraftDeliveryClaimed) { - return true - } - // Why: launch-bound draft paste needs a launch token; all current - // draftPrompt startup callers pair it with launchConfig so this can safely - // fence off delayed sidecar delivery before Codex's first composer frame. - startupDraftDeliveryClaimed = beginAgentStartupDeliveryAttempt({ - worktreeId: deps.worktreeId, - tabId: deps.tabId, - launchToken - }) - return startupDraftDeliveryClaimed - } - const releaseUnattemptedStartupDraftPasteDelivery = (): void => { - if (!startupDraftDeliveryClaimed || startupDraftPasteAttempted || launchToken === undefined) { - return - } - releaseAgentStartupDeliveryAttempt({ - worktreeId: deps.worktreeId, - tabId: deps.tabId, - launchToken - }) - startupDraftDeliveryClaimed = false - } - // Why: reserve before deferred connect so the creation sidecar cannot time out during setup and strand this pane's live scanner. - const ownsStartupDraftPaste = claimStartupDraftPasteDelivery() - if (paneStartup?.launchConfig) { - useAppStore.getState().registerAgentLaunchConfig(cacheKey, paneStartup.launchConfig, { - agentType: paneStartup.launchAgent ?? paneStartup.initialAgentStatus?.agent, - ...(launchToken ? { launchToken } : {}), - tabId: deps.tabId, - leafId: pane.leafId - }) - } else if (paneStartup) { - useAppStore.getState().clearAgentLaunchConfig(cacheKey) - } - const registerEffectiveLaunchConfig = ( - effectiveLaunchConfig: PtyConnectResult['launchConfig'] | undefined, - metadata?: { launchToken?: string; launchAgent?: TuiAgent } - ): void => { - if (!effectiveLaunchConfig) { - if (metadata?.launchAgent) { - // Why: daemon launch identity can outlive the process while Orca is - // closed. Use it to request confirmation, never as current byte authority. - useAppStore.getState().setPaneForegroundAgent(cacheKey, { - agent: metadata.launchAgent, - shellForeground: false - }) - } - return - } - // Why: daemon reattach preserves the pane's exact launch command but not - // renderer metadata; recover only allowlisted command identity from it. - const persistedLaunchAgent = recognizeAgentProcessFromCommandLine( - effectiveLaunchConfig.agentCommand - )?.agent - useAppStore.getState().registerAgentLaunchConfig(cacheKey, effectiveLaunchConfig, { - agentType: - metadata?.launchAgent ?? - paneStartup?.launchAgent ?? - paneStartup?.initialAgentStatus?.agent ?? - persistedLaunchAgent, - ...((metadata?.launchToken ?? launchToken) - ? { launchToken: metadata?.launchToken ?? launchToken } - : {}), - tabId: deps.tabId, - leafId: pane.leafId - }) - } - const clearRegisteredStartupLaunchConfig = (): void => { - useAppStore.getState().clearAgentLaunchConfig(cacheKey) - } - const neutralTerminalTitle = (): string => { - const state = useAppStore.getState() - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - ) - return tab?.defaultTitle?.trim() || 'Terminal' - } - // Why: infer pane ownership from a manually typed agent command (e.g. `omp`) by - // shadowing the shell's current command line, for generic terminals where no - // launch metadata exists. Consumed by getAuthoritativePaneAgent below. - let commandInferredPaneAgent: TuiAgent | null = null - let pendingShellCommandLine = '' - let pendingShellCommandCursor = 0 - let commandInferredPaneAgentGeneration = 0 - let shellCommandInferenceSuspendedUntilCommandEnd = false - let startAcceptedInferredCommand = (_agent: TuiAgent): void => {} - let requestKnownWindowsShiftEnterReconfirmation = (): void => {} - const resetPendingShellCommandLine = (): void => { - pendingShellCommandLine = '' - pendingShellCommandCursor = 0 - } - const rememberCommandInferredPaneAgent = (): void => { - const commandLine = pendingShellCommandLine.trim() - resetPendingShellCommandLine() - const candidateAgent = commandLine - ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) - : null - const state = useAppStore.getState() - const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType - // Why: input inside a live TUI can spell another agent command; process or - // pane-scoped launch ownership is stronger than typed shell inference. - const nextAgent = - state.paneForegroundAgentByPaneKey[cacheKey]?.agent || isTuiAgent(registeredLaunchAgent) - ? null - : candidateAgent - commandInferredPaneAgent = nextAgent - commandInferredPaneAgentGeneration += 1 - if (nextAgent) { - startAcceptedInferredCommand(nextAgent) - } - } - const clearCommandInferredPaneAgent = (): void => { - commandInferredPaneAgent = null - resetPendingShellCommandLine() - commandInferredPaneAgentGeneration += 1 - } - const clearCommandInferredPaneAgentAfterPtySideEffects = (): void => { - const generation = commandInferredPaneAgentGeneration - resetPendingShellCommandLine() - queueMicrotask(() => { - setTimeout(() => { - if (commandInferredPaneAgentGeneration === generation) { - clearCommandInferredPaneAgent() - } - }, 0) - }) - } - const appendPendingShellCommandInput = (text: string): void => { - const available = MANUAL_AGENT_COMMAND_MAX_CHARS - pendingShellCommandLine.length - if (available <= 0) { - shellCommandInferenceSuspendedUntilCommandEnd = true - return - } - const inserted = text.slice(0, available) - pendingShellCommandLine = - pendingShellCommandLine.slice(0, pendingShellCommandCursor) + - inserted + - pendingShellCommandLine.slice(pendingShellCommandCursor) - pendingShellCommandCursor += inserted.length - if (inserted.length < text.length) { - shellCommandInferenceSuspendedUntilCommandEnd = true - } - } - const deletePendingShellCommandWord = (): void => { - const beforeCursor = pendingShellCommandLine.slice(0, pendingShellCommandCursor) - const afterCursor = pendingShellCommandLine.slice(pendingShellCommandCursor) - const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') - pendingShellCommandLine = nextBeforeCursor + afterCursor - pendingShellCommandCursor = nextBeforeCursor.length - } - const cancelSuspendedShellCommandInference = (): void => { - if (!shellCommandInferenceSuspendedUntilCommandEnd) { - return - } - shellCommandInferenceSuspendedUntilCommandEnd = false - resetPendingShellCommandLine() - } - const deletePendingShellCommandCharacter = (): void => { - if (pendingShellCommandCursor === 0) { - return - } - pendingShellCommandLine = - pendingShellCommandLine.slice(0, pendingShellCommandCursor - 1) + - pendingShellCommandLine.slice(pendingShellCommandCursor) - pendingShellCommandCursor -= 1 - } - const deletePendingShellCommandCharacterAtCursor = (): void => { - if (pendingShellCommandCursor >= pendingShellCommandLine.length) { - return - } - pendingShellCommandLine = - pendingShellCommandLine.slice(0, pendingShellCommandCursor) + - pendingShellCommandLine.slice(pendingShellCommandCursor + 1) - } - const movePendingShellCommandCursor = (delta: number): void => { - pendingShellCommandCursor = Math.min( - pendingShellCommandLine.length, - Math.max(0, pendingShellCommandCursor + delta) - ) - } - const consumeShellCommandCsiSequence = (data: string, index: number): number | null => { - if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') { - return null - } - let cursor = index + 2 - while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) { - cursor += 1 - } - const final = data[cursor] - if (!final || !/[~A-Za-z]/.test(final)) { - return null - } - const params = data.slice(index + 2, cursor) - // Why: only emulate a bare one-column move. Parameterized/modified cursor keys - // (e.g. Ctrl+Left `\x1b[1;5D` = word-jump) move the real cursor by more than - // one, so tracking them as ±1 would desync the shadow line — fall through to - // reset instead of silently corrupting the sampled command. - if (final === 'D' && params === '') { - movePendingShellCommandCursor(-1) - } else if (final === 'C' && params === '') { - movePendingShellCommandCursor(1) - } else if (final === 'H' || (final === '~' && params === '1')) { - pendingShellCommandCursor = 0 - } else if (final === 'F' || (final === '~' && params === '4')) { - pendingShellCommandCursor = pendingShellCommandLine.length - } else if (final === '~' && params === '3') { - deletePendingShellCommandCharacterAtCursor() - } else if (final === '~' && (params === '200' || params === '201')) { - // Bracketed paste wrappers are terminal framing, not shell command text. - } else { - resetPendingShellCommandLine() - } - return cursor + 1 - } - const getLivePaneAgentTitle = (): string | null => { - const state = useAppStore.getState() - const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - )?.title - return runtimeTitle ?? tabTitle ?? null - } - // Why: a pane-scoped explicit row only counts as current ownership evidence - // when it is fresh and not already `done` — a stale or completed row is a - // leftover from a prior agent that may no longer own the shell. - const isFreshActivePaneAgentEntry = ( - entry: AgentStatusEntry | undefined - ): entry is AgentStatusEntry => { - return isFreshNonDoneAgentStatus(entry) - } - const shouldSuppressTitleCompletionForFreshHook = ( - title: string, - activeHookStatus: AgentStatusEntry | undefined - ): boolean => { - if ( - detectAgentStatusFromTitle(title) === 'working' || - !isFreshNonDoneAgentStatus(activeHookStatus) - ) { - return false - } - const explicitTitleAgentType = resolveCommittedTitleAgentType(title) - const activeHookAgentForTitle = resolveCompatibleAgentTypeForOwner( - activeHookStatus?.agentType, - explicitTitleAgentType - ) - const titleNamesDifferentKnownAgent = - explicitTitleAgentType && - activeHookStatus?.agentType && - activeHookStatus.agentType !== 'unknown' && - activeHookAgentForTitle !== explicitTitleAgentType - return !titleNamesDifferentKnownAgent - } - let pendingSuppressedTitleSideEffects: { - title: string - agentType: AgentType | undefined - } | null = null - const clearSuppressedTitleSideEffects = (): void => { - pendingSuppressedTitleSideEffects = null - } - const applyAgentCompletionSideEffects = ( - title: string, - agentType: AgentType | undefined - ): void => { - const settings = useAppStore.getState().settings - if ( - (agentType === 'claude' || isClaudeAgent(title)) && - (settings === null || settings.promptCacheTimerEnabled) - ) { - deps.setCacheTimerStartedAt(cacheKey, Date.now()) - } - setFocusReportSuppressionForAgentCompletion(title, agentType) - queueAgentIdleTerminalModeReset() - } - const preserveSuppressedTitleSideEffects = ( - title: string, - activeHookStatus: AgentStatusEntry - ): void => { - pendingSuppressedTitleSideEffects = { - title, - agentType: activeHookStatus.agentType - } - if (activeHookStatus.state === 'waiting' || activeHookStatus.state === 'blocked') { - suppressNativeWindowsIdleCodexFocusReports = false - queueAgentIdleTerminalModeReset() - } - } - const handleAgentHookTerminalLifecycle = (payload: AgentCompletionStatusSnapshot): void => { - const pending = pendingSuppressedTitleSideEffects - if (!pending) { - return - } - const payloadAgentForPending = resolveCompatibleAgentTypeForOwner( - payload.agentType, - pending.agentType - ) - const belongsToPendingAgent = - !pending.agentType || - pending.agentType === 'unknown' || - !payload.agentType || - payload.agentType === 'unknown' || - payloadAgentForPending === pending.agentType - if (!belongsToPendingAgent || payload.state === 'working') { - clearSuppressedTitleSideEffects() - return - } - if (payload.state === 'done') { - applyAgentCompletionSideEffects(pending.title, payload.agentType ?? pending.agentType) - clearSuppressedTitleSideEffects() - return - } - if (payload.state === 'waiting' || payload.state === 'blocked') { - suppressNativeWindowsIdleCodexFocusReports = false - queueAgentIdleTerminalModeReset() - } - } - const unregisterAgentHookTerminalLifecycle = registerAgentHookTerminalLifecycleHandler( - cacheKey, - handleAgentHookTerminalLifecycle - ) - const hasFreshPaneAgentSurface = (): boolean => { - const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] - if (isFreshActivePaneAgentEntry(entry)) { - return true - } - const liveTitle = getLivePaneAgentTitle() - return detectAgentStatusFromTitle(liveTitle ?? '') !== null - } - const observeAcceptedShellCommandInput = (data: string): void => { - if ( - data.includes('\r') || - data.includes('\n') || - data.includes('\x03') || - data.includes('\x04') - ) { - // Why: shells without OSC 133 give no command/exit boundary. An accepted - // submit or interrupt revokes stale trusted Shift+Enter routing and confirms once. - requestKnownWindowsShiftEnterReconfirmation() - } - if (commandInferredPaneAgent) { - return - } - // Why: bytes typed inside a live agent TUI are prompt text, not shell - // commands, even if they spell another agent binary name. - if (hasFreshPaneAgentSurface()) { - resetPendingShellCommandLine() - return - } - if (shellCommandInferenceSuspendedUntilCommandEnd) { - if (data.includes('\x03') || data.includes('\x15')) { - shellCommandInferenceSuspendedUntilCommandEnd = false - resetPendingShellCommandLine() - } - if (data.includes('\r') || data.includes('\n')) { - shellCommandInferenceSuspendedUntilCommandEnd = false - } - return - } - if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { - resetPendingShellCommandLine() - shellCommandInferenceSuspendedUntilCommandEnd = !data.includes('\r') && !data.includes('\n') - return - } - for (let index = 0; index < data.length; index += 1) { - const char = data[index]! - if (char === '\r' || char === '\n') { - shellCommandInferenceSuspendedUntilCommandEnd = false - rememberCommandInferredPaneAgent() - if (commandInferredPaneAgent) { - return - } - continue - } - if (char === '\x7f' || char === '\b') { - deletePendingShellCommandCharacter() - continue - } - if (char === '\x17') { - deletePendingShellCommandWord() - continue - } - if (char === '\x03' || char === '\x15') { - resetPendingShellCommandLine() - continue - } - if (char === '\x1b') { - const nextIndex = consumeShellCommandCsiSequence(data, index) - if (nextIndex !== null) { - index = nextIndex - 1 - continue - } - resetPendingShellCommandLine() - continue - } - if (char < ' ') { - resetPendingShellCommandLine() - continue - } - if (char >= ' ') { - appendPendingShellCommandInput(char) - if (shellCommandInferenceSuspendedUntilCommandEnd) { - return - } - } - } - } - /** - * Resolves the authoritative owner agent type for this pane, checking tab launch, - * pane startup, typed command ownership, and store state configuration. - * - * Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an - * OMP-owned pane back to Pi; command ownership covers manually typed `omp` - * in generic terminals where launch metadata does not exist. - */ - const getAuthoritativePaneAgent = (): AgentType | undefined => { - const state = useAppStore.getState() - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - ) - return ( - resolvePaneAgentOwner({ - launchAgent: tab?.launchAgent, - startupLaunchAgent: paneStartup?.launchAgent, - initialStatusAgent: paneStartup?.initialAgentStatus?.agent, - commandInferredAgent: commandInferredPaneAgent, - hookAgent: state.agentStatusByPaneKey[cacheKey]?.agentType - }) ?? undefined - ) - } - // Why: the renderer veto (owner evidence beating a Gemini-looking title) must - // use only pane-scoped, CURRENT ownership. getAuthoritativePaneAgent leads - // with the tab-shared `tab.launchAgent` and a never-cleared - // `paneStartup.launchAgent`, which would let a sibling split pane or a reused - // pane keep WebGL for a genuine Gemini terminal (#7428 regression class). - // Launch identity is excluded, and the never-clearing startup seed - // (`paneStartup.initialAgentStatus`) too; a stale or `done` explicit row is - // ignored via the freshness predicate so a reused pane cannot inherit a prior - // agent's veto. Only live foreground command inference and a fresh, active - // hook row count. A genuine OMP/Pi pane stays protected owner-independently by - // the isPiAgentTitle guard inside isGeminiTerminalTitle. - const getPaneScopedRendererOwner = (): AgentType | undefined => { - const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] - return ( - commandInferredPaneAgent ?? (isFreshActivePaneAgentEntry(entry) ? entry.agentType : undefined) - ) - } - const clearInferredInterruptWorkingTitle = (): void => { - const state = useAppStore.getState() - const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle - const title = currentTitle ?? statusTitle - if (!title) { - return - } - const neutralTitle = neutralTerminalTitle() - // Why: inferred interrupts update the explicit hook row, but many CLIs leave - // their OSC title stuck on a working spinner. Replace only this fallback - // title signal with a neutral terminal label so the existing process tracker - // can still decide whether an agent TUI is truly alive. - deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle) - if (manager.getActivePane()?.id === pane.id) { - deps.updateTabTitle(deps.tabId, neutralTitle) - } - } - let titleOnlyInterruptTimer: ReturnType | null = null - const clearTitleOnlyInterruptTimer = (): void => { - if (titleOnlyInterruptTimer !== null) { - clearTimeout(titleOnlyInterruptTimer) - titleOnlyInterruptTimer = null - } - } - const observeTitleOnlyInterrupt = (): void => { - const state = useAppStore.getState() - if (state.agentStatusByPaneKey[cacheKey]) { - return - } - const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - )?.title - const baselineTitle = runtimeTitle ?? tabTitle - if (detectAgentStatusFromTitle(baselineTitle ?? '') !== 'working') { - return - } - clearTitleOnlyInterruptTimer() - titleOnlyInterruptTimer = setTimeout(() => { - titleOnlyInterruptTimer = null - if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) { - return - } - const currentState = useAppStore.getState() - const currentRuntimeTitle = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const currentTabTitle = (currentState.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - )?.title - const currentTitle = currentRuntimeTitle ?? currentTabTitle - if ( - currentTitle === baselineTitle && - detectAgentStatusFromTitle(currentTitle ?? '') === 'working' - ) { - // Why: title-only agents such as Pi can miss their own idle title after - // Ctrl+C. Clear only an unchanged, acknowledged working title. - clearInferredInterruptWorkingTitle() - } - }, AGENT_INTERRUPT_SETTLE_MS) - } - const clearReattachIdleAgentCursorResetTimer = (): void => { - if (reattachIdleAgentCursorResetTimer !== null) { - clearTimeout(reattachIdleAgentCursorResetTimer) - reattachIdleAgentCursorResetTimer = null - } - } - const getCurrentTerminalTitle = (): string | null => { - const state = useAppStore.getState() - const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - )?.title - return runtimeTitle ?? tabTitle ?? null - } - let reattachReplayPayloadHasCursorAgentSignal = false - // Why: post-parse veto callbacks must only judge the latest replay frame; a - // newer frame bumps the generation so a stale callback stands down. - let reattachReplayPayloadSignalGeneration = 0 - const rememberReattachPayloadAgentSignal = ( - data: string, - opts: { fullScreenReplay: boolean } - ): void => { - reattachReplayPayloadSignalGeneration += 1 - // Why: ordinary scrollback can mention agent names. Treat replay bytes as - // a live Cursor Agent signal only when they look like its restored screen. - const signal = hasCursorAgentReattachPayloadScreenSignal(data) - // Why: incremental (non-clearing) replay frames repaint only part of the - // screen, so their bytes can only add evidence — a full-screen replay is - // the authoritative repaint that may clear the flag. - reattachReplayPayloadHasCursorAgentSignal = opts.fullScreenReplay - ? signal - : reattachReplayPayloadHasCursorAgentSignal || signal - } - const isCursorAgentNativeTitle = (title: string): boolean => { - return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase() - } - const hasLiveAgentReattachStatusOrTitleSignal = (): boolean => { - // Why: launch ownership (tab.launchAgent) never decays after the agent - // exits, so it must not count as liveness here — only live status, live - // titles, and the replayed screen shape do. - if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) { - return true - } - const title = getCurrentTerminalTitle() ?? '' - // Why: broad token matching (getAgentLabel) fires on titles like - // "ssh devin@host"; that surface is too loose to gate mode preservation - // and PTY byte injection, so only exact/status titles count here. - return detectAgentStatusFromTitle(title) !== null || isCursorAgentNativeTitle(title) - } - const hasLiveAgentReattachSignal = (): boolean => { - return hasLiveAgentReattachStatusOrTitleSignal() || reattachReplayPayloadHasCursorAgentSignal - } - const shouldPreserveAgentReattachModes = (): boolean => { - // Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes. - // Preserve those modes only when reattach still looks agent-owned. - return hasLiveAgentReattachSignal() - } - const shouldSendFocusedAgentReattachFocusIn = (): boolean => { - return terminalOwnsDomFocus(pane.terminal) && shouldPreserveAgentReattachModes() - } - const scheduleReattachIdleAgentCursorReset = (): void => { - const status = detectAgentStatusFromTitle(getCurrentTerminalTitle() ?? '') - if (status !== 'idle' && status !== 'permission') { - return - } - clearReattachIdleAgentCursorResetTimer() - reattachIdleAgentCursorResetTimer = setTimeout(() => { - reattachIdleAgentCursorResetTimer = null - if (disposed) { - return - } - const latestStatus = detectAgentStatusFromTitle(getCurrentTerminalTitle() ?? '') - if (latestStatus !== 'idle' && latestStatus !== 'permission') { - return - } - // Why: restored idle agent TUIs can repaint after reattach SIGWINCH and - // reapply DECSCUSR steady-bar; the normal working→idle reset will not - // fire because the agent was already idle before Orca restarted. - queueAgentIdleTerminalModeReset() - }, REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS) - } - const interruptInference = createAgentInterruptInference({ - paneKey: cacheKey, - getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey], - inferInterrupt: (request) => { - // Why: the explicit hook row is the authority for an in-flight agent turn. - // Codex can reset its terminal title while handling Ctrl+C/Escape, so title - // state must not veto clearing the row's working state. - return window.api.agentStatus - .inferInterrupt(request) - .then((applied) => { - if (applied) { - clearInferredInterruptWorkingTitle() - } - return applied - }) - .catch((err) => { - console.warn('[agent-interrupt] inferInterrupt failed:', err) - return false - }) - } - }) - const questionAnsweredInference = createAgentQuestionAnsweredInference({ - paneKey: cacheKey, - getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey], - inferQuestionAnswered: (request) => - window.api.agentStatus.inferQuestionAnswered(request).catch((err) => { - console.warn('[agent-question] inferQuestionAnswered failed:', err) - return false - }) - }) - const dropCommandFinishedStatusIfSameTurn = ( - entry: AgentStatusEntry | undefined, - options?: { allowInferredInterrupt?: boolean } - ): void => { - const state = useAppStore.getState() - if (!entry) { - // Why: an Orca-started agent can exit before its first hook status. The - // launch registry was still created up front, so clear it on command exit. - state.clearAgentLaunchConfig(cacheKey) - return - } - const current = state.agentStatusByPaneKey[cacheKey] - if (!current) { - state.clearAgentLaunchConfig(cacheKey) - return - } - const unchanged = - current.state === entry.state && - current.prompt === entry.prompt && - current.updatedAt === entry.updatedAt && - current.stateStartedAt === entry.stateStartedAt && - current.agentType === entry.agentType - const inferredFromEntry = - options?.allowInferredInterrupt === true && - current.state === 'done' && - current.interrupted === true && - current.prompt === entry.prompt && - current.agentType === entry.agentType && - current.stateHistory?.some( - (history) => - history.state === entry.state && - history.prompt === entry.prompt && - history.startedAt === entry.stateStartedAt - ) === true - if (!unchanged && !inferredFromEntry) { - return - } - state.dropAgentStatus(cacheKey) - } - let pendingTerminalInputIntent: AgentInterruptInputIntent | null = null - let clearPendingTerminalInputIntentTimer: ReturnType | null = null - const clearPendingTerminalInputIntent = (): void => { - pendingTerminalInputIntent = null - if (clearPendingTerminalInputIntentTimer !== null) { - clearTimeout(clearPendingTerminalInputIntentTimer) - clearPendingTerminalInputIntentTimer = null - } - } - const setPendingTerminalInputIntent = (intent: AgentInterruptInputIntent): void => { - clearPendingTerminalInputIntent() - pendingTerminalInputIntent = intent - clearPendingTerminalInputIntentTimer = setTimeout(() => { - clearPendingTerminalInputIntent() - }, 0) - } - const inputMatchesIntent = (intent: AgentInterruptInputIntent, data: string): boolean => { - return ( - (intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03') - ) - } - const inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => { - if (data === '\x03') { - return 'ctrl-c' - } - if (data === '\x1b') { - return 'plain-escape' - } - return null - } - const observeSentTerminalInputIntent = ( - data: string, - intent = pendingTerminalInputIntent - ): void => { - if (intent && inputMatchesIntent(intent, data)) { - interruptInference.observeInputIntent(intent) - observeTitleOnlyInterrupt() - } - } - const observeAcceptedTerminalInput = ( - data: string, - intent: AgentInterruptInputIntent | null = null - ): void => { - if (intent === 'ctrl-c' || data === '\x03') { - markTerminalBracketedPasteInterrupted(pane.terminal) - } - // Why: every delivered-input path funnels through here, so this is where a - // submit keystroke into a waiting AskUserQuestion pane becomes the - // "question answered" signal no hook will ever deliver. - questionAnsweredInference.observeSentTerminalInput(data) - } - let pendingTerminalInputWrite: Promise | null = null - let sequencedInterruptStatusBaseline: AgentStatusEntry | null | undefined - let interruptStatusBaselineSequence = 0 - const setPendingTerminalInputWrite = (promise: Promise): void => { - pendingTerminalInputWrite = promise - void promise.finally(() => { - if (pendingTerminalInputWrite === promise) { - pendingTerminalInputWrite = null - } - }) - } - const flushPendingInterruptInference = (): boolean | Promise => { - const pendingWrite = pendingTerminalInputWrite - if (!pendingWrite) { - return interruptInference.flushPending() - } - return pendingWrite.then((immediateResult) => { - return immediateResult ?? interruptInference.flushPending() - }) - } - // Why: the 133;D confirmation guard and the visible-pane resampler both key off - // "does this pane expect an agent"; derive each signal once so the two callers - // can't drift and silently reintroduce the icon bug this fix closes. - const paneHasLiveHookAgentIcon = (state: ReturnType): boolean => { - const entry = state.agentStatusByPaneKey[cacheKey] - return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType)) - } - const paneExpectsLaunchAgent = (state: ReturnType): boolean => { - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (candidate) => candidate.id === deps.tabId - ) - const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType - return Boolean( - tab?.launchAgent ?? - paneStartup?.launchAgent ?? - paneStartup?.initialAgentStatus?.agent ?? - (isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined) - ) - } - // Why: the concrete TUI agent a fresh spawn is expected to launch, used to seed - // a command-start confirmation on no-OSC shells (Git Bash/cmd) that never emit - // one. Returns null when the expectation isn't a recognized TUI agent. - const resolveExpectedLaunchTuiAgent = (): TuiAgent | null => { - const state = useAppStore.getState() - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (candidate) => candidate.id === deps.tabId - ) - const candidate = - tab?.launchAgent ?? - paneStartup?.launchAgent ?? - paneStartup?.initialAgentStatus?.agent ?? - state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType - return isTuiAgent(candidate) ? candidate : null - } - // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a - // full-screen agent's leaked nested-shell 133;D can't clear its tab identity, - // even on a restore where no command-start read has recorded evidence yet. - const paneHasKnownAgentIdentity = (): boolean => { - const state = useAppStore.getState() - const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType - return ( - Boolean(state.paneForegroundAgentByPaneKey[cacheKey]?.agent) || - paneHasLiveHookAgentIcon(state) || - isTuiAgent(registeredLaunchAgent) - ) - } - // Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints - // it on exit, so a confirmed return-to-shell must clear a title that still - // names an agent — otherwise the tab reads "grok" over a bare prompt. Only - // reset an agent-named title; user/shell-set titles are left untouched. - const clearStaleAgentTabTitleOnConfirmedShell = (): void => { - const state = useAppStore.getState() - const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - ) - const title = currentTitle ?? tab?.title - if (!title || resolveCommittedTitleAgentType(title) === null) { - return - } - const neutralTitle = neutralTerminalTitle() - deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle) - if (manager.getActivePane()?.id === pane.id) { - deps.updateTabTitle(deps.tabId, neutralTitle) - } - } - let deferredCommandFinishedStatusDrop: (() => void) | null = null - let visibleForegroundSamplePending = false - let visibleForegroundSampleSettled = false - const settleDeferredCommandFinishedStatusDrop = (): void => { - const dropStatus = deferredCommandFinishedStatusDrop - deferredCommandFinishedStatusDrop = null - dropStatus?.() - } - const isForegroundTrackingAllowed = (id: string): boolean => { - if (isRemoteRuntimePtyId(id) || parseAppSshPtyId(id) !== null) { - return false - } - if (!navigator.userAgent.includes('Windows')) { - return true - } - const state = useAppStore.getState() - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (candidate) => candidate.id === deps.tabId - ) - // Why: WSL and remote-runtime panes can never authorize native ConPTY - // bytes, so do not pay for Windows process scans that cannot affect routing. - return isLocalNativeWindowsConpty({ - userAgent: navigator.userAgent, - connectionId: getConnectionId(deps.worktreeId) ?? null, - cwd: deps.cwd, - shellOverride: resolveWindowsShellOverride( - tab?.shellOverride, - state.settings?.terminalWindowsShell - ), - executionHostId: getExecutionHostIdForWorktree(state, deps.worktreeId) - }) - } - const paneForegroundAgentTracker = createPaneForegroundAgentTracker({ - getPtyId: () => transport.getPtyId(), - isTrackablePtyId: isForegroundTrackingAllowed, - readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id), - confirmForegroundProcess: (id) => window.api.pty.confirmForegroundProcess(id), - publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry), - hasKnownAgentIdentity: paneHasKnownAgentIdentity, - onConfirmedShellForeground: (reason) => { - clearStaleAgentTabTitleOnConfirmedShell() - // Why: a hard-killed agent leaves mouse/focus/kitty modes armed, and the - // surviving shell then receives pointer moves as typed SGR reports; the - // replay guard keeps xterm's auto-replies from leaking to the shell. - replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_REATTACH_RESET, { - breadcrumbIdentity: { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - ptyId: transport.getPtyId() - }, - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously - }) - if (reason === 'visible-pty') { - useAppStore.getState().clearAgentLaunchConfig(cacheKey) - return - } - settleDeferredCommandFinishedStatusDrop() - }, - onCommandFinishedUnavailable: settleDeferredCommandFinishedStatusDrop, - onVisibleForegroundSettled: (outcome) => { - visibleForegroundSamplePending = false - visibleForegroundSampleSettled = outcome !== 'inconclusive' - if (outcome !== 'inconclusive') { - return - } - const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[cacheKey] - if (foreground?.routingConfirmationPending !== true) { - return - } - useAppStore.getState().setPaneForegroundAgent(cacheKey, { - agent: foreground.agent, - routingRevoked: true, - shellForeground: foreground.shellForeground - }) - } - }) - // Why: one command-finished policy whether the signal arrives as bytes - // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — - // routing both through this handler keeps the drop/interrupt semantics - // identical across authority modes. - const handleCommandFinished = (bestEffortExitCode: number | null): void => { - clearCommandInferredPaneAgentAfterPtySideEffects() - visibleForegroundSamplePending = false - const shouldDeferStatusDrop = paneForegroundAgentTracker.onCommandFinished() - // Why: the finished command may have moved HEAD or the index (e.g. - // `git checkout`); nudge git UI now instead of waiting for a poll. - dispatchTerminalCommandFinishedEvent(deps.worktreeId, bestEffortExitCode) - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - const inferenceResult = flushPendingInterruptInference() - const dropStatus = (): void => { - if (inferenceResult === true) { - dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) - return - } - if (inferenceResult instanceof Promise) { - void inferenceResult.then((applied) => { - dropCommandFinishedStatusIfSameTurn(entry, { - allowInferredInterrupt: applied === true - }) - }) - return - } - dropCommandFinishedStatusIfSameTurn(entry) - } - if (shouldDeferStatusDrop) { - // Why: keep the concrete pane identity routable while the local process - // check distinguishes a leaked nested-shell D from a genuine agent exit. - deferredCommandFinishedStatusDrop = dropStatus - return - } - deferredCommandFinishedStatusDrop = null - dropStatus() - } - const sampleVisiblePaneForegroundAgent = (forceRoutingConfirmation = false): void => { - if ( - !deps.isVisibleRef.current || - visibleForegroundSamplePending || - visibleForegroundSampleSettled - ) { - return - } - const state = useAppStore.getState() - const foreground = state.paneForegroundAgentByPaneKey[cacheKey] - // Why: a daemon reattach may restore display identity without current - // routing authority. Only fresh evidence can suppress its confirmation. - if (foreground?.agent && foreground.routingTrusted === true) { - return - } - if (!forceRoutingConfirmation && paneHasLiveHookAgentIcon(state)) { - return - } - const expectsAgent = paneExpectsLaunchAgent(state) - // Why: a completed local process ladder is stronger than stale tab/startup - // launch metadata. Command-start clears this mark if the pane becomes busy. - if (foreground?.shellForeground) { - return - } - // Why: tab launch metadata can leak across split panes; rebuild pane-scoped - // identity from local process state, with remote/SSH excluded by the tracker. - visibleForegroundSamplePending = paneForegroundAgentTracker.onVisiblePtyBound(expectsAgent) - } - startAcceptedInferredCommand = (agent) => { - paneForegroundAgentTracker.onCommandStarted(agent) - } - requestKnownWindowsShiftEnterReconfirmation = () => { - const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[cacheKey] - // Why: daemon reattach/launch metadata is display-only until a live provider - // read confirms it, so only proven trust may be revoked-and-retained here. A - // pending entry must never qualify: re-arming itself would let one old read - // authorize CSI-u forever on a shell that emits no OSC boundary to publish over. - if ( - !foreground?.agent || - foreground.routingTrusted !== true || - TUI_AGENT_CONFIG[foreground.agent].windowsShiftEnterEncoding !== 'csi-u' - ) { - return - } - const revokedEntry = { - agent: foreground.agent, - routingRevoked: true, - shellForeground: false - } - useAppStore.getState().setPaneForegroundAgent(cacheKey, revokedEntry) - visibleForegroundSamplePending = false - visibleForegroundSampleSettled = false - // Why: hook rows can suppress display-only sampling, but cannot restore - // byte authority after this function explicitly revoked routing trust. - sampleVisiblePaneForegroundAgent(true) - // Why: cmd.exe and Git Bash have no OSC command boundaries, so retain the prior - // CSI-u capability across the async read rather than let the gap send Esc+CR. - // Only while a read is genuinely in flight — nothing else ever clears the flag. - if (paneForegroundAgentTracker.hasReadInFlight()) { - useAppStore.getState().setPaneForegroundAgent(cacheKey, { - ...revokedEntry, - routingConfirmationPending: true - }) - } - } - const commandLifecycle = createTerminalCommandLifecycle({ - onCommandStarted: () => { - // Why: a new command invalidates cleanup waiting on the previous D; only - // a later confirmed shell boundary may retire this pane's live identity. - deferredCommandFinishedStatusDrop = null - visibleForegroundSamplePending = false - visibleForegroundSampleSettled = false - // Why: typed commands can be aliases, so they only widen the bounded - // process-confirmation window; they never become routing evidence. - paneForegroundAgentTracker.onCommandStarted(commandInferredPaneAgent) - }, - onCommandFinished: handleCommandFinished - }) - // Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect — - // it stays attached in every authority mode. - commandLifecycle.attachXtermConsumer(pane.terminal) - const onTerminalKeyDown = (event: KeyboardEvent): void => { - if (isPlainEscapeKeyEvent(event)) { - setPendingTerminalInputIntent('plain-escape') - // Why: plain Escape produces real terminal input (\x1b), so it is a - // genuine "user is here" signal and must still dismiss attention before - // the early return for interrupt-intent inference. - deps.clearTerminalTabUnread(deps.tabId) - deps.clearTerminalPaneUnread(cacheKey) - deps.clearWorktreeUnread(deps.worktreeId) - return - } - if (isCtrlCKeyEvent(event)) { - if (!navigator.userAgent.includes('Mac') && pane.terminal.hasSelection()) { - return - } - setPendingTerminalInputIntent('ctrl-c') - } - // Why: only treat keydowns that will produce real terminal input as the - // "user is here" signal. Modifier-only presses, autorepeat, and Cmd/Ctrl+C - // copy chords with an active selection must not dismiss attention on a - // sibling pane before the user has seen it. - if ( - event.repeat || - event.key === 'Alt' || - event.key === 'AltGraph' || - event.key === 'Control' || - event.key === 'Meta' || - event.key === 'Shift' - ) { - return - } - if ( - (event.metaKey || event.ctrlKey) && - event.key.toLowerCase() === 'c' && - pane.terminal.hasSelection() - ) { - return - } - // Why: user shell frameworks (bash-preexec/iTerm2) can replace Orca's - // OSC 133;C hook, so a manually launched agent produces no command-start - // signal at all. Enter at a shell-foreground prompt is the user-side - // equivalent; the sample is gated to panes with no live agent identity - // and publishes nothing for an idle shell. - if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey) { - sampleVisiblePaneForegroundAgent() - } - deps.clearTerminalTabUnread(deps.tabId) - deps.clearTerminalPaneUnread(cacheKey) - deps.clearWorktreeUnread(deps.worktreeId) - } - // Why: infer only from focused xterm key events. Raw PTY bytes cannot - // distinguish plain Escape from Alt/meta sequences, and programmatic writes - // should not clear agent status. - const terminalKeyTarget = pane.terminal.element ?? pane.container - const terminalKeyTargetSupportsEvents = - typeof terminalKeyTarget?.addEventListener === 'function' && - typeof terminalKeyTarget?.removeEventListener === 'function' - if (terminalKeyTargetSupportsEvents) { - terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true }) - } - - let visibleRemoteViewportClaimPtyId: string | null = null - let pendingVisibleRemoteViewportClaim = false - const setPanePtyFitBinding = (ptyId: string): void => { - bindPanePtyId(pane.id, ptyId, deps.tabId) - pane.container.dataset.ptyId = ptyId - if ( - deps.isVisibleRef.current && - isRemoteRuntimePtyId(ptyId) && - visibleRemoteViewportClaimPtyId !== ptyId - ) { - // Why: the initial fit event consumes this activation arm before later peer ownership changes. - visibleRemoteViewportClaimPtyId = ptyId - pendingVisibleRemoteViewportClaim = true - } - // Why: override hydration can arrive before this pane knows its PTY. Once - // data-pty-id is bound, safeFit can park xterm at the authoritative grid. - if (getFitOverrideForPty(ptyId)) { - safeFit(pane) - } - claimPendingVisibleRemoteViewport() - } - let activePanePtyBinding: string | null = null - // Why: bind time lets async liveness reconcile ignore a request started - // before this PTY bound (newborn race). Null disables the guard (fail-safe). - let activePanePtyBindingBoundAt: number | null = null - - // Why: with main side-effect authority on, the pane's title/bell/agent - // policy callbacks consume pty:sideEffect facts instead of transport byte - // parsers (which stay unregistered) — same policy code, single consumer. - // restoreTitleOnRegister replaces the eager-replay title restore: main's - // title-only snapshot carries the no-attention-replay rule. - let unregisterSideEffectFactConsumer: (() => void) | null = null - const registerSideEffectFactConsumerForPty = ( - ptyId: string, - remoteOutputPaused = false - ): void => { - if ((!mainSideEffectAuthority && !remoteOutputPaused) || disposed) { - return - } - unregisterSideEffectFactConsumer?.() - unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({ - ptyId, - callbacks: { - onTitleChange, - onBell, - onAgentBecameIdle, - onAgentBecameWorking, - onAgentExited, - onCommandFinished: handleCommandFinished, - onPrLink: (link) => - useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link), - // Why: the Command Code settle policy stays here — the done settle - // timer must consult the live store row (which hook events and - // renderer seeds also write), so main only emits scrape facts. - onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, - onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, - ...(shouldOwnAgentStatusInRenderer - ? { onAgentStatus: (payload) => handleRendererOwnedAgentStatus(payload) } - : {}), - // Why: gated hidden panes never see the subscribe bytes; the fact - // replaces the byte scan (and the old post-latch subscribe drop). - ...(hiddenDeliveryGateActive || remoteOutputPaused - ? { - onMode2031Subscribe: handleHiddenMode2031SubscribeFact, - onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact - } - : {}) - }, - restoreTitleOnRegister: true - }) - } - const dropSideEffectFactConsumer = (): void => { - unregisterSideEffectFactConsumer?.() - unregisterSideEffectFactConsumer = null - remoteOutputFactConsumerPtyId = null - } - const clearPanePtyFitBinding = (): void => { - // Why: fit bindings live in a module-level map, so pane teardown must - // clear them explicitly instead of relying on DOM removal. - bindPanePtyId(pane.id, null, deps.tabId) - visibleRemoteViewportClaimPtyId = null - pendingVisibleRemoteViewportClaim = false - activePanePtyBinding = null - activePanePtyBindingBoundAt = null - delete pane.container.dataset.ptyId - delete pane.container.dataset.ptyRecoveryState - } - - const agentCompletionCoordinator = createAgentCompletionCoordinator({ - paneKey: cacheKey, - statusLane: 'pty', - getPtyId: () => transport.getPtyId(), - getSettings: () => useAppStore.getState().settings, - inspectProcess: inspectRuntimeTerminalProcess, - dispatchHookLifecycle: (payload) => dispatchAgentHookTerminalLifecycle(cacheKey, payload), - shouldSuppressProcessReplacementCompletion: (_exited, replacement) => { - const currentStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const currentAgentForReplacement = resolveCompatibleAgentTypeForOwner( - currentStatus?.agentType, - replacement.agent - ) - return ( - isFreshNonDoneAgentStatus(currentStatus) && currentAgentForReplacement === replacement.agent - ) - }, - shouldSuppressConfirmedProcessExitCompletion: (exited) => { - const currentStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const currentAgentForExited = resolveCompatibleAgentTypeForOwner( - currentStatus?.agentType, - exited.agent - ) - // Why: a replacement hook can lead process visibility by one cadence; - // only a different known active owner can veto confirmed old-process exit. - return Boolean( - isFreshNonDoneAgentStatus(currentStatus) && - currentStatus.agentType && - currentStatus.agentType !== 'unknown' && - currentAgentForExited !== exited.agent - ) - }, - dispatchCompletion: (title, meta) => { - if (meta?.source === 'process-exit') { - clearSuppressedTitleSideEffects() - } - if (meta?.terminalIdleConfirmed === true) { - // Why: an agent can crash before its done hook; confirmed process death - // must still restore cursor and native Windows Kitty keyboard modes. - const currentAgentStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - if (!isFreshNonDoneAgentStatus(currentAgentStatus)) { - setFocusReportSuppressionForAgentCompletion(title, meta.agentStatus?.agentType) - } - queueAgentIdleTerminalModeReset() - } - scheduleAgentTaskCompleteNotification(title, { - allowDoneDetailAfterGrace: meta?.quietedHookDone, - ...(meta?.source === 'process-exit' ? { agentCompletionSource: meta.source } : {}), - ...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {}) - }) - }, - dispatchAttention: (title, meta) => - scheduleAgentTaskCompleteNotification(title, { - agentStatusSnapshot: meta.agentStatus - }), - shouldPollProcessCadence: () => - isAgentTaskCompleteTrackingEnabled() && deps.isVisibleRef.current, - isProcessInspectionCostly: () => { - // Why: local Windows inspection forks a powershell.exe whole-process-table - // CIM scan per poll (~10-40x heavier than POSIX `ps`); SSH/remote PTYs run - // their scans on the remote host, so only local Windows panes relax the - // no-evidence cadence. - if (!navigator.userAgent.includes('Windows')) { - return false - } - const ptyId = transport.getPtyId() - return ptyId !== null && !isRemoteRuntimePtyId(ptyId) && parseAppSshPtyId(ptyId) === null - }, - isLive: () => { - if (disposed) { - return false - } - if (transport.getPtyId()) { - return true - } - return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0 - }, - shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(cacheKey, () => ({ - tabId: deps.tabId, - ...(launchToken ? { launchToken } : {}) - })) - }) - - const focusSurvivingPtyPaneAfterKeptExit = (): void => { - if (manager.getActivePane()?.id !== pane.id) { - return - } - const hasPtyBinding = (paneId: number): boolean => - Boolean(deps.paneTransportsRef.current.get(paneId)?.getPtyId()) - const repairedActiveLeafId = - useAppStore.getState().terminalLayoutsByTabId[deps.tabId]?.activeLeafId ?? null - const repairedActivePaneId = repairedActiveLeafId - ? manager.getNumericIdForLeaf(repairedActiveLeafId) - : null - const targetPaneId = - repairedActivePaneId !== null && - repairedActivePaneId !== pane.id && - hasPtyBinding(repairedActivePaneId) - ? repairedActivePaneId - : (manager - .getPanes() - .find((candidate) => candidate.id !== pane.id && hasPtyBinding(candidate.id))?.id ?? - null) - if (targetPaneId !== null) { - // Why: when a newborn split PTY dies before output/input, the pane stays - // mounted for diagnostics; move live focus to the sibling that still owns a PTY. - manager.setActivePane(targetPaneId, { - focus: deps.isActiveRef.current && deps.isVisibleRef.current - }) - } - } - - // Why: the transport's own exit handler (pty-transport.ts) normally makes - // onExit run-at-most-once by clearing connected/ptyId + unregistering BEFORE - // calling it. reconcileIfSessionDead drives onExit directly (bypassing that), - // so this guards the body so reconcile and any racing real/synthetic pty:exit - // for the same id close the pane exactly once. Scoped to the exiting ptyId - // (not a bare boolean): an intentional suppressed restart keeps the pane - // mounted and rebinds to a NEW ptyId, and that replacement's later real exit - // must still run — a one-shot boolean would strand the pane on rebind. - let handledExitPtyId: string | null = null - // Why: tracks the ptyId of a genuine fresh spawn — onPtySpawn fires only for - // fresh spawns, never reattach/coldRestore (pty-transport.ts). Lets the - // sole-pane exit branch tell "this newborn shell died on its own" from "a - // reattached persisted session was already dead", so a failing .envrc/direnv - // on a brand-new worktree keeps its dead terminal visible instead of bouncing - // the user to Landing. - let spawnedFreshPtyId: string | null = null - // Why: hibernation suppresses its kill's exit while the pane is hidden, so - // onExit must not tear the pane down — but the pane still owes the user a - // wake. Remember the hibernated PTY and exact record; the visibility-resume - // hook consumes both and cannot accidentally adopt a later stale record. - type HibernatedWakeTarget = { ptyId: string; record: SleepingAgentSessionRecord } - let hibernatedWakeTarget: HibernatedWakeTarget | null = null - let wakeHibernatedAgentPane: (() => Promise) | null = null - // Why: a mobile wake can land after the sleeping record is written but - // before the suppressed kill exit arms the wake target. The phone never - // reveals the desktop pane, so without a latch the edge-triggered wake would - // be dropped and the phone left on a frozen terminal. - let pendingHibernatedWakeTarget: HibernatedWakeTarget | null = null - // Why: transport.connect settles asynchronously. Repeated mobile activation - // must keep claiming this provider session until the replacement PTY either - // exists (and clears the sleep record) or the spawn fails and can be retried. - let hibernatedWakeInFlightClaimKey: string | null = null - // Why: reveal is the normal wake trigger, but a reveal that lands *during* the - // in-flight hibernation kill runs noteVisibilityResume before onExit arms the - // wake. Sharing the guarded consume lets both the reveal hook and the - // arm-time foreground check resume the pane exactly once. - const consumeHibernatedAgentWake = (claimedProviderSessions?: Set): string | null => { - const target = hibernatedWakeTarget - if (!target || disposed) { - return null - } - if (deps.paneTransportsRef.current.get(pane.id) !== transport) { - return null - } - const currentRecord = getSleepingRecordForPane(useAppStore.getState())?.record - if (currentRecord !== target.record) { - hibernatedWakeTarget = null - pendingHibernatedWakeTarget = null - return null - } - const currentPtyId = transport.getPtyId() - // Why: a real pty:exit clears the transport's ptyId before onExit while a - // reconcile-driven exit leaves it bound; both mean "nothing respawned since - // hibernation". A different non-null id means another flow (e.g. an - // intentional restart) already rebound the pane — its spawn wins. - if (currentPtyId !== null && currentPtyId !== target.ptyId) { - hibernatedWakeTarget = null - pendingHibernatedWakeTarget = null - return null - } - if (!wakeHibernatedAgentPane) { - return null - } - const claimKey = getProviderSessionClaimKey(target.record) - if (claimedProviderSessions?.has(claimKey)) { - return null - } - // Why: one wake event can visit multiple mounted legacy/stable panes for - // the same provider session. Claim synchronously before any spawn starts. - claimedProviderSessions?.add(claimKey) - hibernatedWakeTarget = null - pendingHibernatedWakeTarget = null - hibernatedWakeInFlightClaimKey = claimKey - // Why: reveal is the wake signal for a hibernated pane. Resume the recorded - // agent session (or fall back to a fresh shell) instead of leaving the - // frozen frame with no PTY behind it. - void wakeHibernatedAgentPane() - .then((spawnedPtyId) => { - if (!spawnedPtyId) { - // Why: a transient replacement-spawn failure leaves the passive - // record owned by this pane. Re-arm the exact target so a later - // mobile open can retry instead of stranding the frozen session; - // consume revalidates disposal, binding, PTY, and record identity. - hibernatedWakeTarget = target - } - }) - .finally(() => { - if (hibernatedWakeInFlightClaimKey === claimKey) { - hibernatedWakeInFlightClaimKey = null - } - }) - return claimKey - } - const onExit = ( - ptyId: string, - exitCode = 0, - opts: { preserveRendererBinding?: boolean } = {} - ): void => { - if (handledExitPtyId === ptyId) { - return - } - if (deps.isPtyShutdownPending(ptyId) || isHostPtySleepPending(ptyId, runtimeEnvironmentId)) { - // Why: the transport emits exit once; replay it only after a verified commit so rollback keeps renderer state retryable. - deferPtyShutdownExit(ptyId, (settlement) => { - if (settlement === 'committed') { - onExit(ptyId, exitCode, { preserveRendererBinding: true }) - } - }) - return - } - const preserveRendererBinding = - opts.preserveRendererBinding === true || - consumeCommittedPtyShutdownExit(ptyId, runtimeEnvironmentId) - resetRendererOrderedSeqForPtyExit(ptyId) - const currentPaneTransport = deps.paneTransportsRef.current.get(pane.id) - if (currentPaneTransport && currentPaneTransport !== transport) { - // Why: an old transport can deliver a late exit after this pane has - // rebound to a replacement PTY; only clear ownership for the exited id. - handledExitPtyId = ptyId - processExitStateByPtyId.delete(ptyId) - if (!preserveRendererBinding) { - deps.clearTabPtyId(deps.tabId, ptyId) - } - deps.consumeSuppressedPtyExit(ptyId) - scheduleRuntimeGraphSync() - return - } - handledExitPtyId = ptyId - const processExitState = processExitStateByPtyId.get(ptyId) ?? currentProcessExitState - processExitStateByPtyId.delete(ptyId) - agentCompletionCoordinator.dispose() - dropSideEffectFactConsumer() - // Why: main clears gate state on PTY exit too; this only resets the - // pane-local marker so a reused pane cannot skip re-marking a new PTY. - releaseHiddenRendererPtyDelivery() - clearPanePtyFitBinding() - // Why: the negotiating application died with its PTY; any replacement - // session starts with kitty keyboard flags at zero. - kittyKeyboardModes.reset() - const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId) || preserveRendererBinding - if (!isSuppressedExit) { - deps.clearExitedPanePtyLayoutBinding(pane.id, ptyId) - } - deps.clearRuntimePaneTitle(deps.tabId, pane.id) - if (!preserveRendererBinding) { - deps.clearTabPtyId(deps.tabId, ptyId) - } - // Why: if the PTY exits abruptly (Ctrl-D, crash, shell termination) without - // first emitting a non-agent title, the cache timer would persist as stale - // state. Clear it unconditionally on PTY exit. - deps.setCacheTimerStartedAt(cacheKey, null) - // Why: a dead terminal has no running agent — remove its explicit status - // entry so the hover UI only shows what is running *now*. - useAppStore.getState().removeAgentStatus(cacheKey) - useAppStore.getState().clearPaneForegroundAgent(cacheKey) - // The runtime graph is the CLI's source for live terminal bindings, so - // we must republish when a pane loses its PTY instead of waiting for a - // broader layout change that may never happen. - scheduleRuntimeGraphSync() - // Why: intentional restarts suppress the PTY exit ahead of time so the - // pane stays mounted and can reconnect in place. Without consuming the - // suppression here, split-pane Codex restarts would still close the pane - // because this handler runs before the tab-level close logic sees the exit. - if (isSuppressedExit) { - // Why: the action that suppressed the exit owns whether the leaf binding - // is a wake hint or should be discarded; runtime cleanup above is enough. - manager.setPaneGpuRendering(pane.id, true) - const sleepingRecordEntry = getSleepingRecordForPane(useAppStore.getState()) - if ( - sleepingRecordEntry && - isPassiveCompletedHibernationEvidence(sleepingRecordEntry.record) - ) { - // Why: hibernation killed this pane's PTY while hidden. The frozen TUI - // frame still has mouse-tracking/bracketed-paste armed, which silently - // eats every click and keystroke against a dead transport — disarm the - // modes now and arm the reveal-time wake. - replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET, { - breadcrumbIdentity: { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - ptyId - }, - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously - }) - hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record } - const pendingWakeMatches = - pendingHibernatedWakeTarget?.ptyId === ptyId && - pendingHibernatedWakeTarget.record === sleepingRecordEntry.record - if (pendingHibernatedWakeTarget && !pendingWakeMatches) { - pendingHibernatedWakeTarget = null - } - if (deps.isVisibleRef.current || pendingWakeMatches) { - // Why: a reveal (or a mobile wake) that raced this kill already ran - // before the exit landed, so it saw nothing armed. Consume the wake - // now (deferred off the exit handler) so the pane still resumes - // without needing a second hide/reveal or wake event. - queueMicrotask(() => { - consumeHibernatedAgentWake() - }) - } - } else if (pendingHibernatedWakeTarget?.ptyId === ptyId) { - pendingHibernatedWakeTarget = null - } - return - } - manager.setPaneGpuRendering(pane.id, true) - const failedLocalProcess = !connectionId && runtimeEnvironmentId === null && exitCode !== 0 - if (failedLocalProcess && deps.onPaneProcessDied) { - const gitBashConsoleCapacityFailure = processExitState.detector.detected() - deps.onPaneProcessDied({ - paneId: pane.id, - exitCode, - startup: gitBashConsoleCapacityFailure ? processExitState.startup : null, - reason: gitBashConsoleCapacityFailure ? 'git-bash-console-capacity' : 'process-failed' - }) - return - } - const panes = manager.getPanes() - if (panes.length <= 1) { - // Why: a worktree's sole newborn terminal can die on shell startup — e.g. - // a PR branch ships an .envrc whose direnv command fails, so the login - // shell exits non-zero immediately. Routing that through onPtyExitRef - // closes the only tab, which deactivates the worktree (setActiveWorktree - // (null)) and strands the user on the Landing screen for a worktree that - // was just created. Keep the dead pane mounted instead (mirrors the - // freshly-split guard below) so the direnv error stays visible and the - // worktree stays active. Gated on a genuine fresh spawn (onPtySpawn fired - // for this ptyId — reattach/coldRestore skip it) that the user never typed - // into, so a reattached-dead session or an explicit `exit` still tears - // down as before. - if (spawnedFreshPtyId === ptyId && !Number.isFinite(lastTerminalInputAt)) { - return - } - deps.onPtyExitRef.current(ptyId) - return - } - if ( - deps.isVisibleRef.current && - hadExistingPaneTransportAtConnect && - !restoredPtyIdForTransport && - !Number.isFinite(lastTerminalInputAt) && - !hasReceivedPtyOutput - ) { - // Why: a freshly split pane can lose its newborn PTY during setup; keep - // the split visible so the failed session does not immediately collapse. - // Hidden panes must close instead: the hidden-delivery gate withholds - // their bytes, so "no output" is meaningless there, and keeping one - // strands a binding-less pane the exit path never revisits — it remounts - // as a permanently blank ghost on reveal. - focusSurvivingPtyPaneAfterKeptExit() - return - } - manager.closePane(pane.id) - } - - // Why: on app restart, restored Claude tabs may already be idle when we first - // see their title. The agent status tracker only fires onBecameIdle for - // working→idle transitions, so the cache timer would never start for these - // sessions. We only allow this one-time seed for reattached PTYs; fresh - // Claude launches also start idle, but they have no prompt cache yet. - let hasConsideredInitialCacheTimerSeed = false - let allowInitialIdleCacheSeed = false - - const resolveCurrentAgentStatusRouting = () => { - const ptyId = activePanePtyBinding ?? transport.getPtyId() - const state = useAppStore.getState() - if (disposed || !ptyId) { - return undefined - } - return resolveLiveAgentStatusConnectionRouting({ - state, - paneKey: cacheKey, - ptyId, - expectedConnectionId: worktreeConnectionId, - runtimeEnvironmentId: transport.getRuntimeEnvironmentId?.() ?? runtimeEnvironmentId - }) - } - - const onTitleChange = ( - title: string, - rawTitle: string, - meta?: { staleWorkingTitleClear?: boolean } - ): void => { - // Why: one owner-aware decision drives the display label, the runtime/tab - // title, task-completion tracking, and the renderer gate, so raw title text - // can no longer disable GPU behind stronger owner evidence (#7428/#7447). - const decision = resolvePaneTitleDecision({ - normalizedTitle: title, - rawTitle, - displayOwnerAgentType: getAuthoritativePaneAgent(), - rendererOwnerAgentType: getPaneScopedRendererOwner(), - userGpuMode: useAppStore.getState().settings?.terminalGpuAcceleration ?? 'auto' - }) - const paneTitle = decision.displayTitle - if ( - shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, { - paneKey: cacheKey, - tabId: deps.tabId, - ...(launchToken ? { launchToken } : {}) - }) - ) { - return - } - manager.setPaneGpuRendering(pane.id, decision.rendererPolicy.gpuEnabled) - deps.setRuntimePaneTitle(deps.tabId, pane.id, paneTitle) - // Why: a stale-derived cleared title comes from main's unthrottled 3s - // timer, not agent output. It must update the visible title but never - // feed completion tracking — observeTitle would classify the cleared - // title as idle and mint a task-complete for a merely-paused agent. - if (!meta?.staleWorkingTitleClear && syncAgentTaskCompleteTrackingEnabled()) { - const activeHookStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - if (!shouldSuppressTitleCompletionForFreshHook(decision.rawTitle, activeHookStatus)) { - // Why: display titles still update while hooks are active, but a stale - // idle frame must not complete the coordinator turn before hook `done`. - agentCompletionCoordinator.observeTitle(decision.rawTitle) - } - } - // Why: only the focused pane should drive the tab title — otherwise two - // agents in split panes cause rapid title flickering as each emits OSC - // sequences. Only the active split's title propagates to the tab. When - // focus changes, onActivePaneChange syncs the newly active pane's stored - // title to the tab. - if (manager.getActivePane()?.id === pane.id) { - deps.updateTabTitle(deps.tabId, paneTitle) - } - - if (!hasConsideredInitialCacheTimerSeed) { - hasConsideredInitialCacheTimerSeed = true - const state = useAppStore.getState() - if ( - shouldSeedCacheTimerOnInitialTitle({ - rawTitle, - allowInitialIdleSeed: allowInitialIdleCacheSeed, - existingTimerStartedAt: state.cacheTimerByKey[cacheKey], - promptCacheTimerEnabled: state.settings?.promptCacheTimerEnabled ?? null - }) - ) { - deps.setCacheTimerStartedAt(cacheKey, Date.now()) - } - } - } - - const applyInitialAgentStatus = (terminalTitle?: string): void => { - const initialStatus = paneStartup?.initialAgentStatus - const routing = resolveCurrentAgentStatusRouting() - if (!initialStatus || !routing) { - return - } - const statusPayload = { - state: 'working' as const, - prompt: initialStatus.prompt, - agentType: resolveCompatibleAgentTypeForOwner( - initialStatus.agent, - getAuthoritativePaneAgent() - ), - // Why: Orca launched this agent, so this row predates any provider signal for the pane. - observation: rendererAgentStatusObservations.observe(cacheKey, { - origin: 'launch', - observedAt: Date.now(), - kind: 'transition' - }) - } - if (paneStartup.launchConfig) { - useAppStore - .getState() - .setAgentStatus(cacheKey, statusPayload, terminalTitle, undefined, routing, { - launchConfig: paneStartup.launchConfig, - ...(launchToken ? { launchToken } : {}) - }) - return - } - useAppStore - .getState() - .setAgentStatus(cacheKey, statusPayload, terminalTitle, undefined, routing) - } - - const canApplyCommandCodeOutputStatus = (): boolean => { - const state = useAppStore.getState() - const foreground = state.paneForegroundAgentByPaneKey[cacheKey] - return canCommandCodeOutputOwnPane({ - foregroundAgent: foreground?.agent, - shellForeground: foreground?.shellForeground, - paneOwnerAgent: getAuthoritativePaneAgent(), - retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[cacheKey]?.agentType - }) - } - - const seedCommandCodeOutputWorkingStatus = (prompt: string): void => { - if (!canApplyCommandCodeOutputStatus()) { - return - } - clearCommandCodeOutputDoneTimer() - const routing = resolveCurrentAgentStatusRouting() - if (!routing) { - return - } - const currentState = useAppStore.getState() - const currentEntry = currentState.agentStatusByPaneKey[cacheKey] - const currentTitle = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const normalizedPrompt = prompt.trim() - if ( - currentEntry?.agentType === 'command-code' && - currentEntry.state === 'done' && - (!normalizedPrompt || normalizedPrompt === currentEntry.prompt.trim()) - ) { - return - } - currentState.setAgentStatus( - cacheKey, - { - state: 'working', - prompt: normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''), - agentType: 'command-code', - // Why: Command Code has no prompt-start hook; this row is read off the pane's own output. - observation: rendererAgentStatusObservations.observe(cacheKey, { - origin: 'process', - observedAt: Date.now(), - kind: 'transition' - }) - }, - currentTitle, - undefined, - routing - ) - } - - // Why the settle window lives outside this binding: park unmounts the pane - // mid-settle, so a pane-owned timer would be cancelled with nothing left to - // complete the turn — the row would stick at 'working'. Only the row write - // (routing + title slot) is pane-local; the deadline transfers to whichever - // owner (parked watcher or remounted pane) holds the pane next. - const releaseCommandCodeDoneSettleExecutor = setCommandCodeDoneSettleExecutor( - cacheKey, - (normalizedPrompt) => { - const routing = resolveCurrentAgentStatusRouting() - if (!routing) { - return - } - const currentState = useAppStore.getState() - const currentEntry = currentState.agentStatusByPaneKey[cacheKey] - if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') { - return - } - const currentPrompt = currentEntry.prompt.trim() - if (currentPrompt && currentPrompt !== normalizedPrompt) { - return - } - const currentTitle = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - currentState.setAgentStatus( - cacheKey, - { - state: 'done', - prompt: currentPrompt || normalizedPrompt, - agentType: 'command-code', - observation: rendererAgentStatusObservations.observe(cacheKey, { - origin: 'process', - observedAt: Date.now(), - kind: 'transition' - }) - }, - currentTitle, - undefined, - routing - ) - } - ) - const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(cacheKey) - const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => { - if (!canApplyCommandCodeOutputStatus()) { - return - } - const normalizedPrompt = prompt.trim() - if (!normalizedPrompt) { - cancelCommandCodeDoneSettle(cacheKey) - return - } - // Why: Command Code keeps rendering the composer while tools run. Only - // complete the row if no active status repaint arrives during this window. - openCommandCodeDoneSettle(cacheKey, normalizedPrompt) - } - - const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() - type ProcessExitState = { - startup: PtyPaneStartup - detector: GitBashConsoleCapacityDetector - } - const createProcessExitState = (startup: PtyPaneStartup): ProcessExitState => ({ - startup, - detector: createGitBashConsoleCapacityDetector() - }) - let currentProcessExitState = createProcessExitState(paneStartup) - const processExitStateByPtyId = new Map() - const bindProcessExitState = (ptyId: string, replacedPtyId?: string): void => { - const state = - (replacedPtyId ? processExitStateByPtyId.get(replacedPtyId) : undefined) ?? - currentProcessExitState - processExitStateByPtyId.set(ptyId, state) - if (replacedPtyId && replacedPtyId !== ptyId) { - processExitStateByPtyId.delete(replacedPtyId) - } - } - const reportPanePtyVisibility = (ptyId: string | null | undefined, visible: boolean): void => { - if (!ptyId || isRemoteRuntimePtyId(ptyId)) { - // Why: remote-runtime PTYs use a relay path outside main's local - // renderer-visibility registry, so reporting them here is misleading. - return - } - setRendererPtyVisibilityClaim(transport, ptyId, visible) - } - const bindActivePanePty = ( - ptyId: string, - options: { - seedInitialAgentStatus?: boolean - updateTabPtyId?: 'always' | 'if-missing' - replacePtyId?: string - sampleVisibleForegroundAgent?: boolean - } = {} - ): void => { - bindProcessExitState(ptyId, options.replacePtyId) - if (activePanePtyBinding && activePanePtyBinding !== ptyId) { - reportPanePtyVisibility(activePanePtyBinding, false) - } - setPanePtyFitBinding(ptyId) - activePanePtyBinding = ptyId - reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) - // Why: record bind time on the spawn/attach chokepoint so the reconcile - // guard knows this binding is newer than any pre-bind snapshot. - activePanePtyBindingBoundAt = performance.now() - registerSideEffectFactConsumerForPty(ptyId) - syncHiddenRendererPtyDelivery() - deps.syncPanePtyLayoutBinding(pane.id, ptyId) - // Why: binding a live PTY here is the proof that this pane is current again, so - // lift any retirement fence left by a detach/reattach cycle before hooks arrive. - // Waiting for a new turn would strand a pane re-attached mid-turn or idle (STA-4114). - useAppStore.getState().restoreAgentPaneAuthority?.(cacheKey) - notifyCodexPaneBoundForStaleSweep(ptyId) - const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? [] - const directSshRetryAttemptId = - capturedDirectSshRetryPtyAccepted && directSshRetryAttempt - ? directSshRetryAttempt.attemptId - : undefined - if ( - directSshRetryAttemptId || - options.updateTabPtyId !== 'if-missing' || - !tabPtyIds.includes(ptyId) - ) { - if (directSshRetryAttemptId) { - deps.updateTabPtyId(deps.tabId, ptyId, options.replacePtyId, directSshRetryAttemptId) - } else if (options.replacePtyId) { - deps.updateTabPtyId(deps.tabId, ptyId, options.replacePtyId) - } else { - deps.updateTabPtyId(deps.tabId, ptyId) - } - } - if (options.seedInitialAgentStatus) { - applyInitialAgentStatus() - } - // Spawn/attach completion is when a pane gains a concrete PTY ID. The initial - // frame-level sync often runs before that async result arrives. - scheduleRuntimeGraphSync() - agentCompletionCoordinator.startProcessTracking() - // Why: fresh spawns normally rely on a future OSC 133 command-start read to - // identify the launched agent; only adopted or restored PTYs may already be - // inside Codex with no new foreground signal. But no-OSC shells (Git Bash, - // cmd.exe) never emit a command-start, so an expected-agent pane spawned into - // one would never gain fresh evidence authorizing trusted Windows CSI-u - // routing for the launched agent, so Shift+Enter could submit (#7620, #9703). - // Seed the SAME command-start confirmation the manually-typed launch path - // uses (onCommandStarted): its bounded retry ladder spans agent boot, and a - // miss publishes shellForeground:false — recoverable by later focus/reveal - // samples. A visible-pty sample would instead let a slow boot latch a - // known-agent shell-confirm that clears launch identity and blocks recovery. - // A real OSC 133;C, if it arrives, simply supersedes this. - if (options.sampleVisibleForegroundAgent === true) { - sampleVisiblePaneForegroundAgent() - } else if (options.seedInitialAgentStatus === true) { - const freshSpawnLaunchAgent = resolveExpectedLaunchTuiAgent() - if (freshSpawnLaunchAgent) { - paneForegroundAgentTracker.onCommandStarted(freshSpawnLaunchAgent) - } - } - } - - const onPtySpawn = (ptyId: string): void => { - if (!claimCapturedDirectSshRetryPty(ptyId)) { - // Why: this callback proves a fresh process was created, so rejecting its obsolete lease must also retire it. - queueMicrotask(() => { - if (transport.getPtyId() === ptyId) { - transport.disconnect() - } - }) - return - } - // Why: record that this exact PTY was freshly spawned (not reattached), so a - // newborn shell that dies before any interaction (e.g. failing direnv on a - // just-created worktree) can be kept visible rather than tearing down the - // worktree. Reattach/coldRestore skip onPtySpawn (pty-transport.ts). - spawnedFreshPtyId = ptyId - // Why: Command Code has no prompt-start hook. Seed the visible working row - // once the PTY exists, then let real hook events refine or complete it. - bindActivePanePty(ptyId, { seedInitialAgentStatus: true }) - // Spend the queued command only after this pane has a bound PTY. Retired panes never reach - // this point, and the pending entry keeps the worktree from being force-parked during spawn. - // This marks spawn, not delivery: Windows may run argv-embedded commands earlier, while POSIX - // delivery can still be waiting for shell-ready. - try { - deps.onQueuedStartupSpawned?.() - } catch { - // Why swallowed: this callback runs inside the connect promise, so a throw would reject the - // spawn and strand the pane with no pty at all — a far worse failure than a stale entry. - } - } - const onPtyRebind = (ptyId: string, replacedPtyId: string): void => { - if (!canAdoptCapturedDirectSshRetryPty(ptyId)) { - return - } - // Why: provider handle rotation keeps the existing pane/session generation; - // replace its stale store identity without fresh-spawn exit semantics. - bindActivePanePty(ptyId, { replacePtyId: replacedPtyId }) - } - // ─── Attention signal: BEL ──────────────────────────────────────────── - // - // BEL (0x07) is the attention signal. A BEL raises tab- and worktree-level - // indicators, and fires an OS notification. The experimental pane marker - // clears when the user interacts with the exact pane. - // - // The one case where BEL falsely fires is when a crashed TUI left DEC - // private mode 1004 (focus event reporting) enabled — pane clicks then - // emit `\e[I`/`\e[O` into the shell, zsh treats them as unbound keys and - // rings the bell. This is specific to terminals with cross-restart - // persistence (as we have); our fix is to reset 1004 and friends after - // scrollback replay so the mode state matches the fresh shell - // underneath. See POST_REPLAY_MODE_RESET in shared/terminal-mode-reset-profiles.ts. - const onBell = (): void => { - // Why: restored Claude Code sessions have been observed to emit a real - // standalone BEL some time after daemon snapshot reattach, even when Orca - // did not just forward focus/control input. Treat the BEL as authoritative - // PTY output here; any product-side suppression should be an explicit UX - // decision higher up, not a transport-layer guess. - deps.markWorktreeUnread(deps.worktreeId) - deps.markTerminalTabUnread(deps.tabId) - if (useAppStore.getState().settings?.experimentalTerminalAttention === true) { - deps.markTerminalPaneUnread(cacheKey) - } - // Why: agent CLIs often emit BEL in the same completion burst as their - // working->idle title change. Delay only the OS notification so the richer - // agent-complete notification can win the main-process worktree cooldown. - pendingTerminalBellNotification = true - if (!hasPendingAgentTaskCompleteNotification()) { - scheduleTerminalBellNotification() - } - } - - const clearTerminalBellNotificationTimer = (): void => { - if (terminalBellNotificationTimer !== null) { - clearTimeout(terminalBellNotificationTimer) - terminalBellNotificationTimer = null - } - } - - const scheduleTerminalBellNotification = (): void => { - if (terminalBellNotificationTimer !== null) { - return - } - terminalBellNotificationTimer = setTimeout(() => { - terminalBellNotificationTimer = null - if (disposed) { - pendingTerminalBellNotification = false - return - } - if (hasPendingAgentTaskCompleteNotification()) { - return - } - pendingTerminalBellNotification = false - deps.dispatchNotification({ source: 'terminal-bell', paneKey: cacheKey }) - }, AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) - } - - const hasPendingAgentTaskCompleteNotification = (): boolean => - isAgentTaskCompleteNotificationEnabled() && - (agentCompletionCoordinator.hasPendingHookDoneCompletion() || - agentTaskCompleteNotificationGraceTimer !== null || - agentTaskCompleteNotificationMaxTimer !== null || - agentTaskCompleteStatusUnsubscribe !== null) - - const clearPendingAgentTaskCompleteNotification = (): void => { - if (agentTaskCompleteNotificationGraceTimer !== null) { - clearTimeout(agentTaskCompleteNotificationGraceTimer) - agentTaskCompleteNotificationGraceTimer = null - } - if (agentTaskCompleteNotificationMaxTimer !== null) { - clearTimeout(agentTaskCompleteNotificationMaxTimer) - agentTaskCompleteNotificationMaxTimer = null - } - if (agentTaskCompleteStatusUnsubscribe !== null) { - agentTaskCompleteStatusUnsubscribe() - agentTaskCompleteStatusUnsubscribe = null - } - } - - const syncAgentTaskCompleteTrackingEnabled = (): boolean => { - const enabled = isAgentTaskCompleteTrackingEnabled() - const osNotificationsEnabled = isAgentTaskCompleteNotificationEnabled() - if ( - !osNotificationsEnabled && - wasAgentTaskCompleteOsNotificationEnabled && - pendingTerminalBellNotification - ) { - scheduleTerminalBellNotification() - } - if (!enabled && wasAgentTaskCompleteTrackingEnabled) { - // Why: disabling every completion consumer is an event-time boundary. - // Drop pending alerts while preserving accepted-hook lifecycle state. - agentTaskCompleteNotificationGeneration += 1 - requiresFreshWorkingForAgentTaskCompleteNotification = true - clearPendingAgentTaskCompleteNotification() - if (pendingTerminalBellNotification) { - scheduleTerminalBellNotification() - } - } else if (enabled && !wasAgentTaskCompleteTrackingEnabled) { - // Why: a pane may have observed work while all completion consumers were - // disabled. Re-enabling should not let the next idle event report old work. - requiresFreshWorkingForAgentTaskCompleteNotification = true - } - wasAgentTaskCompleteTrackingEnabled = enabled - wasAgentTaskCompleteOsNotificationEnabled = osNotificationsEnabled - return enabled - } - - const scheduleAgentTaskCompleteNotification = ( - title: string, - options: { - allowDoneDetailAfterGrace?: boolean - agentStatusSnapshot?: AgentCompletionStatusSnapshot - agentCompletionSource?: AgentCompletionDispatchMeta['source'] - } = {} - ): void => { - if ( - !syncAgentTaskCompleteTrackingEnabled() || - requiresFreshWorkingForAgentTaskCompleteNotification - ) { - return - } - clearPendingAgentTaskCompleteNotification() - let graceElapsed = false - const generationAtSchedule = agentTaskCompleteNotificationGeneration - const agentStatusAtSchedule = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const hasNewerActiveHookStatus = (): boolean => { - const currentStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const scheduledAgentType = agentStatusAtSchedule?.agentType - const currentAgentForScheduledTurn = resolveCompatibleAgentTypeForOwner( - currentStatus?.agentType, - scheduledAgentType - ) - const hasDifferentKnownAgent = Boolean( - currentStatus?.agentType && - scheduledAgentType && - currentStatus.agentType !== 'unknown' && - scheduledAgentType !== 'unknown' && - currentAgentForScheduledTurn !== scheduledAgentType - ) - return ( - options.agentCompletionSource === 'process-exit' && - isFreshNonDoneAgentStatus(currentStatus) && - (!agentStatusAtSchedule || - currentStatus.state !== agentStatusAtSchedule.state || - currentStatus.stateStartedAt !== agentStatusAtSchedule.stateStartedAt || - hasDifferentKnownAgent) - ) - } - - const dispatch = (): void => { - clearPendingAgentTaskCompleteNotification() - if ( - generationAtSchedule !== agentTaskCompleteNotificationGeneration || - !syncAgentTaskCompleteTrackingEnabled() || - hasNewerActiveHookStatus() - ) { - return - } - if (disposed) { - return - } - // Why: terminal attention is a visual pane affordance, not an OS - // notification. Route through dispatch so stale pane completions are - // rejected before unread attention is marked. - const shouldDispatchOsNotification = isAgentTaskCompleteNotificationEnabled() - pendingTerminalBellNotification = false - clearTerminalBellNotificationTimer() - deps.dispatchNotification({ - source: 'agent-task-complete', - terminalTitle: title, - paneKey: cacheKey, - ...(options.agentCompletionSource - ? { agentCompletionSource: options.agentCompletionSource } - : {}), - ...(shouldDispatchOsNotification ? {} : { suppressOsNotification: true }), - ...(options.agentStatusSnapshot ? { agentStatusSnapshot: options.agentStatusSnapshot } : {}) - }) - } - - const dispatchIfDetailed = (): void => { - if (hasNewerActiveHookStatus()) { - // Why: the confirmed exit belongs to the row captured above; a replaced - // active row means a newer turn started during the notification delay. - clearPendingAgentTaskCompleteNotification() - return - } - if (!graceElapsed) { - return - } - const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] - if (canDispatchAgentNotificationAfterGrace(entry, options)) { - dispatch() - } - } - - agentTaskCompleteStatusUnsubscribe = useAppStore.subscribe(dispatchIfDetailed) - agentTaskCompleteNotificationGraceTimer = setTimeout(() => { - agentTaskCompleteNotificationGraceTimer = null - graceElapsed = true - dispatchIfDetailed() - }, AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) - // Why: some agents never surface assistant text through hooks. Keep a hard - // cap so task-complete notifications still fire instead of waiting forever. - agentTaskCompleteNotificationMaxTimer = setTimeout( - dispatch, - AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - ) - } - agentTaskCompleteSettingsUnsubscribe = subscribeAgentTaskCompleteTrackingEnabled(() => { - if (syncAgentTaskCompleteTrackingEnabled()) { - agentCompletionCoordinator.startProcessTracking() - } - }) - - // ─── Agent task-complete: notification-backed attention ─────────────── - // - // The working→idle title transition drives two independent concerns: - // 1. The Claude prompt-cache countdown in the sidebar. - // 2. The "Agent Task Complete" OS notification users toggle in Settings. - // - // This path raises the same terminal attention marker as BEL through the - // shared notification dispatcher. Not every agent CLI reliably emits BEL on - // completion (Gemini, some Codex flows), and the highlight needs to remain - // findable after the OS banner is gone. Double-firing with a concurrent BEL - // is handled by delaying the BEL OS notification below; main still keeps a - // 5 s per-worktree dedupe as the final guard. - const onAgentBecameIdle = (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { - // Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not - // observed bytes — a merely-paused agent (>3s silent mid-task, window - // minimized) would otherwise mint a false task-complete OS notification - // that renderer timer throttling previously damped. Clear session-tied - // state only; never schedule completion attention from it. - if (meta?.staleWorkingTitleClear) { - deps.setCacheTimerStartedAt(cacheKey, null) - return - } - const currentState = useAppStore.getState() - const activeHookStatus = currentState.agentStatusByPaneKey[cacheKey] - if (shouldSuppressTitleCompletionForFreshHook(title, activeHookStatus)) { - // Why: agent CLIs can briefly publish an idle title while hook status - // still says the same agent turn is active (e.g. during tool output). - if (activeHookStatus) { - preserveSuppressedTitleSideEffects(title, activeHookStatus) - } - return - } - // Why: only start the prompt-cache countdown for Claude agents — other - // agents have different (or no) prompt-caching semantics and showing a - // timer for them would be misleading. - // - // Why we check `settings !== null` separately: during startup, settings - // hydrate asynchronously after terminals reconnect. If we treat null - // as disabled, the first working→idle transition on a restored Claude - // tab silently drops the timer. Writing a timestamp is cheap and the - // CacheTimer component gates rendering on the enabled flag, so a - // spurious write when the feature turns out to be disabled is harmless. - const settings = currentState.settings - if (isClaudeAgent(title) && (settings === null || settings.promptCacheTimerEnabled)) { - deps.setCacheTimerStartedAt(cacheKey, Date.now()) - } - if (detectAgentStatusFromTitle(title) === 'idle') { - setFocusReportSuppressionForAgentCompletion(title, activeHookStatus?.agentType) - } - if (syncAgentTaskCompleteTrackingEnabled()) { - agentCompletionCoordinator.observeClassifiedTitleCompletion(title) - } - // Why: some agent TUIs leave xterm renderer modes active after a turn. - // Reset cursor everywhere, and Kitty keyboard state on native Windows. - queueAgentIdleTerminalModeReset() - } - const onAgentBecameWorking = (): void => { - suppressNativeWindowsIdleCodexFocusReports = false - clearSuppressedTitleSideEffects() - if (syncAgentTaskCompleteTrackingEnabled()) { - requiresFreshWorkingForAgentTaskCompleteNotification = false - agentCompletionCoordinator.observeTitleWorking() - } - // Why: a new API call refreshes the prompt-cache TTL, so clear any running - // countdown. The timer will restart when the agent becomes idle again. - deps.setCacheTimerStartedAt(cacheKey, null) - clearPendingAgentTaskCompleteNotification() - if (pendingTerminalBellNotification) { - scheduleTerminalBellNotification() - } - } - const onAgentExited = (): void => { - // Why: eligibility can disappear transiently during reconnect, but a - // confirmed shell-title transition is authoritative for native-chat exit. - deps.onAgentExitedRef.current(pane.leafId) - clearSuppressedTitleSideEffects() - clearCommandInferredPaneAgent() - requestKnownWindowsShiftEnterReconfirmation() - // Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"), - // the agent has exited. Clear any running cache timer so the sidebar doesn't - // show a stale countdown for a tab that no longer has an active Claude session. - deps.setCacheTimerStartedAt(cacheKey, null) - clearTitleOnlyInterruptTimer() - // Why: title reversion alone is not process death. The process/PTY tracker - // owns removing agent rows when the TUI actually exits. - } - // Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their - // callbacks to the correct Orca pane without resolving worktrees from cwd. - // The key matches the `${tabId}:${leafId}` composite used for cacheTimerByKey - // and agentStatusByPaneKey. Treat it as opaque outside Orca. - const state = useAppStore.getState() - const parsedWorkspaceKey = parseWorkspaceKey(deps.worktreeId) - const folderWorkspace = - parsedWorkspaceKey?.type === 'folder' - ? state.folderWorkspaces.find( - (workspace) => workspace.id === parsedWorkspaceKey.folderWorkspaceId - ) - : null - const workspaceEnv: Record = { ORCA_WORKSPACE_ID: deps.worktreeId } - if (folderWorkspace) { - workspaceEnv.ORCA_PROJECT_GROUP_ID = folderWorkspace.projectGroupId - workspaceEnv.ORCA_WORKSPACE_ROOT = folderWorkspace.folderPath - } - const paneIdentityEnv = { - ...workspaceEnv, - ORCA_PANE_KEY: cacheKey, - ORCA_TAB_ID: deps.tabId, - ORCA_WORKTREE_ID: deps.worktreeId, - ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) - } - const paneEnv = { - ...paneStartup?.env, - ...paneIdentityEnv - } - - // Why: folder workspaces can inherit their SSH target from child repos, so - // use the shared resolver instead of only looking up repo-backed worktrees. - const worktree = getWorktreeMapFromState(state).get(deps.worktreeId) - const worktreeConnectionId = getConnectionId(deps.worktreeId) - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId) - const restoredPtyIdForTransport = - deps.restoredLeafId && deps.restoredPtyIdByLeafId - ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) - : null - // Why: the floating terminal and inline setup/onboarding terminals are host-agnostic synthetic - // ids with no worktree/repo row, so the strict owner resolver reports them as unresolved. The - // shared terminal router scopes them to their floating owner (local for the floating terminal, - // the active runtime for setup terminals so remote skill installs land there) and returns null - // only for a genuinely unknown/stale worktree that must fail closed (#9994). - const terminalWorktreeRoute = resolveTerminalWorktreeRoute(state, deps.worktreeId) - const explicitRuntimeEnvironmentId = terminalWorktreeRoute?.runtimeEnvironmentId ?? null - // Why: paired-web worktrees retain HUB execution identity; their runtime-scoped mirrored pane is the session-level transport owner. - const mirroredRuntimeOwners = new Set( - isWebTerminalSurfaceTabId(deps.tabId) - ? [restoredPtyIdForTransport, tab?.ptyId] - .map((ptyId) => (ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null)) - .filter((environmentId): environmentId is string => Boolean(environmentId)) - : [] - ) - const mirroredRuntimeEnvironmentId = mirroredRuntimeOwners.values().next().value ?? null - const terminalOwnerUnresolved = - mirroredRuntimeOwners.size > 1 || - (terminalWorktreeRoute === null && !mirroredRuntimeEnvironmentId) - const runtimeEnvironmentId = explicitRuntimeEnvironmentId - ? explicitRuntimeEnvironmentId - : mirroredRuntimeEnvironmentId - ? mirroredRuntimeEnvironmentId - : null - // Why: host-agnostic synthetic ids (floating terminal, inline setup panels) have no repo - // row by design, and a worktree row stamped 'local' proves its host on its own — both are - // resolved-local, not pending hydration (#10151). Only when nothing names the host does - // `undefined` mean the repo hasn't merged yet; coalescing that to null would fail-open a - // remote cwd onto the local daemon (ENOENT on Docker SSH paths). - const hostAgnosticTerminalWorktree = - deps.worktreeId === FLOATING_TERMINAL_WORKTREE_ID || - isEphemeralSetupTerminalWorktreeId(deps.worktreeId) - const worktreeProvesLocalHost = parseExecutionHostId(worktree?.hostId)?.kind === 'local' - const connectionOwnerHydrating = - !terminalOwnerUnresolved && - !hostAgnosticTerminalWorktree && - !worktreeProvesLocalHost && - runtimeEnvironmentId === null && - worktreeConnectionId === undefined - // Why: an SSH host nested under a HUB is execution identity, not permission for the paired client to dial that host. - const connectionId = - !terminalOwnerUnresolved && !connectionOwnerHydrating && runtimeEnvironmentId === null - ? (worktreeConnectionId ?? null) - : null - type DirectSshRetryLease = Pick< - DirectSshPaneRetryAttempt, - 'attemptId' | 'authority' | 'tabGeneration' - > - const directSshRetryAttempt: DirectSshRetryLease | undefined = (() => { - const pendingAttempt = state.directSshPaneRetryByTabId?.[deps.tabId] - const liveBinding = state.directSshLivePtyBindingByTabId?.[deps.tabId] - const attempt = - pendingAttempt?.authority.targetId === connectionId && - pendingAttempt.tabGeneration === (tab?.generation ?? 0) - ? pendingAttempt - : liveBinding?.authority.targetId === connectionId && - liveBinding.tabGeneration === (tab?.generation ?? 0) - ? liveBinding - : undefined - return attempt - })() - // Only the PENDING retry marks a mount that a reconnect created. directSshRetryAttempt also - // accepts the live binding, which is written at the same tab generation once the reconnect - // succeeds and then outlives it — so it stays truthy for every later remount of that generation, - // not just this one. - const followsDirectSshReconnect = (() => { - const pending = state.directSshPaneRetryByTabId?.[deps.tabId] - return ( - pending?.authority.targetId === connectionId && - pending.tabGeneration === (tab?.generation ?? 0) - ) - })() - const pendingSpawnKey = directSshRetryAttempt - ? JSON.stringify([cacheKey, directSshRetryAttempt.attemptId]) - : cacheKey - let capturedDirectSshRetryPtyAccepted = false - let directSshPaneRetrySettlementCancelled = false - const directSshPaneRetrySettlementTimers = new Set>() - const directSshPaneRetryTimedPromises = new WeakSet() - const capturedDirectSshRetryLeaseMatches = (): boolean => { - if (!directSshRetryAttempt) { - return true - } - const currentState = useAppStore.getState() - const currentConnection = currentState.sshConnectionStates.get( - directSshRetryAttempt.authority.targetId - ) - const currentTab = (currentState.tabsByWorktree[deps.worktreeId] ?? []).find( - (candidate) => candidate.id === deps.tabId - ) - if ( - currentConnection?.providerEpoch !== directSshRetryAttempt.authority.providerEpoch || - currentConnection.connectionGeneration !== - directSshRetryAttempt.authority.connectionGeneration || - (currentTab?.generation ?? 0) !== directSshRetryAttempt.tabGeneration - ) { - return false - } - const pendingAttempt = currentState.directSshPaneRetryByTabId?.[deps.tabId] - const pendingMatches = - pendingAttempt?.attemptId === directSshRetryAttempt.attemptId && - directSshAuthoritiesEqual(pendingAttempt.authority, directSshRetryAttempt.authority) && - pendingAttempt.tabGeneration === directSshRetryAttempt.tabGeneration - const liveBinding = currentState.directSshLivePtyBindingByTabId?.[deps.tabId] - const liveBindingMatchesAttempt = - liveBinding?.attemptId === directSshRetryAttempt.attemptId && - directSshAuthoritiesEqual(liveBinding.authority, directSshRetryAttempt.authority) && - liveBinding.tabGeneration === directSshRetryAttempt.tabGeneration - return pendingMatches || liveBindingMatchesAttempt - } - const capturedDirectSshRetryStateMatches = (ptyId: string): boolean => { - if (!directSshRetryAttempt) { - return true - } - const currentConnection = useAppStore - .getState() - .sshConnectionStates.get(directSshRetryAttempt.authority.targetId) - return ( - parseAppSshPtyId(ptyId)?.connectionId === directSshRetryAttempt.authority.targetId && - currentConnection?.status === 'connected' && - capturedDirectSshRetryLeaseMatches() - ) - } - const claimCapturedDirectSshRetryPty = (ptyId: string): boolean => { - if (!capturedDirectSshRetryStateMatches(ptyId)) { - return false - } - capturedDirectSshRetryPtyAccepted = directSshRetryAttempt !== undefined - return true - } - const canAdoptCapturedDirectSshRetryPty = (ptyId: string): boolean => { - const canAdopt = capturedDirectSshRetryStateMatches(ptyId) - if (canAdopt && directSshRetryAttempt) { - capturedDirectSshRetryPtyAccepted = true - } - return canAdopt - } - const settleDirectSshPaneRetryAttempt = ( - attempt: DirectSshRetryLease | undefined, - status: 'failed' | 'timed-out' - ): void => { - if (!attempt) { - return - } - useAppStore.getState().settleDirectSshPaneRetry?.({ - status, - tabId: deps.tabId, - attemptId: attempt.attemptId, - authority: attempt.authority, - tabGeneration: attempt.tabGeneration - }) - } - const armDirectSshPaneRetryTimeout = ( - promise: Promise, - attempt: DirectSshRetryLease | undefined - ): void => { - if (!attempt || disposed || directSshPaneRetryTimedPromises.has(promise)) { - return - } - directSshPaneRetryTimedPromises.add(promise) - const timer = setTimeout(() => { - directSshPaneRetrySettlementTimers.delete(timer) - if (directSshPaneRetrySettlementCancelled) { - return - } - settleDirectSshPaneRetryAttempt(attempt, 'timed-out') - }, DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS) - directSshPaneRetrySettlementTimers.add(timer) - void promise - .finally(() => { - directSshPaneRetrySettlementTimers.delete(timer) - clearTimeout(timer) - }) - .catch(() => {}) - } - const shellOverride = tab?.shellOverride - // Why: a serve/remote-runtime pane has no SSH connectionId and a Linux cwd, so - // the native-Windows ConPTY heuristic misfires on a Windows client and wrongly - // enables ConPTY synchronized-output protection, which strips an agent's - // transient cursor-show (?25h) and leaves the cursor invisible. The execution - // host is the authoritative signal: only a 'local' host is a local native PTY. - const executionHostId = terminalOwnerUnresolved - ? ('runtime:unresolved-owner' as const) - : getExecutionHostIdForWorktree(state, deps.worktreeId) - const isNativeWindowsConpty = isLocalNativeWindowsConpty({ - userAgent: navigator.userAgent, - connectionId, - cwd: deps.cwd, - // Why: main folds the global Windows shell into its spawn classification - // (pty.ts effectiveShellOverride); fold it here too so both sides treat - // a global-WSL default identically (terminal-query-authority.md ConPTY). - shellOverride: resolveWindowsShellOverride(shellOverride, state.settings?.terminalWindowsShell), - executionHostId - }) - if (isNativeWindowsConpty) { - // Why: Windows ConPTY agent turns can leave renderer keyboard modes armed - // after completion, corrupting plain input with encoded bytes. - idleAgentTerminalModeReset = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` - } - const shouldApplyNativeWindowsRewriteRefresh = isNativeWindowsConpty - const shouldApplyWindowsRendererUnicodeRefresh = CLIENT_PLATFORM === 'win32' - const shouldProtectNativeWindowsSynchronizedOutput = isNativeWindowsConpty - let unsubscribeWindowsDoneTerminalModeReset: (() => void) | null = null - if (isNativeWindowsConpty) { - const initialAgentStatus = state.agentStatusByPaneKey[cacheKey] - let lastAgentDoneStartedAt = resolveLatestAgentDoneStartedAt(initialAgentStatus) - if ( - !initialAgentStatus && - paneStartup?.telemetry?.launch_source === 'sidebar' && - paneStartup.telemetry.request_kind === 'resume' && - (paneStartup.launchAgent === 'codex' || paneStartup.telemetry.agent_kind === 'codex') - ) { - // Why: history resumes open on a completed Codex composer without a done - // row, so arm the same Windows stale-focus guard until work starts again. - suppressNativeWindowsIdleCodexFocusReports = true - } - if (initialAgentStatus?.state === 'done') { - setFocusReportSuppressionForAgentCompletion(undefined, initialAgentStatus.agentType) - } - unsubscribeWindowsDoneTerminalModeReset = useAppStore.subscribe((nextState) => { - const nextAgentStatus = nextState.agentStatusByPaneKey[cacheKey] - const nextAgentStatusState = nextAgentStatus?.state - if (nextAgentStatusState === 'done') { - setFocusReportSuppressionForAgentCompletion(undefined, nextAgentStatus.agentType) - } else if (nextAgentStatusState) { - suppressNativeWindowsIdleCodexFocusReports = false - } - const nextAgentDoneStartedAt = resolveLatestAgentDoneStartedAt(nextAgentStatus) - // Why: a NEW completed turn — same-state `done` pings keep stateStartedAt, so they no-op. - if ( - nextAgentDoneStartedAt !== undefined && - nextAgentDoneStartedAt !== lastAgentDoneStartedAt - ) { - queueAgentIdleTerminalModeReset() - } - lastAgentDoneStartedAt = nextAgentDoneStartedAt - }) - } - - const localWindowsTerminalCapabilities = hasCachedWindowsTerminalCapabilities() - ? getCachedWindowsTerminalCapabilities() - : null - const projectRuntime = - !tab?.forceHostRuntime && !connectionId && runtimeEnvironmentId === null - ? getLocalProjectExecutionRuntimeContext(state, deps.worktreeId, undefined, { - wslAvailable: localWindowsTerminalCapabilities?.wslAvailable, - availableWslDistros: localWindowsTerminalCapabilities?.wslDistros ?? null - }) - : undefined - const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null - // Why: the host also mirrors agent status for this pane through session.tabs. - // Claiming here (decided once at transport creation, like the side-effect - // authority below) lets the mirror keep this renderer's byte-derived status - // instead of overwriting/deleting it on every republication. - const releaseRendererOwnedAgentStatusPane = - runtimeEnvironmentId !== null - ? registerRendererOwnedAgentStatusPane(cacheKey, runtimeEnvironmentId) - : null - handleRendererOwnedAgentStatus = (payload): void => { - if ( - shouldSuppressCodexAutoApprovalStatus(payload, { - paneKey: cacheKey, - tabId: deps.tabId, - ...(launchToken ? { launchToken } : {}) - }) - ) { - return - } - const currentState = useAppStore.getState() - const routing = resolveCurrentAgentStatusRouting() - if (!routing) { - return - } - const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const authoritativePaneAgent = getAuthoritativePaneAgent() - const agentType = resolveCompatibleAgentTypeForOwner(payload.agentType, authoritativePaneAgent) - const statusPayload = agentType === payload.agentType ? payload : { ...payload, agentType } - // Why: this is the remote-runtime path where the renderer, not main, parses OSC 9999 out of - // PTY bytes — so the renderer is the sequencing authority for these rows and says so. Kept - // beside statusPayload, not merged into it, so the notification/title consumers below see - // byte-for-byte what they see today. - const observedStatusPayload = { - ...statusPayload, - observation: rendererAgentStatusObservations.observe(cacheKey, { - origin: 'osc', - observedAt: Date.now(), - kind: 'snapshot' - }) - } - const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title) - const statusTitle = resolvedStatusTitle - ? normalizeCompatibleAgentTitleForOwner( - resolvedStatusTitle, - agentType ?? authoritativePaneAgent - ) - : resolvedStatusTitle - // Why: proves the claim — only a pane that really produced byte-derived - // status may fence the host mirror out of its store key. - markRendererOwnedAgentStatusWrite(cacheKey) - if (launchToken) { - currentState.setAgentStatus( - cacheKey, - observedStatusPayload, - statusTitle, - undefined, - routing, - { - launchToken - } - ) - } else { - currentState.setAgentStatus(cacheKey, observedStatusPayload, statusTitle, undefined, routing) - } - if (payload.state === 'working' && syncAgentTaskCompleteTrackingEnabled()) { - requiresFreshWorkingForAgentTaskCompleteNotification = false - } - const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const notificationPayload = - typeof storedStatus?.stateStartedAt === 'number' - ? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt } - : statusPayload - // Why: hook lifecycle owns deferred side effects even when alerts are disabled. - agentCompletionCoordinator.observeHookStatus(notificationPayload) - if (payload.state === 'working' && pendingTerminalBellNotification) { - scheduleTerminalBellNotification() - } - } - // Why: when main holds side-effect authority for this PTY's bytes, the - // transport must NOT register title/bell/agent byte parsers — the - // pty:sideEffect fact consumer below is the single policy consumer. - // Decided once at transport creation so a fact never has two consumers. - const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ - settings: state.settings, - runtimeEnvironmentId - }) - // Why: Phase-4 hidden-delivery gate — only meaningful under main authority - // (renderer byte parsers need bytes otherwise). Decided once at pane - // creation: it picks which path records a mode-2031 subscription (main's - // fact vs the byte scan), which must have exactly one owner. - const hiddenDeliveryGateActive = - mainSideEffectAuthority && isRendererHiddenPtyDeliveryGateEnabled(state.settings) - // Why: structural per-PTY gate predicate (authority on + gate on + bytes - // transit local main, which implies snapshot-backed). Shared by the hidden - // mark sync and mode-2031 subscription recording so the recorder can never - // disagree with what main may drop — and never depends on the racy hidden - // mark (a fact can outrun the pty:data task that sets it). - const isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string => - hiddenDeliveryGateActive && Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) - // Why (byte-parser mode only): with main authority the Command Code scrape - // runs in main's per-PTY tracker and arrives as command-code facts; running - // the byte detector too would double-drive the seed/settle policy above. - const commandCodeOutputStatusDetector = mainSideEffectAuthority - ? null - : createCommandCodeOutputStatusDetector({ - startupCommand: paneStartup?.command, - // Why the seed: a reveal remount recreates this detector long past the banner - // (and with no startup command); a turn parked mid-flight must still arm the - // scrape so its return to the idle composer completes the row. - inFlightTurn: readInFlightCommandCodeTurn(cacheKey), - onWorking: seedCommandCodeOutputWorkingStatus, - onDone: scheduleCommandCodeOutputDoneStatus - }) - const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' - const shouldUseProviderSshStartupDelivery = - Boolean(connectionId) && !shouldDeliverStartupViaTerminalPaste - const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 - let lastTerminalInputAt = Number.NEGATIVE_INFINITY - // Why: separate from lastTerminalInputAt because onExit reads that one as - // "the user never typed into this pane" to keep a dead newborn pane mounted. - // Captured shortcuts must open the redraw window without arming that teardown. - let lastInteractiveRedrawInputAt = Number.NEGATIVE_INFINITY - let hasReceivedPtyOutput = false - let deferredReattachLiveData: DeferredReattachLiveDataQueue | null = null - let reattachLiveDataDeferralDepth = 0 - let deferredReattachLiveDataOwners = new Map() - let transportStreamGeneration = 0 - const markInteractiveRedrawInput = (): void => { - lastInteractiveRedrawInputAt = performance.now() - // Why: input must probe a wedged xterm even when the PTY produces no renderer output. - requestTerminalWritePipelineProbe(pane.terminal) - } - const markTerminalInputSent = (): void => { - lastTerminalInputAt = performance.now() - markInteractiveRedrawInput() - } - const recordTerminalInputForHibernation = (): void => { - useAppStore.getState().recordTerminalInput(cacheKey) - } - // Why: onData mixes real user input with xterm's parser auto-replies (focus - // reports, DA/DSR/CPR responses). Recording those replies as activity makes - // the hibernation planner treat a pane hidden after its agent finished as - // "input after done" forever. The core user-input signal fires only for real - // input, so hibernation activity records from it; onData recording remains - // solely as the fallback when the internal API is unavailable. - const recordRealUserTerminalInput = (): void => { - recordTerminalInputForHibernation() - // Takeover must never fire from the onData fallback below: it mixes in auto-replies. - reportWorkerTerminalUserInput(cacheKey, runtimeEnvironmentId) - } - const userInputActivityDisposable = subscribeToTerminalUserInput( - pane.terminal, - recordRealUserTerminalInput - ) - const recordTerminalInputForHibernationFallback = (): void => { - if (userInputActivityDisposable === null) { - recordTerminalInputForHibernation() - } - } - const markAcceptedTerminalInputSent = (): void => { - markTerminalInputSent() - recordTerminalInputForHibernationFallback() - } - const terminalTheme = pane.terminal.options.theme - const terminalColorQueryReplies = terminalTheme - ? { foreground: terminalTheme.foreground, background: terminalTheme.background } - : undefined - const agentLaunchPreferences = toAgentLaunchPreferences(paneStartup?.sessionOptions) - const transportOptions = { - cwd: deps.cwd, - // Why: only fresh local IPC spawns may recover from a saved startup cwd - // whose directory was deleted (#7239); remote-runtime and SSH spawns - // resolve cwd on another host and must keep exact cwd semantics. - ...(runtimeEnvironmentId === null && !connectionId ? { cwdFallback: 'worktree' as const } : {}), - env: paneEnv, - ...(paneStartup?.envToDelete ? { envToDelete: paneStartup.envToDelete } : {}), - command: shouldDeliverStartupViaTerminalPaste ? undefined : paneStartup?.command, - ...(shouldUseProviderSshStartupDelivery ? { commandDelivery: 'provider' as const } : {}), - startupCommandDelivery: shouldDeliverStartupViaTerminalPaste - ? undefined - : connectionId && paneStartup?.command - ? 'shell-ready' - : paneStartup?.startupCommandDelivery, - connectionId, - executionHostId, - worktreeId: deps.worktreeId, - // Why: closes the SIGKILL race documented in INVESTIGATION.md by letting - // main sync-flush the (worktreeId, tabId, leafId → ptyId) binding before - // pty:spawn returns. Daemon-host-only: SSH path leaves these undefined - // and the main-side guard short-circuits. - tabId: deps.tabId, - leafId: pane.leafId, - activate: deps.isActiveRef.current && deps.isVisibleRef.current, - ...(shellOverride ? { shellOverride } : {}), - ...(projectRuntime ? { projectRuntime } : {}), - ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), - ...(paneStartup?.launchConfig ? { launchConfig: paneStartup.launchConfig } : {}), - ...(paneStartup?.resumeProviderSession - ? { resumeProviderSession: paneStartup.resumeProviderSession } - : {}), - ...((paneStartup?.initialAgentStatus?.prompt ?? paneStartup?.draftPrompt) - ? { agentPrompt: paneStartup?.initialAgentStatus?.prompt ?? paneStartup?.draftPrompt } - : {}), - ...(paneStartup?.initialAgentStatus?.prompt - ? { agentPromptDelivery: 'auto-submit' as const } - : paneStartup?.draftPrompt - ? { agentPromptDelivery: 'draft' as const } - : {}), - ...(paneStartup?.agentArgsOverride !== undefined - ? { agentArgsOverride: paneStartup.agentArgsOverride } - : {}), - ...(agentLaunchPreferences ? { agentLaunchPreferences } : {}), - ...(launchToken ? { launchToken } : {}), - ...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}), - ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), - onPtyExit: onExit, - onPtySpawn, - onPtyRebind, - ...(mainSideEffectAuthority - ? {} - : { - onTitleChange, - onBell, - onAgentBecameIdle, - onAgentBecameWorking, - onAgentExited - }), - // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService - // parses OSC 9999 before renderer delivery and forwards through the hook - // server with local/SSH identity. Remote-runtime streams do not pass through - // local main, so the renderer remains their status owner for now. - ...(shouldOwnAgentStatusInRenderer ? { onAgentStatus: handleRendererOwnedAgentStatus } : {}) - } - if (connectionOwnerHydrating) { - // Why: this pane holds an inert transport until its host resolves; register it so - // the repos:changed handler remounts it instead of leaving the terminal blank. - recordTerminalTabParkedOnUnresolvedHost(deps.worktreeId, deps.tabId) - } - const transport = - terminalOwnerUnresolved || connectionOwnerHydrating - ? createUnresolvedOwnerPtyTransport( - terminalOwnerUnresolved - ? 'Workspace identity is ambiguous across hosts. Refresh projects and try again.' - : 'Workspace host is still loading. Retry when the project finishes hydrating.' - ) - : runtimeEnvironmentId - ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) - : createIpcPtyTransport(transportOptions) - const canSendDesktopQueryReply = (): boolean => { - const ptyId = transport.getPtyId() - return !ptyId || !isPtyLocked(ptyId) - } - // Why: parser/capability handlers bypass the ordinary onData guard. Keep - // desktop silent while the elected mobile xterm owns query replies. - const sendDesktopQueryReplyImmediate = (data: string): boolean => - canSendDesktopQueryReply() && transport.sendInputImmediate(data) - // Why (gate mode only): gate-managed PTYs never see the subscribe bytes, so this fact is - // their only cue to record the subscription — without the registry entry a later theme - // flip never pushes the CSI 997 update and the TUI keeps a stale theme after reveal. - // Record-only: a subscribe is not a query (see observeLiveMode2031Chunk, #9993). - const handleHiddenMode2031SubscribeFact = (): void => { - const ptyId = transport.getPtyId() - if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputGatedPtyId !== ptyId)) { - return - } - const mode = resolveTerminalColorSchemeMode( - useAppStore.getState().settings, - getSystemPrefersDark() - ) - deps.recordPaneMode2031Subscription?.(pane.id, mode) - } - // Why (gate mode only): the counterpart to the subscribe fact. These panes never - // receive the withdrawal bytes — main drops them before delivery — and the chunk - // scanner is disabled for them, so this fact is the ONLY observer that can retire - // the subscription. Without it a TUI that exits while hidden leaves paneMode2031 - // set, and the next theme flip pushes CSI 997 into the shell that replaced it - // (#9993 via maybePushMode2031Flip). - const handleHiddenMode2031UnsubscribeFact = (): void => { - const ptyId = transport.getPtyId() - if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputGatedPtyId !== ptyId)) { - return - } - deps.paneMode2031Ref.current.delete(pane.id) - deps.paneLastThemeModeRef.current.delete(pane.id) - } - deps.paneTransportsRef.current.set(pane.id, transport) - const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({ - terminal: pane.terminal, - parser: pane.terminal.parser, - // Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode - // read window; the remote transport's input debounce would corrupt them - // (#7329), so send immediately. - sendInput: sendDesktopQueryReplyImmediate, - isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id), - ...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {}) - }) - const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder( - pane.terminal, - sendDesktopQueryReplyImmediate - ) - - const claimViewportForUserActivity = (): void => { - const currentPtyId = transport.getPtyId() - if (!currentPtyId || getFitOverrideForPty(currentPtyId)?.mode !== 'remote-desktop-fit') { - return - } - let proposed: { cols: number; rows: number } | undefined - try { - proposed = pane.fitAddon.proposeDimensions() - } catch { - proposed = undefined - } - const cols = proposed?.cols ?? pane.terminal.cols - const rows = proposed?.rows ?? pane.terminal.rows - if (cols > 0 && rows > 0) { - // Why: queuing a claim is not convergence. Keep the pane parked until the - // runtime confirms desktop-fit so a transient resize failure retries. - transport.claimViewport?.(cols, rows) - } - } - const claimPendingVisibleRemoteViewport = (): void => { - if ( - !pendingVisibleRemoteViewportClaim || - !deps.isVisibleRef.current || - typeof document === 'undefined' || - document.visibilityState === 'hidden' || - typeof document.hasFocus !== 'function' || - !document.hasFocus() - ) { - return - } - claimViewportForUserActivity() - } - const armVisibleRemoteViewportClaim = (): void => { - const ptyId = transport.getPtyId() - if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { - visibleRemoteViewportClaimPtyId = null - pendingVisibleRemoteViewportClaim = false - return - } - if ( - visibleRemoteViewportClaimPtyId !== ptyId || - pendingVisibleRemoteViewportClaim || - getFitOverrideForPty(ptyId)?.mode === 'remote-desktop-fit' - ) { - visibleRemoteViewportClaimPtyId = ptyId - pendingVisibleRemoteViewportClaim = true - } - } - const unsubscribeRemoteDesktopActivationClaim = onOverrideChange((event) => { - if (event.ptyId !== transport.getPtyId() || !isRemoteRuntimePtyId(event.ptyId)) { - return - } - if (event.mode === 'desktop-fit') { - visibleRemoteViewportClaimPtyId = event.ptyId - pendingVisibleRemoteViewportClaim = false - return - } - if (event.mode === 'remote-desktop-fit') { - if (deps.isVisibleRef.current && visibleRemoteViewportClaimPtyId !== event.ptyId) { - visibleRemoteViewportClaimPtyId = event.ptyId - pendingVisibleRemoteViewportClaim = true - } - claimPendingVisibleRemoteViewport() - } - }) - - // Why: an unbound transport (detached during a remount/move and never - // rebound) silently rejects every keystroke while the PTY stays alive and - // the last frame stays painted — the pane looks healthy and eats input - // (issue #8104 class). None of the dead-session reconciles cover it because - // the PTY is live; recover by remounting the tab over the live PTY. - let transportConnectInFlightSince: number | null = null - // Why a grace window instead of a plain flag: a connect that never settles - // (SSH RPC timeout class, wedged daemon call) would otherwise suppress - // input-triggered recovery FOREVER — and such a pane has no output flowing, - // so no other detector can fire. Past the grace, undeliverable input may - // recover again; the transport's destroyed-check no longer kills a - // pre-existing session when a late reattach resolves, so a remount racing - // a slow-but-alive connect costs a wasted view rebuild, not a shell. - const TRANSPORT_CONNECT_SETTLE_GRACE_MS = 60_000 - const requestRecoveryForUndeliverableInput = (providerRejected = false): void => { - if (!providerRejected && transport.isConnected?.() && transport.getPtyId() !== null) { - return - } - // Why: input rejected while a connect/reattach is still settling is "not - // deliverable YET", not a dead binding. Remounting here destroys the - // unbound transport, and pty-transport's destroyed check then kills the - // PTY the in-flight reattach resolves to — the live shell recovery exists - // to preserve. The fossil case this detector targets has no pending - // connect, so it still recovers. Same for a late async reject landing - // after dispose: the successor pane owns the tab now. - const connectStillSettling = - transportConnectInFlightSince !== null && - Date.now() - transportConnectInFlightSince < TRANSPORT_CONNECT_SETTLE_GRACE_MS - if (connectStillSettling || disposed) { - return - } - const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null - const undeliverablePtyId = transport.getPtyId() ?? storePtyId - // Why the split: for a local (daemon/app-SSH) id main's registry can answer, - // and a `false` there means the shell really died — the dead-session - // reconcile owns that teardown and a remount would race it. For a `remote:` - // id main owns no registry entry, so `pty:hasPty` routes to the local - // provider and fabricates "dead"; that answer blocked every recovery this - // signal exists to trigger (STA-2830). The host's own rejection replaces it. - const hostRejectedRemoteInput = providerRejected && isRemoteRuntimePtyId(undeliverablePtyId) - void requestTerminalPaneRecovery({ - tabId: deps.tabId, - ptyId: undeliverablePtyId, - reason: hostRejectedRemoteInput ? 'input-rejected-by-host' : 'input-undeliverable', - terminalRecoveryGeneration, - terminalRecoveryInstanceId: terminalRecoveryInstance.id, - // Why: pty:hasPty answers null for ids the local registry doesn't own, - // and a disconnected remote pane would otherwise remount-churn on every - // cooldown window while typing. Local panes keep the lenient gate. - requireAuthoritativeLiveness: - Boolean(transport.getConnectionId?.()) || isRemoteRuntimePtyId(undeliverablePtyId), - // Why only the rejected path: it is the only one whose remount can land on - // a fresh shell. A stalled-pipeline remount always reattaches to the same - // shell, so its half-typed line is still on screen and intact. - endpointReplaced: providerRejected - }) - } - // Why: the write-pipeline health watch (scheduler stall probe, replay-guard - // wedge certification) detects a dead xterm pipeline; route its verdict to - // the same tab remount. Registered per xterm instance — recovery replaces - // the instance, which resets certification naturally. - const unregisterUndeliverableWriteHandler = registerUndeliverableWriteHandler( - pane.terminal, - (reason) => { - // Certification can arrive while this terminal still owns queued or - // detached scheduler work; release its delivery credits immediately. - discardTerminalOutput(pane.terminal) - const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null - void requestTerminalPaneRecovery({ - tabId: deps.tabId, - ptyId: transport.getPtyId() ?? storePtyId, - reason, - terminalRecoveryGeneration, - terminalRecoveryInstanceId: terminalRecoveryInstance.id - }) - } - ) - - const forwardPtyInput = (data: string): void => { - // Why: xterm auto-replies to embedded query sequences (DA1, DECRQM, - // OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes - // into xterm for scrollback/cold-restore/snapshot, those queries would - // otherwise pipe replies into the freshly spawned shell as stray input - // ("?1;2c", "2026;2$y", OSC color fragments, ...). The replay sites - // engage the guard via replayIntoTerminal; here we drop everything - // xterm emits while the guard is active. See replay-guard.ts. - if (isPaneReplaying(deps.replayingPanesRef, pane.id)) { - return - } - const currentPtyId = transport.getPtyId() - // Why: after a Codex account switch, the runtime auth has already moved to - // the newly selected account. Stale panes must not keep sending input until - // they restart, or work can execute under the wrong account while the UI - // still says the pane is stale. Fall back to the tab's persisted PTY ID so - // the block still holds during reconnect races before the live transport has - // updated its local PTY binding. - if ( - isCodexPaneStale({ - tabId: deps.tabId, - worktreeId: deps.worktreeId, - panePtyId: currentPtyId - }) - ) { - clearPendingTerminalInputIntent() - return - } - // Why: presence-lock input drop. While mobile is the driver for this - // PTY, desktop keystrokes must not reach the shell; the visible overlay's - // explicit Take back action owns restoring desktop input and dimensions. - if (currentPtyId && isPtyLocked(currentPtyId)) { - clearPendingTerminalInputIntent() - return - } - if ( - isNativeWindowsConpty && - suppressNativeWindowsIdleCodexFocusReports && - (data === TERMINAL_FOCUS_IN_SEQUENCE || data === TERMINAL_FOCUS_OUT_SEQUENCE) - ) { - // Why: Codex can leave focus reporting armed after a Windows turn, but - // disabling the mode would permanently silence focus events on resume. - return - } - // Why: xterm answers CPR/DSR/DA queries natively through this same onData - // stream (mixed with keystrokes). Those replies are latency-critical — a - // querying program reads them in raw mode with a short timeout — so send - // them immediately, skipping the remote input debounce that would corrupt - // them (#7329). They are not user input, so they bypass intent inference and - // activity recording below. No pending-intent guard: the only intents are - // plain-escape (`\x1b`) and ctrl-c (`\x03`), neither of which can satisfy - // isTerminalQueryReply (it requires length >= 3 and a full reply grammar), - // so a real keystroke never reaches this branch. - if (isTerminalQueryReply(data)) { - sendDesktopQueryReplyImmediate(data) - return - } - // Why after the query-reply branch: device replies are not user input and - // must always reach the shell, or a program querying during reattach hangs. - // Why at all: a replaced endpoint reattaches to a fresh shell, so the tail - // of the interrupted line would be submitted by the user's own Enter and a - // compound command could run its surviving half (#10065 follow-up). - if (shouldDropQuarantinedTerminalInput(deps.tabId, data)) { - clearPendingTerminalInputIntent() - return - } - const intent = pendingTerminalInputIntent - // Why: real xterm can deliver the terminal byte even when our DOM keydown - // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to - // infer for local/remote acknowledged writes; SSH fire-and-forget remains - // excluded because those transports do not expose sendInputAccepted. - const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) - if (acknowledgedIntent && transport.sendInputAccepted) { - const interruptStatusBaseline = useAppStore.getState().agentStatusByPaneKey[cacheKey] ?? null - // Why: equal snapshots retain double-Escape semantics while older snapshots lose ack races. - if (sequencedInterruptStatusBaseline !== interruptStatusBaseline) { - sequencedInterruptStatusBaseline = interruptStatusBaseline - interruptStatusBaselineSequence += 1 - } - const capturedBaselineSequence = interruptStatusBaselineSequence - claimViewportForUserActivity() - if (acknowledgedIntent === 'ctrl-c') { - // Why: the accepted-write callback is async; let the next command be - // inferred if the user cancelled an oversized line and immediately typed. - cancelSuspendedShellCommandInference() - } - clearPendingTerminalInputIntent() - const writePromise = transport - .sendInputAccepted(data) - .then((accepted): boolean | Promise | null => { - if (accepted) { - // Why: rejected writes use transport recovery and must not arm a parser probe. - markAcceptedTerminalInputSent() - observeAcceptedShellCommandInput(data) - observeAcceptedTerminalInput(data, acknowledgedIntent) - const immediateResult = interruptInference.observeInputIntent( - acknowledgedIntent, - interruptStatusBaseline, - capturedBaselineSequence - ) - observeTitleOnlyInterrupt() - return immediateResult ?? null - } - // Why: Esc/Ctrl+C are the first keys users press on a frozen pane; - // an unbound-transport reject here must arm recovery too. - requestRecoveryForUndeliverableInput() - return null - }) - .catch((err) => { - console.warn('[agent-interrupt] acknowledged terminal input failed:', err) - return null - }) - setPendingTerminalInputWrite(writePromise) - return - } - if (intent) { - claimViewportForUserActivity() - if (transport.sendInput(data)) { - markAcceptedTerminalInputSent() - observeAcceptedShellCommandInput(data) - observeAcceptedTerminalInput(data, intent) - } else { - requestRecoveryForUndeliverableInput() - } - clearPendingTerminalInputIntent() - return - } - claimViewportForUserActivity() - if (transport.sendInput(data)) { - markAcceptedTerminalInputSent() - observeAcceptedShellCommandInput(data) - observeAcceptedTerminalInput(data) - observeSentTerminalInputIntent(data) - } else { - clearPendingTerminalInputIntent() - requestRecoveryForUndeliverableInput() - } - } - const onDataDisposable = pane.terminal.onData((data) => { - if (deps.deferPtyInput) { - deps.deferPtyInput(pane.id, data, forwardPtyInput) - return - } - forwardPtyInput(data) - }) - const imeCompositionRouteDisposable = installTerminalImeCompositionRoute({ - terminalElement: pane.terminal.element, - terminal: pane.terminal, - capturedTransport: transport, - getCurrentTransport: () => deps.paneTransportsRef.current.get(pane.id) - }) - - const shouldSuppressDesktopPtyResize = (): boolean => { - const currentPtyId = transport.getPtyId() - return Boolean( - currentPtyId && (getFitOverrideForPty(currentPtyId) || isPtyLocked(currentPtyId)) - ) - } - - const isRendererPtyResizeAuthoritative = (): boolean => { - if (deps.isVisibleRef.current) { - return true - } - // Why: hidden-tab layout churn is not authoritative; visible resume - // owns correction, and hidden SIGWINCH can reset full-screen TUIs. - return false - } - - const forwardPtyResize = (cols: number, rows: number): void => { - if (!isRendererPtyResizeAuthoritative()) { - return - } - // Why: when a mobile-fit override is active OR mobile is currently the - // driver of this PTY, the PTY is already at phone dims and any desktop - // resize is wrong. Suppress resize forwarding to avoid spurious SIGWINCH - // signals (TUI flicker / wrap corruption). Both checks are needed: - // - getFitOverrideForPty covers the "phone-fit dims" state. - // - isPtyLocked covers the broader "mobile driving" state, including - // transitions where override may not be set (e.g. legacy code paths). - // The pty:resize IPC has a defense-in-depth twin. See - // docs/mobile-presence-lock.md. - if (shouldSuppressDesktopPtyResize()) { - return - } - if (queuePanePtyResizeIfHeld(pane.container, cols, rows)) { - return - } - transport.resize(cols, rows, { claim: true }) - } - - const onHeldPtyResizeFlush = (event: Event): void => { - const detail = (event as CustomEvent).detail - if (!detail) { - return - } - forwardPtyResize(detail.cols, detail.rows) - } - pane.container.addEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) - - const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => { - if (suppressStructuralReplayPtyResize || suppressViewportClaimTerminalResize) { - return - } - forwardPtyResize(cols, rows) - }) - - // Why: renderer resize forwarding is fire-and-forget. A visible pane can - // finish with xterm at the right grid while the PTY silently kept an older - // grid, so Codex keeps composing against stale columns. Fit first so xterm's - // normal onResize can send, then read applied PTY size and repair only drift. - const ptySizeReassertion = createPtySizeReassertion({ - isDisposed: () => disposed, - getPtyId: () => transport.getPtyId(), - isRemotePtyId: isRemoteRuntimePtyId, - shouldSuppressDesktopResize: () => shouldSuppressDesktopPtyResize(), - fitAndRun: (continuation) => safeFitAndThen(pane, 'pty-size-reassertion', continuation), - getTerminalDimensions: () => ({ cols: pane.terminal.cols, rows: pane.terminal.rows }), - getAppliedSize: async (ptyId) => { - // Why: e2e seam — delays the read past the reveal fit to reproduce the - // busy-daemon/SSH ordering; returns 0 outside e2e builds. - const delayMs = getAppliedSizeReadE2eDelayMs() - if (delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)) - } - return window.api.pty.getSize(ptyId) - }, - forwardResize: forwardPtyResize - }) - // Why built here and not inside handleReattachResult: a hidden pane parks this until it is - // revealed, and a closure created in that scope would pin the whole reattach payload - // (snapshot/replay/coldRestore bytes) for as long as the pane stays hidden. Taking the - // generation and pty id by value keeps only connection-level state alive. - const createReattachGridPush = ( - attemptGeneration: number, - reattachPtyId: string - ): { shouldContinue: () => boolean; continuation: () => void } => { - const isCurrent = (): boolean => - !disposed && - attemptGeneration === transportStreamGeneration && - transport.getPtyId() === reattachPtyId - return { - shouldContinue: isCurrent, - continuation: () => { - if (!isCurrent()) { - return - } - // Why re-checked at fire time: the caller's pre-check cannot see a mobile takeover that - // lands while the pane waits for a box, and transport.resize here bypasses - // forwardPtyResize's own suppression. - if (shouldSuppressDesktopPtyResize()) { - return - } - const reattachCols = pane.terminal.cols - const reattachRows = pane.terminal.rows - if (reattachCols > 0 && reattachRows > 0) { - transport.resize(reattachCols, reattachRows) - } - // Why: POSIX only sends SIGWINCH on an actual dimension change; signal explicitly so restored TUIs repaint at the correct cursor after replay. - if (!isRemoteRuntimePtyId(reattachPtyId)) { - window.api.pty.signal(reattachPtyId, 'SIGWINCH') - } - // Why here: a deferred reveal resolves the fit handle as incomplete, so an awaited - // reassertion at the call site would never run for that path. - if (deps.isVisibleRef.current) { - ptySizeReassertion.request({ fit: false }) - } - } - } - } - let pendingForegroundGridDriftCheckRaf: number | null = null - let lastForegroundGridDriftCheckAt = Number.NEGATIVE_INFINITY - const readProposedTerminalGrid = (): { cols: number; rows: number } | null => { - try { - const proposed = pane.fitAddon.proposeDimensions() - if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) { - return null - } - return proposed - } catch { - return null - } - } - const terminalGridDriftedFromFit = (): boolean => { - const proposed = readProposedTerminalGrid() - return Boolean( - proposed && (pane.terminal.cols !== proposed.cols || pane.terminal.rows !== proposed.rows) - ) - } - const scheduleForegroundGridDriftCheck = (): void => { - // Why: mobile-owned PTYs intentionally keep a non-desktop grid; drift - // healing would refit xterm even if resize forwarding is later suppressed. - if ( - disposed || - !deps.isVisibleRef.current || - shouldSuppressDesktopPtyResize() || - pendingForegroundGridDriftCheckRaf !== null - ) { - return - } - const now = performance.now() - if (now - lastForegroundGridDriftCheckAt < FOREGROUND_GRID_DRIFT_CHECK_MIN_MS) { - return - } - lastForegroundGridDriftCheckAt = now - pendingForegroundGridDriftCheckRaf = requestAnimationFrame(() => { - pendingForegroundGridDriftCheckRaf = null - if ( - disposed || - !deps.isVisibleRef.current || - shouldSuppressDesktopPtyResize() || - !terminalGridDriftedFromFit() - ) { - return - } - // Why: xterm cell metrics can settle after the DOM box stops resizing, so - // ResizeObserver never fires even though FitAddon now proposes more cols. - requestStablePaneFit(pane as ManagedPaneInternal, () => - ptySizeReassertion.request({ fit: false }) - ) - }) - } - - // Why: observe the outer pane as the layout signal for both desktop drift - // healing and mobile take-back. Normal desktop panes compare xterm against - // the PTY's applied size; mobile-fit panes only report desktop geometry so - // the parked phone-sized PTY is not resized. See docs/mobile-fit-hold.md. - let pendingGeometryReportRaf: number | null = null - let lastObservedDesktopGrid: { cols: number; rows: number } | null = null - const readPaneSize = (): { width: number; height: number } | null => { - if (typeof pane.container.getBoundingClientRect !== 'function') { - return null - } - const rect = pane.container.getBoundingClientRect() - return { width: rect.width, height: rect.height } - } - let lastObservedPaneSize = readPaneSize() - let pendingPaneGeometryChanged = false - const handleObservedPaneGeometry = (): void => { - pendingGeometryReportRaf = null - if (disposed) { - return - } - if ( - deferTerminalGeometryMutationDuringRebuild( - pane.terminal, - 'observed-pane-geometry', - handleObservedPaneGeometry - ) - ) { - return - } - const paneGeometryChanged = pendingPaneGeometryChanged - pendingPaneGeometryChanged = false - const currentPtyId = transport.getPtyId() - if (!currentPtyId) { - // Why: ResizeObserver may deliver its initial measurement before the - // remote binding completes; retain that passive baseline for the first - // real focused resize instead of swallowing the user's first claim. - const proposed = readProposedTerminalGrid() - if (proposed) { - lastObservedDesktopGrid = proposed - } - return - } - const fitOverride = getFitOverrideForPty(currentPtyId) - if (!fitOverride) { - if (pane.terminal.cols > 0 && pane.terminal.rows > 0) { - // Why: record the local grid before a later remote hold parks xterm; - // the first real window/split resize can then claim immediately. - lastObservedDesktopGrid = { - cols: pane.terminal.cols, - rows: pane.terminal.rows - } - } - if (shouldSuppressDesktopPtyResize()) { - return - } - requestStablePaneFit(pane as ManagedPaneInternal, () => - ptySizeReassertion.request({ fit: false }) - ) - return - } - let proposed: { cols: number; rows: number } | undefined - try { - proposed = pane.fitAddon.proposeDimensions() - } catch { - proposed = undefined - } - if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) { - return - } - const priorProposed = lastObservedDesktopGrid - lastObservedDesktopGrid = proposed - if (fitOverride.mode === 'remote-desktop-fit') { - if ( - shouldClaimRemoteDesktopViewport({ - holdMode: fitOverride.mode, - prior: priorProposed, - current: proposed, - paneGeometryChanged, - paneVisible: deps.isVisibleRef.current, - documentVisible: document.visibilityState !== 'hidden', - documentFocused: document.hasFocus() - }) - ) { - // Why: a focused, visible layout change is genuine activity; release - // the park and update xterm before claiming so the owner does not keep - // rendering the prior owner's stale grid. - suppressViewportClaimTerminalResize = true - try { - pane.terminal.resize(proposed.cols, proposed.rows) - } finally { - suppressViewportClaimTerminalResize = false - } - transport.resize(proposed.cols, proposed.rows, { claim: true }) - } - return - } - if (isRemoteRuntimePtyId(currentPtyId)) { - transport.resize(proposed.cols, proposed.rows) - } else { - window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows) - } - } - const geometryReportObserver = - typeof ResizeObserver === 'undefined' - ? null - : new ResizeObserver(() => { - const paneSize = readPaneSize() - if ( - paneSize && - lastObservedPaneSize && - (paneSize.width !== lastObservedPaneSize.width || - paneSize.height !== lastObservedPaneSize.height) - ) { - pendingPaneGeometryChanged = true - } - lastObservedPaneSize = paneSize - if (pendingGeometryReportRaf !== null) { - return - } - pendingGeometryReportRaf = requestAnimationFrame(handleObservedPaneGeometry) - }) - // Why: pane.xtermContainer is created later in pane-lifecycle's - // attachWebgl/initial-fit path; pane.container is always present at the - // moment connectPanePty runs (it's the .pane element). Both report the - // same layout signal — when the outer pane resizes, the inner xterm - // container resizes too — so this is the safe element to observe. - if (geometryReportObserver && pane.container instanceof Element) { - geometryReportObserver.observe(pane.container) - } - - // Why: the deferred-rAF fit can spawn the PTY at a stale width when the pane's - // real (e.g. split/narrower) layout has not settled by the first frame — the - // PTY is born at the wide window width while xterm later reflows to the pane - // width. The corrective onResize is then dropped (isRendererPtyResizeAuthoritative() - // is false mid-mount), pinning process.stdout.columns forever and garbling - // TUIs. The reconcile re-fits across frames until the grid settles and forces - // the PTY to xterm's dimensions; the spawn-time sync is authoritative by - // definition so it bypasses the visibility gate (but not the mobile-fit - // override, which legitimately parks the PTY at phone dims). See - // pty-size-reconcile.ts for the convergence loop. - let ptySizeReconcileHandle: PtySizeReconcileHandle | null = null - let liveScrollbackRestore: TerminalLiveScrollbackRestore | null = null - const reconcilePtySizeAfterSpawn = ( - ptyId: string, - spawnCols: number, - spawnRows: number - ): void => { - ptySizeReconcileHandle?.cancel() - ptySizeReconcileHandle = reconcilePtySizeAcrossFrames({ - spawnCols, - spawnRows, - isAlive: () => !disposed && transport.getPtyId() === ptyId, - // Mobile legitimately parks the PTY at phone dims; skip those frames - // (neither fit nor forward) instead of cancelling the reconcile window. - isParked: () => Boolean(getFitOverrideForPty(ptyId)) || isPtyLocked(ptyId), - // Once the renderer resize is authoritative (pane visible), the live - // onResize owns future corrections, so the reconcile can hand off after - // the grid stabilizes. While hidden it keeps watching for a late settle. - isAuthoritative: () => isRendererPtyResizeAuthoritative(), - measure: () => { - if (!safeFit(pane)) { - return null - } - const cols = pane.terminal.cols - const rows = pane.terminal.rows - return cols > 0 && rows > 0 ? { cols, rows } : null - }, - resize: (cols, rows) => { - if (!shouldSuppressDesktopPtyResize()) { - transport.resize(cols, rows) - } - }, - // Why: confirm the PTY actually applied the size we forwarded before the - // reconcile hands off. transport.resize is fire-and-forget for daemon/SSH - // PTYs, so the loop can otherwise settle on a size the PTY dropped, leaving - // it pinned wide while xterm shows narrow — the mount-time desync. Skip - // remote-runtime PTYs (separate viewport channel; pty:getSize never tracks - // them) so they fall back to the grid-stable handoff. - getAppliedSize: isRemoteRuntimePtyId(ptyId) ? undefined : () => window.api.pty.getSize(ptyId), - requestFrame: (callback) => requestAnimationFrame(callback), - cancelFrame: (handle) => { - if (typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(handle) - } - } - }) - } - - // Defer PTY spawn/attach to next frame so FitAddon has time to calculate - // the correct terminal dimensions from the laid-out container. - const cancelScheduledConnectFrame = (): void => { - if (connectFrame !== null) { - if (typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(connectFrame) - } - connectFrame = null - } - } - const measureStartupGrid = (): { cols: number; rows: number } | null => { - if (!safeFit(pane)) { - return null - } - const cols = pane.terminal.cols - const rows = pane.terminal.rows - return cols > 0 && rows > 0 ? { cols, rows } : null - } - const shouldSettleStartupGridBeforeConnect = (): boolean => - Boolean(paneStartup?.command) && - deps.isVisibleRef.current && - !connectionId && - runtimeEnvironmentId === null - const isStartupGridReadyForConnect = (): boolean => { - const setupSplitDirection = paneStartup?.waitForSetupSplitDirection - if (!setupSplitDirection) { - return true - } - // Why: the setup split reparents the main pane before its xterm grid - // necessarily reflects the new flex geometry; wait for both to agree. - return isSetupSplitGeometryReady(pane, manager, setupSplitDirection) - } - const settleStartupGridBeforeConnect = (connect: () => void): void => { - startupGridSettleHandle?.cancel() - let settledSynchronously = false - // Why: local startup commands can launch a TUI before the split-pane grid - // has settled; spawn from a briefly stable grid so the TUI paints cleanly. - const handle = waitForStableStartupGrid({ - isAlive: () => !disposed, - isReadyToSettle: paneStartup?.waitForSetupSplitDirection - ? isStartupGridReadyForConnect - : undefined, - measure: measureStartupGrid, - onSettled: () => { - settledSynchronously = true - startupGridSettleHandle = null - connect() - }, - requestFrame: (callback) => requestAnimationFrame(callback), - cancelFrame: (handle) => { - if (typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(handle) - } - } - }) - if (!settledSynchronously) { - startupGridSettleHandle = handle - } - } - const runDeferredConnect = (): void => { - if (connectStarted) { - return - } - if (!startupGridSettledForConnect && shouldSettleStartupGridBeforeConnect()) { - cancelScheduledConnectFrame() - if (connectFallbackTimer !== null) { - clearTimeout(connectFallbackTimer) - connectFallbackTimer = null - } - settleStartupGridBeforeConnect(() => { - startupGridSettledForConnect = true - runDeferredConnect() - }) - return - } - connectStarted = true - cancelScheduledConnectFrame() - if (connectFallbackTimer !== null) { - clearTimeout(connectFallbackTimer) - connectFallbackTimer = null - } - if (disposed) { - return - } - safeFit(pane) - const cols = pane.terminal.cols - const rows = pane.terminal.rows - - // Why: if fitAddon resolved to 0×0, the container likely has no layout - // dimensions (display:none, unmounted, or zero-size parent). Surface a - // diagnostic so the user sees something instead of a blank pane. - // Gate on visibility: background/hidden tabs (orchestration workers, CLI - // `terminal create` without --focus) legitimately connect at 0×0 because - // safeFit skips fitting unmeasurable panes; they refit via the pane resize - // observer once shown, so the diagnostic must not fire while hidden. - if ((cols === 0 || rows === 0) && deps.isVisibleRef.current) { - deps.onPtyErrorRef?.current?.(pane.id, createTerminalZeroDimensionsMessage(cols, rows)) - } - - const reportError = (message: string): void => { - // Why: the transport connect can reject asynchronously after the pane has been - // disposed (e.g. its workspace was deleted) — dropping a late error avoids a toast - // racing the unmount. Mirrors the connect scheduler's disposed guard above. - if (disposed) { - return - } - if (isWorktreeRemovalFenceError(message)) { - // Why: main fences a spawn/reattach whose worktree (or an overlapping - // parent/child root) is being deleted. That is expected teardown, not a - // user-facing failure — the pane unmounts once removal completes, so never - // surface the raw fence error. Covers the parent-removal-fences-child case - // that startFreshSpawn's own-worktree isDeleting skip cannot see. - return - } - deps.onPtyErrorRef?.current?.(pane.id, message) - } - const codexBackfillErrorDetector = - paneStartup?.launchAgent === 'codex' || tab?.launchAgent === 'codex' - ? createCodexBackfillErrorDetector() - : null - - // Why: shared registration so both fresh-spawn and reattach paths install - // the same SerializeAddon-backed serializer plus the onTitleChange wrapper - // that drives lastTitle parity for mobile subscribers. Wires the resulting - // unregister into onDataDisposable.dispose so disposal stays a single - // teardown point. See docs/mobile-prefer-renderer-scrollback.md. - const registerPaneSerializerFor = (ptyId: string): void => { - // Why: StrictMode mounts panes twice; the first mount is disposed - // before the second runs, but its pty:spawn IPC may have resolved by - // the time `disposed` flips. Without this guard, the disposed first - // mount would register against a torn-down xterm and replace the live - // second-mount registration via owner-token shadowing. - if (disposed) { - return - } - const unregisterSerializer = registerPtySerializer( - ptyId, - async (opts) => { - try { - if (isTerminalWritePipelineCertifiedDead(pane.terminal)) { - return null - } - await waitForTerminalOutputParsed(pane.terminal) - // Certification can land while the serializer waits for an older - // write; never publish a fossil frame from a dead renderer. - if (isTerminalWritePipelineCertifiedDead(pane.terminal)) { - return null - } - // Why: alt-screen TUIs (vim, claude-code) hold transient state in - // the alternate screen. The hydration path requests - // altScreenForcesZeroRows so normal-buffer scrollback isn't bled - // into the seed when the user is mid-TUI; the read-fallback path - // omits it because it wants the user's currently-visible content. - const alt = pane.terminal.buffer.active.type === 'alternate' - // Why serializeWithAbsoluteCursor: SerializeAddon's relative - // cursor restore lands one column short when replay of a - // margin-filling final row leaves the target wrap-pending. - const data = - opts?.altScreenForcesZeroRows && alt - ? serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { scrollback: 0 }) - : serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { - scrollback: opts?.scrollbackRows - }) - const orderedSeq = rendererOrderedPtyId === ptyId ? rendererOrderedSeq : null - // Why snapshotFlags and not `flags`: this pane may itself have - // consumed an old-host snapshot that proved nothing, and its - // conservative `0` fallback must not be republished downstream as - // a host-proven inactive protocol. - const provenKittyFlags = kittyKeyboardModes.hasProvenBaseline - ? kittyKeyboardModes.snapshotFlags - : undefined - return { - data, - cols: pane.terminal.cols, - rows: pane.terminal.rows, - ...(orderedSeq !== null ? { seq: orderedSeq } : {}), - ...(orderedSeq !== null && provenKittyFlags !== undefined - ? { kittyKeyboardFlags: provenKittyFlags } - : {}) - } - } catch { - return null - } - }, - () => { - clearHiddenOutputRestoreState() - discardTerminalOutput(pane.terminal) - clearTerminalScrollbackAndFollowOutput(pane.terminal) - } - ) - const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => - pane.terminal.onTitleChange(handler) - ) - const origOnDataDisposableDispose = onDataDisposable.dispose.bind(onDataDisposable) - onDataDisposable.dispose = () => { - unregisterTitleSource() - unregisterSerializer() - origOnDataDisposableDispose() - } - } - - let replayWriteQueue = Promise.resolve() - const settlePaneSerializerAfterReplay = async ( - ptyId: string, - generation: number - ): Promise => { - try { - await replayWriteQueue - if (disposed || transport.getPtyId() !== ptyId) { - await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) - return - } - await waitForTerminalOutputParsed(pane.terminal) - if (!disposed && transport.getPtyId() === ptyId) { - await window.api.pty.settlePaneSerializer(cacheKey, generation) - return - } - } catch { - // Clear below so a failed parser/replay cannot leave the pane generation pending. - } - await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) - } - const reportRemoteRendererSerializerReady = (): void => { - const ptyId = transport.getPtyId() - if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { - return - } - if (!hasPtySerializer(ptyId)) { - registerPaneSerializerFor(ptyId) - } - // Why: onSubscribed follows the snapshot callback, but replay drains - // asynchronously; join it and xterm's parser before reporting readiness. - void replayWriteQueue - .then(() => waitForTerminalOutputParsed(pane.terminal)) - .then(() => { - if (!disposed && transport.getPtyId() === ptyId) { - void window.api.pty.reportRendererSerializerReady?.(ptyId) - } - }) - .catch(() => {}) - } - - // Why: local and ordinary SSH startup commands are provider-owned so delivery - // survives renderer replacement. Only explicit terminal-paste stays renderer-owned. - let pendingStartupCommand: PendingStartupCommand | null = shouldDeliverStartupViaTerminalPaste - ? paneStartup?.command - ? { command: paneStartup.command } - : null - : null - const startupDraftReadyScanner = ownsStartupDraftPaste - ? createDraftPasteReadyScanner( - startupDraftAgentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' - ) - : null - let startupDraftReadinessArmed = false - let startupDraftPasteSettled = !ownsStartupDraftPaste - let startupDraftPasteInFlight = false - let startupDraftInputRecorded = false - let startupDraftQuietTimer: ReturnType | null = null - let startupDraftHardTimer: ReturnType | null = null - const clearStartupDraftPasteTimers = (): void => { - if (startupDraftQuietTimer !== null) { - clearTimeout(startupDraftQuietTimer) - startupDraftQuietTimer = null - } - if (startupDraftHardTimer !== null) { - clearTimeout(startupDraftHardTimer) - startupDraftHardTimer = null - } - } - cleanupStartupDraftPasteTimers = clearStartupDraftPasteTimers - const getStartupDraftPtyId = (): string | null => { - const ptyId = transport.getPtyId() - if ( - !ptyId || - disposed || - deps.paneTransportsRef.current.get(pane.id) !== transport || - transport.getPtyId() !== ptyId - ) { - return null - } - return ptyId - } - const sendStartupDraftPaste = (): void => { - if ( - !startupDraftPrompt || - startupDraftPasteSettled || - startupDraftPasteInFlight || - !startupDraftReadinessArmed - ) { - return - } - const ptyId = getStartupDraftPtyId() - if (!ptyId) { - return - } - startupDraftPasteInFlight = true - startupDraftPasteSettled = true - startupDraftPasteAttempted = true - cleanupStartupDraftPasteTimers() - const settings = getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), deps.worktreeId) - // Why: xterm focus reports share this transport queue. Bypassing it can - // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. - void sendAgentDraftPasteContent(settings, ptyId, startupDraftPrompt, async (data) => { - const accepted = await writeTerminalPastePtyInput(transport, data) - if (accepted && !startupDraftInputRecorded) { - // Why: this transport write bypasses xterm's user-input signal; keep - // the composed draft from being discarded by later hibernation. - startupDraftInputRecorded = true - recordTerminalInputForHibernation() - } - return accepted - }) - .catch(() => false) - .finally(() => { - startupDraftPasteInFlight = false - }) - } - const deliverStartupDraftIfAgentOwnsPty = async (): Promise => { - if (!startupDraftAgentConfig || startupDraftPasteSettled) { - return - } - const ptyId = getStartupDraftPtyId() - if (!ptyId) { - return - } - const settings = getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), deps.worktreeId) - try { - const process = await inspectRuntimeTerminalProcess(settings, ptyId) - const foreground = process.foregroundProcess?.toLowerCase() ?? '' - if ( - getStartupDraftPtyId() === ptyId && - isExpectedAgentProcess(foreground, startupDraftAgentConfig.expectedProcess) - ) { - sendStartupDraftPaste() - } - } catch { - // Best-effort fallback; the primary path is the PTY readiness marker. - } - } - const armStartupDraftHardTimer = (): void => { - if (!startupDraftReadyScanner || startupDraftPasteSettled || startupDraftHardTimer !== null) { - return - } - startupDraftHardTimer = setTimeout(() => { - startupDraftHardTimer = null - void deliverStartupDraftIfAgentOwnsPty() - }, resolveDraftPasteReadyTimeoutMs(startupDraftAgent)) - } - const armStartupDraftQuietTimer = (): void => { - if (!startupDraftReadyScanner || startupDraftPasteSettled) { - return - } - if (startupDraftQuietTimer !== null) { - clearTimeout(startupDraftQuietTimer) - } - startupDraftQuietTimer = setTimeout(() => { - startupDraftQuietTimer = null - sendStartupDraftPaste() - }, STARTUP_DRAFT_PASTE_QUIET_MS) - } - const armStartupDraftReadinessObservation = (): void => { - if (!startupDraftReadyScanner || startupDraftReadinessArmed) { - return - } - startupDraftReadinessArmed = true - armStartupDraftHardTimer() - } - const observeStartupDraftPasteReadiness = (data: string): void => { - if (!startupDraftReadyScanner || !startupDraftReadinessArmed || startupDraftPasteSettled) { - return - } - const scanned = startupDraftReadyScanner.observe(data) - if (scanned.ready) { - sendStartupDraftPaste() - return - } - if (scanned.armQuietTimer) { - armStartupDraftQuietTimer() - } - } - if (ownsStartupDraftPaste && !connectionId && !shouldDeliverStartupViaTerminalPaste) { - armStartupDraftReadinessObservation() - } - let sessionRestoredBannerShown: SessionRestoredBannerReason | null = null - const showSessionRestoredBanner = (reason: SessionRestoredBannerReason = 'restored'): void => { - // Why: a plain 'restored' banner must not latch out the later 'resume-unavailable' - // upgrade — the pane would keep claiming a session it never got back. - if ( - sessionRestoredBannerShown === reason || - sessionRestoredBannerShown === 'resume-unavailable' - ) { - return - } - sessionRestoredBannerShown = reason - deps.onShowSessionRestoredBanner(pane.id, reason) - } - const buildColdRestoreAgentResumeStartup = (): ColdRestoreAgentResumeStartup | null => { - if (pendingStartupCommand) { - return null - } - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - const sleepingRecordEntry = getSleepingRecordForPane(state) - const sleepingRecord = sleepingRecordEntry?.record - if (isLegacyWorkerAutomaticResumeBlocked()) { - return null - } - const useLiveEntry = entry && entry.state !== 'done' - const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent - if (!agent || !isResumableTuiAgent(agent)) { - return null - } - const providerSession = normalizeAgentProviderSession( - useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession - ) - if (!providerSession) { - return null - } - const matchingSleepingLaunchConfig = - sleepingRecord?.launchConfig && - (!useLiveEntry || - (sleepingRecord.agent === agent && - agentProviderSessionsEqual(agent, sleepingRecord.providerSession, providerSession))) - ? sleepingRecord.launchConfig - : undefined - const launchConfig = - (useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? - matchingSleepingLaunchConfig - // Why: the resume line is typed into this pane's live shell, so its quoting must - // follow the tab's effective Windows shell, not the win32 PowerShell default. - const resumeTarget = resolveAgentResumeLaunchTarget({ - projectRuntime, - connectionId, - executionHostId, - worktreePath: worktree?.path, - terminalWindowsShell: state.settings?.terminalWindowsShell, - tabShellOverride: shellOverride - }) - const startupPlan = buildAgentResumeStartupPlan({ - agent, - providerSession, - cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - agentArgs: - launchConfig !== undefined - ? launchConfig.agentArgs - : resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs), - agentEnv: - launchConfig !== undefined - ? launchConfig.agentEnv - : resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv), - ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), - ...(launchConfig?.ompResumeFilePath - ? { ompResumeFilePath: launchConfig.ompResumeFilePath } - : {}), - platform: resumeTarget.platform, - shell: resumeTarget.shell - }) - if (!startupPlan) { - return null - } - const coldRestoreLaunchToken = createBrowserUuid() - // Why: cold restore means the PTY process is gone but the agent provider - // session is still resumable, so the replacement spawn must launch it. - return { - agent, - command: startupPlan.launchCommand, - env: { - ...startupPlan.env, - ORCA_AGENT_LAUNCH_TOKEN: coldRestoreLaunchToken - }, - launchConfig: startupPlan.launchConfig, - resumeProviderSession: providerSession, - launchToken: coldRestoreLaunchToken, - useLiveEntry: Boolean(useLiveEntry), - hasSleepingRecord: Boolean(sleepingRecord), - sleepingRecordEntry - } - } - const applyColdRestoreAgentResumeStartup = ( - startup: ColdRestoreAgentResumeStartup | null - ): boolean => { - if (!startup) { - return false - } - const state = useAppStore.getState() - state.registerAgentLaunchConfig(cacheKey, startup.launchConfig, { - agentType: startup.agent, - launchToken: startup.launchToken, - tabId: deps.tabId, - leafId: pane.leafId - }) - return true - } - const clearSleepingRecordAfterColdRestoreSpawn = ( - startup: ColdRestoreAgentResumeStartup | null - ): void => { - if (startup && !startup.useLiveEntry && startup.sleepingRecordEntry) { - clearSleepingRecordProviderDuplicates(useAppStore.getState(), startup.sleepingRecordEntry) - } - } - const mergeStartupEnvWithPaneIdentity = ( - env: Record | undefined - ): Record | undefined => - env - ? { - ...env, - ...paneIdentityEnv, - ...(env.ORCA_AGENT_LAUNCH_TOKEN - ? { ORCA_AGENT_LAUNCH_TOKEN: env.ORCA_AGENT_LAUNCH_TOKEN } - : {}) - } - : undefined - const toProcessExitStartup = ( - startup: PendingStartupCommand | ColdRestoreAgentResumeStartup | null - ): PtyPaneStartup => - startup && 'launchConfig' in startup && 'agent' in startup - ? { - command: startup.command, - env: startup.env, - launchConfig: startup.launchConfig, - resumeProviderSession: startup.resumeProviderSession, - launchToken: startup.launchToken, - launchAgent: startup.agent, - showSessionRestoredBanner: true - } - : startup - const startFreshColdRestoreAgentResume = ( - startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(), - options: FreshSpawnOptions = {} - ): Promise => { - applyColdRestoreAgentResumeStartup(startup) - return startFreshSpawn(startup, options) - } - // Why: the hibernation wake fires from noteVisibilityResume in the outer - // connection scope, long after this deferred-connect closure has run. - wakeHibernatedAgentPane = () => startFreshColdRestoreAgentResume() - const isStartupPasteTargetCurrent = (ptyId: string | null): boolean => - !disposed && - deps.paneTransportsRef.current.get(pane.id) === transport && - transport.getPtyId() === ptyId - const runTerminalPasteStartupCommand = async (command: string): Promise => { - const ptyId = transport.getPtyId() - const result = await executeTerminalStartupCommandPaste({ - command, - pane, - ptyId, - runtime: resolveTerminalPasteRuntime({ - platform: CLIENT_PLATFORM, - ptyId, - connectionId, - remotePlatform: getTerminalPasteSshRemotePlatform(connectionId), - transport, - isWindowsConpty: isNativeWindowsConpty - }), - transport, - isTargetCurrent: isStartupPasteTargetCurrent - }) - if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { - return false - } - return transport.sendInput('\r') - } - const schedulePendingStartupCommandDelivery = (): void => { - const startup = pendingStartupCommand - if (!startup) { - return - } - if (startupInjectTimer !== null) { - clearTimeout(startupInjectTimer) - } - startupInjectTimer = setTimeout(() => { - startupInjectTimer = null - void (async () => { - if (pendingStartupCommand !== startup || disposed) { - return - } - if (shouldDeliverStartupViaTerminalPaste) { - await waitForTerminalOutputParsed(pane.terminal) - } - if (pendingStartupCommand !== startup || disposed) { - return - } - const command = startup.command - const submitted = shouldDeliverStartupViaTerminalPaste - ? await runTerminalPasteStartupCommand(command) - : transport.sendInput(`${command}\r`) - if (submitted) { - armStartupDraftReadinessObservation() - } else { - releaseUnattemptedStartupDraftPasteDelivery() - } - pendingStartupCommand = null - })() - }, 50) - } - - let freshSpawnFollowResetDisposables: IDisposable[] = [] - cancelFreshSpawnFollowReset = (): void => { - for (const disposable of freshSpawnFollowResetDisposables) { - disposable.dispose() - } - freshSpawnFollowResetDisposables = [] - } - const resetFreshSpawnFollowOutput = (): void => { - cancelFreshSpawnFollowReset() - markTerminalFollowOutput(pane.terminal) - let nativeFollowResetComplete = false - const tryResetNativeFollow = (): void => { - if ( - disposed || - getTerminalScrollIntentKind(pane.terminal) !== 'followOutput' || - deferTerminalGeometryMutationDuringRebuild( - pane.terminal, - 'fresh-spawn-follow-reset', - tryResetNativeFollow - ) - ) { - return - } - try { - pane.terminal.scrollToBottom() - nativeFollowResetComplete = true - cancelFreshSpawnFollowReset() - } catch (err) { - if (!(err instanceof TypeError && /dimensions/.test(err.message))) { - cancelFreshSpawnFollowReset() - throw err - } - } - } - tryResetNativeFollow() - if (!nativeFollowResetComplete) { - // Why: xterm's browser viewport can reject scrolling while its renderer - // is detached; the first render/resize is the earliest safe native retry. - freshSpawnFollowResetDisposables = [ - pane.terminal.onRender(tryResetNativeFollow), - pane.terminal.onResize(tryResetNativeFollow) - ] - } - } - - const startFreshSpawn = ( - startupOverride?: PendingStartupCommand | null, - options: FreshSpawnOptions = {} - ): Promise => { - if (isLegacyWorkerAutomaticResumeBlocked()) { - return Promise.resolve(null) - } - const currentState = useAppStore.getState() - if ( - getDeleteStateForWorktreeHost( - { id: deps.worktreeId, hostId: executionHostId }, - currentState.deleteStateByWorktreeId - )?.isDeleting - ) { - // Why: the worktree is being deleted; its PTYs were just killed for the - // filesystem teardown. A fresh shell must not spawn into a directory the - // removal is about to delete (main fences it anyway), and the pane is - // about to unmount — so skip the doomed respawn instead of racing it. - return Promise.resolve(null) - } - authoritativeReattachGeneration += 1 - clearPaneMode2031State() - clearHiddenOutputRestoreState() - // Why: a canceled old replay clear can preserve xterm's native - // isUserScrolling flag. A replacement shell must start in follow mode. - resetFreshSpawnFollowOutput() - // Why: a fresh spawn is a new process with kitty keyboard flags at - // zero. The exit-handler reset alone is not enough: a late exit from a - // replaced PTY takes the stale-transport early return and skips it, so - // a restart-in-place would leak the old TUI's flags into a fresh shell. - kittyKeyboardModes.reset() - prepareFreshShellViewportForSpawn(options) - const coldRestoreOverride = - startupOverride && 'launchConfig' in startupOverride - ? (startupOverride as ColdRestoreAgentResumeStartup) - : null - // Why: pre-signal the main process so its cooperation gate suppresses - // the daemon-snapshot seed for this paneKey. We issue declare and the - // spawn back-to-back without awaiting, because Electron's - // ipcRenderer→ipcMain channel preserves order across consecutive invoke - // calls from the same renderer. The cooperation gate at pty:spawn time - // sees pendingByPaneKey populated. Settle/clear later echoes the gen - // token captured here. See docs/mobile-prefer-renderer-scrollback.md. - const preSignalPromise = runtimeEnvironmentId - ? Promise.resolve(null) - : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) - - transportConnectInFlightSince = Date.now() - const effectiveStartup = startupOverride === undefined ? paneStartup : startupOverride - const outputCallbacks = captureTransportOutputCallbacks( - reportError, - toProcessExitStartup(coldRestoreOverride ?? effectiveStartup) - ) - const spawnedRaw = transport.connect({ - url: '', - cols, - rows, - ...(startupOverride?.command ? { command: startupOverride.command } : {}), - ...(connectionId && startupOverride?.command && !shouldDeliverStartupViaTerminalPaste - ? { commandDelivery: 'provider' as const } - : {}), - ...(connectionId && startupOverride?.command - ? { startupCommandDelivery: 'shell-ready' as const } - : {}), - ...(startupOverride?.env - ? { env: mergeStartupEnvWithPaneIdentity(startupOverride.env) } - : {}), - ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), - ...(coldRestoreOverride - ? { resumeProviderSession: coldRestoreOverride.resumeProviderSession } - : {}), - ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), - ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), - ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: outputCallbacks.callbacks - }) - - void Promise.resolve(spawnedRaw) - .catch(() => null) - .finally(() => { - transportConnectInFlightSince = null - }) - const trackedPromise: Promise = Promise.resolve(spawnedRaw) - .then(async (spawnedPtyId) => { - if (outputCallbacks.generation !== transportStreamGeneration) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return null - } - const resolvedPtyId = - spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId - ? spawnedPtyId.id - : typeof spawnedPtyId === 'string' - ? spawnedPtyId - : transport.getPtyId() - if (resolvedPtyId && !claimCapturedDirectSshRetryPty(resolvedPtyId)) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - return null - } - const connectResult = - spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId - ? spawnedPtyId - : null - if (connectResult?.isReattach) { - pendingStartupCommand = null - const accepted = await handleReattachResult( - connectResult, - null, - coldRestoreOverride, - outputCallbacks.generation - ) - finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) - const gen = await preSignalPromise - if (accepted && resolvedPtyId && typeof gen === 'number') { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) - } else if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return accepted ? resolvedPtyId : null - } - if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { - registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { - ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), - ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}) - }) - } - if (resolvedPtyId) { - if ( - spawnedPtyId && - typeof spawnedPtyId === 'object' && - spawnedPtyId.startupCwdFallback?.kind === 'worktree' - ) { - writeTerminalOutput(pane.terminal, STARTUP_CWD_FALLBACK_NOTICE, { - foreground: shouldWritePtyOutputForeground(deps.isVisibleRef.current) - }) - } - if ( - spawnedPtyId && - typeof spawnedPtyId === 'object' && - spawnedPtyId.agentResumeUnavailable - ) { - // Why: main dropped the resume argv, so this pane is a NEW session — - // the plain restored banner would claim the old one came back. - showSessionRestoredBanner('resume-unavailable') - } else if (coldRestoreOverride?.hasSleepingRecord) { - showSessionRestoredBanner() - } - clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) - } else if ( - paneStartup?.launchConfig || - (startupOverride && 'launchConfig' in startupOverride) - ) { - // Why: delayed draft/follow-up delivery keys off this launch - // registry. If spawn produced no PTY, the launch is no longer a - // viable delivery target and must not wait for a future pane. - clearRegisteredStartupLaunchConfig() - } - if ( - resolvedPtyId && - spawnedPtyId && - typeof spawnedPtyId === 'object' && - 'id' in spawnedPtyId && - activePanePtyBinding !== resolvedPtyId && - transport.getPtyId() === resolvedPtyId - ) { - // Why: daemon createOrAttach can turn an apparent fresh spawn into - // a reattach; the transport skips onPtySpawn there to preserve recency. - bindActivePanePty(resolvedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - } - if (resolvedPtyId) { - reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) - } - const gen = await preSignalPromise - if (resolvedPtyId && (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId))) { - if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) { - registerPaneSerializerFor(resolvedPtyId) - } - if (typeof gen === 'number') { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) - } - } else if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - if (resolvedPtyId && connectionId) { - if ( - shouldUseProviderSshStartupDelivery && - (startupOverride?.command || paneStartup?.command) - ) { - armStartupDraftReadinessObservation() - } - schedulePendingStartupCommandDelivery() - } - finishReattachLiveDataDeferral(Boolean(resolvedPtyId), outputCallbacks.generation) - return resolvedPtyId - }) - .catch(async () => { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - if (paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride)) { - clearRegisteredStartupLaunchConfig() - } - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return null - }) - .finally(() => { - if (pendingSpawnByPaneKey.get(pendingSpawnKey) === trackedPromise) { - pendingSpawnByPaneKey.delete(pendingSpawnKey) - } - }) - armDirectSshPaneRetryTimeout(trackedPromise, directSshRetryAttempt) - void trackedPromise.then((spawnedPtyId) => { - if (spawnedPtyId) { - return - } - queueMicrotask(() => { - if (disposed || transport.getPtyId() || pendingSpawnByPaneKey.has(pendingSpawnKey)) { - return - } - settleDirectSshPaneRetryAttempt(directSshRetryAttempt, 'failed') - }) - }) - // Why: split panes in the same tab can spawn concurrently. Key by pane - // as well as tab so a remount cannot attach to a sibling setup pane's PTY. - pendingSpawnByPaneKey.set(pendingSpawnKey, trackedPromise) - return trackedPromise - } - - let foregroundRefreshRiskScanTail = '' - - function trailingIncompleteCsiSequence(data: string): string { - const escapeIndex = data.lastIndexOf('\x1b') - if (escapeIndex === -1) { - return '' - } - const tail = data.slice(escapeIndex) - if (tail === '\x1b') { - return tail - } - if (!tail.startsWith('\x1b[')) { - return '' - } - for (let index = 2; index < tail.length; index++) { - const code = tail.charCodeAt(index) - if (code >= 0x40 && code <= 0x7e) { - return '' - } - } - return tail.slice(-TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS) - } - - function foregroundRendererRiskOutputPrefersRenderRefresh(data: string): boolean { - if (!data) { - return false - } - const scanData = foregroundRefreshRiskScanTail - ? `${foregroundRefreshRiskScanTail}${data}` - : data - const prefersRefresh = - (scanData.includes('\x1b[') || containsNonAsciiOutput(scanData)) && - terminalOutputPrefersRenderRefresh(scanData) - foregroundRefreshRiskScanTail = trailingIncompleteCsiSequence(scanData) - return prefersRefresh - } - - // The replay path uses the guard so xterm auto-replies to embedded query - // sequences don't leak into the shell. xterm.write() buffers internally - // regardless of DOM visibility and the guard stays engaged via the - // write-completion callback until xterm finishes parsing. - const writeReplayData = (data: string): void => { - // Why: drain any queued background bytes BEFORE the replay paint, so the - // scheduler's deferred drain cannot land older bytes on top of the replay. - flushTerminalOutput(pane.terminal) - replayIntoTerminal(pane, deps.replayingPanesRef, data, { - breadcrumbIdentity: { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - ptyId: transport.getPtyId() - }, - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously, - shouldReleaseRenderPause: () => deps.isVisibleRef.current - }) - } - - const writeReplayDataAsync = (data: string): Promise => { - // Why: WebGL must be rebuilt after xterm has parsed replay bytes, not - // merely after the write was queued. - flushTerminalOutput(pane.terminal) - return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data, { - breadcrumbIdentity: { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - ptyId: transport.getPtyId() - }, - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously, - shouldReleaseRenderPause: () => deps.isVisibleRef.current - }) - } - - const reattachReplayResetSequence = ( - payload: string, - ownerProcessEnded = false, - isAlternateScreen?: boolean - ): string => { - // Why a cold restore overrides the agent signal: liveness is read from the - // pane's status and title, both of which are persisted, so after a cold - // restore they describe the process that died. Preserving "its" modes arms - // mouse, focus and paste reporting against the fresh shell that replaces it, - // which then prints the reports as junk at the prompt (#12101). - if (ownerProcessEnded) { - return POST_REPLAY_MODE_RESET - } - if (shouldPreserveAgentReattachModes()) { - return buildPostReplayLiveAgentReattachReset(payload) - } - // Why: an alt-screen pane is a live TUI Orca just does not recognise as an agent, and the - // replay already re-armed its mouse modes — keep them instead of wiping them (#8291). - return (isAlternateScreen ?? kittyKeyboardModes.isAlternateScreen) - ? POST_REPLAY_REATTACH_RESET_KEEP_MOUSE - : POST_REPLAY_REATTACH_RESET - } - - const consumeRestoredViewportBlankingMarker = (): boolean => { - return deps.restoredViewportBlankingPanesRef?.current.delete(pane.id) ?? false - } - - const writeFreshShellViewportBlanking = (rows = pane.terminal.rows): void => { - writeReplayData(buildFreshShellViewportBlankingSequence(rows)) - } - - const prepareFreshShellViewportForSpawn = (options: FreshSpawnOptions): void => { - const hadRestoredViewport = consumeRestoredViewportBlankingMarker() - if (!options.forceBlankRestoredViewport && !hadRestoredViewport) { - return - } - // Why: fresh Windows ConPTY output paints at screen coordinates, so - // restored rows must leave the viewport before the first prompt redraw. - writeFreshShellViewportBlanking() - } - - const sendFocusedReattachFocusInAfterReplay = ( - expectedPtyId: string | null = transport.getPtyId(), - expectedStreamGeneration = transportStreamGeneration - ): void => { - const scheduledGeneration = reattachReplayPayloadSignalGeneration - void waitForTerminalOutputParsed(pane.terminal).then(() => { - const currentPtyId = transport.getPtyId() - if ( - disposed || - expectedStreamGeneration !== transportStreamGeneration || - currentPtyId !== expectedPtyId - ) { - return - } - // Why: a newer replay frame owns the judgment; its own post-parse - // callback will re-evaluate against its own viewport. - if (scheduledGeneration !== reattachReplayPayloadSignalGeneration) { - return - } - // Why: the replay-byte signal also matches a dead run's screen — in - // scrollback or still painted above a fresh shell prompt. The parsed - // viewport is the ground truth; unless it shows a parked-cursor - // cursor-agent screen and no status/title corroborates, downgrade to - // the plain-shell behavior (drop focus reporting, skip focus-in). - if ( - !hasLiveAgentReattachStatusOrTitleSignal() && - reattachReplayPayloadHasCursorAgentSignal - ) { - if (parsedViewportShowsParkedCursorAgentScreen(pane.terminal) === false) { - reattachReplayPayloadHasCursorAgentSignal = false - // Why: the live-agent reset preserved the payload's ?25l; a plain - // shell never re-shows the cursor itself. - writeReplayData(`${CURSOR_SHOW_SEQUENCE}${FOCUS_REPORTING_DISABLE_SEQUENCE}`) - return - } - } - // Why: a live TUI such as cursor-agent parks the real terminal cursor off - // its own input caret and moves it back only on a focus-in. Reattach - // reuses the same live PTY and the xterm textarea already holds DOM - // focus, so xterm never emits the focus-in the agent needs and the parked - // cursor anchors the IME/caret to the wrong cell. Gated on ?1004h so a - // bare shell never receives a stray \x1b[I. - const sendFocusMode = terminalHasFocusReportingEnabled(pane.terminal) - if (!shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { - return - } - transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) - }) - } - - type PendingReplayData = { - data: string - clearBeforeReplay: boolean - ptyId: string | null - generation: number - streamGeneration: number - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - snapshotSeq?: number - } - - let pendingReplayData: PendingReplayData | null = null - let replayPayloadGeneration = 0 - let replayDrainQueued = false - const drainReplayDataQueue = async ( - expectedPtyId: string | null, - expectedStreamGeneration: number - ): Promise => { - let appliedCurrentPayload = false - while (pendingReplayData !== null) { - if ( - pendingReplayData.ptyId !== expectedPtyId || - pendingReplayData.streamGeneration !== expectedStreamGeneration - ) { - return false - } - if ( - transport.getPtyId() !== expectedPtyId || - transportStreamGeneration !== expectedStreamGeneration - ) { - pendingReplayData = null - return false - } - const payload = pendingReplayData - const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload - pendingReplayData = null - const isCurrentPayload = (): boolean => - !disposed && - payload.generation === replayPayloadGeneration && - payload.streamGeneration === transportStreamGeneration && - transport.getPtyId() === payload.ptyId - if (!isCurrentPayload()) { - continue - } - // Relay replay buffers may overlap with content already rendered in - // xterm. Local eager replay decides this earlier so metadata-only frames - // can keep restored scrollback while still using the replay guard. - if (clearBeforeReplay) { - await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') - if (!isCurrentPayload()) { - continue - } - } - if (clearBeforeReplay || data.length > 0) { - // Why: an empty clearing frame is still an authoritative repaint and - // must clear a stale agent signal from an earlier payload. - rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay }) - } - // Why: replayed application bytes carry the live TUI's kitty keyboard - // negotiation; the mirror must re-arm from them after a reload. Replay - // semantics: relay reconnects redeliver the same window, so pushes - // apply as sets to keep the mirrored stack from accumulating frames. - applySnapshotKittyKeyboardModes(data, payload) - await writeReplayDataAsync(data) - if (!isCurrentPayload()) { - continue - } - if (clearBeforeReplay || data.length > 0) { - await writeReplayDataAsync(reattachReplayResetSequence(data)) - if (!isCurrentPayload()) { - continue - } - sendFocusedReattachFocusInAfterReplay(payload.ptyId, payload.streamGeneration) - } - // Why: the daemon could not serialize a PTY read that ended mid-escape, - // so the emulator shipped the dangling partial separately. Write it LAST - // — after the reset, whose ESC would otherwise abort it — so the next - // live chunk completes the sequence instead of rendering literally - // (#7329). Guarded so a later ESC cannot leave the parser wedged. - if (pendingEscapeTailAnsi) { - await writeReplayDataAsync(pendingEscapeTailAnsi) - } - if (!isCurrentPayload()) { - continue - } - // Why: remote-runtime snapshots can arrive after WebGL attached to an - // empty buffer; rebuilding after replay parses seeds the glyph atlas - // from the now-populated xterm state. - manager.rebuildPaneWebgl(pane.id) - appliedCurrentPayload = true - } - return appliedCurrentPayload - } - const scheduleReplayDataDrain = (): void => { - if (replayDrainQueued) { - return - } - const scheduledPtyId = pendingReplayData?.ptyId ?? null - replayDrainQueued = true - // Why: live bytes are newer than the authoritative replay frame. Hold - // them until clear + replay + reset have all parsed, or replay can erase them. - const scheduledStreamGeneration = - pendingReplayData?.streamGeneration ?? transportStreamGeneration - beginReattachLiveDataDeferral(scheduledStreamGeneration) - let replayCompleted = false - replayWriteQueue = replayWriteQueue - .catch(() => undefined) - .then(() => - structuralReplayCoordinator.run( - async () => { - replayCompleted = await drainReplayDataQueue( - scheduledPtyId, - scheduledStreamGeneration - ) - }, - { - shouldRestore: () => - !disposed && - transport.getPtyId() === scheduledPtyId && - transportStreamGeneration === scheduledStreamGeneration - } - ) - ) - .then(() => { - replayCompleted &&= !disposed && transport.getPtyId() === scheduledPtyId - }) - .finally(() => { - replayDrainQueued = false - if (pendingReplayData !== null) { - // Why: preserve the PTY identity captured when the callback fired; - // re-reading it here could retag stale bytes for a replacement PTY. - scheduleReplayDataDrain() - } - finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration) - }) - } - const replayDataCallback = ( - data: string, - meta: PtyReplayDataMeta = {}, - streamGeneration = transportStreamGeneration - ): void => { - pendingReplayData = { - data, - clearBeforeReplay: meta.clearBeforeReplay !== false, - ptyId: transport.getPtyId(), - generation: (replayPayloadGeneration += 1), - streamGeneration, - ...(meta.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } - : {}), - ...(meta.kittyKeyboardFlags !== undefined && meta.snapshotSeq !== undefined - ? { - kittyKeyboardFlags: meta.kittyKeyboardFlags, - snapshotSeq: meta.snapshotSeq - } - : {}) - } - scheduleReplayDataDrain() - } - - const captureTransportOutputCallbacks = ( - onError: (message: string) => void, - startup: PtyPaneStartup - ) => { - // Why: a new stream generation cannot inherit an old replay's pending - // destination-grid fit or keep its live-data waiter open. - pendingHiddenSnapshotFit?.cancel() - pendingHiddenSnapshotFit = null - pendingReattachFit?.cancel() - pendingReattachFit = null - const generation = (transportStreamGeneration += 1) - const processExitState = createProcessExitState(startup) - currentProcessExitState = processExitState - const isCurrent = (): boolean => !disposed && generation === transportStreamGeneration - return { - generation, - callbacks: { - onReattachDetermined: (): void => { - if (isCurrent()) { - beginReattachLiveDataDeferralIfUnowned(generation) - } - }, - onConnect: (): void => { - if (isCurrent()) { - reportRemoteRendererSerializerReady() - // Why: a visibility flip during a rebind can't reach the host (no bound - // pty id), and the transport replays the stale pause bit onto the new - // stream — re-derive it from live visibility once the stream is bound. - syncHiddenRendererPtyDelivery() - } - }, - onStreamRecovered: (): void => { - if (isCurrent()) { - syncHiddenRendererPtyDelivery() - markHiddenOutputRestoreNeeded() - } - }, - onData: (data: string, meta?: PtyDataMeta): void => { - if (isCurrent()) { - processExitState.detector.observe(data) - dataCallback(data, meta, generation) - } - }, - onReplayData: (data: string, meta?: PtyReplayDataMeta): void => { - if (isCurrent()) { - replayDataCallback(data, meta, generation) - } - }, - onError: (message: string): void => { - if (isCurrent()) { - onError(message) - } - }, - onWriteUnavailable: (): void => { - if (isCurrent()) { - requestRecoveryForUndeliverableInput(true) - } - }, - onRecoveryStateChange: (state: PtyTransportRecoveryState): void => { - if (isCurrent()) { - // Why: cached pixels remain visible while detached; expose transport truth for diagnostics and recovery UI. - pane.container.dataset.ptyRecoveryState = state.phase - deps.onPtyRecoveryStateRef?.current?.(pane.id, state) - } - }, - onOutputPauseChanged: (paused: boolean, supported: boolean): void => { - if (isCurrent()) { - handleRemoteOutputPauseChanged(paused, supported) - } - } - } - } - } - - type PendingHiddenOutputRestoreChunk = { - data: string - seq?: number - rawLength?: number - } - - let hiddenOutputRestoreNeeded = false - let hiddenOutputRestoreInFlight: Promise | null = null - let hiddenOutputRestorePendingChunks: PendingHiddenOutputRestoreChunk[] = [] - let hiddenOutputRestorePendingChars = 0 - let hiddenOutputRestorePendingOverflow = false - let hiddenOutputRestoreFreshSnapshotNeeded = false - let hiddenOutputRestoreRetryDeferred = false - let hiddenOutputRestoreScheduled = false - let hiddenOutputRestoreDeferredRetryTimer: ReturnType | null = null - let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType | null = null - let hiddenOutputRestoreDeferredRetryAttempts = 0 - let hiddenOutputRestoreRemoteOutcomeAttempts = 0 - let hiddenOutputRestoreLocalGateAttempts = 0 - let hiddenOutputRestoreLegacyPtyId: string | null = null - // Bounded remote re-arms spent instead of the loss banner (per PTY stream). - let hiddenOutputRestoreRemoteAbandonCycles = 0 - let hiddenOutputSnapshotScrollRestore: { - ptyId: string | null - generation: number - valid: boolean - started: boolean - } | null = null - // Why: hidden recovery state belongs to one PTY stream. Reattach/restart - // can reuse the pane object for a different session before visibility. - let hiddenOutputRestorePtyId: string | null = null - // One recovery re-kick per xterm instance. Generation-aware cooldown and - // window-cap retries keep a fresh-but-wedged replacement from fossilizing. - let certifiedDeadRestoreRecoveryRequested = false - let hiddenOutputRestoreGeneration = 0 - // Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS). - let hiddenOutputRestoreFloodSuppressedUntil = 0 - // Why: queued replay writes still paint after deadline abandonment; the - // fallback drain must not write snapshot-covered live bytes a second time. - let hiddenOutputRestoreReplayingSnapshot: { - seq?: number - pendingDeliveryStartSeq?: number - paintsContent?: boolean - } | null = null - let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null - let cancelHiddenOutputRestoreFloodRepaintPark: (() => void) | null = null - // Why: after a snapshot restore, main can still drain ACK-backlog chunks - // whose bytes the snapshot already covers — writing them unguarded - // duplicates visible output. Track the restored baseline seq (per PTY) - // and the expected next chunk start so dataCallback can drop/slice - // overlaps and detect seq gaps from main-side pending-cap trims whose - // one-shot marker was already consumed. - let restoredSnapshotBaselineSeq: number | null = null - let restoredSnapshotBaselinePtyId: string | null = null - let restoredSnapshotExpectedStartSeq: number | null = null - // Why: main samples its pending renderer-delivery queue with the snapshot. - // Chunks at or below this seq can never be backlog duplicates (delivery is - // once-and-in-order), so the dedupe window is (windowStart, baseline]. - let restoredSnapshotDeliveryWindowStartSeq: number | null = null - - function setRestoredSnapshotBaseline( - ptyId: string, - snapshot: { seq?: number; pendingDeliveryStartSeq?: number }, - paintsContent: boolean - ): void { - if (typeof snapshot.seq !== 'number') { - clearRestoredSnapshotBaseline() - return - } - // Why: arming drops the redelivery permanently, so the snapshot's painted - // content must be able to back the seq it claims; a blank image cannot (STA-5179). - if (snapshot.seq > 0 && !paintsContent) { - clearRestoredSnapshotBaseline() - return - } - const windowStartSeq = - typeof snapshot.pendingDeliveryStartSeq === 'number' - ? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq) - : null - if (windowStartSeq !== null && windowStartSeq >= snapshot.seq) { - // Why: main reported an empty undelivered backlog — no chunk at or - // below the snapshot seq can ever arrive again (delivery is once and - // in order) and a future pending-cap trim re-arms the out-of-band - // marker. Arming a baseline anyway would misread live chunks from a - // foreign seq domain (restarted counter / synthetic injection) as - // duplicates or trim gaps and silently drop genuinely-new output. - clearRestoredSnapshotBaseline() - return - } - restoredSnapshotBaselineSeq = snapshot.seq - restoredSnapshotBaselinePtyId = ptyId - restoredSnapshotExpectedStartSeq = snapshot.seq - restoredSnapshotDeliveryWindowStartSeq = windowStartSeq - } - - function clearRestoredSnapshotBaseline(): void { - restoredSnapshotBaselineSeq = null - restoredSnapshotBaselinePtyId = null - restoredSnapshotExpectedStartSeq = null - restoredSnapshotDeliveryWindowStartSeq = null - } - let foregroundImmediateBudgetChars = 0 - let foregroundImmediateBudgetWindowStart = 0 - let foregroundRewriteChunkEndedWithCarriageReturn = false - let foregroundRewriteCsiScanTail = '' - let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE - const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) - let hiddenStartupRendererQueryPending = '' - let hiddenRendererStateDirty = false - let rendererOrderedPtyId: string | null = null - let rendererOrderedSeq: number | null = null - let rendererChannelSeqPtyId: string | null = null - let rendererChannelSeq: number | null = null - - function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { - return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) - } - - function canUseHiddenOutputSnapshot(ptyId: string | null): ptyId is string { - if (!ptyId) { - return false - } - if (canUseMainBufferSnapshot(ptyId)) { - return true - } - return transport.getPtyId() === ptyId && typeof transport.serializeBuffer === 'function' - } - - type HiddenOutputSnapshotResult = - | { kind: 'snapshot'; snapshot: PtyBufferSnapshot } - // `source` picks the budget: only 'host' answers cost the host a serialize attempt. - | { kind: 'retry-worthy'; source: 'host' | 'local' } - | { kind: 'permanently-unavailable' } - | { kind: 'unknown-legacy-host' } - | { kind: 'unavailable' } - - async function serializeHiddenOutputSnapshot( - ptyId: string, - opts: { scrollbackRows?: number } - ): Promise { - const e2eSnapshot = readE2eHiddenSnapshotOverride(ptyId) - if (e2eSnapshot) { - const snapshot = await e2eSnapshot - return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unavailable' } - } - if (canUseMainBufferSnapshot(ptyId)) { - const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, opts) - return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unavailable' } - } - if (transport.getPtyId() !== ptyId || typeof transport.serializeBuffer !== 'function') { - return { kind: 'unavailable' } - } - if ( - hiddenOutputRestoreLegacyPtyId === ptyId || - typeof transport.serializeBufferOutcome !== 'function' - ) { - const snapshot = await transport.serializeBuffer(opts) - return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unknown-legacy-host' } - } - try { - const outcome = await transport.serializeBufferOutcome(opts) - if (outcome.availability.kind === 'snapshot') { - // A success frame with no image is still the host's own answer to a request it received. - return outcome.snapshot - ? { kind: 'snapshot', snapshot: outcome.snapshot } - : { kind: 'retry-worthy', source: 'host' } - } - if (outcome.availability.kind === 'retry-worthy') { - return { - kind: 'retry-worthy', - source: isHostAnsweredSnapshotRetryCause(outcome.availability.cause) ? 'host' : 'local' - } - } - if (outcome.availability.kind === 'permanently-unavailable') { - return { kind: 'permanently-unavailable' } - } - return { kind: 'unknown-legacy-host' } - } catch { - // Why 'host': the reject path is the request timeout — the frame went out and the host stayed silent. - return { kind: 'retry-worthy', source: 'host' } - } - } - - // Why: hidden/parked panes used to mark hidden only at the first - // dataCallback sync, leaving a spawn-time window where neither side - // answered queries (the spawn-time DA1 loss). Declaring hidden on the - // spawn IPC lets main mark the PTY before its first byte — including - // codex spawns: the model responder answers their startup probes from - // byte zero now that the 10s renderer query window is gone. - // Remote-runtime PTYs are never gate-markable (no local main transit). - function shouldDeclareHiddenAtSpawn(): boolean { - return ( - hiddenDeliveryGateActive && - !runtimeEnvironmentId && - !disposed && - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) - } - - // ── Hidden-delivery gate sync (Phase 4) ───────────────────────────── - // Why: marks this pane's PTY hidden in main while no visible view needs - // its bytes; main then drops delivery after model ingestion and reveal - // restores from the snapshot. The marked id is tracked locally so PTY - // changes (reattach/restart) can never leave a stale id gated. - let hiddenDeliverySyncedPtyId: string | null = null - let releaseHiddenDeliveryClaim: (() => void) | null = null - let modelRestoreSubscribedPtyId: string | null = null - let unregisterModelRestoreNeeded: (() => void) | null = null - - function isHiddenOutputRestoreFloodSuppressed(): boolean { - return Date.now() < hiddenOutputRestoreFloodSuppressedUntil - } - - // True when a drop/gap signal on a visible pane is attributable to this - // pane's OWN restore backpressure (a restore is replaying right now, or - // one was just cut off for outrunning the stream). Such signals must not - // re-arm restores — that is the rc.7.perf feedback loop. - function isForegroundRestoreBackpressureContext(): boolean { - return ( - shouldWritePtyOutputForeground(deps.isVisibleRef.current) && - (hiddenOutputRestoreInFlight !== null || isHiddenOutputRestoreFloodSuppressed()) - ) - } - - function clearHiddenOutputRestoreFloodRepaintTimer(): void { - cancelHiddenOutputRestoreFloodRepaintPark?.() - cancelHiddenOutputRestoreFloodRepaintPark = null - if (hiddenOutputRestoreFloodRepaintTimer === null) { - return - } - clearTimeout(hiddenOutputRestoreFloodRepaintTimer) - hiddenOutputRestoreFloodRepaintTimer = null - } - cleanupHiddenOutputRestoreFloodRepaint = clearHiddenOutputRestoreFloodRepaintTimer - - // Why: the repaint discards the buffer and replays a full snapshot, which repositions the viewport; a user reading scrollback must not be yanked to the bottom, so hold it until their own scroll intent returns to follow-output. - function repaintAfterFloodWhenFollowingOutput(ptyId: string): void { - cancelHiddenOutputRestoreFloodRepaintPark?.() - cancelHiddenOutputRestoreFloodRepaintPark = null - let repainted = false - const cancelPark = onTerminalScrollIntentFollowOutput(pane.terminal, () => { - repainted = true - cancelHiddenOutputRestoreFloodRepaintPark = null - if (disposed || transport.getPtyId() !== ptyId) { - return - } - markHiddenOutputRestoreNeeded() - }) - if (!repainted) { - cancelHiddenOutputRestoreFloodRepaintPark = cancelPark - } - } - - function resetHiddenOutputRestoreFloodSuppression(): void { - hiddenOutputRestoreFloodSuppressedUntil = 0 - clearHiddenOutputRestoreFloodRepaintTimer() - } - - // Extends the suppression window; every backpressure signal resets the timer so the deferred repaint fires once, SUPPRESS_MS after the last signal. - function noteHiddenOutputRestoreFloodBackpressure(): void { - hiddenOutputRestoreFloodSuppressedUntil = Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS - const ptyId = transport.getPtyId() - if (ptyId === null) { - return - } - clearHiddenOutputRestoreFloodRepaintTimer() - hiddenOutputRestoreFloodRepaintTimer = setTimeout(() => { - hiddenOutputRestoreFloodRepaintTimer = null - if (disposed || transport.getPtyId() !== ptyId) { - return - } - // Why one repaint: flood-dropped bytes leave a gap the live stream can't heal; once quiet, one snapshot restore repaints from main's authoritative buffer. - repaintAfterFloodWhenFollowingOutput(ptyId) - }, HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS) - } - - // Why: main reports dropped renderer-bound bytes out-of-band, routed per PTY by pty-model-restore-channel.ts. - function handleModelRestoreNeededMarker(): void { - if (disposed) { - return - } - recordTerminalFreezeBreadcrumb('restore-marker', { - id: redactPtyIdForDiagnostics(transport.getPtyId() ?? '') - }) - // Why: dropped bytes invalidate cross-chunk carry — a partial OSC-9999 prefix spanning the gap would corrupt the next live chunk. - transport.resetCrossChunkParserState?.() - // Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint. - if (isForegroundRestoreBackpressureContext()) { - noteHiddenOutputRestoreFloodBackpressure() - return - } - // Why the emulator too: it carries state across chunks exactly like the - // parser does. If the gap swallowed the `ESC[22m` closing a bold run, - // every cell written afterwards inherits it. This marker is the one point - // where "bytes were dropped" is known, so ground the pen here rather than - // relying on each recovery path to remember (STA-4042). - // Why after the backpressure return and not before: under flood these - // markers arrive continuously, and writing per marker would add work in - // exactly the case that guard exists to damp. The flood path repaints via - // buildMainModelSnapshotReplayWrites, which grounds the pen itself, so - // nothing is lost by skipping it here. - writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) - // Why: a marker during an in-flight restore means that snapshot may predate the drop, so a fresh one must follow; capture BEFORE the mark, which starts a restore synchronously on a visible pane. - const restoreWasInFlight = hiddenOutputRestoreInFlight !== null - markHiddenOutputRestoreNeeded() - if (restoreWasInFlight) { - hiddenOutputRestoreFreshSnapshotNeeded = true - } - } - - function syncModelRestoreNeededSubscription(ptyId: string | null): void { - if (modelRestoreSubscribedPtyId === ptyId) { - return - } - unregisterModelRestoreNeeded?.() - unregisterModelRestoreNeeded = null - modelRestoreSubscribedPtyId = ptyId - // Why: markers exist only for PTYs whose bytes transit local main; - // remote-runtime transports are structurally unaffected. - if (!ptyId || isRemoteRuntimePtyId(ptyId)) { - return - } - unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler( - ptyId, - handleModelRestoreNeededMarker - ) - } - - handleRemoteOutputPauseChanged = (paused, supported): void => { - const ptyId = transport.getPtyId() - if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { - return - } - if (!paused) { - const wasGated = remoteOutputGatedPtyId === ptyId - if (wasGated) { - remoteOutputGatedPtyId = null - if (!mainSideEffectAuthority) { - dropSideEffectFactConsumer() - } - } - if (wasGated && hiddenOutputRestorePtyId === ptyId) { - requestHiddenOutputRestoreIfNeeded() - } - return - } - if (remoteOutputGatedPtyId !== ptyId) { - remoteOutputGatedPtyId = ptyId - } - if (supported && remoteOutputFactConsumerPtyId !== ptyId) { - registerSideEffectFactConsumerForPty(ptyId, true) - remoteOutputFactConsumerPtyId = ptyId - } - markHiddenOutputRestoreNeeded() - } - - syncHiddenRendererPtyDelivery = (): void => { - const ptyId = transport.getPtyId() - syncModelRestoreNeededSubscription(ptyId) - if (remoteOutputGatedPtyId !== null && remoteOutputGatedPtyId !== ptyId) { - remoteOutputGatedPtyId = null - if (!mainSideEffectAuthority) { - dropSideEffectFactConsumer() - } - } - if (isRemoteRuntimePtyId(ptyId) && canUseHiddenOutputSnapshot(ptyId)) { - transport.setOutputPaused?.( - !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) - return - } - if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) { - releaseHiddenDeliveryClaim?.() - releaseHiddenDeliveryClaim = null - hiddenDeliverySyncedPtyId = null - } - if (!isHiddenDeliveryGateManagedPty(ptyId) || !canUseHiddenOutputSnapshot(ptyId)) { - return - } - const shouldHide = !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - const isFirstSyncForPty = hiddenDeliverySyncedPtyId !== ptyId - hiddenDeliverySyncedPtyId = ptyId - if (shouldHide) { - if (!releaseHiddenDeliveryClaim) { - releaseHiddenDeliveryClaim = acquireHiddenRendererPtyDeliveryClaim(ptyId) - } - } else if (releaseHiddenDeliveryClaim) { - releaseHiddenDeliveryClaim() - releaseHiddenDeliveryClaim = null - } else if (isFirstSyncForPty) { - // Why: clear unconditionally on first sync — a stale main-side hidden bit can survive a renderer reload for daemon-backed PTYs that keep their session id. - declareRendererPtyDeliveryVisible(ptyId) - } - } - releaseHiddenRendererPtyDelivery = (): void => { - transport.setOutputPaused?.(false) - if (remoteOutputGatedPtyId !== null) { - remoteOutputGatedPtyId = null - if (!mainSideEffectAuthority) { - dropSideEffectFactConsumer() - } - } - releaseHiddenDeliveryClaim?.() - releaseHiddenDeliveryClaim = null - hiddenDeliverySyncedPtyId = null - unregisterModelRestoreNeeded?.() - unregisterModelRestoreNeeded = null - modelRestoreSubscribedPtyId = null - } - - function beforeTerminalOutputWrite(data: string): void { - // Why: shaping must register before xterm parses the RTL bytes that need it. - ensureArabicShapingJoinerForText(pane.terminal, data) - recordTerminalOutput(pane.terminal) - } - - function consumeForegroundImmediateBudget(dataLength: number): boolean { - const now = performance.now() - if (now - foregroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) { - foregroundImmediateBudgetChars = 0 - foregroundImmediateBudgetWindowStart = now - } - if (foregroundImmediateBudgetChars + dataLength > FOREGROUND_IMMEDIATE_BUDGET_CHARS) { - return false - } - foregroundImmediateBudgetChars += dataLength - return true - } - - function isActiveSplitPane(): boolean { - if (!deps.isActiveRef.current) { - return false - } - const activePane = manager.getActivePane?.() ?? null - return activePane ? activePane.id === pane.id : true - } - - function isLatencySensitiveForegroundOutput(data: string): boolean { - if (!isActiveSplitPane()) { - // Why: many visible split panes each emit tiny TUI frames; a shared budget keeps them live without letting aggregate xterm work starve typing in the active pane. - if (data.includes('\x1b[')) { - return false - } - return consumeInactiveForegroundImmediateBudget(data.length) - } - if (data.length <= FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS) { - return consumeForegroundImmediateBudget(data.length) - } - const recentInput = - performance.now() - lastInteractiveRedrawInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS - if ( - recentInput && - data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS && - data.includes('\x1b[') - ) { - return consumeForegroundImmediateBudget(data.length) - } - return false - } - - function containsNonAsciiOutput(data: string): boolean { - for (let index = 0; index < data.length; index++) { - if (data.charCodeAt(index) > 0x7f) { - return true - } - } - return false - } - - function containsWindowsRewriteControl(data: string): boolean { - return data.includes('\r') || terminalRewriteOutputPrefersRenderRefresh(data) - } - - function foregroundRewriteOutputPrefersRenderRefresh(data: string): boolean { - const decision = terminalRewriteOutputRenderRefreshDecision(data, { - previousChunkEndsWithCarriageReturn: foregroundRewriteChunkEndedWithCarriageReturn, - previousRewriteCsiScanTail: foregroundRewriteCsiScanTail - }) - foregroundRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn - foregroundRewriteCsiScanTail = decision.nextRewriteCsiScanTail - return decision.prefersRenderRefresh - } - - function shouldForceForegroundRenderRefresh(data: string): { - refresh: boolean - inPlaceRewrite: boolean - } { - const rewriteOutputPrefersRenderRefresh = foregroundRewriteOutputPrefersRenderRefresh(data) - const recentInput = - performance.now() - lastInteractiveRedrawInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS - if (foregroundRendererRiskOutputPrefersRenderRefresh(data)) { - return { - refresh: true, - inPlaceRewrite: rewriteOutputPrefersRenderRefresh - } - } - if (rewriteOutputPrefersRenderRefresh) { - // Why: xterm's buffer is right but in-place redraw cells stay stale in the renderer until a repaint (resize fixes it). - return { refresh: true, inPlaceRewrite: true } - } - if ( - windowsEastAsianOutputPrefersRenderRefresh(data, { - isWindowsClient: shouldApplyWindowsRendererUnicodeRefresh, - isNativeWindowsConpty: shouldApplyNativeWindowsRewriteRefresh, - hadRecentInput: recentInput, - maxInteractiveRedrawChars: FOREGROUND_INTERACTIVE_REDRAW_CHARS - }) - ) { - // Why: CJK/Korean from Microsoft Pinyin commits and native ConPTY output can leave stale wide-glyph cells in the Windows DOM renderer. - return { refresh: true, inPlaceRewrite: false } - } - return { - refresh: - shouldApplyNativeWindowsRewriteRefresh && - containsNonAsciiOutput(data) && - containsWindowsRewriteControl(data), - inPlaceRewrite: false - } - } - - // Why record-only: DECSET 2031 subscribes to future color changes, it is not a query — - // the protocol's query is `CSI ?996n`. fish arms 2031 for the ~1ms it paints a prompt, so - // any reply lands after the withdrawal and paints `?997;1n` as literal text (#9993). - // Why here and not in xterm's CSI handler: xterm batches several PTY chunks into one - // synchronous parse, so a handler cannot tell where a chunk ended. One raw chunk in, - // one order-aware final state out. - function observeLiveMode2031Chunk(data: string): void { - // Gate-managed PTYs never see these bytes; main's '2031-subscribe' fact records them. - if (isHiddenDeliveryGateManagedPty(transport.getPtyId())) { - return - } - const result = scanMode2031ReplyDecision(mode2031ReplyScanState, data) - mode2031ReplyScanState = result.state - if (result.decision === 'unsubscribed') { - deps.paneMode2031Ref.current.delete(pane.id) - deps.paneLastThemeModeRef.current.delete(pane.id) - } - if (result.decision !== 'subscribed') { - return - } - const settings = useAppStore.getState().settings - // Why seed the mode: maybePushMode2031Flip only pushes on a change, so an unseeded - // subscription would read as a flip on the next unrelated appearance re-apply. - deps.paneMode2031Ref.current.set(pane.id, true) - deps.paneLastThemeModeRef.current.set( - pane.id, - resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) - ) - } - - // Why installed here: the handler observes CSI 3 J inside xterm's parse, so a - // redraw split across PTY chunks is reported once, with the pane's rows for - // this write already applied. - liveScrollbackRestore?.dispose() - liveScrollbackRestore = installTerminalLiveScrollbackRestore(pane.terminal) - - // Why read it live: `?1049` is not a no-op when the pane is already on the - // target buffer — xterm still runs restoreCursor and swaps the kitty flag - // registers — so the replay prologue must only switch when it truly differs. - // - // Best-effort by design. xterm parses asynchronously, so this sees only - // PARSED writes; a `?1049` transition still queued reads stale. Draining - // first with a write sentinel was tried and reverted: it puts the repaint - // behind xterm's queue, so a wedged terminal stalls recovery, and it breaks - // the disposal/cancellation ordering the restore paths rely on. A stale read - // costs one mis-scoped buffer switch; the barrier costs the repaint. - function isPaneOnAlternateScreen(): boolean { - return pane.terminal.buffer.active.type === 'alternate' - } - - function writePtyOutputToXterm( - data: string, - foreground: boolean, - opts?: { hiddenStartupRendererQuery?: boolean } - ): void { - // Why: every application byte funnels through here, so it's the one place the kitty keyboard mirror observes the pane's protocol negotiation. - kittyKeyboardModes.scan(data) - if (foreground) { - resetHiddenOutputRestoreIfPtyChanged() - } - const parseHiddenStartupOutput = - !foreground && - canUseHiddenOutputSnapshot(transport.getPtyId()) && - shouldSnapshotHiddenCodexOutput && - (opts?.hiddenStartupRendererQuery === true || containsHiddenStartupRendererQuery(data)) - const synchronizedForegroundScan = - shouldProtectNativeWindowsSynchronizedOutput && foreground - ? scanSynchronizedForegroundOutput( - data, - synchronizedForegroundMarkerTail, - synchronizedForegroundOutputActive - ) - : null - const synchronizedOutputStarted = synchronizedForegroundScan?.started === true - const synchronizedOutputEnded = synchronizedForegroundScan?.ended === true - const synchronizedForegroundOutput = - synchronizedForegroundScan !== null && - (synchronizedForegroundOutputActive || synchronizedOutputStarted || synchronizedOutputEnded) - const nextSynchronizedForegroundOutputActive = synchronizedForegroundScan?.active === true - // Why: xterm's DOM renderer draws the cursor as row content, so Windows cursor-only restores need row invalidation even outside DEC 2026. - const nativeWindowsCursorRestore = - shouldProtectNativeWindowsSynchronizedOutput && foreground && containsCursorRestore(data) - const foregroundOutput = foreground || parseHiddenStartupOutput - if (foreground) { - scheduleForegroundGridDriftCheck() - } - const renderRefreshDecision = foregroundOutput - ? shouldForceForegroundRenderRefresh(data) - : { refresh: false, inPlaceRewrite: false } - const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh - // Why: Claude Code's in-place prompt redraws on Windows ConPTY can paint one frame late; a follow-up repaint fixes the column desync without a resize. - const nativeWindowsInPlaceRewriteFollowup = nativeWindowsRewriteNeedsFollowupRenderRefresh({ - isNativeWindowsConpty: shouldApplyNativeWindowsRewriteRefresh, - isForeground: foreground, - isInPlaceRewrite: renderRefreshDecision.inPlaceRewrite - }) - // Why: recompute the latch on every synchronized START so each frame's interactivity is judged by its own open time and can't leak across a same-chunk close+open; clear only on leaving synchronized output. - if (synchronizedForegroundOutput && synchronizedOutputStarted) { - synchronizedForegroundFrameInteractive = - performance.now() - lastInteractiveRedrawInputAt <= - FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS - } else if (!nextSynchronizedForegroundOutputActive && !synchronizedOutputEnded) { - synchronizedForegroundFrameInteractive = false - } - // Why: ConPTY can split a submit repaint's closing chunk past the 150ms window, so treat a keystroke-opened frame as latency-sensitive to drain it fast (~16-32ms) not the 1s coalesce fallback. - const synchronizedFrameLatencySensitive = - synchronizedForegroundOutput && synchronizedForegroundFrameInteractive - synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive - synchronizedForegroundMarkerTail = synchronizedForegroundScan?.markerTail ?? '' - writeTerminalOutput(pane.terminal, data, { - foreground: foregroundOutput, - beforeWrite: beforeTerminalOutputWrite, - // Why: every scheduler write claims one child so a split delivery is credited only after all children parse or discard. - ackCredit: takeCurrentTerminalDeliveryCredit() ?? undefined, - onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded, - latencySensitive: - !foreground || parseHiddenStartupOutput - ? true - : synchronizedFrameLatencySensitive || isLatencySensitiveForegroundOutput(data), - forceForegroundRefresh: - foregroundOutput && - (synchronizedForegroundOutput || - nativeWindowsCursorRestore || - foregroundRenderRefreshNeeded), - followupForegroundRefresh: - nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, - // Why: xterm already queued a WebGL frame parsing this chunk; merge the repair into it instead of rendering the grid twice. - shouldRefreshForegroundSynchronously, - stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, - coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, - holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive - }) - } - - queueAgentIdleTerminalModeReset = (): void => { - if (disposed) { - return - } - writePtyOutputToXterm( - idleAgentTerminalModeReset, - shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) - } - - function markHiddenOutputRestoreNeeded(): void { - const ptyId = transport.getPtyId() - if (!canUseHiddenOutputSnapshot(ptyId)) { - return - } - if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) { - clearHiddenOutputRestoreState() - } - hiddenOutputRestorePtyId = ptyId - hiddenOutputRestoreNeeded = true - if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { - requestHiddenOutputRestoreIfNeeded() - } - } - - function shouldSkipHiddenRendererOutput(foreground: boolean, data: string): boolean { - const ptyId = transport.getPtyId() - if ( - foreground || - (!shouldSnapshotHiddenCodexOutput && remoteOutputGatedPtyId !== ptyId) || - !canUseHiddenOutputSnapshot(ptyId) - ) { - return false - } - // Why: CPR/DECRQM replies depend on ordered state; keep a clean stateful-query chunk live, but after skipped bytes avoid stale replies. - return hiddenRendererStateDirty || !containsStatefulRendererQuery(data) - } - - function writeHiddenStartupRendererQueries(data: string): void { - const extracted = extractHiddenStartupRendererQueryData( - data, - hiddenStartupRendererQueryPending - ) - hiddenStartupRendererQueryPending = extracted.pending - if (extracted.oscColorQueryData) { - // Why: Codex's startup palette probe has a 100ms budget; answer hidden color queries immediately so scheduling/remote-input debounce (#7329) can't miss it. - sendTerminalOscColorQueryReplies( - extracted.oscColorQueryData, - pane.terminal, - sendDesktopQueryReplyImmediate - ) - } - if (extracted.statelessQueryData) { - writePtyOutputToXterm(extracted.statelessQueryData, false, { - hiddenStartupRendererQuery: true - }) - } - // Stateful hidden queries need ordered terminal state; if the hidden xterm is dirty, skip rather than send stale CPR/DECRQM. - } - - function takeHiddenStartupRendererQueryPendingForForeground(data: string): { - statelessQueryData: string - statefulQueryData: string - oscColorQueryData: string - remainingData: string - consumedCurrentChars: number - } { - const pending = hiddenStartupRendererQueryPending - hiddenStartupRendererQueryPending = '' - if (!pending) { - return { - statelessQueryData: '', - statefulQueryData: '', - oscColorQueryData: '', - remainingData: data, - consumedCurrentChars: 0 - } - } - - const input = pending + data - let statelessQueryData = '' - let statefulQueryData = '' - let oscColorQueryData = '' - let consumedInputChars = pending.length - let nextPending = '' - if (input.startsWith('\x1b[')) { - const finalByteIndex = findCsiFinalByteIndex(input, 2) - if (finalByteIndex === -1) { - nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS) - consumedInputChars = input.length - } else { - const sequence = input.slice(0, finalByteIndex + 1) - if (isStatelessRendererReplyCsiQuery(sequence)) { - statelessQueryData = sequence - } else if (isStatefulRendererReplyCsiQuery(sequence)) { - statefulQueryData = sequence - } - consumedInputChars = finalByteIndex + 1 - } - } else if (input.startsWith('\x1b]')) { - const query = parseTerminalOscColorQuery(input, 0) - if (query.kind === 'partial') { - nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS) - consumedInputChars = input.length - } else if (query.kind === 'match') { - oscColorQueryData = input.slice(0, query.endIndex) - consumedInputChars = query.endIndex - } else { - consumedInputChars = pending.length - } - } else if (input.length === 1) { - nextPending = input - consumedInputChars = input.length - } else { - consumedInputChars = pending.length - } - - hiddenStartupRendererQueryPending = nextPending - const consumedCurrentChars = Math.max(0, consumedInputChars - pending.length) - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - remainingData: data.slice(consumedCurrentChars), - consumedCurrentChars - } - } - - function metaAfterConsumingCurrentChars( - meta: PtyDataMeta | undefined, - consumedCurrentChars: number - ): PtyDataMeta | undefined { - if (consumedCurrentChars === 0 || typeof meta?.rawLength !== 'number') { - return meta - } - return { - ...meta, - rawLength: Math.max(0, meta.rawLength - consumedCurrentChars) - } - } - - function skipHiddenRendererOutput(data: string): void { - writeHiddenStartupRendererQueries(data) - markHiddenOutputRestoreNeeded() - hiddenRendererStateDirty = true - if (hiddenOutputRestoreInFlight) { - hiddenOutputRestoreFreshSnapshotNeeded = true - } - recordHiddenRendererSkip(data.length) - } - - // Why: discarding flood bytes must not swallow terminal queries (a lost DSR/CPR hangs the program); the snapshot repaint owns the content, so synthesize replies via the immediate input path, not xterm replay. - function salvageRendererQueriesFromDiscardedRestoreData(data: string): void { - if (!data || !data.includes('\x1b')) { - return - } - const extracted = extractHiddenStartupRendererQueryData(data, '') - if (extracted.oscColorQueryData) { - sendTerminalOscColorQueryReplies( - extracted.oscColorQueryData, - pane.terminal, - sendDesktopQueryReplyImmediate - ) - } - let unansweredQueryData = '' - for (const sequence of splitCsiSequences( - extracted.statefulQueryData + extracted.statelessQueryData - )) { - if (sequence === '\x1b[6n') { - // CPR from the live buffer; may be mid-repaint stale, but in a drop scenario liveness (unblock the reader) is the contract, not accuracy. - const buffer = pane.terminal.buffer.active - const row = Math.min(buffer.cursorY + 1, pane.terminal.rows) - const col = Math.min(buffer.cursorX + 1, pane.terminal.cols) - sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`) - } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { - sendDesktopQueryReplyImmediate(DEFAULT_DA1_RESPONSE) - } else { - unansweredQueryData += sequence - } - } - if (unansweredQueryData) { - // Best-effort for rarer queries (DECRQM, DA2, XTVERSION): replay into xterm so its handlers answer when no replay is active. - writePtyOutputToXterm(unansweredQueryData, true, { hiddenStartupRendererQuery: true }) - } - } - - function splitCsiSequences(queryData: string): string[] { - const sequences: string[] = [] - let offset = queryData.indexOf('\x1b[') - while (offset !== -1) { - const finalByteIndex = findCsiFinalByteIndex(queryData, offset + 2) - if (finalByteIndex === -1) { - break - } - sequences.push(queryData.slice(offset, finalByteIndex + 1)) - offset = queryData.indexOf('\x1b[', finalByteIndex + 1) - } - return sequences - } - - function queueLiveChunkDuringRestore(data: string, meta?: PtyDataMeta): void { - if (!data) { - return - } - const ptyId = transport.getPtyId() - if (!canUseHiddenOutputSnapshot(ptyId)) { - return - } - if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) { - clearHiddenOutputRestoreState() - } - hiddenOutputRestorePtyId = ptyId - hiddenOutputRestoreNeeded = true - if (hiddenOutputRestorePendingOverflow) { - // Why: the overflow latch discards everything queued at the next drain, so queueing more only grows the discard; salvage queries, drop content. - salvageRendererQueriesFromDiscardedRestoreData(data) - armHiddenOutputRestoreForegroundDeadline() - return - } - if (hiddenOutputRestorePendingChars + data.length > HIDDEN_OUTPUT_RESTORE_PENDING_CHARS) { - const discardedChunks = hiddenOutputRestorePendingChunks - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - hiddenOutputRestorePendingOverflow = true - for (const chunk of discardedChunks) { - salvageRendererQueriesFromDiscardedRestoreData(chunk.data) - } - salvageRendererQueriesFromDiscardedRestoreData(data) - armHiddenOutputRestoreForegroundDeadline() - return - } - const pending: PendingHiddenOutputRestoreChunk = { data } - if (typeof meta?.seq === 'number') { - pending.seq = meta.seq - } - if (typeof meta?.rawLength === 'number') { - pending.rawLength = meta.rawLength - } - hiddenOutputRestorePendingChunks.push(pending) - hiddenOutputRestorePendingChars += data.length - armHiddenOutputRestoreForegroundDeadline() - } - - function getChunkDataAfterSnapshot( - chunk: PendingHiddenOutputRestoreChunk, - snapshotSeq: number | undefined - ): string | null { - if (typeof snapshotSeq !== 'number' || typeof chunk.seq !== 'number') { - return chunk.data - } - const rawLength = chunk.rawLength ?? chunk.data.length - const startSeq = chunk.seq - rawLength - if (snapshotSeq >= chunk.seq) { - return '' - } - if (snapshotSeq <= startSeq) { - return chunk.data - } - const offset = snapshotSeq - startSeq - if (rawLength !== chunk.data.length) { - return null - } - return chunk.data.slice(offset) - } - - type RestoredSnapshotReconciliation = - | { action: 'write'; data: string; meta: PtyDataMeta | undefined } - | { action: 'drop-duplicate' } - | { action: 'force-fresh-restore' } - - // Why: same slicing as getChunkDataAfterSnapshot but for post-restore live chunks, which main's ACK backlog can still deliver at/before the snapshot seq (and can trim seq ranges silently). - function reconcileChunkAgainstRestoredSnapshot( - data: string, - meta: PtyDataMeta | undefined - ): RestoredSnapshotReconciliation { - if (restoredSnapshotBaselineSeq === null) { - return { action: 'write', data, meta } - } - if (transport.getPtyId() !== restoredSnapshotBaselinePtyId) { - clearRestoredSnapshotBaseline() - return { action: 'write', data, meta } - } - if (typeof meta?.seq !== 'number') { - // Why: seq-less chunks (no runtime metering) can't be reconciled; pass them through like getChunkDataAfterSnapshot. - return { action: 'write', data, meta } - } - if ( - restoredSnapshotDeliveryWindowStartSeq !== null && - meta.seq <= restoredSnapshotDeliveryWindowStartSeq - ) { - // Why: all still-deliverable bytes started after this seq and delivery is in-order, so this can't be a backlog dup — it's a new seq domain; retire the baseline and write. - clearRestoredSnapshotBaseline() - return { action: 'write', data, meta } - } - const rawLength = meta.rawLength ?? data.length - const startSeq = meta.seq - rawLength - const expectedStartSeq = restoredSnapshotExpectedStartSeq - restoredSnapshotExpectedStartSeq = Math.max(expectedStartSeq ?? meta.seq, meta.seq) - if (expectedStartSeq !== null && startSeq > expectedStartSeq) { - // Why: the chunk starts past the continuity point — bytes between were dropped (pending-cap trim); only a fresh snapshot heals the gap. - return { action: 'force-fresh-restore' } - } - if (meta.seq <= restoredSnapshotBaselineSeq) { - return { action: 'drop-duplicate' } - } - if (startSeq >= restoredSnapshotBaselineSeq) { - return { action: 'write', data, meta } - } - if (rawLength !== data.length) { - // Why: renderer-only OSC stripping makes raw seq offsets unmappable onto cleaned text; refetch instead of risking duplicate output. - return { action: 'force-fresh-restore' } - } - const sliced = data.slice(restoredSnapshotBaselineSeq - startSeq) - return { - action: 'write', - data: sliced, - // Why: keep seq metadata consistent with the sliced payload so a later queue drain slices against accurate offsets. - meta: { ...meta, rawLength: sliced.length } - } - } - - /** - * Apply an authoritative snapshot to the pane's kitty mirror in the order: - * unproven reset, replay-semantics scan of the snapshot - * bytes (so screen selection lands first), then the owner's proven flags. - * - * Why the reset only happens when the owner proved something: an old host - * omits the field, and downgrading a mirror that is already tracking live - * output would lose correct state instead of preserving it. - */ - function applySnapshotKittyKeyboardModes( - snapshotData: string, - snapshot: { kittyKeyboardFlags?: number; snapshotSeq?: number } - ): void { - const proven = - snapshot.snapshotSeq === undefined - ? undefined - : parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) - if (proven === undefined) { - // Why the demotion: a mirror grounded in this PTY's stream keeps its - // state, but a constructor-fresh tracker (window reload) holds a - // known-zero that was never proven for the reattached PTY — demote it - // so the serializer cannot republish it downstream as host-proven - // inactive. - if (!kittyKeyboardModes.hasProvenBaseline) { - kittyKeyboardModes.resetForSnapshot() - } - kittyKeyboardModes.scanReplay(snapshotData) - return - } - kittyKeyboardModes.resetForSnapshot() - kittyKeyboardModes.scanReplay(snapshotData) - kittyKeyboardModes.restoreSnapshotFlags(proven) - // Why in the same critical section: without the baseline a quiet pane - // could not publish a coherent snapshot until unrelated output arrived. - recordRendererOrderedSeq({ seq: snapshot.snapshotSeq }) - } - - function recordRendererOrderedSeq(meta?: Pick): void { - if (typeof meta?.seq !== 'number') { - return - } - const ptyId = transport.getPtyId() - if (!ptyId) { - return - } - if (rendererOrderedPtyId !== ptyId) { - rendererOrderedPtyId = ptyId - rendererOrderedSeq = meta.seq - return - } - rendererOrderedSeq = Math.max(rendererOrderedSeq ?? 0, meta.seq) - } - - resetRendererOrderedSeqForPtyExit = (exitedPtyId: string): void => { - // Why: an exit ends this ptyId's seq domain; a revived id restarts main's counter, so both seq high-water marks (ordered + restored baseline) must reset here or they drop revived bytes as duplicates. - if (restoredSnapshotBaselinePtyId === exitedPtyId) { - clearRestoredSnapshotBaseline() - } - if (rendererOrderedPtyId === exitedPtyId) { - rendererOrderedPtyId = null - rendererOrderedSeq = null - } - if (rendererChannelSeqPtyId === exitedPtyId) { - rendererChannelSeqPtyId = null - rendererChannelSeq = null - } - } - - function observeRendererOrderedSeqRegression(meta: PtyDataMeta | undefined): void { - if (typeof meta?.seq !== 'number') { - return - } - const ptyId = transport.getPtyId() - if (!ptyId) { - return - } - if (rendererChannelSeqPtyId !== ptyId) { - rendererChannelSeqPtyId = ptyId - rendererChannelSeq = meta.seq - return - } - if (rendererChannelSeq !== null && meta.seq < rendererChannelSeq) { - // Why: pty:data is FIFO, so seq regresses only when a session revived without an observed exit and restarted its counter; drop the stale baseline. - if (rendererOrderedPtyId === ptyId) { - rendererOrderedPtyId = null - rendererOrderedSeq = null - } - } - rendererChannelSeq = meta.seq - } - - function getHiddenRendererDataAfterOrderedSeq( - data: string, - meta: PtyDataMeta | undefined - ): string | null { - if ( - rendererOrderedPtyId === null || - rendererOrderedSeq === null || - transport.getPtyId() !== rendererOrderedPtyId - ) { - return data - } - return getChunkDataAfterSnapshot( - { data, seq: meta?.seq, rawLength: meta?.rawLength }, - rendererOrderedSeq - ) - } - - // 'drained' = painted all queued bytes; 'overflow' = queue blew its cap (stream outran fetch+replay); 'refetch' = offsets unmappable, need a fresher snapshot. - function drainPendingLiveChunksAfterSnapshot( - snapshotSeq: number | undefined - ): 'drained' | 'overflow' | 'refetch' { - if (hiddenOutputRestorePendingOverflow) { - hiddenOutputRestorePendingOverflow = false - discardPendingLiveChunksSalvagingQueries() - return 'overflow' - } - while (hiddenOutputRestorePendingChunks.length > 0) { - const chunks = hiddenOutputRestorePendingChunks - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - for (const [index, chunk] of chunks.entries()) { - const data = getChunkDataAfterSnapshot(chunk, snapshotSeq) - if (data === null) { - // Why: renderer-only OSC stripping makes raw seq offsets unmappable onto cleaned text; refetch instead of risking duplicate output. - for (const discarded of chunks.slice(index)) { - salvageRendererQueriesFromDiscardedRestoreData(discarded.data) - } - discardPendingLiveChunksSalvagingQueries() - return 'refetch' - } - // Why: advance the continuity point so reconciliation neither re-drops drained chunks as duplicates nor misreads the next live chunk as a gap. - if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) { - restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) - } - if (data) { - writePtyOutputToXterm(data, true) - recordRendererOrderedSeq(chunk) - } - } - if (hiddenOutputRestorePendingOverflow) { - hiddenOutputRestorePendingOverflow = false - discardPendingLiveChunksSalvagingQueries() - return 'overflow' - } - } - return 'drained' - } - - function discardPendingLiveChunksSalvagingQueries(): void { - const discarded = hiddenOutputRestorePendingChunks - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - for (const chunk of discarded) { - salvageRendererQueriesFromDiscardedRestoreData(chunk.data) - } - } - - function clearPendingLiveChunksDuringRestore(): void { - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - hiddenOutputRestorePendingOverflow = false - hiddenOutputRestoreFreshSnapshotNeeded = false - hiddenOutputRestoreRetryDeferred = false - hiddenOutputRestoreScheduled = false - cancelScheduledHiddenOutputRestore(pane.terminal) - clearHiddenOutputRestoreDeferredRetryTimer() - clearHiddenOutputRestoreForegroundDeadlineTimer() - hiddenOutputRestoreDeferredRetryAttempts = 0 - } - - function clearHiddenOutputRestoreDeferredRetryTimer(): void { - if (hiddenOutputRestoreDeferredRetryTimer === null) { - return - } - clearTimeout(hiddenOutputRestoreDeferredRetryTimer) - hiddenOutputRestoreDeferredRetryTimer = null - } - cleanupHiddenOutputRestoreDeferredRetry = clearHiddenOutputRestoreDeferredRetryTimer - - function clearHiddenOutputRestoreForegroundDeadlineTimer(): void { - if (hiddenOutputRestoreForegroundDeadlineTimer === null) { - return - } - clearTimeout(hiddenOutputRestoreForegroundDeadlineTimer) - hiddenOutputRestoreForegroundDeadlineTimer = null - } - cleanupHiddenOutputRestoreForegroundDeadline = clearHiddenOutputRestoreForegroundDeadlineTimer - - function armHiddenOutputRestoreForegroundDeadline(): void { - if ( - disposed || - hiddenOutputRestoreForegroundDeadlineTimer !== null || - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) || - (isRemoteRuntimePtyId(hiddenOutputRestorePtyId) && - hiddenOutputRestoreLegacyPtyId !== hiddenOutputRestorePtyId && - typeof transport.serializeBufferOutcome === 'function') || - (hiddenOutputRestorePendingChunks.length === 0 && !hiddenOutputRestorePendingOverflow) - ) { - return - } - const ptyId = hiddenOutputRestorePtyId - if (ptyId === null || transport.getPtyId() !== ptyId) { - return - } - const deadlineGeneration = hiddenOutputRestoreGeneration - // Why: only foreground output blocked behind recovery gets a deadline; hidden-time restore work has no user impact. - hiddenOutputRestoreForegroundDeadlineTimer = setTimeout(() => { - hiddenOutputRestoreForegroundDeadlineTimer = null - if ( - disposed || - hiddenOutputRestoreGeneration !== deadlineGeneration || - hiddenOutputRestorePtyId !== ptyId || - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) { - return - } - abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId) - }, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS) - } - - // Trades the loss banner for one bounded post-suppression repaint from the - // host's authoritative buffer. Ordered before the state reset so the repaint - // timer arms against the live ptyId (mirrors the flood-abandon call sites). - function rearmRemoteHiddenOutputRestoreInsteadOfWarning( - ptyId: string, - reason: string - ): boolean { - if ( - !isRemoteRuntimePtyId(ptyId) || - hiddenOutputRestoreRemoteAbandonCycles >= HIDDEN_OUTPUT_RESTORE_REMOTE_REARM_MAX - ) { - return false - } - hiddenOutputRestoreRemoteAbandonCycles += 1 - recordTerminalFreezeBreadcrumb('restore-abandon-rearm', { - id: redactPtyIdForDiagnostics(ptyId), - reason, - cycle: hiddenOutputRestoreRemoteAbandonCycles - }) - noteHiddenOutputRestoreFloodBackpressure() - writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) - return true - } - - function abandonHiddenOutputRestoreAndDrainPendingForeground( - expectedPtyId: string, - opts: { quiet?: boolean; rearmRemote?: boolean } = {} - ): void { - if (transport.getPtyId() !== expectedPtyId || hiddenOutputRestorePtyId !== expectedPtyId) { - resetHiddenOutputRestoreIfPtyChanged() - return - } - const rearmedRemoteRestore = - opts.rearmRemote !== false && - !opts.quiet && - canUseHiddenOutputSnapshot(expectedPtyId) && - rearmRemoteHiddenOutputRestoreInsteadOfWarning(expectedPtyId, 'abandon-deadline') - const pendingChunks = hiddenOutputRestorePendingOverflow - ? [] - : hiddenOutputRestorePendingChunks.slice() - const hadPendingOverflow = hiddenOutputRestorePendingOverflow - const replayingSnapshot = hiddenOutputRestoreReplayingSnapshot - hiddenOutputRestoreReplayingSnapshot = null - hiddenOutputRestoreGeneration += 1 - if ( - hiddenOutputSnapshotScrollRestore?.valid && - hiddenOutputSnapshotScrollRestore.ptyId === expectedPtyId - ) { - // Why: flood abandonment stops recovery bookkeeping, but its already-queued replay must keep the rebuild bracket and final pin. - hiddenOutputSnapshotScrollRestore.generation = hiddenOutputRestoreGeneration - } - hiddenOutputRestoreInFlight = null - hiddenOutputRestoreNeeded = false - hiddenOutputRestorePtyId = null - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - hiddenOutputRestorePendingOverflow = false - hiddenOutputRestoreFreshSnapshotNeeded = false - hiddenOutputRestoreRetryDeferred = false - hiddenOutputRestoreScheduled = false - hiddenStartupRendererQueryPending = '' - hiddenRendererStateDirty = false - cancelScheduledHiddenOutputRestore(pane.terminal) - clearHiddenOutputRestoreDeferredRetryTimer() - clearHiddenOutputRestoreForegroundDeadlineTimer() - hiddenOutputRestoreDeferredRetryAttempts = 0 - - // Why quiet: flood cuts abandon deliberately and repaint post-flood, so the "restore unavailable" warning would be noise the repaint wipes. - if (!opts.quiet && !rearmedRemoteRestore) { - // Why: this abandon declares the bytes unrecoverable, so a repaint armed by earlier live - // backpressure must not outlive it — it would re-open recovery and banner a second time. - clearHiddenOutputRestoreFloodRepaintTimer() - writeRestoreUnavailableWarning() - } - // Why an else: the branch above already grounds the gap inside - // writeRestoreUnavailableWarning, and the remote re-arm grounds it in - // rearmRemoteHiddenOutputRestoreInsteadOfWarning. Only the quiet - // flood-abandon reaches neither, and it still drains chunks below. - else if (!rearmedRemoteRestore) { - writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) - } - if (hadPendingOverflow) { - return - } - const replayedSeq = typeof replayingSnapshot?.seq === 'number' ? replayingSnapshot.seq : null - let pendingData = '' - for (const chunk of pendingChunks) { - const sliced = - replayedSeq === null ? chunk.data : getChunkDataAfterSnapshot(chunk, replayedSeq) - pendingData += sliced ?? chunk.data - } - if (replayingSnapshot && replayedSeq !== null) { - setRestoredSnapshotBaseline( - expectedPtyId, - replayingSnapshot, - replayingSnapshot.paintsContent === true - ) - for (const chunk of pendingChunks) { - if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) { - restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) - } - } - } - if (pendingData) { - writePtyOutputToXterm(pendingData, true) - } - } - - function scheduleHiddenOutputRestoreDeferredRetry(): void { - if ( - disposed || - hiddenOutputRestoreDeferredRetryTimer !== null || - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) { - return - } - if (hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX) { - const ptyId = hiddenOutputRestorePtyId - if (ptyId !== null) { - abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId) - } else { - clearHiddenOutputRestoreState() - writeRestoreUnavailableWarning() - } - return - } - hiddenOutputRestoreDeferredRetryAttempts += 1 - // Why: a null snapshot usually means remote output was still mutating; retry after one quiet tick instead of spinning. - hiddenOutputRestoreDeferredRetryTimer = setTimeout(() => { - hiddenOutputRestoreDeferredRetryTimer = null - if (disposed || !hiddenOutputRestoreNeeded) { - return - } - hiddenOutputRestoreRetryDeferred = false - requestHiddenOutputRestoreIfNeeded() - }, HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS) - } - - function clearHiddenOutputRestoreState(): void { - cancelSnapshotScrollRestore() - clearPendingLiveChunksDuringRestore() - // Re-arm budget is per PTY stream, like the rest of this state. - hiddenOutputRestoreRemoteAbandonCycles = 0 - hiddenOutputRestoreRemoteOutcomeAttempts = 0 - hiddenOutputRestoreLocalGateAttempts = 0 - hiddenStartupRendererQueryPending = '' - hiddenRendererStateDirty = false - hiddenOutputRestoreNeeded = false - hiddenOutputRestorePtyId = null - hiddenOutputRestoreReplayingSnapshot = null - hiddenOutputRestoreGeneration += 1 - } - - function cancelSnapshotScrollRestore(): void { - pendingHiddenSnapshotFit?.cancel() - pendingHiddenSnapshotFit = null - const scrollRestore = hiddenOutputSnapshotScrollRestore - if (!scrollRestore) { - return - } - scrollRestore.valid = false - hiddenOutputSnapshotScrollRestore = null - if (scrollRestore.started) { - cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) - } - // Why: invalidation suppresses restoration, but queued bytes still own the bracket until their FIFO sentinels prove parsing finished. - } - cancelHiddenOutputSnapshotScrollRestore = cancelSnapshotScrollRestore - - function clearPaneMode2031State(): void { - deps.paneMode2031Ref.current.delete(pane.id) - deps.paneLastThemeModeRef.current.delete(pane.id) - // A partial CSI prefix belongs to the stream that produced it; carrying it into a - // replacement PTY would splice two unrelated byte ranges into one sequence. - mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE - } - - function pulseVisibleLocalPtySizeForTuiRepaint(ptyId: string): void { - if ( - !isRendererPtyResizeAuthoritative() || - shouldSuppressDesktopPtyResize() || - isRemoteRuntimePtyId(ptyId) - ) { - return - } - const cols = pane.terminal.cols - const rows = pane.terminal.rows - if (cols <= 2 || rows <= 0) { - return - } - // Why: a hidden alt-screen TUI can miss the same-size restore SIGWINCH; a one-column pulse makes the repaint observable to the child. - transport.resize(cols - 1, rows) - transport.resize(cols, rows) - } - - function skipBackgroundAlternateScreenOutput(data: string): void { - writeHiddenStartupRendererQueries(data) - hiddenRendererStateDirty = true - recordHiddenRendererSkip(data.length) - const ptyId = transport.getPtyId() - if (!ptyId || alternateScreenBackgroundRepaintTimer !== null) { - return - } - pulseVisibleLocalPtySizeForTuiRepaint(ptyId) - alternateScreenBackgroundRepaintTimer = setTimeout(() => { - alternateScreenBackgroundRepaintTimer = null - }, 100) - } - - function resetHiddenOutputRestoreIfPtyChanged(): void { - if (hiddenOutputRestorePtyId === null) { - return - } - if (transport.getPtyId() !== hiddenOutputRestorePtyId) { - // Why: renderer backlog is tied to the old PTY stream; after reattach it must not delay or replay before the new PTY. - clearHiddenOutputRestoreState() - clearRestoredSnapshotBaseline() - clearPaneMode2031State() - // Why: flood-backpressure evidence is per PTY stream too. - resetHiddenOutputRestoreFloodSuppression() - discardTerminalOutput(pane.terminal) - } - } - - function writeRestoreUnavailableWarning(): void { - // The reset must parse before both the warning and any foreground drain. - writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) - if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { - return - } - writeTerminalOutput(pane.terminal, HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING, { - foreground: true, - beforeWrite: beforeTerminalOutputWrite - }) - } - - async function applyMainBufferSnapshot(snapshot: { - data: string - frameRestoreAnsi?: string - cols: number - rows: number - seq?: number - pendingDeliveryStartSeq?: number - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - }): Promise { - const restorePtyId = transport.getPtyId() - const restoreGeneration = hiddenOutputRestoreGeneration - if (hiddenOutputSnapshotScrollRestore) { - cancelSnapshotScrollRestore() - } - const scrollRestore = { - ptyId: restorePtyId, - generation: restoreGeneration, - valid: true, - started: false - } - hiddenOutputSnapshotScrollRestore = scrollRestore - const colsBeforeReplay = pane.terminal.cols - const rowsBeforeReplay = pane.terminal.rows - const hasSnapshotDimensions = hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) - let skippedAltFrame = false - try { - await structuralReplayCoordinator.run( - async () => { - if ( - !scrollRestore.valid || - disposed || - transport.getPtyId() !== scrollRestore.ptyId || - hiddenOutputRestoreGeneration !== scrollRestore.generation - ) { - return - } - scrollRestore.started = true - if (typeof snapshot.seq === 'number') { - hiddenOutputRestoreReplayingSnapshot = { - seq: snapshot.seq, - paintsContent: restoredSnapshotPaintsPrintableContent(snapshot), - ...(typeof snapshot.pendingDeliveryStartSeq === 'number' - ? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq } - : {}) - } - } - discardTerminalOutput(pane.terminal) - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) - ) { - // Why: xterm parses writes later; hold snapshot dimensions until the FIFO sentinel completes so serialized wraps stay exact. - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(snapshot.cols, snapshot.rows) - } finally { - suppressStructuralReplayPtyResize = false - } - } - // Why here and not from the writes below: the mirror tracks what the - // APPLICATION negotiated, so it must see the snapshot's own bytes - // before adopting the flags main proved for this boundary. - applySnapshotKittyKeyboardModes(`${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`, { - kittyKeyboardFlags: snapshot.kittyKeyboardFlags, - snapshotSeq: snapshot.seq - }) - // Why shared: the SSH reattach model paint inlines the same - // choreography (coordinator nesting would deadlock there); one - // builder keeps the alt-screen branches from drifting. - skippedAltFrame = - snapshot.alternateScreen === true && - snapshot.frameRestoreAnsi !== undefined && - shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane)) - // Why: an imageless success is not proof the pane is empty; normal replay clears screen and scrollback. - const snapshotCarriesNoImage = - snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi - if (snapshotCarriesNoImage) { - // Why still ground: a restore only runs because bytes were dropped, so - // the gap's pen outlives a snapshot that cannot repaint over it. The - // live-path constant, not the replay baseline — nothing repaints here, - // so the pane is handed back to whatever is still running. - writeReplayData(RESET_AFTER_BYTE_GAP) - } else { - for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { - skipAltFrame: skippedAltFrame, - paneOnAlternateScreen: isPaneOnAlternateScreen() - })) { - writeReplayData(replayChunk) - } - } - // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). - writeReplayData( - hasLiveAgentReattachStatusOrTitleSignal() - ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET - : POST_REPLAY_LIVE_SNAPSHOT_RESET - ) - if (snapshot.pendingEscapeTailAnsi) { - // Why last: snapshot taken mid-escape; re-arm as the FINAL replay write (any later ESC aborts it) so the live tail completes it, not render literally (Bug E / #7329). - writeReplayData(snapshot.pendingEscapeTailAnsi) - } - hiddenRendererStateDirty = false - recordRendererOrderedSeq(snapshot) - recordTerminalOutput(pane.terminal) - await waitForTerminalReplayWritesParsed(pane.terminal) - }, - { - shouldRestore: () => - scrollRestore.valid && - !disposed && - transport.getPtyId() === scrollRestore.ptyId && - hiddenOutputRestoreGeneration === scrollRestore.generation, - afterRestore: async () => { - const isCurrentRestore = (): boolean => - scrollRestore.valid && - !disposed && - transport.getPtyId() === scrollRestore.ptyId && - hiddenOutputRestoreGeneration === scrollRestore.generation - if (!isCurrentRestore()) { - return - } - const currentPtyId = transport.getPtyId() - if (!currentPtyId) { - return - } - if (getFitOverrideForPty(currentPtyId)) { - safeFit(pane) - if (skippedAltFrame && !isRemoteRuntimePtyId(currentPtyId)) { - window.api.pty.signal(currentPtyId, 'SIGWINCH') - } - return - } - const fit = safeFitAndThen( - pane, - 'hidden-snapshot-pty-resize', - () => { - if (!isCurrentRestore() || transport.getPtyId() !== currentPtyId) { - return - } - const replayChangedDimensions = hasSnapshotDimensions - ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows - : pane.terminal.cols !== colsBeforeReplay || - pane.terminal.rows !== rowsBeforeReplay - if (skippedAltFrame) { - pulseVisibleLocalPtySizeForTuiRepaint(currentPtyId) - return - } - if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { - transport.resize(pane.terminal.cols, pane.terminal.rows) - if (!isRemoteRuntimePtyId(currentPtyId)) { - // Why: redundant SIGWINCH makes alt-screen TUIs rebuild their scroll viewport to the top on tab return. - window.api.pty.signal(currentPtyId, 'SIGWINCH') - } - } - }, - { - shouldContinue: isCurrentRestore, - retryIfUnmeasurable: true, - // A skipped frame is blank until this fit pushes the final - // grid/SIGWINCH, so carry the continuation through reveal. - deferIfHidden: true - } - ) - pendingHiddenSnapshotFit = fit - try { - await fit.completion - } finally { - if (pendingHiddenSnapshotFit === fit) { - pendingHiddenSnapshotFit = null - } - } - if (isCurrentRestore()) { - scheduleReattachIdleAgentCursorReset() - } - } - } - ) - } finally { - if (hiddenOutputSnapshotScrollRestore === scrollRestore) { - hiddenOutputSnapshotScrollRestore = null - } - } - } - - function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean { - // Why: once the write pipeline is probe-certified dead a restore can never parse; recovery owns the pane and the remount gets a fresh xterm + restore. - if (isTerminalWritePipelineCertifiedDead(pane.terminal)) { - // Why the re-kick: certification's recovery request can be budget-declined or cancelled by a sibling remount; without this, a revealed dead pane keeps the stale frame forever. - if (!certifiedDeadRestoreRecoveryRequested && !disposed) { - certifiedDeadRestoreRecoveryRequested = true - const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null - void requestTerminalPaneRecovery({ - tabId: deps.tabId, - ptyId: transport.getPtyId() ?? storePtyId, - reason: 'restore-blocked', - terminalRecoveryGeneration, - terminalRecoveryInstanceId: terminalRecoveryInstance.id - }) - } - return false - } - resetHiddenOutputRestoreIfPtyChanged() - const ptyId = hiddenOutputRestorePtyId ?? transport.getPtyId() - if (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) { - return false - } - if (!canUseHiddenOutputSnapshot(ptyId)) { - return false - } - hiddenOutputRestorePtyId = ptyId - if (hiddenOutputRestoreInFlight) { - armHiddenOutputRestoreForegroundDeadline() - return true - } - if (!opts?.bypassScheduler) { - const priority = isActiveSplitPane() ? 'active' : 'inactive' - if (priority === 'inactive') { - if (!hiddenOutputRestoreScheduled) { - hiddenOutputRestoreScheduled = true - const scheduledPtyId = ptyId - const scheduledGeneration = hiddenOutputRestoreGeneration - // Why: resume can reveal many split panes at once; spread inactive replays across frames so xterm scrollback replay doesn't block return. - scheduleHiddenOutputRestore( - pane.terminal, - () => { - hiddenOutputRestoreScheduled = false - if ( - disposed || - hiddenOutputRestoreGeneration !== scheduledGeneration || - hiddenOutputRestorePtyId !== scheduledPtyId || - transport.getPtyId() !== scheduledPtyId || - !canUseHiddenOutputSnapshot(scheduledPtyId) || - (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) || - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) { - return - } - requestHiddenOutputRestoreIfNeeded({ bypassScheduler: true }) - }, - priority - ) - } - return true - } - cancelScheduledHiddenOutputRestore(pane.terminal) - hiddenOutputRestoreScheduled = false - } - clearHiddenOutputRestoreDeferredRetryTimer() - hiddenOutputRestoreRetryDeferred = false - - hiddenOutputRestoreInFlight = (async () => { - // Backstop (rc.7.perf loop): bound how many snapshot fetch+replay rounds one task burns before yielding to the live stream. - let restoreIterations = 0 - while (!disposed) { - const currentPtyId = hiddenOutputRestorePtyId - if (currentPtyId === null) { - clearHiddenOutputRestoreState() - return - } - if (!canUseHiddenOutputSnapshot(currentPtyId)) { - if (hiddenOutputRestorePtyId === currentPtyId) { - clearHiddenOutputRestoreState() - } - // Remote-only path: the transport swapped PTYs mid-restore, which is a - // stream change, not proof the hidden bytes are unrecoverable. - if ( - !rearmRemoteHiddenOutputRestoreInsteadOfWarning(currentPtyId, 'restore-pty-swapped') - ) { - writeRestoreUnavailableWarning() - } - return - } - if (transport.getPtyId() !== currentPtyId) { - if (hiddenOutputRestorePtyId === currentPtyId) { - clearHiddenOutputRestoreState() - } - return - } - const restoreGeneration = hiddenOutputRestoreGeneration - hiddenOutputRestoreNeeded = false - let snapshotResult: HiddenOutputSnapshotResult - try { - snapshotResult = await serializeHiddenOutputSnapshot(currentPtyId, { - scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) - }) - } catch { - snapshotResult = - !isRemoteRuntimePtyId(currentPtyId) || - hiddenOutputRestoreLegacyPtyId === currentPtyId || - typeof transport.serializeBufferOutcome !== 'function' - ? { kind: 'unavailable' } - : // Why 'host': the only reject here is the request timeout — the frame went out and the host stayed silent. - { kind: 'retry-worthy', source: 'host' } - } - if (disposed) { - return - } - const restoreGenerationChanged = hiddenOutputRestoreGeneration !== restoreGeneration - const restorePtyChanged = - transport.getPtyId() !== currentPtyId || hiddenOutputRestorePtyId !== currentPtyId - if (restoreGenerationChanged || restorePtyChanged) { - // Why: the snapshot belongs to the requested PTY; after reattach it's stale, and a stale generation may be an abandoned timeout superseded by a newer restore. - if (restorePtyChanged && hiddenOutputRestorePtyId === currentPtyId) { - clearHiddenOutputRestoreState() - } - return - } - if (snapshotResult.kind === 'retry-worthy') { - let budgetExhausted: boolean - if (snapshotResult.source === 'host') { - hiddenOutputRestoreRemoteOutcomeAttempts += 1 - budgetExhausted = - hiddenOutputRestoreRemoteOutcomeAttempts >= - HIDDEN_OUTPUT_RESTORE_REMOTE_OUTCOME_MAX_ATTEMPTS - } else { - hiddenOutputRestoreLocalGateAttempts += 1 - budgetExhausted = - hiddenOutputRestoreLocalGateAttempts >= - HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS - } - if (budgetExhausted) { - abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { - rearmRemote: false - }) - return - } - hiddenOutputRestoreNeeded = true - hiddenOutputRestoreFreshSnapshotNeeded = false - noteHiddenOutputRestoreFloodBackpressure() - abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) - return - } - if (snapshotResult.kind === 'permanently-unavailable') { - abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { - rearmRemote: false - }) - return - } - if (snapshotResult.kind === 'unknown-legacy-host') { - hiddenOutputRestoreLegacyPtyId = currentPtyId - armHiddenOutputRestoreForegroundDeadline() - } - if (snapshotResult.kind !== 'snapshot') { - hiddenOutputRestoreNeeded = true - hiddenOutputRestoreFreshSnapshotNeeded = false - hiddenOutputRestoreRetryDeferred = true - scheduleHiddenOutputRestoreDeferredRetry() - return - } - const snapshot = snapshotResult.snapshot - hiddenOutputRestoreDeferredRetryAttempts = 0 - hiddenOutputRestoreRemoteAbandonCycles = 0 - hiddenOutputRestoreRemoteOutcomeAttempts = 0 - hiddenOutputRestoreLocalGateAttempts = 0 - restoreIterations += 1 - await applyMainBufferSnapshot(snapshot) - if ( - disposed || - hiddenOutputRestoreGeneration !== restoreGeneration || - hiddenOutputRestorePtyId !== currentPtyId || - transport.getPtyId() !== currentPtyId - ) { - return - } - // Why: everything at/before snapshot.seq is now painted; chunks still draining from main's ACK backlog below it are duplicates to suppress. - setRestoredSnapshotBaseline( - currentPtyId, - snapshot, - restoredSnapshotPaintsPrintableContent(snapshot) - ) - hiddenOutputRestoreReplayingSnapshot = null - const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded - hiddenOutputRestoreFreshSnapshotNeeded = false - const drainOutcome = drainPendingLiveChunksAfterSnapshot(snapshot.seq) - if (drainOutcome === 'drained' && !needsFreshSnapshot) { - hiddenOutputRestoreNeeded = false - hiddenOutputRestorePtyId = null - clearHiddenOutputRestoreForegroundDeadlineTimer() - return - } - if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { - // Why: hidden bytes arriving during the snapshot aren't in renderer memory; leave recovery pending for reveal, don't loop snapshots in a throttled tab. - hiddenOutputRestoreNeeded = true - return - } - if (drainOutcome === 'overflow') { - // Cut 1 (rc.7.perf loop): a FOREGROUND queue overflow means the stream outruns fetch+replay; re-fetching starves ACKs, so abandon and heal with one post-flood repaint. - noteHiddenOutputRestoreFloodBackpressure() - abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) - return - } - if (restoreIterations >= HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS) { - // Backstop: re-looping this many times means the stream is winning the race. - warnTerminalLifecycleAnomaly('hidden output restore hit its iteration cap', { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - leafId: pane.leafId, - paneId: pane.id, - ptyId: currentPtyId, - reason: drainOutcome - }) - noteHiddenOutputRestoreFloodBackpressure() - abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) - return - } - hiddenOutputRestoreNeeded = true - } - })() - const hiddenOutputRestoreTask = hiddenOutputRestoreInFlight - let trackedHiddenOutputRestore: Promise - trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => { - if (hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) { - hiddenOutputRestoreInFlight = null - } - // Why: after dispose the task body exits immediately, so re-arming here - // resolves instantly and re-enters this handler — an unbounded promise - // chain that eats the heap. The pane is gone; there is nothing to restore. - if (disposed) { - return - } - if (hiddenOutputRestorePendingChunks.length > 0 || hiddenOutputRestorePendingOverflow) { - hiddenOutputRestoreNeeded = true - armHiddenOutputRestoreForegroundDeadline() - } - if ( - !hiddenOutputRestoreRetryDeferred && - hiddenOutputRestoreNeeded && - shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) { - requestHiddenOutputRestoreIfNeeded() - } - }) - hiddenOutputRestoreInFlight = trackedHiddenOutputRestore - return true - } - - unregisterBacklogRecovery = registerTerminalBacklogRecovery(pane.terminal, () => { - // Why: clear the hidden-delivery bit BEFORE the restore snapshot request; bytes arriving in between are reconciled by the seq guard. - syncHiddenRendererPtyDelivery() - return requestHiddenOutputRestoreIfNeeded() - }) - if ( - typeof document !== 'undefined' && - typeof document.addEventListener === 'function' && - typeof document.removeEventListener === 'function' - ) { - const onDocumentVisibilityChange = (): void => { - // Why: document hide/show flips the foreground predicate with no pane lifecycle event; re-sync the hidden-delivery gate both ways. - syncHiddenRendererPtyDelivery() - if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { - requestHiddenOutputRestoreIfNeeded() - } - } - document.addEventListener('visibilitychange', onDocumentVisibilityChange) - // Why: on stale macOS occlusion (visibilityState wedged 'hidden'), user input forces a resync — no visibilitychange fires, else the gate drops bytes forever. - const unregisterStaleVisibilityRecovery = registerStaleDocumentVisibilityRecovery( - onDocumentVisibilityChange - ) - unregisterDocumentVisibilityRecovery = () => { - document.removeEventListener('visibilitychange', onDocumentVisibilityChange) - unregisterStaleVisibilityRecovery() - } - } - - const dataCallback = ( - data: string, - meta?: PtyDataMeta, - streamGeneration = transportStreamGeneration - ): void => { - if (streamGeneration !== transportStreamGeneration) { - return - } - if (deferredReattachLiveData !== null) { - const ackCredit = takeCurrentTerminalDeliveryCredit() - deferredReattachLiveData.enqueue({ - data, - ptyId: transport.getPtyId(), - streamGeneration, - ...(meta ? { meta } : {}), - ...(ackCredit ? { ackCredit } : {}) - }) - return - } - if (data.length > 0) { - hasReceivedPtyOutput = true - recordAgentHibernationPaneOutput(cacheKey) - // Why: output is the agent-start signal that ends the relaxed no-evidence process-scan cadence (a starting agent always prints). - agentCompletionCoordinator.observeOutputActivity() - } - observeStartupDraftPasteReadiness(data) - resetHiddenOutputRestoreIfPtyChanged() - observeLiveMode2031Chunk(data) - if (meta?.droppedOutput === true) { - // Why gated (rc.7.perf loop): a visible pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel. - if (meta?.background !== true && isForegroundRestoreBackpressureContext()) { - noteHiddenOutputRestoreFloodBackpressure() - } else { - // Why: main dropped buffered output at the pending cap, so the stream has a gap; repaint from the main-owned snapshot instead of writing on. - markHiddenOutputRestoreNeeded() - if (data) { - // The sentinel can carry query bytes carved from the bulk drop (extractDroppedPtyQueryBytes in main); replies must still flow. - salvageRendererQueriesFromDiscardedRestoreData(data) - } - return - } - } - respondToTerminalPixelSizeQueries(data) - observeTerminalBracketedPasteModeOutput(pane.terminal, data) - // Why: under main side-effect authority these facts arrive via pty:sideEffect; byte-scanning here would double-fire. Remote PTYs / kill-switch-off keep this path. - if (!mainSideEffectAuthority) { - for (const link of observeTerminalGitHubPRLink(data)) { - useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) - } - commandLifecycle.handlePtyData(data) - } - commandCodeOutputStatusDetector?.observe(data) - const codexBackfillNotice = codexBackfillErrorDetector?.observe(data) - if (codexBackfillNotice) { - reportError(codexBackfillNotice) - } - // Why: split panes have visible-but-inactive panes the user watches; throttle only when the pane or whole document is hidden. - const foreground = - shouldWritePtyOutputForeground(deps.isVisibleRef.current) && meta?.background !== true - // Why: latch the hidden-delivery gate from the byte path too, covering a PTY id that arrives after the initial sync (no-op when current). - if (!foreground) { - syncHiddenRendererPtyDelivery() - } - // Post-restore reconciliation: drop chunks the snapshot covers, force a fresh restore for unmappable seq gaps; runs after byte observers, before any xterm write. - const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta) - if (reconciliation.action === 'drop-duplicate') { - return - } - if (reconciliation.action === 'force-fresh-restore') { - // Why gated (rc.7.perf loop): foreground-flood seq gaps are our own backpressure drops; snapshot-per-gap IS the loop, so retire the baseline and heal post-flood. - if (foreground && isForegroundRestoreBackpressureContext()) { - noteHiddenOutputRestoreFloodBackpressure() - clearRestoredSnapshotBaseline() - // fall through with the ORIGINAL data/meta — post-gap bytes are new - } else { - // Why: capture in-flight BEFORE the mark — on a visible pane the mark starts the restore synchronously and must not flag itself. - const restoreWasInFlight = hiddenOutputRestoreInFlight !== null - markHiddenOutputRestoreNeeded() - if (restoreWasInFlight) { - hiddenOutputRestoreFreshSnapshotNeeded = true - } - return - } - } else { - data = reconciliation.data - meta = reconciliation.meta - } - // Why: a hidden Codex query can split just before visibility flips; hand xterm the completed query while other bytes still follow restore. - const pendingForegroundQuery = foreground - ? takeHiddenStartupRendererQueryPendingForForeground(data) - : null - const rendererData = pendingForegroundQuery?.remainingData ?? data - const rendererMeta = metaAfterConsumingCurrentChars( - meta, - pendingForegroundQuery?.consumedCurrentChars ?? 0 - ) - observeRendererOrderedSeqRegression(meta) - const orderedRendererData = foreground - ? rendererData - : getHiddenRendererDataAfterOrderedSeq(rendererData, rendererMeta) - if (orderedRendererData === null) { - // Why: renderer filtering can't map cleaned text back to raw seq offsets; rebuild from main instead of risking stale bytes. - markHiddenOutputRestoreNeeded() - schedulePendingStartupCommandDelivery() - return - } - if (!foreground && orderedRendererData.length === 0) { - recordRendererOrderedSeq(rendererMeta) - schedulePendingStartupCommandDelivery() - return - } - if (pendingForegroundQuery?.statelessQueryData) { - writePtyOutputToXterm(pendingForegroundQuery.statelessQueryData, true, { - hiddenStartupRendererQuery: true - }) - } - if (pendingForegroundQuery?.oscColorQueryData) { - sendTerminalOscColorQueryReplies( - pendingForegroundQuery.oscColorQueryData, - pane.terminal, - // Why: OSC color reply sent immediately so the remote debounce can't delay it past the program's read window (#7329). - sendDesktopQueryReplyImmediate - ) - } - const restoreAppliesToCurrentPty = - hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId - const skipBackgroundAlternateScreenFrame = - meta?.background === true && - shouldWritePtyOutputForeground(deps.isVisibleRef.current) && - pane.terminal.buffer.active.type === 'alternate' && - !containsStatefulRendererQuery(orderedRendererData) - if (skipBackgroundAlternateScreenFrame) { - skipBackgroundAlternateScreenOutput(orderedRendererData) - } else if (shouldSkipHiddenRendererOutput(foreground, orderedRendererData)) { - skipHiddenRendererOutput(orderedRendererData) - } else if ( - (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && - restoreAppliesToCurrentPty - ) { - if (foreground) { - if (pendingForegroundQuery?.statefulQueryData) { - queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData) - } - queueLiveChunkDuringRestore(orderedRendererData, rendererMeta) - requestHiddenOutputRestoreIfNeeded() - } else if (hiddenOutputRestoreInFlight) { - hiddenOutputRestoreNeeded = true - hiddenOutputRestoreFreshSnapshotNeeded = true - } - // Why: hidden chunks with a restore already latched are dropped; the reveal snapshot covers their bytes. - } else { - // Why: hidden panes normally get no bytes (main drops post-ingestion); stragglers ride the bounded background queue, overflow latches restore. - if (pendingForegroundQuery?.statefulQueryData) { - writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, { - hiddenStartupRendererQuery: true - }) - } - writePtyOutputToXterm(orderedRendererData, foreground) - if (foreground) { - recordRendererOrderedSeq(rendererMeta) - } - } - - schedulePendingStartupCommandDelivery() - } - unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(cacheKey, (data, meta) => { - if (!disposed) { - dataCallback(data, meta) - } - }) - - const beginReattachLiveDataDeferral = (ownerGeneration = transportStreamGeneration): void => { - reattachLiveDataDeferralDepth += 1 - if (reattachLiveDataDeferralDepth === 1) { - deferredReattachLiveData = new DeferredReattachLiveDataQueue() - deferredReattachLiveDataOwners = new Map() - } - if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { - deferredReattachLiveDataOwners.set(ownerGeneration, { failed: false }) - } - } - - const beginReattachLiveDataDeferralIfUnowned = ( - ownerGeneration = transportStreamGeneration - ): void => { - if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { - beginReattachLiveDataDeferral(ownerGeneration) - } - } - - const finishReattachLiveDataDeferral = ( - deliver: boolean, - acceptedGeneration = transportStreamGeneration - ): void => { - if (reattachLiveDataDeferralDepth <= 0) { - return - } - if (!deliver) { - const owner = deferredReattachLiveDataOwners.get(acceptedGeneration) - if (owner) { - owner.failed = true - } - } - reattachLiveDataDeferralDepth -= 1 - if (reattachLiveDataDeferralDepth > 0) { - return - } - const queue = deferredReattachLiveData - deferredReattachLiveData = null - const currentPtyId = transport.getPtyId() - const currentGeneration = transportStreamGeneration - const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) - deferredReattachLiveDataOwners = new Map() - if (disposed || !queue) { - queue?.discard() - return - } - const chunks = queue.takeAll() - // Why: paint the authoritative replay first, then admit deferred live chunks so the replay clear can't erase newer output. - let deliveredDeferredChunks = 0 - for (const chunk of chunks) { - if ( - chunk.ptyId !== currentPtyId || - chunk.streamGeneration !== currentGeneration || - currentOwner?.failed === true - ) { - chunk.ackCredit?.() - continue - } - if (chunk.ackCredit) { - deliverTerminalDataWithDeferredCredit(chunk.ackCredit, () => { - dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) - }) - } else { - dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) - } - deliveredDeferredChunks += 1 - } - if (deliveredDeferredChunks > 0) { - // Why: replay restores the viewport before these newer bytes parse; settle the deferred slice, then apply the latest user intent. - flushTerminalOutput(pane.terminal, { maxChars: MAX_DEFERRED_REATTACH_LIVE_CHARS }) - void waitForTerminalReplayWritesParsed(pane.terminal).then(() => { - if ( - disposed || - !deps.isVisibleRef.current || - transport.getPtyId() !== currentPtyId || - transportStreamGeneration !== currentGeneration - ) { - return - } - enforceTerminalCurrentScrollIntent(pane.terminal) - }) - } - } - - const isCapturedDirectSshReattachCurrent = (ptyId: string): boolean => - !directSshRetryAttempt || capturedDirectSshRetryStateMatches(ptyId) - const rejectObsoleteDirectSshReattach = (ptyId: string | null | undefined): boolean => { - if (!directSshRetryAttempt || (ptyId && claimCapturedDirectSshRetryPty(ptyId))) { - return false - } - transport.detach?.({ preserveExitObserver: false }) - return true - } - - let parkedSshSnapshotPrefetch: { - ptyId: string - fetch: () => Promise - } | null = null - - const createSshMainModelSnapshotProbe = ( - ptyId: string - ): (() => Promise) => - memoizeSshReattachModelSnapshotProbe(async (): Promise => { - const sshParkingEnabled = useAppStore.getState().settings?.terminalSshViewParking !== false - if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) { - return null - } - const snapshot = await resolveSshReattachModelSnapshotWithTimeout( - window.api.pty.getMainBufferSnapshot(ptyId, { - scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) - }) - ) - return snapshot && - decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) === - 'main-model-snapshot' - ? snapshot - : null - }) - - const getSshMainModelSnapshotProbe = ( - ptyId: string - ): (() => Promise) => { - if (parkedSshSnapshotPrefetch?.ptyId !== ptyId) { - parkedSshSnapshotPrefetch = { ptyId, fetch: createSshMainModelSnapshotProbe(ptyId) } - } - return parkedSshSnapshotPrefetch.fetch - } - - const prepaintParkedSshSnapshot = (ptyId: string | null): void => { - const parsedPtyId = ptyId ? parseAppSshPtyId(ptyId) : null - if ( - !ptyId || - !mountFollowsTerminalPark || - parsedPtyId?.connectionId !== connectionId || - !capturedDirectSshRetryLeaseMatches() - ) { - return - } - const capturedGeneration = authoritativeReattachGeneration - const isCurrent = (): boolean => - !disposed && - mountFollowsTerminalPark && - authoritativeReattachGeneration === capturedGeneration && - capturedDirectSshRetryLeaseMatches() - const fetchSnapshot = getSshMainModelSnapshotProbe(ptyId) - void fetchSnapshot() - .then(async (snapshot) => { - if (!snapshot || !isCurrent()) { - return - } - await structuralReplayCoordinator.run( - async () => { - if (!isCurrent()) { - return - } - const modelData = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` - rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true }) - if ( - hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) && - (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) - ) { - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(snapshot.cols, snapshot.rows) - } finally { - suppressStructuralReplayPtyResize = false - } - } - applySnapshotKittyKeyboardModes(modelData, { - kittyKeyboardFlags: snapshot.kittyKeyboardFlags, - snapshotSeq: snapshot.seq - }) - // Why keep a too-wide frame: preconnect SSH has no live repaint owner or post-restore fit. - for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { - paneOnAlternateScreen: isPaneOnAlternateScreen() - })) { - writeReplayData(replayChunk) - } - writeReplayData(reattachReplayResetSequence(modelData)) - if (snapshot.pendingEscapeTailAnsi) { - writeReplayData(snapshot.pendingEscapeTailAnsi) - } - recordTerminalOutput(pane.terminal) - await waitForTerminalReplayWritesParsed(pane.terminal) - if (isCurrent()) { - manager.rebuildPaneWebgl(pane.id) - } - }, - { shouldRestore: isCurrent } - ) - }) - .catch(() => {}) - } - - const handleReattachResult = async ( - result: PtyConnectResult | string | void, - staleSessionId?: string | null, - coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, - attemptGeneration = transportStreamGeneration - ): Promise => { - authoritativeReattachGeneration += 1 - if (disposed) { - return false - } - if (attemptGeneration !== transportStreamGeneration) { - return false - } - const connectResult = - result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null - - if (connectResult?.exitedBeforeAttach) { - // Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach. - return true - } - - const retryPtyId = - connectResult?.id ?? - (typeof result === 'string' ? result : (staleSessionId ?? transport.getPtyId())) - if (rejectObsoleteDirectSshReattach(retryPtyId)) { - // Why: an obsolete reattach must stop consuming frames without killing the durable PTY a newer lease may adopt. - return false - } - const ptyId = - connectResult?.id ?? (typeof result === 'string' ? result : transport.getPtyId()) - if (!ptyId) { - warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - leafId: deps.restoredLeafId ?? pane.leafId, - paneId: pane.id, - ptyId: staleSessionId ?? null - }) - // Why: a stale restored session can fail reattach after mount; don't leave xterm alive without a backing PTY. - if (staleSessionId) { - deps.clearExitedPanePtyLayoutBinding(pane.id, staleSessionId) - } else { - deps.syncPanePtyLayoutBinding(pane.id, null) - } - if (staleSessionId) { - deps.clearTabPtyId(deps.tabId, staleSessionId) - } - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return false - } - registerEffectiveLaunchConfig(connectResult?.launchConfig, { - ...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}), - ...(connectResult?.launchAgent - ? { launchAgent: connectResult.launchAgent } - : coldRestoreStartup - ? { launchAgent: coldRestoreStartup.agent } - : {}) - }) - if (connectResult?.sessionExpired) { - if (staleSessionId) { - deps.clearExitedPanePtyLayoutBinding(pane.id, staleSessionId) - } else { - deps.syncPanePtyLayoutBinding(pane.id, null) - } - if (staleSessionId) { - deps.clearTabPtyId(deps.tabId, staleSessionId) - } - // Why: SSH sleep/reconnect can invalidate the relay PTY while the tab stays mounted; replace the dead lease in-place, not a stale overlay. - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return false - } - const isCurrentReattachPayload = (): boolean => { - const currentPtyId = transport.getPtyId() - return ( - !disposed && attemptGeneration === transportStreamGeneration && currentPtyId === ptyId - ) - } - if (!isCurrentReattachPayload()) { - return false - } - // Strict precedence snapshot > replay > coldRestore: paint exactly one, else overlapping tails duplicate TUI output on worktree switch. - const hasStructuralReplay = Boolean( - connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore - ) - const resumeComesFromPassiveHibernation = Boolean( - coldRestoreStartup && - !coldRestoreStartup.useLiveEntry && - coldRestoreStartup.sleepingRecordEntry && - isPassiveCompletedHibernationEvidence(coldRestoreStartup.sleepingRecordEntry.record) - ) - // Why: reattach drops startup commands; only passive hibernation is authority to retire an empty adopted shell and resume its provider session. - if (!hasStructuralReplay && connectResult?.isReattach && resumeComesFromPassiveHibernation) { - transport.disconnect() - if (staleSessionId) { - deps.clearExitedPanePtyLayoutBinding(pane.id, staleSessionId) - deps.clearTabPtyId(deps.tabId, staleSessionId) - } else { - deps.syncPanePtyLayoutBinding(pane.id, null) - } - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return false - } - bindProcessExitState(ptyId) - setPanePtyFitBinding(ptyId) - reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) - registerSideEffectFactConsumerForPty(ptyId) - syncHiddenRendererPtyDelivery() - deps.syncPanePtyLayoutBinding(pane.id, ptyId) - // Why: this is the daemon-backed reattach path — the live PTY outlived the - // renderer, so the pane is current the moment it binds (STA-4114). - useAppStore.getState().restoreAgentPaneAuthority?.(cacheKey) - notifyCodexPaneBoundForStaleSweep(ptyId) - if (capturedDirectSshRetryPtyAccepted && directSshRetryAttempt) { - deps.updateTabPtyId(deps.tabId, ptyId, undefined, directSshRetryAttempt.attemptId) - } else { - deps.updateTabPtyId(deps.tabId, ptyId) - } - agentCompletionCoordinator.startProcessTracking() - sampleVisiblePaneForegroundAgent() - - // Why: mobile streaming needs xterm's exact screen state; install the serializer + lastTitle source for main-process hydration parity. - registerPaneSerializerFor(ptyId) - - // Why (C1 SSH parking): main's headless model holds ~5k rows for SSH ptys - // while the relay replay is a 100KiB raw-byte tail; prefer the model on - // reveal. Only a non-empty 'headless'-sourced snapshot qualifies — the - // renderer-serializer fallback has no mounted xterm after a park. The - // paint happens inline in the snapshot-branch style: applyMainBufferSnapshot - // would nest structuralReplayCoordinator.run inside the reattach task and - // deadlock on the coordinator's tail chain. - // Memoized: the prefetch and the payload task share one probe result, so a - // null prefetch can never buy a second timeout before the relay paint. - const fetchSshMainModelReattachSnapshot = getSshMainModelSnapshotProbe(ptyId) - // Why consume-once: only the first reattach of a reveal remount may pay - // the probe; a later in-place reconnect on this same mount must not buy a - // second timeout before the relay paint. - const revealFollowsTerminalPark = - mountFollowsTerminalPark && - (connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId)) - // An SSH reconnect remounts the pane (tab.generation is its React key), so it also paints into - // a fresh xterm — but unlike a park it may only use the model for a FULL-SCREEN app. See - // sshReconnectPaintsFromModel for why. - // - // NOT consume-once, unlike mountFollowsTerminalPark: followsDirectSshReconnect is captured per - // connectPanePty and never cleared, so this re-arms if one connect reaches handleReattachResult - // twice. Bounded by connectStarted and by the emptiness/alt-screen gates rather than by the - // read itself. It still reads the PENDING retry rather than directSshRetryAttempt, which also - // accepts the live binding and so stays truthy for every later remount of the generation. - const reconnectMayUseModel = followsDirectSshReconnect && !revealFollowsTerminalPark - mountFollowsTerminalPark = false - // Why: ordinary parking destroys xterm. Rebuild from the authoritative - // host snapshot before releasing queued live bytes; null falls back to - // the subscribe screen without keeping the old xterm mounted. - let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null - if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) { - if (parseAppSshPtyId(ptyId)) { - prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot() - } else { - try { - const result = await serializeHiddenOutputSnapshot(ptyId, { - scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) - }) - prefetchedParkModelSnapshot = result.kind === 'snapshot' ? result.snapshot : null - } catch { - prefetchedParkModelSnapshot = null - } - } - if (!isCurrentReattachPayload()) { - return false - } - } - let reattachPayloadApplied = !hasStructuralReplay && prefetchedParkModelSnapshot === null - const applyReattachPayload = async (): Promise => { - if (!isCurrentReattachPayload()) { - return - } - if (connectResult?.snapshot) { - const snapshotPrefixAnsi = connectResult.snapshotPrefixAnsi - const snapshotFrameAnsi = connectResult.snapshotFrameAnsi - const snapshotFrameRestoreAnsi = connectResult.snapshotFrameRestoreAnsi - const hasSplitDaemonAltFrame = - typeof snapshotPrefixAnsi === 'string' && - snapshotPrefixAnsi.length > 0 && - typeof snapshotFrameAnsi === 'string' && - snapshotFrameAnsi.length > 0 - const daemonSnapshotReplay = hasSplitDaemonAltFrame - ? snapshotPrefixAnsi + snapshotFrameAnsi - : connectResult.snapshot - rememberReattachPayloadAgentSignal(daemonSnapshotReplay, { fullScreenReplay: true }) - // Why: replay at the snapshot's own dimensions to avoid rewrapping soft-wrapped rows at a different column count (#7279); suppress the PTY forward so this layout-only resize doesn't SIGWINCH the remote TUI. - const snapshotDimensions = resolvePositiveTerminalDimensions( - connectResult.snapshotCols, - connectResult.snapshotRows - ) - if ( - snapshotDimensions && - (pane.terminal.cols !== snapshotDimensions.cols || - pane.terminal.rows !== snapshotDimensions.rows) - ) { - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(snapshotDimensions.cols, snapshotDimensions.rows) - } finally { - suppressStructuralReplayPtyResize = false - } - } - writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`) - // Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload. - applySnapshotKittyKeyboardModes(daemonSnapshotReplay, { - kittyKeyboardFlags: connectResult.snapshotKittyKeyboardFlags, - snapshotSeq: connectResult.snapshotSeq - }) - // A narrower fit clips the fixed-grid alt frame; drop it and let SIGWINCH repaint. - // A dead-owner restore keeps history plus its restored-session treatment; - // a frozen foreign-width frame would look live when no owner remains. - const daemonAltFrameSkippable = - hasSplitDaemonAltFrame && - typeof snapshotFrameRestoreAnsi === 'string' && - shouldSkipAltFrameForWidthMismatch( - connectResult.snapshotCols, - readProposedTerminalCols(pane) - ) - const groundDaemonSnapshot = - Boolean(connectResult.coldRestore) || - (!shouldPreserveAgentReattachModes() && - !(connectResult.isAlternateScreen ?? kittyKeyboardModes.isAlternateScreen)) - writeReplayData( - `${groundDaemonSnapshot ? RESET_GRAPHIC_RENDITION : ''}${ - daemonAltFrameSkippable - ? snapshotPrefixAnsi + snapshotFrameRestoreAnsi - : daemonSnapshotReplay - }` - ) - writeReplayData( - reattachReplayResetSequence( - daemonSnapshotReplay, - Boolean(connectResult.coldRestore), - connectResult.isAlternateScreen - ) - ) - if (connectResult.pendingEscapeTailAnsi) { - // Why last: re-arm the dangling mid-escape after the reset (whose ESC would abort it) so the live continuation completes it (#7329). - writeReplayData(connectResult.pendingEscapeTailAnsi) - } - sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) - if (connectResult.coldRestore) { - // Snapshot superseded the cold-restore payload; ack so the daemon doesn't redeliver it. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.replay || prefetchedParkModelSnapshot) { - // Why scoped to a park-reveal: the 100KiB relay tail loses scrollback the - // model still holds, but an in-place reattach (network reconnect, wake, - // reload) already has that replay in hand, so probing would only delay its - // paint by the timeout. Memoized, so this is never a second probe. - // An SSH reconnect may use the model only under sshReconnectPaintsFromModel: the reconnect - // replay reaches the renderer without passing through main's model (forwardReattachReplay - // and the inline attach replay both bypass onPtyData), so the model is stale by exactly - // the outage and the replay is the only witness to it. A park has no such hole, so it - // keeps using the model either way. - const revealSnapshot = revealFollowsTerminalPark - ? (prefetchedParkModelSnapshot ?? - (isRemoteRuntimePtyId(ptyId) ? null : await fetchSshMainModelReattachSnapshot())) - : null - // Asked before the probe, not after: if the replay shows the app left the alternate - // screen, no snapshot can be used no matter what it says, so fetching one only spends the - // 750ms timeout to throw the answer away — and spends it inside the coordinator, with live - // PTY bytes deferred and the payload still able to be superseded. - const replayTransition = lastAlternateScreenTransition(connectResult?.replay) - const replayVetoesModel = replayTransition === 'exited' - const reconnectSnapshot = - !revealSnapshot && reconnectMayUseModel && !replayVetoesModel - ? await fetchSshMainModelReattachSnapshot() - : null - const paintsReconnectFromModel = sshReconnectPaintsFromModel({ - snapshot: reconnectSnapshot, - hasReplay: Boolean(connectResult?.replay), - replayTransition, - altFrameWouldBeSkipped: shouldSkipAltFrameForWidthMismatch( - reconnectSnapshot?.cols, - readProposedTerminalCols(pane) - ) - }) - const modelSnapshot = - revealSnapshot ?? (paintsReconnectFromModel ? reconnectSnapshot : null) - if (!isCurrentReattachPayload()) { - return - } - if (modelSnapshot) { - // Why composed for scan/reset only: kitty + reset heuristics need - // the full byte stream; the actual writes go through the shared - // alt-screen choreography below (scrollbackAnsi is '' for - // normal-buffer snapshots, so composition matches data there). - const modelData = `${modelSnapshot.scrollbackAnsi ?? ''}${modelSnapshot.data}` - rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true }) - const modelCols = modelSnapshot.cols - const modelRows = modelSnapshot.rows - if ( - hasPositiveTerminalDimensions(modelCols, modelRows) && - (pane.terminal.cols !== modelCols || pane.terminal.rows !== modelRows) - ) { - // Why: replay at the snapshot's own dimensions (see the daemon-snapshot branch, #7279). - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(modelCols, modelRows) - } finally { - suppressStructuralReplayPtyResize = false - } - } - applySnapshotKittyKeyboardModes(modelData, { - kittyKeyboardFlags: modelSnapshot.kittyKeyboardFlags, - snapshotSeq: modelSnapshot.seq - }) - if (paintsReconnectFromModel && connectResult?.replay) { - // Why after the snapshot: painting the model discards the replay, but the app's kitty - // pushes during the outage exist ONLY there. Snapshot first for the pre-outage - // baseline, then the replay layers the outage on top — otherwise Option/Alt chords - // encode against a stale flag stack. - kittyKeyboardModes.scanReplay(connectResult.replay) - } - // Why shared: park+reveal of an alt-screen TUI needs the same - // ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips - // the ?1049h marker when splitting scrollbackAnsi) — inlined here - // because nesting structuralReplayCoordinator would deadlock. - for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, { - skipAltFrame: shouldSkipAltFrameForWidthMismatch( - modelCols, - readProposedTerminalCols(pane) - ), - paneOnAlternateScreen: isPaneOnAlternateScreen() - })) { - writeReplayData(replayChunk) - } - writeReplayData( - reattachReplayResetSequence( - modelData, - Boolean(connectResult?.coldRestore), - modelSnapshot.alternateScreen ?? connectResult?.isAlternateScreen - ) - ) - if (modelSnapshot.pendingEscapeTailAnsi) { - // Why last: re-arm the dangling mid-escape after the reset so the live continuation completes it (#7329). - writeReplayData(modelSnapshot.pendingEscapeTailAnsi) - } - // Why: main sampled its delivery backlog with the snapshot; the baseline drops/slices deferred and live chunks the snapshot already covers. - setRestoredSnapshotBaseline( - ptyId, - modelSnapshot, - restoredSnapshotPaintsPrintableContent(modelSnapshot) - ) - recordRendererOrderedSeq(modelSnapshot) - sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) - if (connectResult?.coldRestore && !isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } else if (connectResult?.replay) { - rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) - // Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication. - writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`) - // Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack. - // A constructor-fresh mirror (window reload) first demotes to unproven: - // the replay window proves nothing about negotiations that predate it. - if (!kittyKeyboardModes.hasProvenBaseline) { - kittyKeyboardModes.resetForSnapshot() - } - kittyKeyboardModes.scanReplay(connectResult.replay) - writeReplayData( - `${connectResult.coldRestore ? RESET_GRAPHIC_RENDITION : ''}${connectResult.replay}` - ) - writeReplayData( - reattachReplayResetSequence( - connectResult.replay, - Boolean(connectResult.coldRestore), - connectResult.isAlternateScreen - ) - ) - sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) - if (connectResult.coldRestore) { - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } - } else if (connectResult?.coldRestore) { - let destinationRows = pane.terminal.rows - try { - const proposedDestination = pane.fitAddon.proposeDimensions() - if ( - proposedDestination && - Number.isFinite(proposedDestination.rows) && - proposedDestination.rows > 0 - ) { - destinationRows = Math.max(destinationRows, proposedDestination.rows) - } - } catch { - // The current xterm grid remains a safe lower bound for blanking. - } - // Why: shrinking first would promote clipped stale viewport rows into scrollback, beyond the reach of a later viewport-only clear. - writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H`) - await waitForTerminalReplayWritesParsed(pane.terminal) - if (!isCurrentReattachPayload()) { - return - } - const coldRestoreDimensions = resolvePositiveTerminalDimensions( - connectResult.coldRestore.cols, - connectResult.coldRestore.rows - ) - if ( - coldRestoreDimensions && - (pane.terminal.cols !== coldRestoreDimensions.cols || - pane.terminal.rows !== coldRestoreDimensions.rows) - ) { - // Why: recovered ANSI cursor positions belong to the checkpoint's grid; keep this layout-only resize from reaching the fresh PTY. - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(coldRestoreDimensions.cols, coldRestoreDimensions.rows) - } finally { - suppressStructuralReplayPtyResize = false - } - } - // Why: recorded scrollback is raw PTY output that may hold query sequences; xterm.write would auto-reply into the new shell's stdin. See replay-guard.ts. - writeReplayData(`${RESET_GRAPHIC_RENDITION}${connectResult.coldRestore.scrollback}`) - const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() - const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) - if (didPrepareResume) { - if (connectResult.agentResumeUnavailable) { - // Why: main dropped the resume argv, so this pane is a NEW session — - // the plain restored banner would claim the old one came back. - showSessionRestoredBanner('resume-unavailable') - } else if (preparedStartup?.hasSleepingRecord) { - showSessionRestoredBanner() - } - clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) - } - // Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes. - writeReplayData(POST_REPLAY_MODE_RESET) - // Why: the dead run's kitty flags died with it and its scrollback was never scanned — the fresh shell starts at zero. - kittyKeyboardModes.reset() - consumeRestoredViewportBlankingMarker() - // Why: a taller destination fit must not pull recovered rows back into the fresh shell's viewport after source-grid replay. - writeFreshShellViewportBlanking(Math.max(destinationRows, pane.terminal.rows)) - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - if (didPrepareResume && !coldRestoreStartup) { - schedulePendingStartupCommandDelivery() - } - } - if (hasStructuralReplay || prefetchedParkModelSnapshot) { - await waitForTerminalReplayWritesParsed(pane.terminal) - if (!isCurrentReattachPayload()) { - return - } - reattachPayloadApplied = true - } - } - - const fitAfterReattachRestore = async (): Promise => { - if (!isCurrentReattachPayload()) { - return - } - const reattachPtyId = transport.getPtyId() - if (!reattachPtyId) { - return - } - if (!getFitOverrideForPty(reattachPtyId)) { - const gridPush = createReattachGridPush(attemptGeneration, reattachPtyId) - const fit = safeFitAndThen(pane, 'reattach-pty-resize', gridPush.continuation, { - shouldContinue: gridPush.shouldContinue, - retryIfUnmeasurable: true, - // Why only this caller: a restored floating workspace is display:none until the - // user opens it, so dropping the grid push strands the PTY at the replay grid. - deferIfHidden: true - }) - pendingReattachFit = fit - try { - // Why: reattach resize is fire-and-forget, so the continuation itself requests the - // applied-grid verification — it is the only point reached by both the immediate - // and the deferred-until-revealed path. - await fit.completion - } finally { - if (pendingReattachFit === fit) { - pendingReattachFit = null - } - } - } else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { - window.api.pty.signal(reattachPtyId, 'SIGWINCH') - } - } - if (hasStructuralReplay || prefetchedParkModelSnapshot) { - await structuralReplayCoordinator.run(applyReattachPayload, { - shouldRestore: isCurrentReattachPayload, - afterRestore: fitAfterReattachRestore - }) - } else { - await applyReattachPayload() - await fitAfterReattachRestore() - } - if (!isCurrentReattachPayload() || !reattachPayloadApplied) { - return false - } - scheduleReattachIdleAgentCursorReset() - - scheduleRuntimeGraphSync() - return true - } - - const attachRetainedLegacyPty = (ptyId: string): boolean => { - try { - authoritativeReattachGeneration += 1 - clearPaneMode2031State() - clearHiddenOutputRestoreState() - const outputCallbacks = captureTransportOutputCallbacks(reportError, null) - transport.attach({ - existingPtyId: ptyId, - callbacks: outputCallbacks.callbacks - }) - const attachedPtyId = transport.getPtyId() ?? ptyId - bindActivePanePty(attachedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - if (isRemoteRuntimePtyId(attachedPtyId)) { - registerPaneSerializerFor(attachedPtyId) - } - return true - } catch (err) { - reportError(err instanceof Error ? err.message : String(err)) - return false - } - } - - // Why: trigger the deferred SSH connect per-tab (not per-target) so multiple tabs for one target reattach independently. - // Must run before session-id resolution: the SSH provider isn't registered until connect succeeds. - if (connectionId) { - const storeState = useAppStore.getState() - // Why: a removed SSH target (ghost workspace) would fail reattach with a spurious "file an issue" banner for an expected action, so skip it (runtime-owned targets exempt). - // A present map missing this id = target removed; an absent map = not yet hydrated (test stubs), so don't treat it as gone. - if ( - !isRuntimeOwnedSshTargetId(connectionId) && - storeState.sshTargetLabels instanceof Map && - !storeState.sshTargetLabels.has(connectionId) - ) { - return - } - const restoredLeafSessionId = - deps.restoredLeafId && deps.restoredPtyIdByLeafId - ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) - : null - const gate = resolveSshPaneConnectGate({ - connectionId, - sshStatus: storeState.sshConnectionStates.get(connectionId)?.status, - isDeferredTarget: storeState.deferredSshReconnectTargets.includes(connectionId), - restoredLeafSessionId, - deferredTabSessionId: storeState.deferredSshSessionIdsByTabId[deps.tabId], - tabPtyId: storeState.tabsByWorktree[deps.worktreeId]?.find((t) => t.id === deps.tabId) - ?.ptyId, - hasLeafSessionMap: Boolean( - deps.restoredPtyIdByLeafId && Object.keys(deps.restoredPtyIdByLeafId).length > 0 - ) - }) - const pendingSessionId = gate.pendingSessionId - console.warn( - `[pty-connection] SSH tab=${deps.tabId} connectionId=${connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}` - ) - const legacyWorkerOwnsPane = isLegacyWorkerAutomaticResumeBlocked() - if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) { - // Paint main's parked model while SSH recovery continues off the render path. - prepaintParkedSshSnapshot(pendingSessionId) - void (async () => { - // Why: for a passphrase target with no cached credential, don't auto-fire ssh.connect — a prompt popping just from focusing a tab / Cmd+J would surprise the user. - // Wait for a user-initiated connect first; no-passphrase targets return false here and auto-connect as before. - let needsPrompt = false - try { - needsPrompt = await window.api.ssh.needsPassphrasePrompt({ - targetId: connectionId - }) - } catch (err) { - console.warn('[pty-connection] needsPassphrasePrompt probe failed:', err) - // Why: on probe failure fall through to auto-connect rather than stranding the tab — a stuck tab is worse than a surprising prompt. - } - if (disposed || !capturedDirectSshRetryLeaseMatches()) { - return - } - if (needsPrompt) { - const alreadyConnected = - useAppStore.getState().sshConnectionStates.get(connectionId)?.status === 'connected' - if (!alreadyConnected) { - // Wait for the user-driven connect (sidebar card control or terminal reconnect overlay → passphrase → ssh.connect) to complete. - // Why: resolve on terminal-failure statuses too ('auth-failed'/'error'/'reconnection-failed') so it can't hang forever if the user cancels or the connect fails. - const outcome = await new Promise((resolve) => { - // Why: 'disconnected' counts as terminal only after a non-disconnected status was seen (a real connect attempt that returned to 'disconnected'). - // Treating the entry-time 'disconnected' as terminal would skip the gate, defeating the passphrase-prompt deferral. - let sawNonDisconnected = - useAppStore.getState().sshConnectionStates.get(connectionId)?.status !== - 'disconnected' && - useAppStore.getState().sshConnectionStates.get(connectionId)?.status !== undefined - let resolvedOutcome: UserInitiatedSshConnectOutcome = 'cancelled' - let settled = false - const finish = (nextOutcome: UserInitiatedSshConnectOutcome): void => { - if (settled) { - return - } - resolvedOutcome = nextOutcome - settled = true - unsub() - const idx = waitTeardowns.indexOf(teardown) - if (idx !== -1) { - waitTeardowns.splice(idx, 1) - } - resolve(resolvedOutcome) - } - const teardown = (): void => finish('cancelled') - // Why: register a teardown so dispose() can unsubscribe+resolve if the pane is torn down mid-wait. - // Else the zustand subscriber + async IIFE leak: the callback only checks `disposed` when it next fires, which may never happen. - waitTeardowns.push(teardown) - const unsub = useAppStore.subscribe((state) => { - if (disposed) { - finish('cancelled') - return - } - const status = state.sshConnectionStates.get(connectionId)?.status - if (status && status !== 'disconnected') { - sawNonDisconnected = true - } - const nextOutcome = sshPromptConnectOutcomeForStatus(status, sawNonDisconnected) - if (nextOutcome) { - finish(nextOutcome) - } - }) - // Why: re-read state after subscribing to catch a status change that landed between the alreadyConnected check and the subscribe — else we'd wait forever. - if (disposed) { - finish('cancelled') - return - } - const currentStatus = useAppStore - .getState() - .sshConnectionStates.get(connectionId)?.status - const currentOutcome = sshPromptConnectOutcomeForStatus( - currentStatus, - sawNonDisconnected - ) - if (currentOutcome) { - finish(currentOutcome) - } - }) - if (disposed || !capturedDirectSshRetryLeaseMatches()) { - return - } - if (outcome === 'cancelled') { - return - } - if (outcome === 'failed') { - reportError('SSH connection failed') - return - } - } - } - - // Why: wait for the shared SSH connection (multiple panes/tabs may need it) before PTY reattach, rather than returning early when it's in-flight. - const connectResult = await waitForSshConnection(connectionId) - if (disposed || !capturedDirectSshRetryLeaseMatches()) { - return - } - if (!connectResult.connected) { - reportError(`SSH connection failed: ${connectResult.error}`) - return - } - useAppStore.getState().removeDeferredSshReconnectTarget(connectionId) - if (disposed) { - return - } - if (pendingSessionId) { - if (isLegacyWorkerAutomaticResumeBlocked()) { - if (attachRetainedLegacyPty(pendingSessionId)) { - useAppStore.getState().removeDeferredSshSessionId(deps.tabId) - scheduleRuntimeGraphSync() - } - return - } - console.warn( - `[pty-connection] Attempting reattach for tab=${deps.tabId} sessionId=${pendingSessionId}` - ) - // Why: the saved remote PTY id is single-use restore metadata; clear it before attach so remounts don't keep retrying an expired session. - useAppStore.getState().removeDeferredSshSessionId(deps.tabId) - // Why: pre-signal SSH-deferred reattach too so the cooperation gate applies uniformly to remote sessions (Electron preserves the declare→connect order). - // See docs/mobile-prefer-renderer-scrollback.md. - const preSignalPromise = - runtimeEnvironmentId || isRemoteRuntimePtyId(pendingSessionId) - ? Promise.resolve(null) - : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) - let expiredReattachError = false - const coldRestoreStartup = buildColdRestoreAgentResumeStartup() - clearPaneMode2031State() - clearHiddenOutputRestoreState() - const outputCallbacks = captureTransportOutputCallbacks( - (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - if (!isCapturedDirectSshReattachCurrent(pendingSessionId)) { - return - } - reportError(message) - }, - toProcessExitStartup(coldRestoreStartup ?? paneStartup) - ) - beginReattachLiveDataDeferral(outputCallbacks.generation) - transportConnectInFlightSince = Date.now() - const reattachPromise = transport.connect({ - url: '', - cols, - rows, - sessionId: pendingSessionId, - ...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}), - ...(coldRestoreStartup?.env - ? { env: mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) } - : {}), - ...(coldRestoreStartup?.launchConfig - ? { launchConfig: coldRestoreStartup.launchConfig } - : {}), - ...(coldRestoreStartup?.resumeProviderSession - ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } - : {}), - ...(coldRestoreStartup?.launchToken - ? { launchToken: coldRestoreStartup.launchToken } - : {}), - ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), - ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - ...(directSshRetryAttempt ? { admitPtyId: claimCapturedDirectSshRetryPty } : {}), - callbacks: outputCallbacks.callbacks - }) - void Promise.resolve(reattachPromise) - .catch(() => null) - .finally(() => { - transportConnectInFlightSince = null - }) - const trackedReattachPromise = Promise.resolve(reattachPromise) - .then(async (result) => { - if (outputCallbacks.generation !== transportStreamGeneration) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return - } - console.warn( - `[pty-connection] Reattach result for tab=${deps.tabId}:`, - result - ? { - sessionExpired: (result as Record).sessionExpired, - replay: !!(result as Record).replay - } - : 'undefined' - ) - if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - if (disposed) { - return - } - if (rejectObsoleteDirectSshReattach(pendingSessionId)) { - return - } - deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) - deps.clearTabPtyId(deps.tabId, pendingSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return - } - const accepted = await handleReattachResult( - result, - pendingSessionId, - coldRestoreStartup, - outputCallbacks.generation - ) - finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - if (!accepted) { - await window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } else if (!isRemoteRuntimePtyId(pendingSessionId)) { - const settledPtyId = - result && typeof result === 'object' && 'id' in result - ? result.id - : (transport.getPtyId() ?? pendingSessionId) - const hasRestorePayload = - result && - typeof result === 'object' && - ('snapshot' in result || 'replay' in result || 'coldRestore' in result) - await (hasRestorePayload - ? settlePaneSerializerAfterReplay(settledPtyId, gen) - : window.api.pty.settlePaneSerializer(cacheKey, gen)) - } - } - }) - .catch(async (err) => { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - console.warn(`[pty-connection] Reattach FAILED for tab=${deps.tabId}:`, err) - if (disposed || outputCallbacks.generation !== transportStreamGeneration) { - return - } - if (rejectObsoleteDirectSshReattach(pendingSessionId)) { - return - } - if (isSshSessionExpiredError(err)) { - deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) - deps.clearTabPtyId(deps.tabId, pendingSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return - } - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - }) - armDirectSshPaneRetryTimeout(trackedReattachPromise, directSshRetryAttempt) - } else { - startFreshColdRestoreAgentResume() - } - })() - return - } - } - - // Why: re-read session IDs inside the rAF — cleanup during the one-frame gap could otherwise reattach a dead session. - const restoredPtyId = - deps.restoredLeafId && deps.restoredPtyIdByLeafId - ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) - : null - const storeSnapshot = useAppStore.getState() - const existingPtyId = storeSnapshot.tabsByWorktree[deps.worktreeId]?.find( - (t) => t.id === deps.tabId - )?.ptyId - const hasSleepingAgentSession = Boolean(getSleepingRecordForPane(storeSnapshot)) - - // Why: the tab-level fallback must not steal a PTY a setup sibling already published while the main pane waited for split geometry. - const tabFallbackPtyId = - existingPtyId && - !Array.from(deps.paneTransportsRef.current.entries()).some( - ([candidatePaneId, candidateTransport]) => - candidatePaneId !== pane.id && candidateTransport.getPtyId() === existingPtyId - ) - ? existingPtyId - : null - - const restoredSessionId = restoredPtyId ?? null - const sleptRemoteRuntimeSessionId = - restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && hasSleepingAgentSession - ? restoredSessionId - : null - const detachedLivePtyId = - tabFallbackPtyId && !hadExistingPaneTransportAtConnect && !sleptRemoteRuntimeSessionId - ? restoredSessionId - ? restoredSessionId === tabFallbackPtyId - ? restoredSessionId - : null - : tabFallbackPtyId - : null - const detachedRemoteLeafPtyId = - restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && !hasSleepingAgentSession - ? restoredSessionId - : null - const candidateReattachSessionId = - restoredSessionId && restoredSessionId !== detachedLivePtyId - ? restoredSessionId - : detachedLivePtyId - const runtimeHostPtyWakeHint = - runtimeEnvironmentId && - candidateReattachSessionId && - !isRemoteRuntimePtyId(candidateReattachSessionId) - ? candidateReattachSessionId - : null - const sleptRemoteColdRestoreStartup = sleptRemoteRuntimeSessionId - ? buildColdRestoreAgentResumeStartup() - : null - if (sleptRemoteRuntimeSessionId) { - deps.syncPanePtyLayoutBinding(pane.id, null) - deps.clearTabPtyId(deps.tabId, sleptRemoteRuntimeSessionId) - } - const currentTabLivePtyIds = storeSnapshot.ptyIdsByTabId[deps.tabId] ?? [] - const candidateHasEagerBuffer = Boolean( - candidateReattachSessionId && - !isRemoteRuntimePtyId(candidateReattachSessionId) && - getEagerPtyBufferHandle(candidateReattachSessionId) - ) - // Why: a still-live locally-spawned PTY (e.g. a background automation agent) keeps an eager buffer until a pane adopts it. - // It must be adopted via attach()+replay — connect({ sessionId }) on its non-session ptyId would spawn a fresh shell and orphan the agent. - const eagerLivePtyId = - candidateReattachSessionId && - candidateHasEagerBuffer && - currentTabLivePtyIds.includes(candidateReattachSessionId) - ? candidateReattachSessionId - : null - // Why: after a daemon crash + cold restore, a stale session-to-tab mapping can make a tab hold a ptyId from another worktree. - // Restoring it would paint the wrong terminal content, so drop the reattach and spawn fresh. - const legacyAttachOnlyPtyId = isLegacyWorkerAutomaticResumeBlocked() - ? candidateReattachSessionId - : null - const pairedParkedReattachSessionId = - mountFollowsTerminalPark && - candidateReattachSessionId && - isRemoteRuntimePtyId(candidateReattachSessionId) && - canRestorePairedParkedTerminal(candidateReattachSessionId) - ? candidateReattachSessionId - : null - const deferredReattachSessionId = legacyAttachOnlyPtyId - ? null - : (runtimeHostPtyWakeHint ?? - pairedParkedReattachSessionId ?? - (candidateReattachSessionId && - !isRemoteRuntimePtyId(candidateReattachSessionId) && - !candidateHasEagerBuffer && - isSessionOwnedByWorktree(candidateReattachSessionId, deps.worktreeId) - ? candidateReattachSessionId - : null)) - recordPtyConnectDiagnostic( - `pane=${pane.id} tab=${deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedRemoteLeafPtyId ?? detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${hadExistingPaneTransportAtConnect} pendingKey=${pendingSpawnKey}` - ) - - if (deferredReattachSessionId) { - allowInitialIdleCacheSeed = true - recordPtyConnectDiagnostic(`pane=${pane.id} -> REATTACH ${deferredReattachSessionId}`) - prepaintParkedSshSnapshot(deferredReattachSessionId) - - // Why: pre-signal (declare) before the reattach connect so the cooperation gate suppresses the daemon seed for this paneKey; Electron preserves IPC order. - // See docs/mobile-prefer-renderer-scrollback.md (Renderer-side prerequisite requirement #4). - const preSignalPromise = - runtimeEnvironmentId || isRemoteRuntimePtyId(deferredReattachSessionId) - ? Promise.resolve(null) - : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) - - let expiredReattachError = false - let paneOwnerUnverified = false - const coldRestoreStartup = buildColdRestoreAgentResumeStartup() - const outputCallbacks = captureTransportOutputCallbacks( - (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) { - paneOwnerUnverified = true - } - if (!isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) { - return - } - reportError(message) - }, - toProcessExitStartup(coldRestoreStartup ?? paneStartup) - ) - beginReattachLiveDataDeferral(outputCallbacks.generation) - transportConnectInFlightSince = Date.now() - const reattachPromise = transport.connect({ - url: '', - cols, - rows, - sessionId: deferredReattachSessionId, - ...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}), - ...(coldRestoreStartup?.env - ? { env: mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) } - : {}), - ...(coldRestoreStartup?.launchConfig - ? { launchConfig: coldRestoreStartup.launchConfig } - : {}), - ...(coldRestoreStartup?.resumeProviderSession - ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } - : {}), - ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), - ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), - ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - ...(directSshRetryAttempt ? { admitPtyId: claimCapturedDirectSshRetryPty } : {}), - callbacks: outputCallbacks.callbacks - }) - - void Promise.resolve(reattachPromise) - .catch(() => null) - .finally(() => { - transportConnectInFlightSince = null - }) - const trackedReattachPromise = Promise.resolve(reattachPromise) - .then(async (result) => { - if (outputCallbacks.generation !== transportStreamGeneration) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return - } - if (!result && paneOwnerUnverified) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return - } - if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - if (disposed) { - return - } - if (rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { - return - } - deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) - deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return - } - const accepted = await handleReattachResult( - result, - deferredReattachSessionId, - coldRestoreStartup, - outputCallbacks.generation - ) - finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - if (!accepted) { - await window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } else if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { - const settledPtyId = - result && typeof result === 'object' && 'id' in result - ? result.id - : (transport.getPtyId() ?? deferredReattachSessionId) - const hasRestorePayload = - result && - typeof result === 'object' && - ('snapshot' in result || 'replay' in result || 'coldRestore' in result) - await (hasRestorePayload - ? settlePaneSerializerAfterReplay(settledPtyId, gen) - : window.api.pty.settlePaneSerializer(cacheKey, gen)) - } - } - }) - .catch(async (err) => { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - const message = err instanceof Error ? err.message : String(err) - if (outputCallbacks.generation !== transportStreamGeneration) { - return - } - if (rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { - return - } - if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) { - reportError(message) - return - } - warnTerminalLifecycleAnomaly('restored PTY reattach threw', { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - leafId: deps.restoredLeafId ?? pane.leafId, - paneId: pane.id, - ptyId: deferredReattachSessionId, - reason: message - }) - deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) - deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) - if (connectionId && isSshSessionExpiredError(err)) { - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return - } - reportError(message) - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - }) - armDirectSshPaneRetryTimeout(trackedReattachPromise, directSshRetryAttempt) - } else if ( - legacyAttachOnlyPtyId || - detachedRemoteLeafPtyId || - detachedLivePtyId || - eagerLivePtyId - ) { - // Why: mirrored web-leaf panes must attach to their exact remote PTY, not spawn a replacement host tab. - // eagerLivePtyId covers a still-live background PTY (e.g. an automation agent) with a live eager buffer to adopt. - const attachPtyId = - legacyAttachOnlyPtyId ?? detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId! - recordPtyConnectDiagnostic(`pane=${pane.id} -> ATTACH detached=${attachPtyId}`) - allowInitialIdleCacheSeed = false - if (legacyAttachOnlyPtyId) { - if (attachRetainedLegacyPty(legacyAttachOnlyPtyId) && connectionId) { - useAppStore.getState().removeDeferredSshSessionId(deps.tabId) - } - } else { - // Why: surface synchronous attach failures via reportError so the pane shows a diagnostic instead of a blank surface. - // On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here. - try { - clearPaneMode2031State() - clearHiddenOutputRestoreState() - const outputCallbacks = captureTransportOutputCallbacks(reportError, null) - transport.attach({ - existingPtyId: attachPtyId, - cols, - rows, - callbacks: outputCallbacks.callbacks - }) - const attachedPtyId = transport.getPtyId() ?? attachPtyId - bindActivePanePty(attachedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { - registerPaneSerializerFor(attachedPtyId) - } - } catch (err) { - reportError(err instanceof Error ? err.message : String(err)) - deps.clearTabPtyId(deps.tabId, attachPtyId) - startFreshSpawn() - } - } - } else { - allowInitialIdleCacheSeed = false - const pendingSpawn = pendingSpawnByPaneKey.get(pendingSpawnKey) - if (pendingSpawn) { - recordPtyConnectDiagnostic(`pane=${pane.id} -> PENDING SPAWN`) - armDirectSshPaneRetryTimeout(pendingSpawn, directSshRetryAttempt) - void pendingSpawn - .then((spawnedPtyId) => { - if (disposed) { - return - } - if (transport.getPtyId()) { - return - } - if (!spawnedPtyId) { - // Why: React StrictMode can mount+spawn then immediately remount; if the first mount produced no PTY id, - // the remounted pane must issue its own spawn instead of attaching to a completed-but-empty promise (a dead surface). - if (!isWebTerminalSurfaceTabId(deps.tabId)) { - console.warn( - `Pending PTY spawn for tab ${deps.tabId} resolved without a PTY id, retrying fresh spawn` - ) - } - if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) { - startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined) - } else { - startFreshSpawn() - } - return - } - if (!canAdoptCapturedDirectSshRetryPty(spawnedPtyId)) { - return - } - clearPaneMode2031State() - clearHiddenOutputRestoreState() - const outputCallbacks = captureTransportOutputCallbacks(reportError, null) - transport.attach({ - existingPtyId: spawnedPtyId, - cols, - rows, - callbacks: outputCallbacks.callbacks - }) - const attachedPtyId = transport.getPtyId() ?? spawnedPtyId - // Why: this reuses a PTY spawned by an earlier mount, so no later spawn event will bind this remounted pane's DOM/container. - bindActivePanePty(attachedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - }) - .catch((err) => { - reportError(err instanceof Error ? err.message : String(err)) - }) - } else { - recordPtyConnectDiagnostic(`pane=${pane.id} -> FRESH SPAWN`) - if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) { - startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined) - } else { - startFreshSpawn() - } - } - } - scheduleRuntimeGraphSync() - } - - // Why: Wayland/CI compositors can starve rAF while timers/CDP stay responsive; the terminal must still start its PTY once. - connectFallbackTimer = setTimeout(runDeferredConnect, 250) - connectFrame = requestAnimationFrame(runDeferredConnect) - - // Why: on visibility resume a pane may still be bound to a session reaped while hidden (missed-exit defect); route through onExit's teardown, - // re-validating identity at apply time so a reattach racing the listSessions snapshot isn't clobbered. - const reconcileIfSessionDead = ( - liveSessionIds: Set, - snapshotRequestedAt?: number - ): void => { - if (disposed) { - return - } - const currentPtyId = transport.getPtyId() - if ( - !currentPtyId || - // Why: this exit was already handled — onExit guards it too, but skipping here avoids a redundant shouldReconcile evaluation. - handledExitPtyId === currentPtyId || - !shouldReconcileDeadSession({ - ptyId: currentPtyId, - connectionId: transport.getConnectionId?.(), - liveSessionIds, - ptyBoundAt: activePanePtyBindingBoundAt, - snapshotRequestedAt - }) - ) { - return - } - onExit(currentPtyId) - } - - const reconcileIfSessionMissing = ( - hasPty: HasPty, - livenessRequestedAt = performance.now() - ): void => { - const requestedPtyId = transport.getPtyId() - if ( - !requestedPtyId || - requestedPtyId === handledExitPtyId || - requestedPtyId.startsWith(REMOTE_PTY_ID_PREFIX) || - transport.getConnectionId?.() != null - ) { - return - } - - let livenessPromise: Promise - try { - livenessPromise = Promise.resolve(hasPty(requestedPtyId)) - } catch { - return - } - - void livenessPromise - .then((isLive) => { - if (disposed) { - return - } - const currentPtyId = transport.getPtyId() - if ( - !currentPtyId || - currentPtyId !== requestedPtyId || - handledExitPtyId === currentPtyId || - !shouldReconcileMissingSession({ - ptyId: currentPtyId, - connectionId: transport.getConnectionId?.(), - isLive, - ptyBoundAt: activePanePtyBindingBoundAt, - livenessRequestedAt - }) - ) { - return - } - onExit(currentPtyId) - }) - .catch(() => {}) - } - - return { - syncProcessTracking() { - agentCompletionCoordinator.startProcessTracking() - // Why: the hidden-delivery gate must follow every pane visibility flip. - syncHiddenRendererPtyDelivery() - if (!deps.isVisibleRef.current) { - pendingVisibleRemoteViewportClaim = false - } - }, - // Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback. - noteVisibilityResume() { - armVisibleRemoteViewportClaim() - claimPendingVisibleRemoteViewport() - ptySizeReassertion.request({ fit: false }) - consumeHibernatedAgentWake() - requestKnownWindowsShiftEnterReconfirmation() - sampleVisiblePaneForegroundAgent() - }, - reassertPtySizeAfterWindowWake() { - armVisibleRemoteViewportClaim() - claimPendingVisibleRemoteViewport() - ptySizeReassertion.request({ fit: false }) - }, - // Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads. - wakeHibernatedAgentIfArmed(claimedProviderSessions) { - if (hibernatedWakeInFlightClaimKey) { - if (claimedProviderSessions?.has(hibernatedWakeInFlightClaimKey)) { - return null - } - claimedProviderSessions?.add(hibernatedWakeInFlightClaimKey) - return hibernatedWakeInFlightClaimKey - } - const consumedClaimKey = consumeHibernatedAgentWake(claimedProviderSessions) - if (consumedClaimKey) { - return consumedClaimKey - } - // Why: wake arrived mid-hibernation-kill before onExit armed the wake target (transport still bound to the dying PTY). - // Only the exact PTY marked for suppressed shutdown may latch — never a stale/manual record beside an ordinary live PTY. - const state = useAppStore.getState() - const recordEntry = getSleepingRecordForPane(state) - const currentPtyId = transport.getPtyId() - if ( - recordEntry && - isPassiveCompletedHibernationEvidence(recordEntry.record) && - currentPtyId !== null && - state.suppressedPtyExitIds[currentPtyId] === true && - !disposed && - hibernatedWakeTarget === null && - deps.paneTransportsRef.current.get(pane.id) === transport && - transport.getPtyId() === currentPtyId - ) { - const claimKey = getProviderSessionClaimKey(recordEntry.record) - if (claimedProviderSessions?.has(claimKey)) { - return null - } - claimedProviderSessions?.add(claimKey) - pendingHibernatedWakeTarget = { ptyId: currentPtyId, record: recordEntry.record } - return claimKey - } - return null - }, - sampleForegroundAgentOnFocus() { - requestKnownWindowsShiftEnterReconfirmation() - sampleVisiblePaneForegroundAgent() - }, - requestWindowsShiftEnterReconfirmation() { - if (shiftEnterReconfirmTimer !== null) { - clearTimeout(shiftEnterReconfirmTimer) - } - // Why: confirm the composer only after the Shift+Enter burst goes idle, preserving rapid multiline input. - shiftEnterReconfirmTimer = setTimeout(() => { - shiftEnterReconfirmTimer = null - requestKnownWindowsShiftEnterReconfirmation() - sampleVisiblePaneForegroundAgent() - }, SHIFT_ENTER_RECONFIRM_IDLE_MS) - }, - markShortcutTerminalInputSent() { - markInteractiveRedrawInput() - }, - reconcileIfSessionDead, - reconcileIfSessionMissing, - dispose() { - disposed = true - // Why: a detached client stops observing the pane's bytes, so it must cede - // agent-status authority back to the host on the next mirrored snapshot. - releaseRendererOwnedAgentStatusPane?.() - directSshPaneRetrySettlementCancelled = true - for (const timer of directSshPaneRetrySettlementTimers) { - clearTimeout(timer) - } - directSshPaneRetrySettlementTimers.clear() - // Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream. - const queue = deferredReattachLiveData - deferredReattachLiveData = null - queue?.discard() - reattachLiveDataDeferralDepth = 0 - deferredReattachLiveDataOwners = new Map() - cancelPendingSafeFitContinuations(pane) - pendingHiddenSnapshotFit = null - pendingReattachFit = null - // Why: park/reconnect/remount doesn't advance the recovery epoch, so invalidate this xterm or its delayed retry could hit the next instance. - terminalRecoveryInstance.unregister() - unregisterUndeliverableWriteHandler() - unsubscribeRemoteDesktopActivationClaim() - cancelHiddenOutputSnapshotScrollRestore() - liveScrollbackRestore?.dispose() - liveScrollbackRestore = null - structuralReplayCoordinator.dispose() - cancelFreshSpawnFollowReset() - // Why: cancel the post-spawn reconcile's pending rAF so a torn-down pane can't keep fitting/resizing after disposal. - ptySizeReconcileHandle?.cancel() - ptySizeReconcileHandle = null - startupGridSettleHandle?.cancel() - startupGridSettleHandle = null - ptySizeReassertion.dispose() - if (pendingForegroundGridDriftCheckRaf !== null) { - cancelAnimationFrame(pendingForegroundGridDriftCheckRaf) - pendingForegroundGridDriftCheckRaf = null - } - // Why: a pane unmount must never leave its PTY delivery gated — the parked watcher or remounted pane re-decides. - releaseHiddenRendererPtyDelivery() - if (terminalKeyTargetSupportsEvents) { - terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true }) - } - clearPendingTerminalInputIntent() - pendingTerminalInputWrite = null - interruptInference.dispose() - clearTitleOnlyInterruptTimer() - // Why release, not cancel: the pending settle belongs to the turn, not to - // this pane — a park mid-settle hands it to the parked watcher instead. - releaseCommandCodeDoneSettleExecutor() - if (shiftEnterReconfirmTimer !== null) { - clearTimeout(shiftEnterReconfirmTimer) - shiftEnterReconfirmTimer = null - } - // Why: resolve in-flight passphrase-gate waits so their zustand subscribers + async IIFEs don't hang when the pane is torn down before SSH state changes. - while (waitTeardowns.length > 0) { - const teardown = waitTeardowns.pop() - teardown?.() - } - if (startupInjectTimer !== null) { - clearTimeout(startupInjectTimer) - startupInjectTimer = null - } - cleanupStartupDraftPasteTimers() - releaseUnattemptedStartupDraftPasteDelivery() - unregisterAgentHookTerminalLifecycle() - clearSuppressedTitleSideEffects() - clearPendingAgentTaskCompleteNotification() - pendingTerminalBellNotification = false - clearTerminalBellNotificationTimer() - clearReattachIdleAgentCursorResetTimer() - if (alternateScreenBackgroundRepaintTimer !== null) { - clearTimeout(alternateScreenBackgroundRepaintTimer) - alternateScreenBackgroundRepaintTimer = null - } - cleanupHiddenOutputRestoreDeferredRetry() - cleanupHiddenOutputRestoreForegroundDeadline() - cleanupHiddenOutputRestoreFloodRepaint() - unregisterBacklogRecovery?.() - unregisterBacklogRecovery = null - unregisterDocumentVisibilityRecovery?.() - unregisterDocumentVisibilityRecovery = null - releaseRendererPtyVisibilityClaim(transport) - // Why: the pane's fact consumer must be gone before a parked-tab watcher takes over this PTY's facts in the same effect flush. - dropSideEffectFactConsumer() - clearPanePtyFitBinding() - discardTerminalOutput(pane.terminal) - unregisterE2ePtyDataInjection() - if (agentTaskCompleteSettingsUnsubscribe !== null) { - agentTaskCompleteSettingsUnsubscribe() - agentTaskCompleteSettingsUnsubscribe = null - } - if (unsubscribeWindowsDoneTerminalModeReset !== null) { - unsubscribeWindowsDoneTerminalModeReset() - unsubscribeWindowsDoneTerminalModeReset = null - } - if (connectFrame !== null) { - // Why: cancel the queued connect frame so a disposed pane (StrictMode/split-group remount) can't reattach the PTY and steal the live pane's handler wiring. - cancelScheduledConnectFrame() - } - if (connectFallbackTimer !== null) { - clearTimeout(connectFallbackTimer) - connectFallbackTimer = null - } - imeCompositionRouteDisposable.dispose() - onDataDisposable.dispose() - userInputActivityDisposable?.dispose() - terminalCapabilityRepliesDisposable.dispose() - onResizeDisposable.dispose() - pane.container.removeEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) - geometryReportObserver?.disconnect() - if (pendingGeometryReportRaf !== null) { - cancelAnimationFrame(pendingGeometryReportRaf) - pendingGeometryReportRaf = null - } - commandLifecycle.dispose() - deferredCommandFinishedStatusDrop = null - visibleForegroundSamplePending = false - visibleForegroundSampleSettled = false - paneForegroundAgentTracker.dispose() - agentCompletionCoordinator.dispose() - } - } -} +export { connectPanePty, STARTUP_CWD_FALLBACK_NOTICE } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-done-started-at.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-done-started-at.ts new file mode 100644 index 00000000000..db5e0df0e93 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-done-started-at.ts @@ -0,0 +1,22 @@ +import type { AgentStatusEntry } from '../../../../../shared/agent-status-types' + +/** Start time of the newest completed turn, counting turns already folded into history. + * Why: batched publications coalesce done→working→done into a single store notification, + * so the done EDGE survives only in stateHistory; comparing `state` alone misses it. */ +export function resolveLatestAgentDoneStartedAt( + entry: AgentStatusEntry | undefined +): number | undefined { + if (!entry) { + return undefined + } + if (entry.state === 'done') { + return entry.stateStartedAt + } + const history = entry.stateHistory ?? [] + for (let index = history.length - 1; index >= 0; index -= 1) { + if (history[index].state === 'done') { + return history[index].startedAt + } + } + return undefined +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts new file mode 100644 index 00000000000..523b77a32f1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts @@ -0,0 +1,237 @@ +import { useAppStore } from '@/store' +import { getWorktreeMapFromState } from '@/store/selectors' +import { parseWorkspaceKey } from '../../../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants' +import { isEphemeralSetupTerminalWorktreeId } from '../../../../../shared/ephemeral-setup-terminal-worktree-id' +import { parseExecutionHostId } from '../../../../../shared/execution-host' +import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import { getConnectionId } from '@/lib/connection-context' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id' +import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' +import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery' +import { directSshAuthoritiesEqual } from '@/store/slices/direct-ssh-terminal-authority-ledger' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): void { + session.onAgentBecameWorking = (): void => { + session.suppressNativeWindowsIdleCodexFocusReports = false + session.clearSuppressedTitleSideEffects() + if (session.syncAgentTaskCompleteTrackingEnabled()) { + session.requiresFreshWorkingForAgentTaskCompleteNotification = false + session.agentCompletionCoordinator.observeTitleWorking() + } + // Why: a new API call refreshes the prompt-cache TTL, so clear any running + // countdown. The timer will restart when the agent becomes idle again. + session.deps.setCacheTimerStartedAt(session.cacheKey, null) + session.clearPendingAgentTaskCompleteNotification() + if (session.pendingTerminalBellNotification) { + session.scheduleTerminalBellNotification() + } + } + session.onAgentExited = (): void => { + // Why: eligibility can disappear transiently during reconnect, but a + // confirmed shell-title transition is authoritative for native-chat exit. + session.deps.onAgentExitedRef.current(session.pane.leafId) + session.clearSuppressedTitleSideEffects() + session.clearCommandInferredPaneAgent() + session.requestKnownWindowsShiftEnterReconfirmation() + // Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"), + // the agent has exited. Clear any running cache timer so the sidebar doesn't + // show a stale countdown for a tab that no longer has an active Claude session. + session.deps.setCacheTimerStartedAt(session.cacheKey, null) + session.clearTitleOnlyInterruptTimer() + // Why: title reversion alone is not process death. The process/PTY tracker + // owns removing agent rows when the TUI actually exits. + } + // Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their + // callbacks to the correct Orca pane without resolving worktrees from cwd. + // The key matches the `${tabId}:${leafId}` composite used for cacheTimerByKey + // and agentStatusByPaneKey. Treat it as opaque outside Orca. + session.state = useAppStore.getState() + session.parsedWorkspaceKey = parseWorkspaceKey(session.deps.worktreeId) + session.folderWorkspace = + session.parsedWorkspaceKey?.type === 'folder' + ? session.state.folderWorkspaces.find( + (workspace) => workspace.id === session.parsedWorkspaceKey.folderWorkspaceId + ) + : null + session.workspaceEnv = { ORCA_WORKSPACE_ID: session.deps.worktreeId } + if (session.folderWorkspace) { + session.workspaceEnv.ORCA_PROJECT_GROUP_ID = session.folderWorkspace.projectGroupId + session.workspaceEnv.ORCA_WORKSPACE_ROOT = session.folderWorkspace.folderPath + } + session.paneIdentityEnv = { + ...session.workspaceEnv, + ORCA_PANE_KEY: session.cacheKey, + ORCA_TAB_ID: session.deps.tabId, + ORCA_WORKTREE_ID: session.deps.worktreeId, + ...(session.launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: session.launchToken } : {}) + } + session.paneEnv = { + ...session.paneStartup?.env, + ...session.paneIdentityEnv + } + + // Why: folder workspaces can inherit their SSH target from child repos, so + // use the shared resolver instead of only looking up repo-backed worktrees. + session.worktree = getWorktreeMapFromState(session.state).get(session.deps.worktreeId) + session.worktreeConnectionId = getConnectionId(session.deps.worktreeId) + session.tab = (session.state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (t) => t.id === session.deps.tabId + ) + session.restoredPtyIdForTransport = + session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId + ? (session.deps.restoredPtyIdByLeafId[session.deps.restoredLeafId] ?? null) + : null + // Why: the floating terminal and inline setup/onboarding terminals are host-agnostic synthetic + // ids with no worktree/repo row, so the strict owner resolver reports them as unresolved. The + // shared terminal router scopes them to their floating owner (local for the floating terminal, + // the active runtime for setup terminals so remote skill installs land there) and returns null + // only for a genuinely unknown/stale worktree that must fail closed (#9994). + session.terminalWorktreeRoute = resolveTerminalWorktreeRoute( + session.state, + session.deps.worktreeId + ) + session.explicitRuntimeEnvironmentId = session.terminalWorktreeRoute?.runtimeEnvironmentId ?? null + // Why: paired-web worktrees retain HUB execution identity; their runtime-scoped mirrored pane is the session-level transport owner. + session.mirroredRuntimeOwners = new Set( + isWebTerminalSurfaceTabId(session.deps.tabId) + ? [session.restoredPtyIdForTransport, session.tab?.ptyId] + .map((ptyId) => (ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null)) + .filter((environmentId): environmentId is string => Boolean(environmentId)) + : [] + ) + session.mirroredRuntimeEnvironmentId = session.mirroredRuntimeOwners.values().next().value ?? null + session.terminalOwnerUnresolved = + session.mirroredRuntimeOwners.size > 1 || + (session.terminalWorktreeRoute === null && !session.mirroredRuntimeEnvironmentId) + session.runtimeEnvironmentId = session.explicitRuntimeEnvironmentId + ? session.explicitRuntimeEnvironmentId + : session.mirroredRuntimeEnvironmentId + ? session.mirroredRuntimeEnvironmentId + : null + // Why: host-agnostic synthetic ids (floating terminal, inline setup panels) have no repo + // row by design, and a worktree row stamped 'local' proves its host on its own — both are + // resolved-local, not pending hydration (#10151). Only when nothing names the host does + // `undefined` mean the repo hasn't merged yet; coalescing that to null would fail-open a + // remote cwd onto the local daemon (ENOENT on Docker SSH paths). + session.hostAgnosticTerminalWorktree = + session.deps.worktreeId === FLOATING_TERMINAL_WORKTREE_ID || + isEphemeralSetupTerminalWorktreeId(session.deps.worktreeId) + session.worktreeProvesLocalHost = parseExecutionHostId(session.worktree?.hostId)?.kind === 'local' + session.connectionOwnerHydrating = + !session.terminalOwnerUnresolved && + !session.hostAgnosticTerminalWorktree && + !session.worktreeProvesLocalHost && + session.runtimeEnvironmentId === null && + session.worktreeConnectionId === undefined + // Why: an SSH host nested under a HUB is execution identity, not permission for the paired client to dial that host. + session.connectionId = + !session.terminalOwnerUnresolved && + !session.connectionOwnerHydrating && + session.runtimeEnvironmentId === null + ? (session.worktreeConnectionId ?? null) + : null + type DirectSshRetryLease = Pick< + DirectSshPaneRetryAttempt, + 'attemptId' | 'authority' | 'tabGeneration' + > + session.directSshRetryAttempt = (() => { + const pendingAttempt = session.state.directSshPaneRetryByTabId?.[session.deps.tabId] + const liveBinding = session.state.directSshLivePtyBindingByTabId?.[session.deps.tabId] + const attempt = + pendingAttempt?.authority.targetId === session.connectionId && + pendingAttempt.tabGeneration === (session.tab?.generation ?? 0) + ? pendingAttempt + : liveBinding?.authority.targetId === session.connectionId && + liveBinding.tabGeneration === (session.tab?.generation ?? 0) + ? liveBinding + : undefined + return attempt + })() + session.pendingSpawnKey = session.directSshRetryAttempt + ? JSON.stringify([session.cacheKey, session.directSshRetryAttempt.attemptId]) + : session.cacheKey + session.capturedDirectSshRetryPtyAccepted = false + session.directSshPaneRetrySettlementCancelled = false + session.directSshPaneRetrySettlementTimers = new Set>() + session.directSshPaneRetryTimedPromises = new WeakSet() + session.capturedDirectSshRetryLeaseMatches = (): boolean => { + if (!session.directSshRetryAttempt) { + return true + } + const currentState = useAppStore.getState() + const currentConnection = currentState.sshConnectionStates.get( + session.directSshRetryAttempt.authority.targetId + ) + const currentTab = (currentState.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (candidate) => candidate.id === session.deps.tabId + ) + if ( + currentConnection?.providerEpoch !== session.directSshRetryAttempt.authority.providerEpoch || + currentConnection?.connectionGeneration !== + session.directSshRetryAttempt.authority.connectionGeneration || + (currentTab?.generation ?? 0) !== session.directSshRetryAttempt.tabGeneration + ) { + return false + } + const pendingAttempt = currentState.directSshPaneRetryByTabId?.[session.deps.tabId] + const pendingMatches = + pendingAttempt?.attemptId === session.directSshRetryAttempt.attemptId && + directSshAuthoritiesEqual( + pendingAttempt.authority, + session.directSshRetryAttempt.authority + ) && + pendingAttempt.tabGeneration === session.directSshRetryAttempt.tabGeneration + const liveBinding = currentState.directSshLivePtyBindingByTabId?.[session.deps.tabId] + const liveBindingMatchesAttempt = + liveBinding?.attemptId === session.directSshRetryAttempt.attemptId && + directSshAuthoritiesEqual(liveBinding.authority, session.directSshRetryAttempt.authority) && + liveBinding.tabGeneration === session.directSshRetryAttempt.tabGeneration + return pendingMatches || liveBindingMatchesAttempt + } + session.capturedDirectSshRetryStateMatches = (ptyId: string): boolean => { + if (!session.directSshRetryAttempt) { + return true + } + const currentConnection = useAppStore + .getState() + .sshConnectionStates.get(session.directSshRetryAttempt.authority.targetId) + return ( + parseAppSshPtyId(ptyId)?.connectionId === session.directSshRetryAttempt.authority.targetId && + currentConnection?.status === 'connected' && + session.capturedDirectSshRetryLeaseMatches() + ) + } + session.claimCapturedDirectSshRetryPty = (ptyId: string): boolean => { + if (!session.capturedDirectSshRetryStateMatches(ptyId)) { + return false + } + session.capturedDirectSshRetryPtyAccepted = session.directSshRetryAttempt !== undefined + return true + } + session.canAdoptCapturedDirectSshRetryPty = (ptyId: string): boolean => { + const canAdopt = session.capturedDirectSshRetryStateMatches(ptyId) + if (canAdopt && session.directSshRetryAttempt) { + session.capturedDirectSshRetryPtyAccepted = true + } + return canAdopt + } + session.settleDirectSshPaneRetryAttempt = ( + attempt: DirectSshRetryLease | undefined, + status: 'failed' | 'timed-out' + ): void => { + if (!attempt) { + return + } + useAppStore.getState().settleDirectSshPaneRetry?.({ + status, + tabId: session.deps.tabId, + attemptId: attempt.attemptId, + authority: attempt.authority, + tabGeneration: attempt.tabGeneration + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts new file mode 100644 index 00000000000..bded6461270 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts @@ -0,0 +1,192 @@ +import { detectAgentStatusFromTitle, isClaudeAgent } from '@/lib/agent-status' +import { useAppStore } from '@/store' +import { isFreshNonDoneAgentStatus } from '../../../../../shared/agent-status-types' +import type { + AgentCompletionDispatchMeta, + AgentCompletionStatusSnapshot +} from '../agent-completion-coordinator-types' +import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS, + canDispatchAgentNotificationAfterGrace +} from '../agent-task-complete-policy' + +import { + isAgentTaskCompleteNotificationEnabled, + subscribeAgentTaskCompleteTrackingEnabled +} from './agent-task-complete-settings' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +import { installAgentIdleWorkingHandlers } from './agent-idle-working-handlers' + +export function installAgentTaskCompleteNotify(session: ConnectPanePtySession): void { + session.scheduleAgentTaskCompleteNotification = ( + title: string, + options: { + allowDoneDetailAfterGrace?: boolean + agentStatusSnapshot?: AgentCompletionStatusSnapshot + agentCompletionSource?: AgentCompletionDispatchMeta['source'] + } = {} + ): void => { + if ( + !session.syncAgentTaskCompleteTrackingEnabled() || + session.requiresFreshWorkingForAgentTaskCompleteNotification + ) { + return + } + session.clearPendingAgentTaskCompleteNotification() + let graceElapsed = false + const generationAtSchedule = session.agentTaskCompleteNotificationGeneration + const agentStatusAtSchedule = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + const hasNewerActiveHookStatus = (): boolean => { + const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + const scheduledAgentType = agentStatusAtSchedule?.agentType + const currentAgentForScheduledTurn = resolveCompatibleAgentTypeForOwner( + currentStatus?.agentType, + scheduledAgentType + ) + const hasDifferentKnownAgent = Boolean( + currentStatus?.agentType && + scheduledAgentType && + currentStatus.agentType !== 'unknown' && + scheduledAgentType !== 'unknown' && + currentAgentForScheduledTurn !== scheduledAgentType + ) + return ( + options.agentCompletionSource === 'process-exit' && + isFreshNonDoneAgentStatus(currentStatus) && + (!agentStatusAtSchedule || + currentStatus.state !== agentStatusAtSchedule.state || + currentStatus.stateStartedAt !== agentStatusAtSchedule.stateStartedAt || + hasDifferentKnownAgent) + ) + } + + const dispatch = (): void => { + session.clearPendingAgentTaskCompleteNotification() + if ( + generationAtSchedule !== session.agentTaskCompleteNotificationGeneration || + !session.syncAgentTaskCompleteTrackingEnabled() || + hasNewerActiveHookStatus() + ) { + return + } + if (session.disposed) { + return + } + // Why: terminal attention is a visual pane affordance, not an OS + // notification. Route through dispatch so stale pane completions are + // rejected before unread attention is marked. + const shouldDispatchOsNotification = isAgentTaskCompleteNotificationEnabled() + session.pendingTerminalBellNotification = false + session.clearTerminalBellNotificationTimer() + session.deps.dispatchNotification({ + source: 'agent-task-complete', + terminalTitle: title, + paneKey: session.cacheKey, + ...(options.agentCompletionSource + ? { agentCompletionSource: options.agentCompletionSource } + : {}), + ...(shouldDispatchOsNotification ? {} : { suppressOsNotification: true }), + ...(options.agentStatusSnapshot ? { agentStatusSnapshot: options.agentStatusSnapshot } : {}) + }) + } + + const dispatchIfDetailed = (): void => { + if (hasNewerActiveHookStatus()) { + // Why: the confirmed exit belongs to the row captured above; a replaced + // active row means a newer turn started during the notification delay. + session.clearPendingAgentTaskCompleteNotification() + return + } + if (!graceElapsed) { + return + } + const entry = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + if (canDispatchAgentNotificationAfterGrace(entry, options)) { + dispatch() + } + } + + session.agentTaskCompleteStatusUnsubscribe = useAppStore.subscribe(dispatchIfDetailed) + session.agentTaskCompleteNotificationGraceTimer = setTimeout(() => { + session.agentTaskCompleteNotificationGraceTimer = null + graceElapsed = true + dispatchIfDetailed() + }, AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + // Why: some agents never surface assistant text through hooks. Keep a hard + // cap so task-complete notifications still fire instead of waiting forever. + session.agentTaskCompleteNotificationMaxTimer = setTimeout( + dispatch, + AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS + ) + } + session.agentTaskCompleteSettingsUnsubscribe = subscribeAgentTaskCompleteTrackingEnabled(() => { + if (session.syncAgentTaskCompleteTrackingEnabled()) { + session.agentCompletionCoordinator.startProcessTracking() + } + }) + + // ─── Agent task-complete: notification-backed attention ─────────────── + // + // The working→idle title transition drives two independent concerns: + // 1. The Claude prompt-cache countdown in the sidebar. + // 2. The "Agent Task Complete" OS notification users toggle in Settings. + // + // This path raises the same terminal attention marker as BEL through the + // shared notification dispatcher. Not every agent CLI reliably emits BEL on + // completion (Gemini, some Codex flows), and the highlight needs to remain + // findable after the OS banner is gone. Double-firing with a concurrent BEL + // is handled by delaying the BEL OS notification below; main still keeps a + // 5 s per-worktree dedupe as the final guard. + session.onAgentBecameIdle = ( + title: string, + meta?: { staleWorkingTitleClear?: boolean } + ): void => { + // Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not + // observed bytes — a merely-paused agent (>3s silent mid-task, window + // minimized) would otherwise mint a false task-complete OS notification + // that renderer timer throttling previously damped. Clear session-tied + // state only; never schedule completion attention from it. + if (meta?.staleWorkingTitleClear) { + session.deps.setCacheTimerStartedAt(session.cacheKey, null) + return + } + const currentState = useAppStore.getState() + const activeHookStatus = currentState.agentStatusByPaneKey[session.cacheKey] + if (session.shouldSuppressTitleCompletionForFreshHook(title, activeHookStatus)) { + // Why: agent CLIs can briefly publish an idle title while hook status + // still says the same agent turn is active (e.g. during tool output). + if (activeHookStatus) { + session.preserveSuppressedTitleSideEffects(title, activeHookStatus) + } + return + } + // Why: only start the prompt-cache countdown for Claude agents — other + // agents have different (or no) prompt-caching semantics and showing a + // timer for them would be misleading. + // + // Why we check `settings !== null` separately: during startup, settings + // hydrate asynchronously after terminals reconnect. If we treat null + // as disabled, the first working→idle transition on a restored Claude + // tab silently drops the timer. Writing a timestamp is cheap and the + // CacheTimer component gates rendering on the enabled flag, so a + // spurious write when the feature turns out to be disabled is harmless. + const settings = currentState.settings + if (isClaudeAgent(title) && (settings === null || settings.promptCacheTimerEnabled)) { + session.deps.setCacheTimerStartedAt(session.cacheKey, Date.now()) + } + if (detectAgentStatusFromTitle(title) === 'idle') { + session.setFocusReportSuppressionForAgentCompletion(title, activeHookStatus?.agentType) + } + if (session.syncAgentTaskCompleteTrackingEnabled()) { + session.agentCompletionCoordinator.observeClassifiedTitleCompletion(title) + } + // Why: some agent TUIs leave xterm renderer modes active after a turn. + // Reset cursor everywhere, and Kitty keyboard state on native Windows. + session.queueAgentIdleTerminalModeReset() + } + installAgentIdleWorkingHandlers(session) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-settings.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-settings.ts new file mode 100644 index 00000000000..95d493a23a6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-settings.ts @@ -0,0 +1,54 @@ +import { useAppStore } from '@/store' +import { + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from '../agent-task-complete-policy' + +export function isAgentTaskCompleteNotificationEnabled(): boolean { + return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState()) +} + +export function isAgentTaskCompleteTrackingEnabled(): boolean { + return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState()) +} + +const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>() +let agentTaskCompleteTrackingSettingsUnsubscribe: (() => void) | null = null +let agentTaskCompleteTrackingSettingsSnapshot: string | null = null + +function getAgentTaskCompleteTrackingSettingsSnapshot( + state: ReturnType +): string { + return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}` +} + +export function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void { + if (agentTaskCompleteTrackingSettingsUnsubscribe === null) { + agentTaskCompleteTrackingSettingsSnapshot = getAgentTaskCompleteTrackingSettingsSnapshot( + useAppStore.getState() + ) + agentTaskCompleteTrackingSettingsUnsubscribe = useAppStore.subscribe((state) => { + const snapshot = getAgentTaskCompleteTrackingSettingsSnapshot(state) + if (snapshot === agentTaskCompleteTrackingSettingsSnapshot) { + return + } + agentTaskCompleteTrackingSettingsSnapshot = snapshot + for (const subscriber of Array.from(agentTaskCompleteTrackingEnabledListeners)) { + subscriber() + } + }) + } + + agentTaskCompleteTrackingEnabledListeners.add(listener) + return () => { + agentTaskCompleteTrackingEnabledListeners.delete(listener) + if ( + agentTaskCompleteTrackingEnabledListeners.size === 0 && + agentTaskCompleteTrackingSettingsUnsubscribe !== null + ) { + agentTaskCompleteTrackingSettingsUnsubscribe() + agentTaskCompleteTrackingSettingsUnsubscribe = null + agentTaskCompleteTrackingSettingsSnapshot = null + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts new file mode 100644 index 00000000000..2395bbeeafb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -0,0 +1,320 @@ +import { safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' +import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' +import { waitForTerminalReplayWritesParsed } from '../replay-guard' +import { + POST_REPLAY_MODE_RESET, + RESET_GRAPHIC_RENDITION +} from '../../../../../shared/terminal-mode-reset-profiles' +import { + buildMainModelSnapshotReplayWrites, + hasPositiveTerminalDimensions, + readProposedTerminalCols, + resolvePositiveTerminalDimensions, + shouldSkipAltFrameForWidthMismatch +} from '../terminal-snapshot-replay-paint' + +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' + +import type { ReattachPayloadContext } from './reattach-payload-context' +import type { ReattachPayloadSession } from './reattach-payload-session' + +export function createReattachPayloadHandlers( + session: ReattachPayloadSession, + ctx: ReattachPayloadContext +): { + applyReattachPayload: () => Promise + fitAfterReattachRestore: () => Promise +} { + const applyReattachPayload = async (): Promise => { + if (!ctx.isCurrentReattachPayload()) { + return + } + if (ctx.connectResult?.snapshot) { + const snapshotPrefixAnsi = ctx.connectResult.snapshotPrefixAnsi + const snapshotFrameAnsi = ctx.connectResult.snapshotFrameAnsi + const snapshotFrameRestoreAnsi = ctx.connectResult.snapshotFrameRestoreAnsi + const hasSplitDaemonAltFrame = + typeof snapshotPrefixAnsi === 'string' && + snapshotPrefixAnsi.length > 0 && + typeof snapshotFrameAnsi === 'string' && + snapshotFrameAnsi.length > 0 + const daemonSnapshotReplay = hasSplitDaemonAltFrame + ? snapshotPrefixAnsi + snapshotFrameAnsi + : ctx.connectResult.snapshot + session.rememberReattachPayloadAgentSignal(daemonSnapshotReplay, { fullScreenReplay: true }) + // Why: replay at the snapshot's own dimensions to avoid rewrapping soft-wrapped rows at a different column count (#7279); suppress the PTY forward so this layout-only resize doesn't SIGWINCH the remote TUI. + const snapshotDimensions = resolvePositiveTerminalDimensions( + ctx.connectResult.snapshotCols, + ctx.connectResult.snapshotRows + ) + if ( + snapshotDimensions && + (session.pane.terminal.cols !== snapshotDimensions.cols || + session.pane.terminal.rows !== snapshotDimensions.rows) + ) { + session.suppressStructuralReplayPtyResize = true + try { + session.pane.terminal.resize(snapshotDimensions.cols, snapshotDimensions.rows) + } finally { + session.suppressStructuralReplayPtyResize = false + } + } + session.writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`) + // Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload. + session.applySnapshotKittyKeyboardModes(daemonSnapshotReplay, { + kittyKeyboardFlags: ctx.connectResult.snapshotKittyKeyboardFlags, + snapshotSeq: ctx.connectResult.snapshotSeq + }) + // A narrower fit clips the fixed-grid alt frame; drop it and let SIGWINCH repaint. + // A dead-owner restore keeps history plus its restored-session treatment; + // a frozen foreign-width frame would look live when no owner remains. + const daemonAltFrameSkippable = + hasSplitDaemonAltFrame && + typeof snapshotFrameRestoreAnsi === 'string' && + shouldSkipAltFrameForWidthMismatch( + ctx.connectResult.snapshotCols, + readProposedTerminalCols(session.pane) + ) + const groundDaemonSnapshot = + Boolean(ctx.connectResult.coldRestore) || + (!session.shouldPreserveAgentReattachModes() && + !(ctx.connectResult.isAlternateScreen ?? session.kittyKeyboardModes.isAlternateScreen)) + session.writeReplayData( + `${groundDaemonSnapshot ? RESET_GRAPHIC_RENDITION : ''}${ + daemonAltFrameSkippable + ? snapshotPrefixAnsi + snapshotFrameRestoreAnsi + : daemonSnapshotReplay + }` + ) + session.writeReplayData( + session.reattachReplayResetSequence( + daemonSnapshotReplay, + Boolean(ctx.connectResult.coldRestore), + ctx.connectResult.isAlternateScreen + ) + ) + if (ctx.connectResult.pendingEscapeTailAnsi) { + // Why last: re-arm the dangling mid-escape after the reset (whose ESC would abort it) so the live continuation completes it (#7329). + session.writeReplayData(ctx.connectResult.pendingEscapeTailAnsi) + } + session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration) + if (ctx.connectResult.coldRestore) { + // Snapshot superseded the cold-restore payload; ack so the daemon doesn't redeliver it. + if (!isRemoteRuntimePtyId(ctx.ptyId)) { + window.api.pty.ackColdRestore(ctx.ptyId) + } + } + } else if (ctx.connectResult?.replay || ctx.prefetchedParkModelSnapshot) { + // Why scoped to a park-reveal: the 100KiB relay tail loses scrollback the + // model still holds, but an in-place reattach (network reconnect, wake, + // reload) already has that replay in hand, so probing would only delay its + // paint by the timeout. Memoized, so this is never a second probe. + const modelSnapshot = ctx.revealFollowsTerminalPark + ? (ctx.prefetchedParkModelSnapshot ?? + (isRemoteRuntimePtyId(ctx.ptyId) ? null : await ctx.fetchSshMainModelReattachSnapshot())) + : null + if (!ctx.isCurrentReattachPayload()) { + return + } + if (modelSnapshot) { + // Why composed for scan/reset only: kitty + reset heuristics need + // the full byte stream; the actual writes go through the shared + // alt-screen choreography below (scrollbackAnsi is '' for + // normal-buffer snapshots, so composition matches data there). + const modelData = `${modelSnapshot.scrollbackAnsi ?? ''}${modelSnapshot.data}` + session.rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true }) + const modelCols = modelSnapshot.cols + const modelRows = modelSnapshot.rows + if ( + hasPositiveTerminalDimensions(modelCols, modelRows) && + (session.pane.terminal.cols !== modelCols || session.pane.terminal.rows !== modelRows) + ) { + // Why: replay at the snapshot's own dimensions (see the daemon-snapshot branch, #7279). + session.suppressStructuralReplayPtyResize = true + try { + session.pane.terminal.resize(modelCols, modelRows) + } finally { + session.suppressStructuralReplayPtyResize = false + } + } + session.applySnapshotKittyKeyboardModes(modelData, { + kittyKeyboardFlags: modelSnapshot.kittyKeyboardFlags, + snapshotSeq: modelSnapshot.seq + }) + // Why shared: park+reveal of an alt-screen TUI needs the same + // ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips + // the ?1049h marker when splitting scrollbackAnsi) — inlined here + // because nesting structuralReplayCoordinator would deadlock. + for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, { + skipAltFrame: shouldSkipAltFrameForWidthMismatch( + modelCols, + readProposedTerminalCols(session.pane) + ), + paneOnAlternateScreen: session.isPaneOnAlternateScreen() + })) { + session.writeReplayData(replayChunk) + } + session.writeReplayData( + session.reattachReplayResetSequence( + modelData, + Boolean(ctx.connectResult?.coldRestore), + modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen + ) + ) + if (modelSnapshot.pendingEscapeTailAnsi) { + // Why last: re-arm the dangling mid-escape after the reset so the live continuation completes it (#7329). + session.writeReplayData(modelSnapshot.pendingEscapeTailAnsi) + } + // Why: main sampled its delivery backlog with the snapshot; the baseline drops/slices deferred and live chunks the snapshot already covers. + session.setRestoredSnapshotBaseline( + ctx.ptyId, + modelSnapshot, + restoredSnapshotPaintsPrintableContent(modelSnapshot) + ) + session.recordRendererOrderedSeq(modelSnapshot) + session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration) + if (ctx.connectResult?.coldRestore && !isRemoteRuntimePtyId(ctx.ptyId)) { + window.api.pty.ackColdRestore(ctx.ptyId) + } + } else if (ctx.connectResult?.replay) { + session.rememberReattachPayloadAgentSignal(ctx.connectResult.replay, { + fullScreenReplay: true + }) + // Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication. + session.writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`) + // Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack. + // A constructor-fresh mirror (window reload) first demotes to unproven: + // the replay window proves nothing about negotiations that predate it. + if (!session.kittyKeyboardModes.hasProvenBaseline) { + session.kittyKeyboardModes.resetForSnapshot() + } + session.kittyKeyboardModes.scanReplay(ctx.connectResult.replay) + session.writeReplayData( + `${ctx.connectResult.coldRestore ? RESET_GRAPHIC_RENDITION : ''}${ctx.connectResult.replay}` + ) + session.writeReplayData( + session.reattachReplayResetSequence( + ctx.connectResult.replay, + Boolean(ctx.connectResult.coldRestore), + ctx.connectResult.isAlternateScreen + ) + ) + session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration) + if (ctx.connectResult.coldRestore) { + if (!isRemoteRuntimePtyId(ctx.ptyId)) { + window.api.pty.ackColdRestore(ctx.ptyId) + } + } + } + } else if (ctx.connectResult?.coldRestore) { + let destinationRows = session.pane.terminal.rows + try { + const proposedDestination = session.pane.fitAddon.proposeDimensions() + if ( + proposedDestination && + Number.isFinite(proposedDestination.rows) && + proposedDestination.rows > 0 + ) { + destinationRows = Math.max(destinationRows, proposedDestination.rows) + } + } catch { + // The current xterm grid remains a safe lower bound for blanking. + } + // Why: shrinking first would promote clipped stale viewport rows into scrollback, beyond the reach of a later viewport-only clear. + session.writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H`) + await waitForTerminalReplayWritesParsed(session.pane.terminal) + if (!ctx.isCurrentReattachPayload()) { + return + } + const coldRestoreDimensions = resolvePositiveTerminalDimensions( + ctx.connectResult.coldRestore.cols, + ctx.connectResult.coldRestore.rows + ) + if ( + coldRestoreDimensions && + (session.pane.terminal.cols !== coldRestoreDimensions.cols || + session.pane.terminal.rows !== coldRestoreDimensions.rows) + ) { + // Why: recovered ANSI cursor positions belong to the checkpoint's grid; keep this layout-only resize from reaching the fresh PTY. + session.suppressStructuralReplayPtyResize = true + try { + session.pane.terminal.resize(coldRestoreDimensions.cols, coldRestoreDimensions.rows) + } finally { + session.suppressStructuralReplayPtyResize = false + } + } + // Why: recorded scrollback is raw PTY output that may hold query sequences; xterm.write would auto-reply into the new shell's stdin. See replay-guard.ts. + session.writeReplayData( + `${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}` + ) + const preparedStartup = ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup() + const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup) + if (didPrepareResume) { + if (ctx.connectResult.agentResumeUnavailable) { + // Why: main dropped the resume argv, so this pane is a NEW session — + // the plain restored banner would claim the old one came back. + session.showSessionRestoredBanner('resume-unavailable') + } else if (preparedStartup?.hasSleepingRecord) { + session.showSessionRestoredBanner() + } + session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) + } + // Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes. + session.writeReplayData(POST_REPLAY_MODE_RESET) + // Why: the dead run's kitty flags died with it and its scrollback was never scanned — the fresh shell starts at zero. + session.kittyKeyboardModes.reset() + session.consumeRestoredViewportBlankingMarker() + // Why: a taller destination fit must not pull recovered rows back into the fresh shell's viewport after source-grid replay. + session.writeFreshShellViewportBlanking(Math.max(destinationRows, session.pane.terminal.rows)) + if (!isRemoteRuntimePtyId(ctx.ptyId)) { + window.api.pty.ackColdRestore(ctx.ptyId) + } + if (didPrepareResume && !ctx.coldRestoreStartup) { + session.schedulePendingStartupCommandDelivery() + } + } + if (ctx.hasStructuralReplay || ctx.prefetchedParkModelSnapshot) { + await waitForTerminalReplayWritesParsed(session.pane.terminal) + if (!ctx.isCurrentReattachPayload()) { + return + } + ctx.reattachPayloadApplied = true + } + } + + const fitAfterReattachRestore = async (): Promise => { + if (!ctx.isCurrentReattachPayload()) { + return + } + const reattachPtyId = session.transport.getPtyId() + if (!reattachPtyId) { + return + } + if (!getFitOverrideForPty(reattachPtyId)) { + const gridPush = session.createReattachGridPush(ctx.attemptGeneration, reattachPtyId) + const fit = safeFitAndThen(session.pane, 'reattach-pty-resize', gridPush.continuation, { + shouldContinue: gridPush.shouldContinue, + retryIfUnmeasurable: true, + // Why only this caller: a restored floating workspace is display:none until the + // user opens it, so dropping the grid push strands the PTY at the replay grid. + deferIfHidden: true + }) + session.pendingReattachFit = fit + try { + // Why: reattach resize is fire-and-forget, so the continuation itself requests the + // applied-grid verification — it is the only point reached by both the immediate + // and the deferred-until-revealed path. + await fit.completion + } finally { + if (session.pendingReattachFit === fit) { + session.pendingReattachFit = null + } + } + } else if (ctx.isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') + } + } + + return { applyReattachPayload, fitAfterReattachRestore } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/codex-pane-stale.ts b/src/renderer/src/components/terminal-pane/pty-connection/codex-pane-stale.ts new file mode 100644 index 00000000000..a19bd0f00b2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/codex-pane-stale.ts @@ -0,0 +1,48 @@ +import { useAppStore } from '@/store' +import { blocksCodexPaneInput } from '../../codex-restart-notice-state' + +let codexRestartNoticePresenceSource: Record< + string, + { previousAccountLabel: string; nextAccountLabel: string } +> | null = null +let codexRestartNoticePresence = false + +export function hasCodexRestartNotices( + noticesByPtyId: Record +): boolean { + if (codexRestartNoticePresenceSource !== noticesByPtyId) { + codexRestartNoticePresenceSource = noticesByPtyId + codexRestartNoticePresence = Object.keys(noticesByPtyId).length > 0 + } + return codexRestartNoticePresence +} + +export function isCodexPaneStale(args: { + tabId: string + worktreeId: string + panePtyId: string | null +}): boolean { + const state = useAppStore.getState() + const { codexRestartNoticeByPtyId } = state + if (!hasCodexRestartNotices(codexRestartNoticeByPtyId)) { + return false + } + // Why: a bound pane's own record is the last word — its ptyId is exactly the + // shell its keystrokes reach. `tab.ptyId` holds one sibling's id, not this + // pane's, so in a split tab consulting it would kill this pane's keyboard over + // that sibling's notice, with no prompt on screen once the sibling is answered. + if (args.panePtyId) { + return blocksCodexPaneInput(codexRestartNoticeByPtyId[args.panePtyId]) + } + + // Why: only an unbound pane needs the tab's persisted id. Both transports + // refuse writes while their pty binding is null, so a keystroke here arms + // recovery — a coarser signal than a bound pane's own record, but the tab's id + // is the only evidence available of which shell this pane is about to own. + const tab = (state.tabsByWorktree[args.worktreeId] ?? []).find((entry) => entry.id === args.tabId) + if (tab?.ptyId && blocksCodexPaneInput(codexRestartNoticeByPtyId[tab.ptyId])) { + return true + } + + return false +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts new file mode 100644 index 00000000000..3e4ab1e38d5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts @@ -0,0 +1,102 @@ +import { useAppStore } from '@/store' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { resolveAgentResumeLaunchTarget } from '@/lib/agent-resume-launch-target' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../../../shared/tui-agent-launch-defaults' +import { + agentProviderSessionsEqual, + isResumableTuiAgent, + normalizeAgentProviderSession +} from '../../../../../shared/agent-session-resume' + +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySession): void { + session.buildColdRestoreAgentResumeStartup = (): ColdRestoreAgentResumeStartup | null => { + if (session.pendingStartupCommand) { + return null + } + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[session.cacheKey] + const sleepingRecordEntry = session.getSleepingRecordForPane(state) + const sleepingRecord = sleepingRecordEntry?.record + if (session.isLegacyWorkerAutomaticResumeBlocked()) { + return null + } + const useLiveEntry = entry && entry.state !== 'done' + const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent + if (!agent || !isResumableTuiAgent(agent)) { + return null + } + const providerSession = normalizeAgentProviderSession( + useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession + ) + if (!providerSession) { + return null + } + const matchingSleepingLaunchConfig = + sleepingRecord?.launchConfig && + (!useLiveEntry || + (sleepingRecord.agent === agent && + agentProviderSessionsEqual(agent, sleepingRecord.providerSession, providerSession))) + ? sleepingRecord.launchConfig + : undefined + const launchConfig = + (useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? + matchingSleepingLaunchConfig + // Why: the resume line is typed into this pane's live shell, so its quoting must + // follow the tab's effective Windows shell, not the win32 PowerShell default. + const resumeTarget = resolveAgentResumeLaunchTarget({ + projectRuntime: session.projectRuntime, + connectionId: session.connectionId, + executionHostId: session.executionHostId, + worktreePath: session.worktree?.path, + terminalWindowsShell: state.settings?.terminalWindowsShell, + tabShellOverride: session.shellOverride + }) + const startupPlan = buildAgentResumeStartupPlan({ + agent, + providerSession, + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + agentArgs: + launchConfig !== undefined + ? launchConfig.agentArgs + : resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs), + agentEnv: + launchConfig !== undefined + ? launchConfig.agentEnv + : resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv), + ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), + ...(launchConfig?.ompResumeFilePath + ? { ompResumeFilePath: launchConfig.ompResumeFilePath } + : {}), + platform: resumeTarget.platform, + shell: resumeTarget.shell + }) + if (!startupPlan) { + return null + } + const coldRestoreLaunchToken = createBrowserUuid() + // Why: cold restore means the PTY process is gone but the agent provider + // session is still resumable, so the replacement spawn must launch it. + return { + agent, + command: startupPlan.launchCommand, + env: { + ...startupPlan.env, + ORCA_AGENT_LAUNCH_TOKEN: coldRestoreLaunchToken + }, + launchConfig: startupPlan.launchConfig, + resumeProviderSession: providerSession, + launchToken: coldRestoreLaunchToken, + useLiveEntry: Boolean(useLiveEntry), + hasSleepingRecord: Boolean(sleepingRecord), + sleepingRecordEntry + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts b/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts new file mode 100644 index 00000000000..8ed19498c36 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts @@ -0,0 +1,269 @@ +import { detectAgentStatusFromTitle, isClaudeAgent } from '@/lib/agent-status' +import { useAppStore } from '@/store' +import { + isFreshNonDoneAgentStatus, + type AgentStatusEntry, + type AgentType +} from '../../../../../shared/agent-status-types' +import { registerAgentHookTerminalLifecycleHandler } from '../agent-hook-terminal-lifecycle' +import type { AgentCompletionStatusSnapshot } from '../agent-completion-coordinator-types' +import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import { recognizeAgentProcessFromCommandLine } from '../../../../../shared/agent-process-recognition' +import type { TuiAgent } from '../../../../../shared/tui-agent' +import { isTuiAgent } from '../../../../../shared/tui-agent-config' + +import { MANUAL_AGENT_COMMAND_MAX_CHARS } from './pty-connect-limits' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function installCommandInferredPaneAgent(session: ConnectPanePtySession): void { + // Why: infer pane ownership from a manually typed agent command (e.g. `omp`) by + // shadowing the shell's current command line, for generic terminals where no + // launch metadata exists. Consumed by getAuthoritativePaneAgent below. + session.commandInferredPaneAgent = null + session.pendingShellCommandLine = '' + session.pendingShellCommandCursor = 0 + session.commandInferredPaneAgentGeneration = 0 + session.shellCommandInferenceSuspendedUntilCommandEnd = false + session.startAcceptedInferredCommand = (_agent: TuiAgent): void => {} + session.requestKnownWindowsShiftEnterReconfirmation = (): void => {} + session.resetPendingShellCommandLine = (): void => { + session.pendingShellCommandLine = '' + session.pendingShellCommandCursor = 0 + } + session.rememberCommandInferredPaneAgent = (): void => { + const commandLine = session.pendingShellCommandLine.trim() + session.resetPendingShellCommandLine() + const candidateAgent = commandLine + ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) + : null + const state = useAppStore.getState() + const registeredLaunchAgent = + state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType + // Why: input inside a live TUI can spell another agent command; process or + // pane-scoped launch ownership is stronger than typed shell inference. + const hasStrongerAgentOwnership = + Boolean(state.paneForegroundAgentByPaneKey[session.cacheKey]?.agent) || + isTuiAgent(registeredLaunchAgent) + const nextAgent = hasStrongerAgentOwnership ? null : candidateAgent + session.commandInferredPaneAgent = nextAgent + session.commandInferredPaneAgentGeneration += 1 + if (nextAgent) { + session.startAcceptedInferredCommand(nextAgent) + } + } + session.clearCommandInferredPaneAgent = (): void => { + session.commandInferredPaneAgent = null + session.resetPendingShellCommandLine() + session.commandInferredPaneAgentGeneration += 1 + } + session.clearCommandInferredPaneAgentAfterPtySideEffects = (): void => { + const generation = session.commandInferredPaneAgentGeneration + session.resetPendingShellCommandLine() + queueMicrotask(() => { + setTimeout(() => { + if (session.commandInferredPaneAgentGeneration === generation) { + session.clearCommandInferredPaneAgent() + } + }, 0) + }) + } + session.appendPendingShellCommandInput = (text: string): void => { + const available = MANUAL_AGENT_COMMAND_MAX_CHARS - session.pendingShellCommandLine.length + if (available <= 0) { + session.shellCommandInferenceSuspendedUntilCommandEnd = true + return + } + const inserted = text.slice(0, available) + session.pendingShellCommandLine = + session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor) + + inserted + + session.pendingShellCommandLine.slice(session.pendingShellCommandCursor) + session.pendingShellCommandCursor += inserted.length + if (inserted.length < text.length) { + session.shellCommandInferenceSuspendedUntilCommandEnd = true + } + } + session.deletePendingShellCommandWord = (): void => { + const beforeCursor = session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor) + const afterCursor = session.pendingShellCommandLine.slice(session.pendingShellCommandCursor) + const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') + session.pendingShellCommandLine = nextBeforeCursor + afterCursor + session.pendingShellCommandCursor = nextBeforeCursor.length + } + session.cancelSuspendedShellCommandInference = (): void => { + if (!session.shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + session.shellCommandInferenceSuspendedUntilCommandEnd = false + session.resetPendingShellCommandLine() + } + session.deletePendingShellCommandCharacter = (): void => { + if (session.pendingShellCommandCursor === 0) { + return + } + session.pendingShellCommandLine = + session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor - 1) + + session.pendingShellCommandLine.slice(session.pendingShellCommandCursor) + session.pendingShellCommandCursor -= 1 + } + session.deletePendingShellCommandCharacterAtCursor = (): void => { + if (session.pendingShellCommandCursor >= session.pendingShellCommandLine.length) { + return + } + session.pendingShellCommandLine = + session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor) + + session.pendingShellCommandLine.slice(session.pendingShellCommandCursor + 1) + } + session.movePendingShellCommandCursor = (delta: number): void => { + session.pendingShellCommandCursor = Math.min( + session.pendingShellCommandLine.length, + Math.max(0, session.pendingShellCommandCursor + delta) + ) + } + session.consumeShellCommandCsiSequence = (data: string, index: number): number | null => { + if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') { + return null + } + let cursor = index + 2 + while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) { + cursor += 1 + } + const final = data[cursor] + if (!final || !/[~A-Za-z]/.test(final)) { + return null + } + const params = data.slice(index + 2, cursor) + // Why: only emulate a bare one-column move. Parameterized/modified cursor keys + // (e.g. Ctrl+Left `\x1b[1;5D` = word-jump) move the real cursor by more than + // one, so tracking them as ±1 would desync the shadow line — fall through to + // reset instead of silently corrupting the sampled command. + if (final === 'D' && params === '') { + session.movePendingShellCommandCursor(-1) + } else if (final === 'C' && params === '') { + session.movePendingShellCommandCursor(1) + } else if (final === 'H' || (final === '~' && params === '1')) { + session.pendingShellCommandCursor = 0 + } else if (final === 'F' || (final === '~' && params === '4')) { + session.pendingShellCommandCursor = session.pendingShellCommandLine.length + } else if (final === '~' && params === '3') { + session.deletePendingShellCommandCharacterAtCursor() + } else if (final === '~' && (params === '200' || params === '201')) { + // Bracketed paste wrappers are terminal framing, not shell command text. + } else { + session.resetPendingShellCommandLine() + } + return cursor + 1 + } + session.getLivePaneAgentTitle = (): string | null => { + const state = useAppStore.getState() + const runtimeTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + const tabTitle = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + )?.title + return runtimeTitle ?? tabTitle ?? null + } + // Why: a pane-scoped explicit row only counts as current ownership evidence + // when it is fresh and not already `done` — a stale or completed row is a + // leftover from a prior agent that may no longer own the shell. + session.isFreshActivePaneAgentEntry = ( + entry: AgentStatusEntry | undefined + ): entry is AgentStatusEntry => { + return isFreshNonDoneAgentStatus(entry) + } + session.shouldSuppressTitleCompletionForFreshHook = ( + title: string, + activeHookStatus: AgentStatusEntry | undefined + ): boolean => { + if ( + detectAgentStatusFromTitle(title) === 'working' || + !isFreshNonDoneAgentStatus(activeHookStatus) + ) { + return false + } + const explicitTitleAgentType = resolveCommittedTitleAgentType(title) + const activeHookAgentForTitle = resolveCompatibleAgentTypeForOwner( + activeHookStatus?.agentType, + explicitTitleAgentType + ) + const titleNamesDifferentKnownAgent = + explicitTitleAgentType && + activeHookStatus?.agentType && + activeHookStatus.agentType !== 'unknown' && + activeHookAgentForTitle !== explicitTitleAgentType + return !titleNamesDifferentKnownAgent + } + session.pendingSuppressedTitleSideEffects = null + session.clearSuppressedTitleSideEffects = (): void => { + session.pendingSuppressedTitleSideEffects = null + } + session.applyAgentCompletionSideEffects = ( + title: string, + agentType: AgentType | undefined + ): void => { + const settings = useAppStore.getState().settings + if ( + (agentType === 'claude' || isClaudeAgent(title)) && + (settings === null || settings.promptCacheTimerEnabled) + ) { + session.deps.setCacheTimerStartedAt(session.cacheKey, Date.now()) + } + session.setFocusReportSuppressionForAgentCompletion(title, agentType) + session.queueAgentIdleTerminalModeReset() + } + session.preserveSuppressedTitleSideEffects = ( + title: string, + activeHookStatus: AgentStatusEntry + ): void => { + session.pendingSuppressedTitleSideEffects = { + title, + agentType: activeHookStatus.agentType + } + if (activeHookStatus.state === 'waiting' || activeHookStatus.state === 'blocked') { + session.suppressNativeWindowsIdleCodexFocusReports = false + session.queueAgentIdleTerminalModeReset() + } + } + session.handleAgentHookTerminalLifecycle = (payload: AgentCompletionStatusSnapshot): void => { + const pending = session.pendingSuppressedTitleSideEffects + if (!pending) { + return + } + const payloadAgentForPending = resolveCompatibleAgentTypeForOwner( + payload.agentType, + pending.agentType + ) + const belongsToPendingAgent = + !pending.agentType || + pending.agentType === 'unknown' || + !payload.agentType || + payload.agentType === 'unknown' || + payloadAgentForPending === pending.agentType + if (!belongsToPendingAgent || payload.state === 'working') { + session.clearSuppressedTitleSideEffects() + return + } + if (payload.state === 'done') { + session.applyAgentCompletionSideEffects(pending.title, payload.agentType ?? pending.agentType) + session.clearSuppressedTitleSideEffects() + return + } + if (payload.state === 'waiting' || payload.state === 'blocked') { + session.suppressNativeWindowsIdleCodexFocusReports = false + session.queueAgentIdleTerminalModeReset() + } + } + session.unregisterAgentHookTerminalLifecycle = registerAgentHookTerminalLifecycleHandler( + session.cacheKey, + session.handleAgentHookTerminalLifecycle + ) + session.hasFreshPaneAgentSurface = (): boolean => { + const entry = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + if (session.isFreshActivePaneAgentEntry(entry)) { + return true + } + const liveTitle = session.getLivePaneAgentTitle() + return detectAgentStatusFromTitle(liveTitle ?? '') !== null + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts new file mode 100644 index 00000000000..c6a25e0d461 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts @@ -0,0 +1,12 @@ +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyConnectionDeps } from '../pty-connection-types' + +// Why: connectPanePty is a single closure factory. The bag lets extracted +// installers share the same mutable bindings without changing call order. +export type ConnectPanePtySession = { + pane: ManagedPane + manager: PaneManager + deps: PtyConnectionDeps + // oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract + [key: string]: any +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts new file mode 100644 index 00000000000..c689b66f3ee --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -0,0 +1,177 @@ +import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import { TerminalKittyKeyboardModeTracker } from '../../../../../shared/terminal-kitty-keyboard-mode-tracker' +import type { PtyConnectionDeps } from '../pty-connection-types' +import { + captureTerminalPaneRecoveryGeneration, + registerTerminalPaneRecoveryInstance +} from '../terminal-pane-recovery' +import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../../shared/terminal-mode-reset-profiles' +import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { createTerminalStructuralReplayCoordinator } from '@/lib/pane-manager/terminal-structural-replay-coordinator' +import { makePaneKey } from '../../../../../shared/stable-pane-id' +import type { AgentType } from '../../../../../shared/agent-status-types' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' + +import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { exposeE2eTerminalPtyOutputDebug } from './e2e-terminal-pty-harness' +import type { PanePtyBinding } from './pane-pty-binding' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { + isAgentTaskCompleteNotificationEnabled, + isAgentTaskCompleteTrackingEnabled +} from './agent-task-complete-settings' + +import { installRunDeferredConnect } from './run-deferred-connect' + +import { installSleepingRecordAccess } from './sleeping-record-access' +import { installShellCommandInference } from './shell-command-inference' +import { installInterruptInputIntent } from './interrupt-input-intent' +import { installTerminalKeydownFit } from './terminal-keydown-fit' +import { installPtyExitHibernate } from './pty-exit-hibernate' +import { installTitleSpawnBell } from './title-spawn-bell' +import { installAgentTaskCompleteNotify } from './agent-task-complete-notify' +import { installDirectSshRetryStatus } from './direct-ssh-retry-status' +import { installPtyInputRecovery } from './pty-input-recovery' +import { installPtyInputForward } from './pty-input-forward' +import { installPtyResizeGeometry } from './pty-resize-geometry' +import { installSessionReconcileDispose } from './session-reconcile-dispose' + +/** + * Establishes a binding between a terminal pane and its corresponding PTY stream, + * managing input, output, title synchronization, and agent status tracking. + */ +export function connectPanePty( + pane: ManagedPane, + manager: PaneManager, + deps: PtyConnectionDeps +): PanePtyBinding { + const session = { pane, manager, deps } as ConnectPanePtySession + session.shouldRefreshForegroundSynchronously = (): boolean => + !session.manager.hasWebglRenderer(session.pane.id) + // Why: recovery ownership belongs to this xterm instance. A request that + // settles after remount must not remount its already-replaced successor. + session.terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(session.deps.tabId) + session.terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(session.deps.tabId) + session.mountFollowsTerminalPark = session.deps.mountFollowsTerminalPark + session.authoritativeReattachGeneration = 0 + exposeE2eTerminalPtyOutputDebug() + session.disposed = false + session.structuralReplayCoordinator = createTerminalStructuralReplayCoordinator( + session.pane.terminal + ) + session.connectFrame = null + session.connectFallbackTimer = null + session.startupGridSettleHandle = null + session.startupGridSettledForConnect = false + session.connectStarted = false + session.unregisterBacklogRecovery = null + session.unregisterDocumentVisibilityRecovery = null + session.cancelHiddenOutputSnapshotScrollRestore = (): void => {} + session.pendingHiddenSnapshotFit = null + session.pendingReattachFit = null + session.cancelFreshSpawnFollowReset = (): void => {} + session.cleanupHiddenOutputRestoreDeferredRetry = (): void => {} + session.cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} + session.cleanupHiddenOutputRestoreFloodRepaint = (): void => {} + session.resetRendererOrderedSeqForPtyExit = () => {} + session.cleanupStartupDraftPasteTimers = (): void => {} + session.unregisterE2ePtyDataInjection = (): void => {} + session.startupInjectTimer = null + session.agentTaskCompleteNotificationGraceTimer = null + session.agentTaskCompleteNotificationMaxTimer = null + session.agentTaskCompleteStatusUnsubscribe = null + session.agentTaskCompleteSettingsUnsubscribe = null + session.agentTaskCompleteNotificationGeneration = 0 + session.wasAgentTaskCompleteTrackingEnabled = isAgentTaskCompleteTrackingEnabled() + session.requiresFreshWorkingForAgentTaskCompleteNotification = + !session.wasAgentTaskCompleteTrackingEnabled + session.wasAgentTaskCompleteOsNotificationEnabled = isAgentTaskCompleteNotificationEnabled() + session.terminalBellNotificationTimer = null + session.pendingTerminalBellNotification = false + session.reattachIdleAgentCursorResetTimer = null + session.alternateScreenBackgroundRepaintTimer = null + session.shiftEnterReconfirmTimer = null + session.synchronizedForegroundOutputActive = false + // Why: carries up to one marker-length-1 of trailing bytes so a ConPTY-split DEC 2026 marker is still detected (#8754). + session.synchronizedForegroundMarkerTail = '' + // Why: tracks the keystroke proximity captured when the current synchronized + // foreground frame opened, so a split end marker that lands after the redraw + // window still drains on the fast path instead of the 1s coalesce fallback. + session.synchronizedForegroundFrameInteractive = false + session.suppressStructuralReplayPtyResize = false + // Why: hidden-delivery gate sync is wired up alongside the deferred PTY + // output plumbing inside the connect frame; lifecycle hooks (visibility + // flips, exit, dispose) run before/after it exists, so start with no-ops. + session.syncHiddenRendererPtyDelivery = () => {} + session.releaseHiddenRendererPtyDelivery = () => {} + session.handleRemoteOutputPauseChanged = () => {} + session.handleRendererOwnedAgentStatus = () => {} + session.remoteOutputGatedPtyId = null + session.remoteOutputFactConsumerPtyId = null + session.suppressViewportClaimTerminalResize = false + // Why: idle callbacks are registered before the deferred PTY output plumbing + // exists. Start with the shared scheduler, then switch to the PTY writer + // below so hidden-tab resets keep backlog-recovery callbacks and byte order. + session.idleAgentTerminalModeReset = RESET_TERMINAL_CURSOR_STYLE + session.suppressNativeWindowsIdleCodexFocusReports = false + session.setFocusReportSuppressionForAgentCompletion = ( + title: string | undefined, + agentType: AgentType | undefined + ): void => { + const titleAgentType = resolveCommittedTitleAgentType(title ?? '') + session.suppressNativeWindowsIdleCodexFocusReports = + agentType && agentType !== 'unknown' ? agentType === 'codex' : titleAgentType === 'codex' + } + session.queueAgentIdleTerminalModeReset = (): void => { + if (session.disposed) { + return + } + writeTerminalOutput(session.pane.terminal, session.idleAgentTerminalModeReset, { + foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + }) + } + // Why: passphrase-gate waits register a teardown here so dispose() can + // actively unsubscribe + resolve them. Without this, a pane disposed + // mid-wait leaks its zustand subscriber and the surrounding async IIFE + // forever, since the subscriber's `disposed` check only fires when the + // store next emits — which may never happen after disconnect. + session.waitTeardowns = [] + // Why: startup commands must only run once — in the pane they were + // targeted at. Capture `deps.startup` into a local and clear the field on + // the (already spread-copied) `deps` so nothing else inside this function + // can accidentally re-read it. The caller is responsible for clearing its + // own outer reference, since `deps` here is a shallow copy and our + // mutation does not propagate back. + session.paneStartup = session.deps.startup ?? null + session.deps.startup = undefined + + // Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay. + // Use the stable layout leaf UUID, not the renderer-local numeric pane id. + session.cacheKey = makePaneKey(session.deps.tabId, session.pane.leafId) + // Why: mirrors the kitty keyboard flags the pane's application negotiates. + // Fed only from application output (live PTY bytes + daemon replay + // payloads), never from renderer-generated resets, so it reflects what the + // application expects even after defensive renderer-side kitty wipes. + session.kittyKeyboardModes = (() => { + const existing = session.deps.paneKittyKeyboardModesRef.current.get(session.pane.id) + if (existing) { + return existing + } + const created = new TerminalKittyKeyboardModeTracker() + session.deps.paneKittyKeyboardModesRef.current.set(session.pane.id, created) + return created + })() + installSleepingRecordAccess(session) + installShellCommandInference(session) + installInterruptInputIntent(session) + installTerminalKeydownFit(session) + installPtyExitHibernate(session) + installTitleSpawnBell(session) + installAgentTaskCompleteNotify(session) + installDirectSshRetryStatus(session) + installPtyInputRecovery(session) + installPtyInputForward(session) + installPtyResizeGeometry(session) + installRunDeferredConnect(session) + return installSessionReconcileDispose(session) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cursor-agent-reattach-screen.ts b/src/renderer/src/components/terminal-pane/pty-connection/cursor-agent-reattach-screen.ts new file mode 100644 index 00000000000..525d20ea602 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/cursor-agent-reattach-screen.ts @@ -0,0 +1,89 @@ +import type { IBuffer } from '@xterm/xterm' +import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' +import { CSI_SEQUENCE_PATTERN } from '../../../../../shared/ansi-escape-sequences' + +export type TerminalWithFocusMode = { + textarea?: HTMLTextAreaElement | null + modes?: { + sendFocusMode?: boolean + } +} + +export type TerminalWithInspectableBuffer = { + cols: number + rows: number + buffer?: { + active?: IBuffer + } +} + +// Why: replay bytes can carry a dead run's screen in scrollback — or still +// painted in the viewport with a shell prompt below it — so once xterm has +// parsed the replay the confirmation needs both the cursor-agent screen shape +// AND the parked cursor. A dead screen leaves the shell cursor after its +// prompt; a live agent that needs the focus-in is by definition parked, and a +// live agent that is not parked only loses focus reporting the way the +// pre-fix reattach always did. Returns null when the buffer is not +// inspectable (e.g. test doubles). +export function parsedViewportShowsParkedCursorAgentScreen( + terminal: TerminalWithInspectableBuffer +): boolean | null { + const buffer = terminal.buffer?.active + if ( + !buffer || + typeof buffer.getLine !== 'function' || + typeof buffer.cursorX !== 'number' || + typeof buffer.cursorY !== 'number' + ) { + return null + } + return ( + resolveCursorAgentImeAnchor({ + buffer, + rows: terminal.rows, + cols: terminal.cols, + cursorX: buffer.cursorX, + cursorY: buffer.cursorY + }) !== null + ) +} + +export function terminalHasFocusReportingEnabled(terminal: TerminalWithFocusMode): boolean { + return terminal.modes?.sendFocusMode === true +} + +export function terminalOwnsDomFocus(terminal: TerminalWithFocusMode): boolean { + if (typeof document === 'undefined' || !terminal.textarea) { + return false + } + return document.activeElement === terminal.textarea +} + +export const CURSOR_AGENT_REATTACH_HEADER = 'Cursor Agent' +const CURSOR_AGENT_REATTACH_INPUT_MARKER = '→' +const CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS = 5000 +// Why bounded: reattach payloads reach multiple MB, but every replay puts the current screen last +// and only the header nearest the end matters. 256KB clears even a fully SGR-styled frame by ~2x, +// so the cut only ever drops stale scrollback — which would have been rejected anyway. +const CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS = 256 * 1024 + +export function hasCursorAgentReattachPayloadScreenSignal(data: string): boolean { + const tail = + data.length > CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS + ? data.slice(-CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS) + : data + // Why CSI only: OSC-carried titles must keep counting as a header occurrence, as they did when + // this stripped CSI by hand. + const normalized = tail.replace(CSI_SEQUENCE_PATTERN, '') + // Why: anchor on the LAST header occurrence — replay buffers keep scrollback, + // and an earlier finished run must not classify the current screen. + const headerIndex = normalized.lastIndexOf(CURSOR_AGENT_REATTACH_HEADER) + if (headerIndex === -1) { + return false + } + const screenTail = normalized.slice( + headerIndex + CURSOR_AGENT_REATTACH_HEADER.length, + headerIndex + CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS + ) + return screenTail.includes(`${CURSOR_AGENT_REATTACH_INPUT_MARKER} `) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts new file mode 100644 index 00000000000..192dd042a84 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -0,0 +1,217 @@ +import { useAppStore } from '@/store' +import type { PtyReplayDataMeta } from '../pty-transport' +import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../../../../shared/terminal-color-scheme-protocol' +import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { executeTerminalStartupCommandPaste } from '../terminal-startup-command-paste' +import { getTerminalPasteSshRemotePlatform } from '../terminal-paste-ssh-platform' +import { resolveTerminalPasteRuntime } from '../terminal-paste-runtime' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' + +import { shouldKeepHiddenStartupRendererQueriesLive } from './hidden-startup-renderer-query' +import { createForegroundImmediateBudget } from './foreground-output-budgets' +import type { FreshSpawnOptions, ColdRestoreAgentResumeStartup } from './fresh-spawn-types' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { bindCaptureTransportOutputCallbacks } from './transport-output-callbacks' + +import { bindReplayDataDrain } from './replay-data-drain' +import { bindStartFreshSpawn } from './fresh-spawn-start' + +import { bindFreshSpawnFollowReset } from './fresh-spawn-follow-reset' + +export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySession): void { + session.applyColdRestoreAgentResumeStartup = ( + startup: ColdRestoreAgentResumeStartup | null + ): boolean => { + if (!startup) { + return false + } + const state = useAppStore.getState() + state.registerAgentLaunchConfig(session.cacheKey, startup.launchConfig, { + agentType: startup.agent, + launchToken: startup.launchToken, + tabId: session.deps.tabId, + leafId: session.pane.leafId + }) + return true + } + session.clearSleepingRecordAfterColdRestoreSpawn = ( + startup: ColdRestoreAgentResumeStartup | null + ): void => { + if (startup && !startup.useLiveEntry && startup.sleepingRecordEntry) { + session.clearSleepingRecordProviderDuplicates( + useAppStore.getState(), + startup.sleepingRecordEntry + ) + } + } + session.mergeStartupEnvWithPaneIdentity = ( + env: Record | undefined + ): Record | undefined => + env + ? { + ...env, + ...session.paneIdentityEnv, + ...(env.ORCA_AGENT_LAUNCH_TOKEN + ? { ORCA_AGENT_LAUNCH_TOKEN: env.ORCA_AGENT_LAUNCH_TOKEN } + : {}) + } + : undefined + session.startFreshColdRestoreAgentResume = ( + startup: ColdRestoreAgentResumeStartup | null = session.buildColdRestoreAgentResumeStartup(), + options: FreshSpawnOptions = {} + ): Promise => { + session.applyColdRestoreAgentResumeStartup(startup) + return session.startFreshSpawn(startup, options) + } + // Why: the hibernation wake fires from noteVisibilityResume in the outer + // connection scope, long after this deferred-connect closure has run. + session.wakeHibernatedAgentPane = () => session.startFreshColdRestoreAgentResume() + const isStartupPasteTargetCurrent = (ptyId: string | null): boolean => + !session.disposed && + session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport && + session.transport.getPtyId() === ptyId + const runTerminalPasteStartupCommand = async (command: string): Promise => { + const ptyId = session.transport.getPtyId() + const result = await executeTerminalStartupCommandPaste({ + command, + pane: session.pane, + ptyId, + runtime: resolveTerminalPasteRuntime({ + platform: CLIENT_PLATFORM, + ptyId, + connectionId: session.connectionId, + remotePlatform: getTerminalPasteSshRemotePlatform(session.connectionId), + transport: session.transport, + isWindowsConpty: session.isNativeWindowsConpty + }), + transport: session.transport, + isTargetCurrent: isStartupPasteTargetCurrent + }) + if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { + return false + } + return session.transport.sendInput('\r') + } + session.schedulePendingStartupCommandDelivery = (): void => { + const startup = session.pendingStartupCommand + if (!startup) { + return + } + if (session.startupInjectTimer !== null) { + clearTimeout(session.startupInjectTimer) + } + session.startupInjectTimer = setTimeout(() => { + session.startupInjectTimer = null + void (async () => { + if (session.pendingStartupCommand !== startup || session.disposed) { + return + } + if (session.shouldDeliverStartupViaTerminalPaste) { + await waitForTerminalOutputParsed(session.pane.terminal) + } + if (session.pendingStartupCommand !== startup || session.disposed) { + return + } + const command = startup.command + const submitted = session.shouldDeliverStartupViaTerminalPaste + ? await runTerminalPasteStartupCommand(command) + : session.transport.sendInput(`${command}\r`) + if (submitted) { + session.armStartupDraftReadinessObservation() + } else { + session.releaseUnattemptedStartupDraftPasteDelivery() + } + session.pendingStartupCommand = null + })() + }, 50) + } + + session.freshSpawnFollowResetDisposables = [] + session.cancelFreshSpawnFollowReset = (): void => { + for (const disposable of session.freshSpawnFollowResetDisposables) { + disposable.dispose() + } + session.freshSpawnFollowResetDisposables = [] + } + bindFreshSpawnFollowReset(session) + bindStartFreshSpawn(session) + bindReplayDataDrain(session) + session.replayDataCallback = ( + data: string, + meta: PtyReplayDataMeta = {}, + streamGeneration = session.transportStreamGeneration + ): void => { + session.pendingReplayData = { + data, + clearBeforeReplay: meta.clearBeforeReplay !== false, + ptyId: session.transport.getPtyId(), + generation: (session.replayPayloadGeneration += 1), + streamGeneration, + ...(meta.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } : {}), + ...(meta.kittyKeyboardFlags !== undefined && meta.snapshotSeq !== undefined + ? { + kittyKeyboardFlags: meta.kittyKeyboardFlags, + snapshotSeq: meta.snapshotSeq + } + : {}) + } + session.scheduleReplayDataDrain() + } + + bindCaptureTransportOutputCallbacks(session) + session.setRestoredSnapshotBaseline = function ( + ptyId: string, + snapshot: { seq?: number; pendingDeliveryStartSeq?: number }, + paintsContent: boolean + ): void { + if (typeof snapshot.seq !== 'number') { + session.clearRestoredSnapshotBaseline() + return + } + // Why: arming drops the redelivery permanently, so the snapshot's painted + // content must be able to back the seq it claims; a blank image cannot (STA-5179). + if (snapshot.seq > 0 && !paintsContent) { + session.clearRestoredSnapshotBaseline() + return + } + const windowStartSeq = + typeof snapshot.pendingDeliveryStartSeq === 'number' + ? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq) + : null + if (windowStartSeq !== null && windowStartSeq >= snapshot.seq) { + // Why: main reported an empty undelivered backlog — no chunk at or + // below the snapshot seq can ever arrive again (delivery is once and + // in order) and a future pending-cap trim re-arms the out-of-band + // marker. Arming a baseline anyway would misread live chunks from a + // foreign seq domain (restarted counter / synthetic injection) as + // duplicates or trim gaps and silently drop genuinely-new output. + session.clearRestoredSnapshotBaseline() + return + } + session.restoredSnapshotBaselineSeq = snapshot.seq + session.restoredSnapshotBaselinePtyId = ptyId + session.restoredSnapshotExpectedStartSeq = snapshot.seq + session.restoredSnapshotDeliveryWindowStartSeq = windowStartSeq + } + + session.clearRestoredSnapshotBaseline = function (): void { + session.restoredSnapshotBaselineSeq = null + session.restoredSnapshotBaselinePtyId = null + session.restoredSnapshotExpectedStartSeq = null + session.restoredSnapshotDeliveryWindowStartSeq = null + } + session.foregroundImmediateBudget = createForegroundImmediateBudget() + session.foregroundRewriteChunkEndedWithCarriageReturn = false + session.foregroundRewriteCsiScanTail = '' + session.mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE + session.shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive( + session.paneStartup + ) + session.hiddenStartupRendererQueryPending = '' + session.hiddenRendererStateDirty = false + session.rendererOrderedPtyId = null + session.rendererOrderedSeq = null + session.rendererChannelSeqPtyId = null + session.rendererChannelSeq = null +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts new file mode 100644 index 00000000000..534bf88e41e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts @@ -0,0 +1,304 @@ +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { useAppStore } from '@/store' +import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host' +import { resolveSshPaneConnectGate } from '../ssh-pane-connect-gate' + +import { + isSshSessionExpiredError, + waitForUserInitiatedSshConnect, + waitForSshConnection +} from './ssh-session-connect' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { toProcessExitStartup } from './process-exit-startup' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +import { runDeferredSessionReattachChoice } from './deferred-session-reattach-choice' +import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery' + +export function runDeferredSessionAttach(session: ConnectPanePtySession): void { + // Why: trigger the deferred SSH connect per-tab (not per-target) so multiple tabs for one target reattach independently. + // Must run before session-id resolution: the SSH provider isn't registered until connect succeeds. + if (session.connectionId) { + const storeState = useAppStore.getState() + // Why: a removed SSH target (ghost workspace) would fail reattach with a spurious "file an issue" banner for an expected action, so skip it (runtime-owned targets exempt). + // A present map missing this id = target removed; an absent map = not yet hydrated (test stubs), so don't treat it as gone. + if ( + !isRuntimeOwnedSshTargetId(session.connectionId) && + storeState.sshTargetLabels instanceof Map && + !storeState.sshTargetLabels.has(session.connectionId) + ) { + return + } + const restoredLeafSessionId = + session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId + ? (session.deps.restoredPtyIdByLeafId[session.deps.restoredLeafId] ?? null) + : null + const deferredTabSessionId = storeState.deferredSshSessionIdsByTabId[session.deps.tabId] + const tabPtyId = storeState.tabsByWorktree[session.deps.worktreeId]?.find( + (t) => t.id === session.deps.tabId + )?.ptyId + const gate = resolveSshPaneConnectGate({ + connectionId: session.connectionId, + sshStatus: storeState.sshConnectionStates.get(session.connectionId)?.status, + isDeferredTarget: storeState.deferredSshReconnectTargets.includes(session.connectionId), + restoredLeafSessionId, + deferredTabSessionId, + tabPtyId, + hasLeafSessionMap: Boolean( + session.deps.restoredPtyIdByLeafId && + Object.keys(session.deps.restoredPtyIdByLeafId).length > 0 + ) + }) + const pendingSessionId = gate.pendingSessionId + const deferredSessionIsOnlyRetryBinding = Boolean( + pendingSessionId && + pendingSessionId === deferredTabSessionId && + restoredLeafSessionId == null && + tabPtyId !== pendingSessionId + ) + console.warn( + `[pty-connection] SSH tab=${session.deps.tabId} connectionId=${session.connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}` + ) + const legacyWorkerOwnsPane = session.isLegacyWorkerAutomaticResumeBlocked() + if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) { + // Paint main's parked model while SSH recovery continues off the render path. + session.prepaintParkedSshSnapshot(pendingSessionId) + void (async () => { + // Why: for a passphrase target with no cached credential, don't auto-fire ssh.connect — a prompt popping just from focusing a tab / Cmd+J would surprise the user. + // Wait for a user-initiated connect first; no-passphrase targets return false here and auto-connect as before. + let needsPrompt = false + try { + needsPrompt = await window.api.ssh.needsPassphrasePrompt({ + targetId: session.connectionId + }) + } catch (err) { + console.warn('[pty-connection] needsPassphrasePrompt probe failed:', err) + // Why: on probe failure fall through to auto-connect rather than stranding the tab — a stuck tab is worse than a surprising prompt. + } + if (session.disposed || !session.capturedDirectSshRetryLeaseMatches()) { + return + } + if (needsPrompt) { + const alreadyConnected = + useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status === + 'connected' + if (!alreadyConnected) { + // Wait for the user-driven connect (sidebar card control or terminal reconnect overlay → passphrase → ssh.connect) to complete. + // Why: resolve on terminal-failure statuses too ('auth-failed'/'error'/'reconnection-failed') so it can't hang forever if the user cancels or the connect fails. + const outcome = await waitForUserInitiatedSshConnect(session) + if (session.disposed || !session.capturedDirectSshRetryLeaseMatches()) { + return + } + if (outcome === 'cancelled') { + return + } + if (outcome === 'failed') { + session.reportError('SSH connection failed') + return + } + } + } + + // Why: wait for the shared SSH connection (multiple panes/tabs may need it) before PTY reattach, rather than returning early when it's in-flight. + const connectResult = await waitForSshConnection(session.connectionId) + if (session.disposed || !session.capturedDirectSshRetryLeaseMatches()) { + return + } + if (!connectResult.connected) { + session.reportError(`SSH connection failed: ${connectResult.error}`) + return + } + useAppStore.getState().removeDeferredSshReconnectTarget(session.connectionId) + if (session.disposed) { + return + } + if (pendingSessionId) { + if (session.isLegacyWorkerAutomaticResumeBlocked()) { + if (session.attachRetainedLegacyPty(pendingSessionId)) { + useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId) + scheduleRuntimeGraphSync() + } + return + } + console.warn( + `[pty-connection] Attempting reattach for tab=${session.deps.tabId} sessionId=${pendingSessionId}` + ) + // Why: consume redundant restore metadata before attach, but keep a sole deferred ID until the host gives a conclusive result. + if (!deferredSessionIsOnlyRetryBinding) { + useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId) + } + // Why: pre-signal SSH-deferred reattach too so the cooperation gate applies uniformly to remote sessions (Electron preserves the declare→connect order). + // See docs/mobile-prefer-renderer-scrollback.md. + const preSignalPromise = + session.runtimeEnvironmentId || isRemoteRuntimePtyId(pendingSessionId) + ? Promise.resolve(null) + : window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null) + const clearPreSignaledSerializer = async (): Promise => { + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + } + let expiredReattachError = false + const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup() + session.clearPaneMode2031State() + session.clearHiddenOutputRestoreState() + const outputCallbacks = session.captureTransportOutputCallbacks( + (message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + if (!session.isCapturedDirectSshReattachCurrent(pendingSessionId)) { + return + } + session.reportError(message) + }, + toProcessExitStartup(coldRestoreStartup ?? session.paneStartup) + ) + session.beginReattachLiveDataDeferral(outputCallbacks.generation) + session.transportConnectInFlightSince = Date.now() + const reattachPromise = session.transport.connect({ + url: '', + cols: session.cols, + rows: session.rows, + sessionId: pendingSessionId, + ...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}), + ...(coldRestoreStartup?.env + ? { env: session.mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) } + : {}), + ...(coldRestoreStartup?.launchConfig + ? { launchConfig: coldRestoreStartup.launchConfig } + : {}), + ...(coldRestoreStartup?.resumeProviderSession + ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } + : {}), + ...(coldRestoreStartup?.launchToken + ? { launchToken: coldRestoreStartup.launchToken } + : {}), + ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), + ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + ...(session.directSshRetryAttempt + ? { admitPtyId: session.claimCapturedDirectSshRetryPty } + : {}), + callbacks: outputCallbacks.callbacks + }) + void Promise.resolve(reattachPromise) + .catch(() => null) + .finally(() => { + session.transportConnectInFlightSince = null + }) + const trackedReattachPromise = Promise.resolve(reattachPromise) + .then(async (result) => { + if (outputCallbacks.generation !== session.transportStreamGeneration) { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + await clearPreSignaledSerializer() + return + } + console.warn( + `[pty-connection] Reattach result for tab=${session.deps.tabId}:`, + result + ? { + sessionExpired: (result as Record).sessionExpired, + replay: !!(result as Record).replay + } + : 'undefined' + ) + if (!result && expiredReattachError) { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + await clearPreSignaledSerializer() + if (session.disposed) { + return + } + if (session.rejectObsoleteDirectSshReattach(pendingSessionId)) { + return + } + useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId) + session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, pendingSessionId) + session.deps.clearTabPtyId(session.deps.tabId, pendingSessionId) + session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) + return + } + const accepted = await session.handleReattachResult( + result, + pendingSessionId, + coldRestoreStartup, + outputCallbacks.generation + ) + session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) + const sessionExpired = Boolean( + result && + typeof result === 'object' && + 'sessionExpired' in result && + result.sessionExpired + ) + if ( + deferredSessionIsOnlyRetryBinding && + (accepted || sessionExpired) && + session.isCapturedDirectSshReattachCurrent(pendingSessionId) + ) { + useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId) + } + const gen = await preSignalPromise + if (typeof gen === 'number') { + if (!accepted) { + await window.api.pty + .clearPendingPaneSerializer(session.cacheKey, gen) + .catch(() => {}) + } else if (!isRemoteRuntimePtyId(pendingSessionId)) { + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (session.transport.getPtyId() ?? pendingSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? session.settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(session.cacheKey, gen)) + } + } + }) + .catch(async (err) => { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + await clearPreSignaledSerializer() + console.warn(`[pty-connection] Reattach FAILED for tab=${session.deps.tabId}:`, err) + if ( + session.disposed || + outputCallbacks.generation !== session.transportStreamGeneration + ) { + return + } + if (session.rejectObsoleteDirectSshReattach(pendingSessionId)) { + return + } + if (isSshSessionExpiredError(err)) { + useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId) + session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, pendingSessionId) + session.deps.clearTabPtyId(session.deps.tabId, pendingSessionId) + session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) + return + } + session.reportError(err instanceof Error ? err.message : String(err)) + recoverUnverifiableDirectSshReattach(session, pendingSessionId) + }) + session.armDirectSshPaneRetryTimeout( + trackedReattachPromise, + session.directSshRetryAttempt + ) + } else { + session.startFreshColdRestoreAgentResume() + } + })() + return + } + } + + runDeferredSessionReattachChoice(session) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts new file mode 100644 index 00000000000..4d97ecf82c6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts @@ -0,0 +1,218 @@ +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { useAppStore } from '@/store' +import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' +import { getEagerPtyBufferHandle } from '../pty-dispatcher' + +import { pendingSpawnByPaneKey, recordPtyConnectDiagnostic } from './pty-connect-limits' +import { + isRemoteRuntimePtyId, + canRestorePairedParkedTerminal, + isSessionOwnedByWorktree +} from './paired-parked-terminal-restore' +import { startDeferredSessionReattach } from './deferred-session-reattach-connect' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function runDeferredSessionReattachChoice(session: ConnectPanePtySession): void { + // Why: re-read session IDs here rather than at connect scheduling — cleanup during the caller's one-frame gap could otherwise reattach a dead session. + const restoredPtyId = + session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId + ? (session.deps.restoredPtyIdByLeafId[session.deps.restoredLeafId] ?? null) + : null + const storeSnapshot = useAppStore.getState() + const existingPtyId = storeSnapshot.tabsByWorktree[session.deps.worktreeId]?.find( + (t) => t.id === session.deps.tabId + )?.ptyId + const hasSleepingAgentSession = Boolean(session.getSleepingRecordForPane(storeSnapshot)) + + // Why: the tab-level fallback must not steal a PTY a setup sibling already published while the main pane waited for split geometry. + const tabFallbackPtyId = + existingPtyId && + !Array.from(session.deps.paneTransportsRef.current.entries()).some( + ([candidatePaneId, candidateTransport]) => + candidatePaneId !== session.pane.id && candidateTransport.getPtyId() === existingPtyId + ) + ? existingPtyId + : null + + const restoredSessionId = restoredPtyId ?? null + const sleptRemoteRuntimeSessionId = + restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && hasSleepingAgentSession + ? restoredSessionId + : null + const detachedLivePtyId = + tabFallbackPtyId && !session.hadExistingPaneTransportAtConnect && !sleptRemoteRuntimeSessionId + ? restoredSessionId + ? restoredSessionId === tabFallbackPtyId + ? restoredSessionId + : null + : tabFallbackPtyId + : null + const detachedRemoteLeafPtyId = + restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && !hasSleepingAgentSession + ? restoredSessionId + : null + const candidateReattachSessionId = + restoredSessionId && restoredSessionId !== detachedLivePtyId + ? restoredSessionId + : detachedLivePtyId + const runtimeHostPtyWakeHint = + session.runtimeEnvironmentId && + candidateReattachSessionId && + !isRemoteRuntimePtyId(candidateReattachSessionId) + ? candidateReattachSessionId + : null + const sleptRemoteColdRestoreStartup = sleptRemoteRuntimeSessionId + ? session.buildColdRestoreAgentResumeStartup() + : null + if (sleptRemoteRuntimeSessionId) { + session.deps.syncPanePtyLayoutBinding(session.pane.id, null) + session.deps.clearTabPtyId(session.deps.tabId, sleptRemoteRuntimeSessionId) + } + const currentTabLivePtyIds = storeSnapshot.ptyIdsByTabId[session.deps.tabId] ?? [] + const candidateHasEagerBuffer = Boolean( + candidateReattachSessionId && + !isRemoteRuntimePtyId(candidateReattachSessionId) && + getEagerPtyBufferHandle(candidateReattachSessionId) + ) + // Why: a still-live locally-spawned PTY (e.g. a background automation agent) keeps an eager buffer until a pane adopts it. + // It must be adopted via attach()+replay — connect({ sessionId }) on its non-session ptyId would spawn a fresh shell and orphan the agent. + const eagerLivePtyId = + candidateReattachSessionId && + candidateHasEagerBuffer && + currentTabLivePtyIds.includes(candidateReattachSessionId) + ? candidateReattachSessionId + : null + // Why: after a daemon crash + cold restore, a stale session-to-tab mapping can make a tab hold a ptyId from another worktree. + // Restoring it would paint the wrong terminal content, so drop the reattach and spawn fresh. + const legacyAttachOnlyPtyId = session.isLegacyWorkerAutomaticResumeBlocked() + ? candidateReattachSessionId + : null + const pairedParkedReattachSessionId = + session.mountFollowsTerminalPark && + candidateReattachSessionId && + isRemoteRuntimePtyId(candidateReattachSessionId) && + canRestorePairedParkedTerminal(candidateReattachSessionId) + ? candidateReattachSessionId + : null + const deferredReattachSessionId = legacyAttachOnlyPtyId + ? null + : (runtimeHostPtyWakeHint ?? + pairedParkedReattachSessionId ?? + (candidateReattachSessionId && + !isRemoteRuntimePtyId(candidateReattachSessionId) && + !candidateHasEagerBuffer && + isSessionOwnedByWorktree(candidateReattachSessionId, session.deps.worktreeId) + ? candidateReattachSessionId + : null)) + recordPtyConnectDiagnostic( + `pane=${session.pane.id} tab=${session.deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedRemoteLeafPtyId ?? detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${session.hadExistingPaneTransportAtConnect} pendingKey=${session.pendingSpawnKey}` + ) + + if (deferredReattachSessionId) { + startDeferredSessionReattach(session, deferredReattachSessionId) + } else if ( + legacyAttachOnlyPtyId || + detachedRemoteLeafPtyId || + detachedLivePtyId || + eagerLivePtyId + ) { + // Why: mirrored web-leaf panes must attach to their exact remote PTY, not spawn a replacement host tab. + // eagerLivePtyId covers a still-live background PTY (e.g. an automation agent) with a live eager buffer to adopt. + const attachPtyId = + legacyAttachOnlyPtyId ?? detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId! + recordPtyConnectDiagnostic(`pane=${session.pane.id} -> ATTACH detached=${attachPtyId}`) + session.allowInitialIdleCacheSeed = false + if (legacyAttachOnlyPtyId) { + if (session.attachRetainedLegacyPty(legacyAttachOnlyPtyId) && session.connectionId) { + useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId) + } + } else { + // Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface. + // On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here. + try { + session.clearPaneMode2031State() + session.clearHiddenOutputRestoreState() + const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null) + session.transport.attach({ + existingPtyId: attachPtyId, + cols: session.cols, + rows: session.rows, + callbacks: outputCallbacks.callbacks + }) + const attachedPtyId = session.transport.getPtyId() ?? attachPtyId + session.bindActivePanePty(attachedPtyId, { + updateTabPtyId: 'if-missing', + sampleVisibleForegroundAgent: true + }) + if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { + session.registerPaneSerializerFor(attachedPtyId) + } + } catch (err) { + session.reportError(err instanceof Error ? err.message : String(err)) + session.deps.clearTabPtyId(session.deps.tabId, attachPtyId) + session.startFreshSpawn() + } + } + } else { + session.allowInitialIdleCacheSeed = false + const pendingSpawn = pendingSpawnByPaneKey.get(session.pendingSpawnKey) + if (pendingSpawn) { + recordPtyConnectDiagnostic(`pane=${session.pane.id} -> PENDING SPAWN`) + session.armDirectSshPaneRetryTimeout(pendingSpawn, session.directSshRetryAttempt) + void pendingSpawn + .then((spawnedPtyId) => { + if (session.disposed) { + return + } + if (session.transport.getPtyId()) { + return + } + if (!spawnedPtyId) { + // Why: React StrictMode can mount+spawn then immediately remount; if the first mount produced no PTY id, + // the remounted pane must issue its own spawn instead of attaching to a completed-but-empty promise (a dead surface). + if (!isWebTerminalSurfaceTabId(session.deps.tabId)) { + console.warn( + `Pending PTY spawn for tab ${session.deps.tabId} resolved without a PTY id, retrying fresh spawn` + ) + } + if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) { + session.startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined) + } else { + session.startFreshSpawn() + } + return + } + if (!session.canAdoptCapturedDirectSshRetryPty(spawnedPtyId)) { + return + } + session.clearPaneMode2031State() + session.clearHiddenOutputRestoreState() + const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null) + session.transport.attach({ + existingPtyId: spawnedPtyId, + cols: session.cols, + rows: session.rows, + callbacks: outputCallbacks.callbacks + }) + const attachedPtyId = session.transport.getPtyId() ?? spawnedPtyId + // Why: this reuses a PTY spawned by an earlier mount, so no later spawn event will bind this remounted pane's DOM/container. + session.bindActivePanePty(attachedPtyId, { + updateTabPtyId: 'if-missing', + sampleVisibleForegroundAgent: true + }) + }) + .catch((err) => { + session.reportError(err instanceof Error ? err.message : String(err)) + }) + } else { + recordPtyConnectDiagnostic(`pane=${session.pane.id} -> FRESH SPAWN`) + if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) { + session.startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined) + } else { + session.startFreshSpawn() + } + } + } + scheduleRuntimeGraphSync() +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts new file mode 100644 index 00000000000..90d8e9f3eed --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -0,0 +1,182 @@ +import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics' +import { recordPtyConnectDiagnostic } from './pty-connect-limits' +import { isSshSessionExpiredError } from './ssh-session-connect' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { toProcessExitStartup } from './process-exit-startup' +import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +const PANE_OWNER_UNVERIFIED_ERROR = 'terminal_pane_owner_unverified' + +export function startDeferredSessionReattach( + session: ConnectPanePtySession, + deferredReattachSessionId: string +): void { + session.allowInitialIdleCacheSeed = true + recordPtyConnectDiagnostic(`pane=${session.pane.id} -> REATTACH ${deferredReattachSessionId}`) + session.prepaintParkedSshSnapshot(deferredReattachSessionId) + + // Why: pre-signal (declare) before the reattach connect so the cooperation gate suppresses the daemon seed for this paneKey; Electron preserves IPC order. + // See docs/mobile-prefer-renderer-scrollback.md (Renderer-side prerequisite requirement #4). + const preSignalPromise = + session.runtimeEnvironmentId || isRemoteRuntimePtyId(deferredReattachSessionId) + ? Promise.resolve(null) + : window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null) + + let expiredReattachError = false + let paneOwnerUnverified = false + const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup() + const outputCallbacks = session.captureTransportOutputCallbacks( + (message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) { + paneOwnerUnverified = true + } + if (!session.isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) { + return + } + session.reportError(message) + }, + toProcessExitStartup(coldRestoreStartup ?? session.paneStartup) + ) + session.beginReattachLiveDataDeferral(outputCallbacks.generation) + session.transportConnectInFlightSince = Date.now() + const reattachPromise = session.transport.connect({ + url: '', + cols: session.cols, + rows: session.rows, + sessionId: deferredReattachSessionId, + ...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}), + ...(coldRestoreStartup?.env + ? { env: session.mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) } + : {}), + ...(coldRestoreStartup?.launchConfig ? { launchConfig: coldRestoreStartup.launchConfig } : {}), + ...(coldRestoreStartup?.resumeProviderSession + ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } + : {}), + ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), + ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), + ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + ...(session.directSshRetryAttempt + ? { admitPtyId: session.claimCapturedDirectSshRetryPty } + : {}), + callbacks: outputCallbacks.callbacks + }) + + void Promise.resolve(reattachPromise) + .catch(() => null) + .finally(() => { + session.transportConnectInFlightSince = null + }) + const trackedReattachPromise = Promise.resolve(reattachPromise) + .then(async (result) => { + if (outputCallbacks.generation !== session.transportStreamGeneration) { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + return + } + if (!result && paneOwnerUnverified) { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + return + } + if (!result && expiredReattachError) { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + if (session.disposed) { + return + } + if (session.rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { + return + } + session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId) + session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId) + session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) + return + } + const accepted = await session.handleReattachResult( + result, + deferredReattachSessionId, + coldRestoreStartup, + outputCallbacks.generation + ) + session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + if (!accepted) { + await window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } else if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (session.transport.getPtyId() ?? deferredReattachSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? session.settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(session.cacheKey, gen)) + } + } + }) + .catch(async (err) => { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + const message = err instanceof Error ? err.message : String(err) + if (outputCallbacks.generation !== session.transportStreamGeneration) { + return + } + if (session.rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { + return + } + if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) { + session.reportError(message) + return + } + warnTerminalLifecycleAnomaly('restored PTY reattach threw', { + tabId: session.deps.tabId, + worktreeId: session.deps.worktreeId, + leafId: session.deps.restoredLeafId ?? session.pane.leafId, + paneId: session.pane.id, + ptyId: deferredReattachSessionId, + reason: message + }) + if (session.connectionId && isSshSessionExpiredError(err)) { + session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId) + session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId) + session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) + return + } + session.reportError(message) + if (session.connectionId) { + recoverUnverifiableDirectSshReattach(session, deferredReattachSessionId) + return + } + session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId) + session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId) + session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) + }) + session.armDirectSshPaneRetryTimeout(trackedReattachPromise, session.directSshRetryAttempt) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts new file mode 100644 index 00000000000..03f3da2f158 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { requestTerminalPaneRecovery } from '../terminal-pane-recovery' +import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery' + +vi.mock('../terminal-pane-recovery', () => ({ + requestTerminalPaneRecovery: vi.fn() +})) + +describe('recoverUnverifiableDirectSshReattach', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('retries through the exact direct SSH lease when one exists', () => { + const attempt = { attemptId: 'attempt-1' } + const settleDirectSshPaneRetryAttempt = vi.fn() + + recoverUnverifiableDirectSshReattach( + { directSshRetryAttempt: attempt, settleDirectSshPaneRetryAttempt } as never, + 'ssh:target@@pty-1' + ) + + expect(settleDirectSshPaneRetryAttempt).toHaveBeenCalledExactlyOnceWith(attempt, 'failed') + expect(requestTerminalPaneRecovery).not.toHaveBeenCalled() + }) + + it('remounts over the preserved PTY when no retry lease exists', () => { + recoverUnverifiableDirectSshReattach( + { + directSshRetryAttempt: undefined, + deps: { tabId: 'tab-1' }, + terminalRecoveryGeneration: 2, + terminalRecoveryInstance: { id: 3 } + } as never, + 'ssh:target@@pty-1' + ) + + expect(requestTerminalPaneRecovery).toHaveBeenCalledExactlyOnceWith({ + tabId: 'tab-1', + ptyId: 'ssh:target@@pty-1', + reason: 'reattach-unverifiable', + terminalRecoveryGeneration: 2, + terminalRecoveryInstanceId: 3 + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts new file mode 100644 index 00000000000..f32896fc254 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts @@ -0,0 +1,19 @@ +import { requestTerminalPaneRecovery } from '../terminal-pane-recovery' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function recoverUnverifiableDirectSshReattach( + session: ConnectPanePtySession, + ptyId: string | null | undefined +): void { + if (session.directSshRetryAttempt) { + session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed') + return + } + void requestTerminalPaneRecovery({ + tabId: session.deps.tabId, + ptyId: ptyId ?? null, + reason: 'reattach-unverifiable', + terminalRecoveryGeneration: session.terminalRecoveryGeneration, + terminalRecoveryInstanceId: session.terminalRecoveryInstance.id + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-lease.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-lease.ts new file mode 100644 index 00000000000..cf183e24796 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-lease.ts @@ -0,0 +1,6 @@ +import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery' + +export type DirectSshRetryLease = Pick< + DirectSshPaneRetryAttempt, + 'attemptId' | 'authority' | 'tabGeneration' +> diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts new file mode 100644 index 00000000000..a0f72936c5d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts @@ -0,0 +1,305 @@ +import { reportWorkerTerminalUserInput } from '@/lib/worker-terminal-takeover-report' +import { useAppStore } from '@/store' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { + getCachedWindowsTerminalCapabilities, + hasCachedWindowsTerminalCapabilities +} from '@/lib/windows-terminal-capabilities' +import { requestTerminalWritePipelineProbe } from '@/lib/pane-manager/terminal-write-pipeline-health' +import { + RESET_KITTY_KEYBOARD_PROTOCOL, + RESET_TERMINAL_CURSOR_STYLE +} from '../../../../../shared/terminal-mode-reset-profiles' +import { subscribeToTerminalUserInput } from '../terminal-user-input-signal' +import { + isLocalNativeWindowsConpty, + resolveWindowsShellOverride +} from '@/lib/pane-manager/windows-pty-compatibility' +import { shouldSuppressCodexAutoApprovalStatus } from '../codex-auto-approval-notification-suppression' +import { createCommandCodeOutputStatusDetector } from '../../../../../shared/command-code-output-status' +import { readInFlightCommandCodeTurn } from '../parked-terminal-command-status' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' +import { + normalizeCompatibleAgentTitleForOwner, + resolveCompatibleAgentTypeForOwner +} from '../../../../../shared/agent-title-owner' +import { isMainTerminalSideEffectAuthorityForPty } from '../terminal-side-effect-facts-handler' +import { isRendererHiddenPtyDeliveryGateEnabled } from '../terminal-hidden-delivery-gate' +import { + markRendererOwnedAgentStatusWrite, + registerRendererOwnedAgentStatusPane +} from '../renderer-owned-agent-status-registry' + +import { DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS } from './pty-connect-limits' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { resolveLatestAgentDoneStartedAt } from './agent-done-started-at' +import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { DirectSshRetryLease } from './direct-ssh-retry-lease' + +/** Direct-SSH retry arming plus the host/Windows capability flags and input-activity tracking its status routing reads. */ +export function installDirectSshRetryStatus(session: ConnectPanePtySession): void { + session.armDirectSshPaneRetryTimeout = ( + promise: Promise, + attempt: DirectSshRetryLease | undefined + ): void => { + if (!attempt || session.disposed || session.directSshPaneRetryTimedPromises.has(promise)) { + return + } + session.directSshPaneRetryTimedPromises.add(promise) + const timer = setTimeout(() => { + session.directSshPaneRetrySettlementTimers.delete(timer) + if (session.directSshPaneRetrySettlementCancelled) { + return + } + session.settleDirectSshPaneRetryAttempt(attempt, 'timed-out') + }, DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS) + session.directSshPaneRetrySettlementTimers.add(timer) + void promise + .finally(() => { + session.directSshPaneRetrySettlementTimers.delete(timer) + clearTimeout(timer) + }) + .catch(() => {}) + } + session.shellOverride = session.tab?.shellOverride + // Why: a serve/remote-runtime pane has no SSH connectionId and a Linux cwd, so + // the native-Windows ConPTY heuristic misfires on a Windows client and wrongly + // enables ConPTY synchronized-output protection, which strips an agent's + // transient cursor-show (?25h) and leaves the cursor invisible. The execution + // host is the authoritative signal: only a 'local' host is a local native PTY. + session.executionHostId = session.terminalOwnerUnresolved + ? ('runtime:unresolved-owner' as const) + : getExecutionHostIdForWorktree(session.state, session.deps.worktreeId) + session.isNativeWindowsConpty = isLocalNativeWindowsConpty({ + userAgent: navigator.userAgent, + connectionId: session.connectionId, + cwd: session.deps.cwd, + // Why: main folds the global Windows shell into its spawn classification + // (pty.ts effectiveShellOverride); fold it here too so both sides treat + // a global-WSL default identically (terminal-query-authority.md ConPTY). + shellOverride: resolveWindowsShellOverride( + session.shellOverride, + session.state.settings?.terminalWindowsShell + ), + executionHostId: session.executionHostId + }) + if (session.isNativeWindowsConpty) { + // Why: Windows ConPTY agent turns can leave renderer keyboard modes armed + // after completion, corrupting plain input with encoded bytes. + session.idleAgentTerminalModeReset = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` + } + session.shouldApplyNativeWindowsRewriteRefresh = session.isNativeWindowsConpty + session.shouldApplyWindowsRendererUnicodeRefresh = CLIENT_PLATFORM === 'win32' + session.shouldProtectNativeWindowsSynchronizedOutput = session.isNativeWindowsConpty + session.unsubscribeWindowsDoneTerminalModeReset = null + if (session.isNativeWindowsConpty) { + const initialAgentStatus = session.state.agentStatusByPaneKey[session.cacheKey] + let lastAgentDoneStartedAt = resolveLatestAgentDoneStartedAt(initialAgentStatus) + if ( + !initialAgentStatus && + session.paneStartup?.telemetry?.launch_source === 'sidebar' && + session.paneStartup.telemetry.request_kind === 'resume' && + (session.paneStartup.launchAgent === 'codex' || + session.paneStartup.telemetry.agent_kind === 'codex') + ) { + // Why: history resumes open on a completed Codex composer without a done + // row, so arm the same Windows stale-focus guard until work starts again. + session.suppressNativeWindowsIdleCodexFocusReports = true + } + if (initialAgentStatus?.state === 'done') { + session.setFocusReportSuppressionForAgentCompletion(undefined, initialAgentStatus.agentType) + } + session.unsubscribeWindowsDoneTerminalModeReset = useAppStore.subscribe((nextState) => { + const nextAgentStatus = nextState.agentStatusByPaneKey[session.cacheKey] + const nextAgentStatusState = nextAgentStatus?.state + if (nextAgentStatusState === 'done') { + session.setFocusReportSuppressionForAgentCompletion(undefined, nextAgentStatus.agentType) + } else if (nextAgentStatusState) { + session.suppressNativeWindowsIdleCodexFocusReports = false + } + const nextAgentDoneStartedAt = resolveLatestAgentDoneStartedAt(nextAgentStatus) + // Why: a NEW completed turn — same-state `done` pings keep stateStartedAt, so they no-op. + if ( + nextAgentDoneStartedAt !== undefined && + nextAgentDoneStartedAt !== lastAgentDoneStartedAt + ) { + session.queueAgentIdleTerminalModeReset() + } + lastAgentDoneStartedAt = nextAgentDoneStartedAt + }) + } + + session.localWindowsTerminalCapabilities = hasCachedWindowsTerminalCapabilities() + ? getCachedWindowsTerminalCapabilities() + : null + session.projectRuntime = + !session.tab?.forceHostRuntime && !session.connectionId && session.runtimeEnvironmentId === null + ? getLocalProjectExecutionRuntimeContext(session.state, session.deps.worktreeId, undefined, { + wslAvailable: session.localWindowsTerminalCapabilities?.wslAvailable, + availableWslDistros: session.localWindowsTerminalCapabilities?.wslDistros ?? null + }) + : undefined + session.shouldOwnAgentStatusInRenderer = session.runtimeEnvironmentId !== null + // Why: the host also mirrors agent status for this pane through tabs. + // Claiming here (decided once at transport creation, like the side-effect + // authority below) lets the mirror keep this renderer's byte-derived status + // instead of overwriting/deleting it on every republication. + session.releaseRendererOwnedAgentStatusPane = + session.runtimeEnvironmentId !== null + ? registerRendererOwnedAgentStatusPane(session.cacheKey, session.runtimeEnvironmentId) + : null + session.handleRendererOwnedAgentStatus = (payload): void => { + if ( + shouldSuppressCodexAutoApprovalStatus(payload, { + paneKey: session.cacheKey, + tabId: session.deps.tabId, + ...(session.launchToken ? { launchToken: session.launchToken } : {}) + }) + ) { + return + } + const currentState = useAppStore.getState() + const routing = session.resolveCurrentAgentStatusRouting() + if (!routing) { + return + } + const title = currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + const authoritativePaneAgent = session.getAuthoritativePaneAgent() + const agentType = resolveCompatibleAgentTypeForOwner(payload.agentType, authoritativePaneAgent) + const statusPayload = agentType === payload.agentType ? payload : { ...payload, agentType } + const observedStatusPayload = { + ...statusPayload, + observation: rendererAgentStatusObservations.observe(session.cacheKey, { + origin: 'osc', + observedAt: Date.now(), + kind: 'snapshot' + }) + } + const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title) + const statusTitle = resolvedStatusTitle + ? normalizeCompatibleAgentTitleForOwner( + resolvedStatusTitle, + agentType ?? authoritativePaneAgent + ) + : resolvedStatusTitle + // Why: proves the claim — only a pane that really produced byte-derived + // status may fence the host mirror out of its store key. + markRendererOwnedAgentStatusWrite(session.cacheKey) + if (session.launchToken) { + currentState.setAgentStatus( + session.cacheKey, + observedStatusPayload, + statusTitle, + undefined, + routing, + { + launchToken: session.launchToken + } + ) + } else { + currentState.setAgentStatus( + session.cacheKey, + observedStatusPayload, + statusTitle, + undefined, + routing + ) + } + if (payload.state === 'working' && session.syncAgentTaskCompleteTrackingEnabled()) { + session.requiresFreshWorkingForAgentTaskCompleteNotification = false + } + const storedStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + const notificationPayload = + typeof storedStatus?.stateStartedAt === 'number' + ? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt } + : statusPayload + // Why: hook lifecycle owns deferred side effects even when alerts are disabled. + session.agentCompletionCoordinator.observeHookStatus(notificationPayload) + if (payload.state === 'working' && session.pendingTerminalBellNotification) { + session.scheduleTerminalBellNotification() + } + } + // Why: when main holds side-effect authority for this PTY's bytes, the + // transport must NOT register title/bell/agent byte parsers — the + // pty:sideEffect fact consumer below is the single policy consumer. + // Decided once at transport creation so a fact never has two consumers. + session.mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ + settings: session.state.settings, + runtimeEnvironmentId: session.runtimeEnvironmentId + }) + // Why: Phase-4 hidden-delivery gate — only meaningful under main authority + // (renderer byte parsers need bytes otherwise). Decided once at pane + // creation: it picks which path records a mode-2031 subscription (main's + // fact vs the byte scan), which must have exactly one owner. + session.hiddenDeliveryGateActive = + session.mainSideEffectAuthority && + isRendererHiddenPtyDeliveryGateEnabled(session.state.settings) + // Why: structural per-PTY gate predicate (authority on + gate on + bytes + // transit local main, which implies snapshot-backed). Shared by the hidden + // mark sync and mode-2031 subscription recording so the recorder can never + // disagree with what main may drop — and never depends on the racy hidden + // mark (a fact can outrun the pty:data task that sets it). + session.isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string => + session.hiddenDeliveryGateActive && Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) + // Why (byte-parser mode only): with main authority the Command Code scrape + // runs in main's per-PTY tracker and arrives as command-code facts; running + // the byte detector too would double-drive the seed/settle policy above. + session.commandCodeOutputStatusDetector = session.mainSideEffectAuthority + ? null + : createCommandCodeOutputStatusDetector({ + startupCommand: session.paneStartup?.command, + // Why the seed: a reveal remount recreates this detector long past the banner + // (and with no startup command); a turn parked mid-flight must still arm the + // scrape so its return to the idle composer completes the row. + inFlightTurn: readInFlightCommandCodeTurn(session.cacheKey), + onWorking: session.seedCommandCodeOutputWorkingStatus, + onDone: session.scheduleCommandCodeOutputDoneStatus + }) + session.shouldDeliverStartupViaTerminalPaste = session.paneStartup?.delivery === 'terminal-paste' + session.shouldUseProviderSshStartupDelivery = + Boolean(session.connectionId) && !session.shouldDeliverStartupViaTerminalPaste + session.hadExistingPaneTransportAtConnect = session.deps.paneTransportsRef.current.size > 0 + session.lastTerminalInputAt = Number.NEGATIVE_INFINITY + session.lastInteractiveRedrawInputAt = Number.NEGATIVE_INFINITY + session.hasReceivedPtyOutput = false + session.deferredReattachLiveData = null + session.reattachLiveDataDeferralDepth = 0 + session.deferredReattachLiveDataOwners = new Map() + session.transportStreamGeneration = 0 + session.markTerminalInputSent = (): void => { + session.lastTerminalInputAt = performance.now() + session.markInteractiveRedrawInput() + } + session.markInteractiveRedrawInput = (): void => { + session.lastInteractiveRedrawInputAt = performance.now() + // Why: input must probe a wedged xterm even when the PTY produces no renderer output. + requestTerminalWritePipelineProbe(session.pane.terminal) + } + session.recordTerminalInputForHibernation = (): void => { + useAppStore.getState().recordTerminalInput(session.cacheKey) + } + // Why: onData mixes real user input with xterm's parser auto-replies (focus + // reports, DA/DSR/CPR responses). Recording those replies as activity makes + // the hibernation planner treat a pane hidden after its agent finished as + // "input after done" forever. The core user-input signal fires only for real + // input, so hibernation activity records from it; onData recording remains + // solely as the fallback when the internal API is unavailable. + session.recordRealUserTerminalInput = (): void => { + session.recordTerminalInputForHibernation() + // Takeover must never fire from the onData fallback below: it mixes in auto-replies. + reportWorkerTerminalUserInput(session.cacheKey, session.runtimeEnvironmentId) + } + session.userInputActivityDisposable = subscribeToTerminalUserInput( + session.pane.terminal, + session.recordRealUserTerminalInput + ) + session.recordTerminalInputForHibernationFallback = (): void => { + if (session.userInputActivityDisposable === null) { + session.recordTerminalInputForHibernation() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/e2e-terminal-pty-harness.ts b/src/renderer/src/components/terminal-pane/pty-connection/e2e-terminal-pty-harness.ts new file mode 100644 index 00000000000..8939a63b6c1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/e2e-terminal-pty-harness.ts @@ -0,0 +1,148 @@ +import { e2eConfig } from '@/lib/e2e-config' +import type { PtyBufferSnapshot } from '../pty-transport' +import type { PtyDataMeta } from '../pty-dispatcher' + +type E2eTerminalPtyDataInjectionApi = { + inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean + keys: () => string[] +} + +type E2eTerminalPtyDataInjectionWindow = Window & { + __terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi + __terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi +} + +const e2eTerminalPtyDataInjectors = new Map void>() + +type E2eTerminalHiddenSnapshotOverrideApi = { + setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void + resolve: (ptyId: string) => void + clear: (ptyId: string) => void +} + +type E2eTerminalHiddenSnapshotOverride = { + promise: Promise + resolve: () => void +} + +const e2eTerminalHiddenSnapshotOverrides = new Map() + +// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes +// either never reach the renderer (delivery gate) or ride the background +// scheduler queue. +type E2eTerminalPtyOutputDebugSnapshot = { + hiddenRendererSkipCount: number + hiddenRendererSkippedChars: number +} + +type E2eTerminalPtyOutputDebugApi = { + reset: () => void + snapshot: () => E2eTerminalPtyOutputDebugSnapshot +} + +type E2eTerminalPtyOutputDebugWindow = Window & { + __terminalPtyOutputDebug?: E2eTerminalPtyOutputDebugApi +} + +const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = { + hiddenRendererSkipCount: 0, + hiddenRendererSkippedChars: 0 +} + +export function resetE2eTerminalPtyOutputDebug(): void { + e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount = 0 + e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars = 0 +} + +export function exposeE2eTerminalPtyOutputDebug(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + const target = window as E2eTerminalPtyOutputDebugWindow + target.__terminalPtyOutputDebug ??= { + reset: resetE2eTerminalPtyOutputDebug, + snapshot: () => ({ ...e2eTerminalPtyOutputDebugState }) + } +} + +export function recordHiddenRendererSkip(chars: number): void { + if (!e2eConfig.exposeStore) { + return + } + exposeE2eTerminalPtyOutputDebug() + e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount += 1 + e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars += chars +} + +export function exposeE2eTerminalPtyDataInjection(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + // Why: a real PTY can coalesce tiny TUI redraws before E2E sees them. This + // e2e-only seam lets tests replay the renderer-side data callback exactly. + const target = window as E2eTerminalPtyDataInjectionWindow + target.__terminalPtyDataInjection ??= { + inject: (paneKey, data, meta) => { + const inject = e2eTerminalPtyDataInjectors.get(paneKey) + if (!inject) { + return false + } + inject(data, meta) + return true + }, + keys: () => [...e2eTerminalPtyDataInjectors.keys()] + } + target.__terminalHiddenSnapshotOverride ??= { + setPending: (ptyId, snapshot) => { + let resolve = (): void => {} + const wait = new Promise((nextResolve) => { + resolve = nextResolve + }) + e2eTerminalHiddenSnapshotOverrides.set(ptyId, { + promise: wait.then(() => snapshot), + resolve + }) + }, + resolve: (ptyId) => { + e2eTerminalHiddenSnapshotOverrides.get(ptyId)?.resolve() + }, + clear: (ptyId) => { + e2eTerminalHiddenSnapshotOverrides.delete(ptyId) + } + } +} + +export function registerE2eTerminalPtyDataInjection( + paneKey: string, + inject: (data: string, meta?: PtyDataMeta) => void +): () => void { + if (!e2eConfig.exposeStore) { + return () => {} + } + exposeE2eTerminalPtyDataInjection() + e2eTerminalPtyDataInjectors.set(paneKey, inject) + return () => { + if (e2eTerminalPtyDataInjectors.get(paneKey) === inject) { + e2eTerminalPtyDataInjectors.delete(paneKey) + } + } +} + +export function readE2eHiddenSnapshotOverride( + ptyId: string +): Promise | null { + if (!e2eConfig.exposeStore) { + return null + } + const override = e2eTerminalHiddenSnapshotOverrides.get(ptyId) + if (!override) { + return null + } + // Why: visual E2E needs to hold a hidden restore snapshot in flight so a + // newer live TUI frame can race it deterministically. + return override.promise.finally(() => { + if (e2eTerminalHiddenSnapshotOverrides.get(ptyId) === override) { + e2eTerminalHiddenSnapshotOverrides.delete(ptyId) + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts new file mode 100644 index 00000000000..9b3708322f6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + FOREGROUND_BUDGET_WINDOW_MS, + consumeForegroundImmediateBudget, + createForegroundImmediateBudget +} from './foreground-output-budgets' + +afterEach(() => { + vi.restoreAllMocks() +}) + +function mockNow(values: number[]): void { + const now = vi.spyOn(performance, 'now') + for (const value of values) { + now.mockReturnValueOnce(value) + } +} + +describe('consumeForegroundImmediateBudget', () => { + it('rejects a write once the cap is spent within the window', () => { + const budget = createForegroundImmediateBudget() + mockNow([1000, 1100]) + expect(consumeForegroundImmediateBudget(budget, 10, 10)).toBe(true) + expect(consumeForegroundImmediateBudget(budget, 1, 10)).toBe(false) + }) + + it('rolls over at the exact window boundary', () => { + const budget = createForegroundImmediateBudget() + mockNow([1000, 1000 + FOREGROUND_BUDGET_WINDOW_MS]) + expect(consumeForegroundImmediateBudget(budget, 10, 10)).toBe(true) + expect(consumeForegroundImmediateBudget(budget, 10, 10)).toBe(true) + expect(budget.chars).toBe(10) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.ts new file mode 100644 index 00000000000..15ceb9d5ee7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.ts @@ -0,0 +1,39 @@ +export const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 +export const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024 +export const FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS = 150 +// Why: a submit repaint can take longer than one keystroke echo to fully +// arrive, so a synchronized frame that *began* this close to a keystroke stays +// latency-sensitive even when ConPTY splits its end marker past the redraw +// window — the keystroke is the "user is here, paint now" signal, not the +// late closing chunk. +export const FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS = 400 +// Why: OpenTUI can emit many tiny redraws that each look interactive but +// collectively starve timers unless foreground writes have a rolling budget. +export const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024 +export const FOREGROUND_BUDGET_WINDOW_MS = 500 +export const INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS = 32 * 1024 +export const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250 + +export type ForegroundImmediateBudget = { chars: number; windowStart: number } + +export function createForegroundImmediateBudget(): ForegroundImmediateBudget { + return { chars: 0, windowStart: 0 } +} + +/** Rolling window budget: false once `capChars` is spent, until the window rolls over. */ +export function consumeForegroundImmediateBudget( + budget: ForegroundImmediateBudget, + dataLength: number, + capChars: number +): boolean { + const now = performance.now() + if (now - budget.windowStart >= FOREGROUND_BUDGET_WINDOW_MS) { + budget.chars = 0 + budget.windowStart = now + } + if (budget.chars + dataLength > capChars) { + return false + } + budget.chars += dataLength + return true +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts new file mode 100644 index 00000000000..dff163f81d8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts @@ -0,0 +1,298 @@ +import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs' +import { redactPtyIdForDiagnostics } from '../../../../../shared/pty-delivery-diagnostics' +import { + terminalRewriteOutputRenderRefreshDecision, + terminalRewriteOutputPrefersRenderRefresh, + windowsEastAsianOutputPrefersRenderRefresh +} from '@/lib/pane-manager/terminal-complex-script' +import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles' +import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' +import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' +import { registerPtyModelRestoreNeededHandler } from '../pty-model-restore-channel' +import { + acquireHiddenRendererPtyDeliveryClaim, + declareRendererPtyDeliveryVisible +} from '../pty-renderer-delivery-claims' + +import { HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS } from './hidden-output-restore-limits' +import { + FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS, + FOREGROUND_INTERACTIVE_REDRAW_CHARS, + FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS, + FOREGROUND_IMMEDIATE_BUDGET_CHARS, + consumeForegroundImmediateBudget +} from './foreground-output-budgets' +import { + shouldWritePtyOutputForeground, + consumeInactiveForegroundImmediateBudget +} from './foreground-output-scan' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindForegroundOutputRefresh(session: ConnectPanePtySession): void { + // Extends the suppression window; every backpressure signal resets the timer so the deferred repaint fires once, SUPPRESS_MS after the last signal. + session.noteHiddenOutputRestoreFloodBackpressure = function (): void { + session.hiddenOutputRestoreFloodSuppressedUntil = + Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS + const ptyId = session.transport.getPtyId() + if (ptyId === null) { + return + } + session.clearHiddenOutputRestoreFloodRepaintTimer() + session.hiddenOutputRestoreFloodRepaintTimer = setTimeout(() => { + session.hiddenOutputRestoreFloodRepaintTimer = null + if (session.disposed || session.transport.getPtyId() !== ptyId) { + return + } + // Why one repaint: flood-dropped bytes leave a gap the live stream can't heal; once quiet, one snapshot restore repaints from main's authoritative buffer. + session.repaintAfterFloodWhenFollowingOutput(ptyId) + }, HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS) + } + + // Why: main reports dropped renderer-bound bytes out-of-band, routed per PTY by pty-model-restore-channel.ts. + function handleModelRestoreNeededMarker(): void { + if (session.disposed) { + return + } + recordTerminalFreezeBreadcrumb('restore-marker', { + id: redactPtyIdForDiagnostics(session.transport.getPtyId() ?? '') + }) + // Why: dropped bytes invalidate cross-chunk carry — a partial OSC-9999 prefix spanning the gap would corrupt the next live chunk. + session.transport.resetCrossChunkParserState?.() + // Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint. + if (session.isForegroundRestoreBackpressureContext()) { + session.noteHiddenOutputRestoreFloodBackpressure() + return + } + // Why the emulator too: it carries state across chunks exactly like the + // parser does. If the gap swallowed the `ESC[22m` closing a bold run, + // every cell written afterwards inherits it. This marker is the one point + // where "bytes were dropped" is known, so ground the pen here rather than + // relying on each recovery path to remember (STA-4042). + // Why after the backpressure return and not before: under flood these + // markers arrive continuously, and writing per marker would add work in + // exactly the case that guard exists to damp. The flood path repaints via + // session.buildMainModelSnapshotReplayWrites, which grounds the pen itself, so + // nothing is lost by skipping it here. + session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) + // Why: a marker during an in-flight restore means that snapshot may predate the drop, so a fresh one must follow; capture BEFORE the mark, which starts a restore synchronously on a visible pane. + const restoreWasInFlight = session.hiddenOutputRestoreInFlight !== null + session.markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + session.hiddenOutputRestoreFreshSnapshotNeeded = true + } + } + + function syncModelRestoreNeededSubscription(ptyId: string | null): void { + if (session.modelRestoreSubscribedPtyId === ptyId) { + return + } + session.unregisterModelRestoreNeeded?.() + session.unregisterModelRestoreNeeded = null + session.modelRestoreSubscribedPtyId = ptyId + // Why: markers exist only for PTYs whose bytes transit local main; + // remote-runtime transports are structurally unaffected. + if (!ptyId || isRemoteRuntimePtyId(ptyId)) { + return + } + session.unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler( + ptyId, + handleModelRestoreNeededMarker + ) + } + + session.handleRemoteOutputPauseChanged = (paused, supported): void => { + const ptyId = session.transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + return + } + if (!paused) { + const wasGated = session.remoteOutputGatedPtyId === ptyId + if (wasGated) { + session.remoteOutputGatedPtyId = null + if (!session.mainSideEffectAuthority) { + session.dropSideEffectFactConsumer() + } + } + if (wasGated && session.hiddenOutputRestorePtyId === ptyId) { + session.requestHiddenOutputRestoreIfNeeded() + } + return + } + if (session.remoteOutputGatedPtyId !== ptyId) { + session.remoteOutputGatedPtyId = ptyId + } + if (supported && session.remoteOutputFactConsumerPtyId !== ptyId) { + session.registerSideEffectFactConsumerForPty(ptyId, true) + session.remoteOutputFactConsumerPtyId = ptyId + } + session.markHiddenOutputRestoreNeeded() + } + + session.syncHiddenRendererPtyDelivery = (): void => { + const ptyId = session.transport.getPtyId() + syncModelRestoreNeededSubscription(ptyId) + if (session.remoteOutputGatedPtyId !== null && session.remoteOutputGatedPtyId !== ptyId) { + session.remoteOutputGatedPtyId = null + if (!session.mainSideEffectAuthority) { + session.dropSideEffectFactConsumer() + } + } + if (isRemoteRuntimePtyId(ptyId) && session.canUseHiddenOutputSnapshot(ptyId)) { + session.transport.setOutputPaused?.( + !session.disposed && !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + ) + return + } + if (session.hiddenDeliverySyncedPtyId !== null && session.hiddenDeliverySyncedPtyId !== ptyId) { + session.releaseHiddenDeliveryClaim?.() + session.releaseHiddenDeliveryClaim = null + session.hiddenDeliverySyncedPtyId = null + } + if ( + !session.isHiddenDeliveryGateManagedPty(ptyId) || + !session.canUseHiddenOutputSnapshot(ptyId) + ) { + return + } + const shouldHide = + !session.disposed && !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + const isFirstSyncForPty = session.hiddenDeliverySyncedPtyId !== ptyId + session.hiddenDeliverySyncedPtyId = ptyId + if (shouldHide) { + if (!session.releaseHiddenDeliveryClaim) { + session.releaseHiddenDeliveryClaim = acquireHiddenRendererPtyDeliveryClaim(ptyId) + } + } else if (session.releaseHiddenDeliveryClaim) { + session.releaseHiddenDeliveryClaim() + session.releaseHiddenDeliveryClaim = null + } else if (isFirstSyncForPty) { + // Why: clear unconditionally on first sync — a stale main-side hidden bit can survive a renderer reload for daemon-backed PTYs that keep their session id. + declareRendererPtyDeliveryVisible(ptyId) + } + } + session.releaseHiddenRendererPtyDelivery = (): void => { + session.transport.setOutputPaused?.(false) + if (session.remoteOutputGatedPtyId !== null) { + session.remoteOutputGatedPtyId = null + if (!session.mainSideEffectAuthority) { + session.dropSideEffectFactConsumer() + } + } + session.releaseHiddenDeliveryClaim?.() + session.releaseHiddenDeliveryClaim = null + session.hiddenDeliverySyncedPtyId = null + session.unregisterModelRestoreNeeded?.() + session.unregisterModelRestoreNeeded = null + session.modelRestoreSubscribedPtyId = null + } + + session.beforeTerminalOutputWrite = function (data: string): void { + // Why: shaping must register before xterm parses the RTL bytes that need it. + ensureArabicShapingJoinerForText(session.pane.terminal, data) + recordTerminalOutput(session.pane.terminal) + } + + session.consumeForegroundImmediateBudget = function (dataLength: number): boolean { + return consumeForegroundImmediateBudget( + session.foregroundImmediateBudget, + dataLength, + FOREGROUND_IMMEDIATE_BUDGET_CHARS + ) + } + + session.isActiveSplitPane = function (): boolean { + if (!session.deps.isActiveRef.current) { + return false + } + const activePane = session.manager.getActivePane?.() ?? null + return activePane ? activePane.id === session.pane.id : true + } + + session.isLatencySensitiveForegroundOutput = function (data: string): boolean { + if (!session.isActiveSplitPane()) { + // Why: many visible split panes each emit tiny TUI frames; a shared budget keeps them live without letting aggregate xterm work starve typing in the active pane. + if (data.includes('\x1b[')) { + return false + } + return consumeInactiveForegroundImmediateBudget(data.length) + } + if (data.length <= FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS) { + return session.consumeForegroundImmediateBudget(data.length) + } + const recentInput = + performance.now() - session.lastInteractiveRedrawInputAt <= + FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS + if ( + recentInput && + data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS && + data.includes('\x1b[') + ) { + return session.consumeForegroundImmediateBudget(data.length) + } + return false + } + + session.containsNonAsciiOutput = function (data: string): boolean { + for (let index = 0; index < data.length; index++) { + if (data.charCodeAt(index) > 0x7f) { + return true + } + } + return false + } + + function containsWindowsRewriteControl(data: string): boolean { + return data.includes('\r') || terminalRewriteOutputPrefersRenderRefresh(data) + } + + session.foregroundRewriteOutputPrefersRenderRefresh = function (data: string): boolean { + const decision = terminalRewriteOutputRenderRefreshDecision(data, { + previousChunkEndsWithCarriageReturn: session.foregroundRewriteChunkEndedWithCarriageReturn, + previousRewriteCsiScanTail: session.foregroundRewriteCsiScanTail + }) + session.foregroundRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn + session.foregroundRewriteCsiScanTail = decision.nextRewriteCsiScanTail + return decision.prefersRenderRefresh + } + + session.shouldForceForegroundRenderRefresh = function (data: string): { + refresh: boolean + inPlaceRewrite: boolean + } { + const rewriteOutputPrefersRenderRefresh = + session.foregroundRewriteOutputPrefersRenderRefresh(data) + const recentInput = + performance.now() - session.lastInteractiveRedrawInputAt <= + FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS + if (session.foregroundRendererRiskOutputPrefersRenderRefresh(data)) { + return { + refresh: true, + inPlaceRewrite: rewriteOutputPrefersRenderRefresh + } + } + if (rewriteOutputPrefersRenderRefresh) { + // Why: xterm's buffer is right but in-place redraw cells stay stale in the renderer until a repaint (resize fixes it). + return { refresh: true, inPlaceRewrite: true } + } + if ( + windowsEastAsianOutputPrefersRenderRefresh(data, { + isWindowsClient: session.shouldApplyWindowsRendererUnicodeRefresh, + isNativeWindowsConpty: session.shouldApplyNativeWindowsRewriteRefresh, + hadRecentInput: recentInput, + maxInteractiveRedrawChars: FOREGROUND_INTERACTIVE_REDRAW_CHARS + }) + ) { + // Why: CJK/Korean from Microsoft Pinyin commits and native ConPTY output can leave stale wide-glyph cells in the Windows DOM renderer. + return { refresh: true, inPlaceRewrite: false } + } + return { + refresh: + session.shouldApplyNativeWindowsRewriteRefresh && + session.containsNonAsciiOutput(data) && + containsWindowsRewriteControl(data), + inPlaceRewrite: false + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts new file mode 100644 index 00000000000..8d8d53eebb6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts @@ -0,0 +1,124 @@ +import { isDocumentVisibilityProvenStale } from '../stale-document-visibility' +import { + INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS, + consumeForegroundImmediateBudget, + createForegroundImmediateBudget +} from './foreground-output-budgets' + +export const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 +export const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' +export const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' +export const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 +export const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' +export const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' +export const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' +export const TERMINAL_FOCUS_OUT_SEQUENCE = '\x1b[O' +export const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' +export const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 +export const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350 + +const inactiveForegroundImmediateBudget = createForegroundImmediateBudget() + +export function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { + if (!isPaneVisible) { + return false + } + if (typeof document === 'undefined') { + return true + } + // Why: Electron can keep visible panes mounted while the whole app is + // backgrounded. Treat hidden documents like background tabs so Chromium + // timer throttling cannot pin terminal writes on the renderer foreground path. + if (document.visibilityState === 'visible') { + return true + } + // Why: macOS occlusion tracking can wedge visibilityState at 'hidden' after + // display sleep; proven-stale means real user input contradicted it, so the + // hidden-delivery gate must not keep dropping a watched pane's bytes. + return isDocumentVisibilityProvenStale() +} + +export type SynchronizedForegroundScan = { + started: boolean + ended: boolean + active: boolean + markerTail: string +} + +// Why the carried tail: ConPTY can split \x1b[?2026l across chunks; scanning the raw +// chunk alone left the foreground DEC 2026 latch stuck open so every later chunk was +// held instead of coalesced, freezing the visible pane (#8754). Mirrors the hidden path. +export function scanSynchronizedForegroundOutput( + data: string, + markerTail: string, + wasActive: boolean +): SynchronizedForegroundScan { + const scanData = markerTail ? `${markerTail}${data}` : data + const currentChunkStartIndex = scanData.length - data.length + let active = wasActive + let started = false + let ended = false + let offset = 0 + + while (offset < scanData.length) { + const startIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE, offset) + const endIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE, offset) + if (startIndex === -1 && endIndex === -1) { + break + } + if (endIndex !== -1 && (startIndex === -1 || endIndex < startIndex)) { + active = false + if (endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length > currentChunkStartIndex) { + ended = true + } + offset = endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length + continue + } + active = true + if (startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length > currentChunkStartIndex) { + started = true + } + offset = startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length + } + + return { + started, + ended, + active, + // Why length-1: a full marker can never hide in the tail, so no marker is counted twice. + markerTail: scanData.slice(-SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS) + } +} + +export function containsCursorPositionSequence(data: string): boolean { + let offset = data.indexOf('\x1b[') + while (offset !== -1) { + let index = offset + 2 + while (index < data.length) { + const char = data[index] + if (char === 'G' || char === 'H' || char === 'f') { + return true + } + if ((char < '0' || char > '9') && char !== ';') { + break + } + index += 1 + } + offset = data.indexOf('\x1b[', offset + 2) + } + return false +} + +export function containsCursorRestore(data: string): boolean { + const hideIndex = data.indexOf(CURSOR_HIDE_SEQUENCE) + const showIndex = data.lastIndexOf(CURSOR_SHOW_SEQUENCE) + return hideIndex !== -1 && showIndex > hideIndex && containsCursorPositionSequence(data) +} + +export function consumeInactiveForegroundImmediateBudget(dataLength: number): boolean { + return consumeForegroundImmediateBudget( + inactiveForegroundImmediateBudget, + dataLength, + INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts new file mode 100644 index 00000000000..e29842991c4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts @@ -0,0 +1,173 @@ +import { replayIntoTerminal, replayIntoTerminalAsync } from '../replay-guard' +import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' +import { + buildPostReplayLiveAgentReattachReset, + POST_REPLAY_MODE_RESET, + POST_REPLAY_REATTACH_RESET, + POST_REPLAY_REATTACH_RESET_KEEP_MOUSE +} from '../../../../../shared/terminal-mode-reset-profiles' +import { buildFreshShellViewportBlankingSequence } from '../terminal-restored-viewport' +import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { + getTerminalScrollIntentKind, + markTerminalFollowOutput +} from '@/lib/pane-manager/terminal-scroll-intent' +import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild' + +import { TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS } from './foreground-output-scan' +import type { FreshSpawnOptions } from './fresh-spawn-types' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** Follow-output reset, replay writes, and fresh-shell viewport blanking. */ +export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void { + session.resetFreshSpawnFollowOutput = (): void => { + session.cancelFreshSpawnFollowReset() + markTerminalFollowOutput(session.pane.terminal) + let nativeFollowResetComplete = false + const tryResetNativeFollow = (): void => { + if ( + session.disposed || + getTerminalScrollIntentKind(session.pane.terminal) !== 'followOutput' || + deferTerminalGeometryMutationDuringRebuild( + session.pane.terminal, + 'fresh-spawn-follow-reset', + tryResetNativeFollow + ) + ) { + return + } + try { + session.pane.terminal.scrollToBottom() + nativeFollowResetComplete = true + session.cancelFreshSpawnFollowReset() + } catch (err) { + if (!(err instanceof TypeError && /dimensions/.test(err.message))) { + session.cancelFreshSpawnFollowReset() + throw err + } + } + } + tryResetNativeFollow() + if (!nativeFollowResetComplete) { + // Why: xterm's browser viewport can reject scrolling while its renderer + // is detached; the first render/resize is the earliest safe native retry. + session.freshSpawnFollowResetDisposables = [ + session.pane.terminal.onRender(tryResetNativeFollow), + session.pane.terminal.onResize(tryResetNativeFollow) + ] + } + } + + function trailingIncompleteCsiSequence(data: string): string { + const escapeIndex = data.lastIndexOf('\x1b') + if (escapeIndex === -1) { + return '' + } + const tail = data.slice(escapeIndex) + if (tail === '\x1b') { + return tail + } + if (!tail.startsWith('\x1b[')) { + return '' + } + for (let index = 2; index < tail.length; index++) { + const code = tail.charCodeAt(index) + if (code >= 0x40 && code <= 0x7e) { + return '' + } + } + // Why: keep the head — a suffix slice of an oversized parameter run drops + // the `\x1b[` the next scan needs to recognize the carried sequence. + return tail.slice(0, TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS) + } + + session.foregroundRendererRiskOutputPrefersRenderRefresh = function (data: string): boolean { + if (!data) { + return false + } + const scanData = session.foregroundRefreshRiskScanTail + ? `${session.foregroundRefreshRiskScanTail}${data}` + : data + const prefersRefresh = + (scanData.includes('\x1b[') || session.containsNonAsciiOutput(scanData)) && + terminalOutputPrefersRenderRefresh(scanData) + session.foregroundRefreshRiskScanTail = trailingIncompleteCsiSequence(scanData) + return prefersRefresh + } + + // The replay path uses the guard so xterm auto-replies to embedded query + // sequences don't leak into the shell. xterm.write() buffers internally + // regardless of DOM visibility and the guard stays engaged via the + // write-completion callback until xterm finishes parsing. + session.writeReplayData = (data: string): void => { + // Why: drain any queued background bytes BEFORE the replay paint, so the + // scheduler's deferred drain cannot land older bytes on top of the replay. + flushTerminalOutput(session.pane.terminal) + replayIntoTerminal(session.pane, session.deps.replayingPanesRef, data, { + breadcrumbIdentity: { + tabId: session.deps.tabId, + worktreeId: session.deps.worktreeId, + ptyId: session.transport.getPtyId() + }, + shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously, + shouldReleaseRenderPause: () => session.deps.isVisibleRef.current + }) + } + + session.writeReplayDataAsync = (data: string): Promise => { + // Why: WebGL must be rebuilt after xterm has parsed replay bytes, not + // merely after the write was queued. + flushTerminalOutput(session.pane.terminal) + return replayIntoTerminalAsync(session.pane, session.deps.replayingPanesRef, data, { + breadcrumbIdentity: { + tabId: session.deps.tabId, + worktreeId: session.deps.worktreeId, + ptyId: session.transport.getPtyId() + }, + shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously, + shouldReleaseRenderPause: () => session.deps.isVisibleRef.current + }) + } + + session.reattachReplayResetSequence = ( + payload: string, + ownerProcessEnded = false, + isAlternateScreen?: boolean + ): string => { + // Why a cold restore overrides the agent signal: liveness is read from the + // pane's status and title, both of which are persisted, so after a cold + // restore they describe the process that died. Preserving "its" modes arms + // mouse, focus and paste reporting against the fresh shell that replaces it, + // which then prints the reports as junk at the prompt (#12101). + if (ownerProcessEnded) { + return POST_REPLAY_MODE_RESET + } + if (session.shouldPreserveAgentReattachModes()) { + return buildPostReplayLiveAgentReattachReset(payload) + } + // Why: an alt-screen pane is a live TUI Orca just does not recognise as an agent, and the + // replay already re-armed its mouse modes — keep them instead of wiping them (#8291). + return (isAlternateScreen ?? session.kittyKeyboardModes.isAlternateScreen) + ? POST_REPLAY_REATTACH_RESET_KEEP_MOUSE + : POST_REPLAY_REATTACH_RESET + } + + session.consumeRestoredViewportBlankingMarker = (): boolean => { + return session.deps.restoredViewportBlankingPanesRef?.current.delete(session.pane.id) ?? false + } + + session.writeFreshShellViewportBlanking = (rows = session.pane.terminal.rows): void => { + session.writeReplayData(buildFreshShellViewportBlankingSequence(rows)) + } + + session.prepareFreshShellViewportForSpawn = (options: FreshSpawnOptions): void => { + const hadRestoredViewport = session.consumeRestoredViewportBlankingMarker() + if (!options.forceBlankRestoredViewport && !hadRestoredViewport) { + return + } + // Why: fresh Windows ConPTY output paints at screen coordinates, so + // restored rows must leave the viewport before the first prompt redraw. + session.writeFreshShellViewportBlanking() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts new file mode 100644 index 00000000000..3bbbf76f2d9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -0,0 +1,264 @@ +import { useAppStore } from '@/store' +import { hasPtySerializer } from '../pty-buffer-serializer' +import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' + +import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice' +import { pendingSpawnByPaneKey } from './pty-connect-limits' +import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { toProcessExitStartup } from './process-exit-startup' +import type { + PendingStartupCommand, + FreshSpawnOptions, + ColdRestoreAgentResumeStartup +} from './fresh-spawn-types' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindStartFreshSpawn(session: ConnectPanePtySession): void { + session.startFreshSpawn = ( + startupOverride?: PendingStartupCommand | null, + options: FreshSpawnOptions = {} + ): Promise => { + if (session.isLegacyWorkerAutomaticResumeBlocked()) { + return Promise.resolve(null) + } + if (useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]?.isDeleting) { + // Why: the worktree is being deleted; its PTYs were just killed for the + // filesystem teardown. A fresh shell must not spawn into a directory the + // removal is about to delete (main fences it anyway), and the pane is + // about to unmount — so skip the doomed respawn instead of racing it. + return Promise.resolve(null) + } + session.authoritativeReattachGeneration += 1 + session.clearPaneMode2031State() + session.clearHiddenOutputRestoreState() + // Why: a canceled old replay clear can preserve xterm's native + // isUserScrolling flag. A replacement shell must start in follow mode. + session.resetFreshSpawnFollowOutput() + // Why: a fresh spawn is a new process with kitty keyboard flags at + // zero. The exit-handler reset alone is not enough: a late exit from a + // replaced PTY takes the stale-transport early return and skips it, so + // a restart-in-place would leak the old TUI's flags into a fresh shell. + session.kittyKeyboardModes.reset() + session.prepareFreshShellViewportForSpawn(options) + const coldRestoreOverride = + startupOverride && 'launchConfig' in startupOverride + ? (startupOverride as ColdRestoreAgentResumeStartup) + : null + // Why: pre-signal the main process so its cooperation gate suppresses + // the daemon-snapshot seed for this paneKey. We issue declare and the + // spawn back-to-back without awaiting, because Electron's + // ipcRenderer→ipcMain channel preserves order across consecutive invoke + // calls from the same renderer. The cooperation gate at pty:spawn time + // sees pendingByPaneKey populated. Settle/clear later echoes the gen + // token captured here. See docs/mobile-prefer-renderer-scrollback.md. + const preSignalPromise = session.runtimeEnvironmentId + ? Promise.resolve(null) + : window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null) + + session.transportConnectInFlightSince = Date.now() + const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride + const outputCallbacks = session.captureTransportOutputCallbacks( + session.reportError, + toProcessExitStartup(coldRestoreOverride ?? effectiveStartup) + ) + const spawnedRaw = session.transport.connect({ + url: '', + cols: session.cols, + rows: session.rows, + ...(startupOverride?.command ? { command: startupOverride.command } : {}), + ...(session.connectionId && + startupOverride?.command && + !session.shouldDeliverStartupViaTerminalPaste + ? { commandDelivery: 'provider' as const } + : {}), + ...(session.connectionId && startupOverride?.command + ? { startupCommandDelivery: 'shell-ready' as const } + : {}), + ...(startupOverride?.env + ? { env: session.mergeStartupEnvWithPaneIdentity(startupOverride.env) } + : {}), + ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), + ...(coldRestoreOverride + ? { resumeProviderSession: coldRestoreOverride.resumeProviderSession } + : {}), + ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), + ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), + ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), + callbacks: outputCallbacks.callbacks + }) + + void Promise.resolve(spawnedRaw) + .catch(() => null) + .finally(() => { + session.transportConnectInFlightSince = null + }) + const trackedPromise: Promise = Promise.resolve(spawnedRaw) + .then(async (spawnedPtyId) => { + if (outputCallbacks.generation !== session.transportStreamGeneration) { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + return null + } + const resolvedPtyId = + spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId + ? spawnedPtyId.id + : typeof spawnedPtyId === 'string' + ? spawnedPtyId + : session.transport.getPtyId() + if (resolvedPtyId && !session.claimCapturedDirectSshRetryPty(resolvedPtyId)) { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + // Why: an outstanding declare keeps main's cooperation gate suppressing + // this paneKey's daemon-snapshot seed until something releases it. + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + return null + } + const connectResult = + spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId + ? spawnedPtyId + : null + if (connectResult?.isReattach) { + session.pendingStartupCommand = null + const accepted = await session.handleReattachResult( + connectResult, + null, + coldRestoreOverride, + outputCallbacks.generation + ) + session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) + const gen = await preSignalPromise + if (accepted && resolvedPtyId && typeof gen === 'number') { + void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {}) + } else if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + return accepted ? resolvedPtyId : null + } + if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { + session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { + ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), + ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}) + }) + } + if (resolvedPtyId) { + if ( + spawnedPtyId && + typeof spawnedPtyId === 'object' && + spawnedPtyId.startupCwdFallback?.kind === 'worktree' + ) { + writeTerminalOutput(session.pane.terminal, STARTUP_CWD_FALLBACK_NOTICE, { + foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + }) + } + if ( + spawnedPtyId && + typeof spawnedPtyId === 'object' && + spawnedPtyId.agentResumeUnavailable + ) { + // Why: main dropped the resume argv, so this pane is a NEW session — + // the plain restored banner would claim the old one came back. + session.showSessionRestoredBanner('resume-unavailable') + } else if (coldRestoreOverride?.hasSleepingRecord) { + session.showSessionRestoredBanner() + } + session.clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) + } else if ( + session.paneStartup?.launchConfig || + (startupOverride && 'launchConfig' in startupOverride) + ) { + // Why: delayed draft/follow-up delivery keys off this launch + // registry. If spawn produced no PTY, the launch is no longer a + // viable delivery target and must not wait for a future pane. + session.clearRegisteredStartupLaunchConfig() + } + if ( + resolvedPtyId && + spawnedPtyId && + typeof spawnedPtyId === 'object' && + 'id' in spawnedPtyId && + session.activePanePtyBinding !== resolvedPtyId && + session.transport.getPtyId() === resolvedPtyId + ) { + // Why: daemon createOrAttach can turn an apparent fresh spawn into + // a reattach; the transport skips onPtySpawn there to preserve recency. + session.bindActivePanePty(resolvedPtyId, { + updateTabPtyId: 'if-missing', + sampleVisibleForegroundAgent: true + }) + } + if (resolvedPtyId) { + session.reconcilePtySizeAfterSpawn(resolvedPtyId, session.cols, session.rows) + } + const gen = await preSignalPromise + // Why: a bound PTY owns the renderer serializer even when the declare was + // rejected; the gen token only settles or clears the pending declaration. + if (resolvedPtyId) { + if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) { + session.registerPaneSerializerFor(resolvedPtyId) + } + if (typeof gen === 'number') { + void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {}) + } + } else if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + if (resolvedPtyId && session.connectionId) { + if ( + session.shouldUseProviderSshStartupDelivery && + (startupOverride?.command || session.paneStartup?.command) + ) { + session.armStartupDraftReadinessObservation() + } + session.schedulePendingStartupCommandDelivery() + } + session.finishReattachLiveDataDeferral(Boolean(resolvedPtyId), outputCallbacks.generation) + return resolvedPtyId + }) + .catch(async () => { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + if ( + session.paneStartup?.launchConfig || + (startupOverride && 'launchConfig' in startupOverride) + ) { + session.clearRegisteredStartupLaunchConfig() + } + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + return null + }) + .finally(() => { + if (pendingSpawnByPaneKey.get(session.pendingSpawnKey) === trackedPromise) { + pendingSpawnByPaneKey.delete(session.pendingSpawnKey) + } + }) + session.armDirectSshPaneRetryTimeout(trackedPromise, session.directSshRetryAttempt) + void trackedPromise.then((spawnedPtyId) => { + if (spawnedPtyId) { + return + } + queueMicrotask(() => { + if ( + session.disposed || + session.transport.getPtyId() || + pendingSpawnByPaneKey.has(session.pendingSpawnKey) + ) { + return + } + session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed') + }) + }) + // Why: split panes in the same tab can spawn concurrently. Key by pane + // as well as tab so a remount cannot attach to a sibling setup pane's PTY. + pendingSpawnByPaneKey.set(session.pendingSpawnKey, trackedPromise) + return trackedPromise + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts new file mode 100644 index 00000000000..4bb06f8dbde --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts @@ -0,0 +1,25 @@ +import type { + AgentProviderSessionMetadata, + ResumableTuiAgent, + SleepingAgentSessionRecord +} from '../../../../../shared/agent-session-resume' +import type { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' + +export type PendingStartupCommand = { + command: string + env?: Record +} + +export type FreshSpawnOptions = { + forceBlankRestoredViewport?: boolean +} + +export type ColdRestoreAgentResumeStartup = PendingStartupCommand & { + agent: ResumableTuiAgent + resumeProviderSession: AgentProviderSessionMetadata + launchConfig: NonNullable>['launchConfig'] + launchToken: string + useLiveEntry: boolean + hasSleepingRecord: boolean + sleepingRecordEntry: { paneKey: string; record: SleepingAgentSessionRecord } | null +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts new file mode 100644 index 00000000000..3d08e5d495b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts @@ -0,0 +1,96 @@ +import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles' +import { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession): void { + session.abandonHiddenOutputRestoreAndDrainPendingForeground = function ( + expectedPtyId: string, + opts: { quiet?: boolean; rearmRemote?: boolean } = {} + ): void { + if ( + session.transport.getPtyId() !== expectedPtyId || + session.hiddenOutputRestorePtyId !== expectedPtyId + ) { + session.resetHiddenOutputRestoreIfPtyChanged() + return + } + const rearmedRemoteRestore = + opts.rearmRemote !== false && + !opts.quiet && + session.canUseHiddenOutputSnapshot(expectedPtyId) && + session.rearmRemoteHiddenOutputRestoreInsteadOfWarning(expectedPtyId, 'abandon-deadline') + const pendingChunks = session.hiddenOutputRestorePendingOverflow + ? [] + : session.hiddenOutputRestorePendingChunks.slice() + const hadPendingOverflow = session.hiddenOutputRestorePendingOverflow + const replayingSnapshot = session.hiddenOutputRestoreReplayingSnapshot + session.hiddenOutputRestoreReplayingSnapshot = null + session.hiddenOutputRestoreGeneration += 1 + if ( + session.hiddenOutputSnapshotScrollRestore?.valid && + session.hiddenOutputSnapshotScrollRestore.ptyId === expectedPtyId + ) { + // Why: flood abandonment stops recovery bookkeeping, but its already-queued replay must keep the rebuild bracket and final pin. + session.hiddenOutputSnapshotScrollRestore.generation = session.hiddenOutputRestoreGeneration + } + session.hiddenOutputRestoreInFlight = null + session.hiddenOutputRestoreNeeded = false + session.hiddenOutputRestorePtyId = null + session.hiddenOutputRestorePendingChunks = [] + session.hiddenOutputRestorePendingChars = 0 + session.hiddenOutputRestorePendingOverflow = false + session.hiddenOutputRestoreFreshSnapshotNeeded = false + session.hiddenOutputRestoreRetryDeferred = false + session.hiddenOutputRestoreScheduled = false + session.hiddenStartupRendererQueryPending = '' + session.hiddenRendererStateDirty = false + cancelScheduledHiddenOutputRestore(session.pane.terminal) + session.clearHiddenOutputRestoreDeferredRetryTimer() + session.clearHiddenOutputRestoreForegroundDeadlineTimer() + session.hiddenOutputRestoreDeferredRetryAttempts = 0 + + // Why quiet: flood cuts abandon deliberately and repaint post-flood, so the "restore unavailable" warning would be noise the repaint wipes. + if (!opts.quiet && !rearmedRemoteRestore) { + // Why: this abandon declares the bytes unrecoverable, so a repaint armed by earlier live + // backpressure must not outlive it — it would re-open recovery and banner a second time. + session.clearHiddenOutputRestoreFloodRepaintTimer() + session.writeRestoreUnavailableWarning() + } + // Why an else: the branch above already grounds the gap inside + // session.writeRestoreUnavailableWarning, and the remote re-arm grounds it in + // session.rearmRemoteHiddenOutputRestoreInsteadOfWarning. Only the quiet + // flood-abandon reaches neither, and it still drains chunks below. + else if (!rearmedRemoteRestore) { + session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) + } + if (hadPendingOverflow) { + return + } + const replayedSeq = typeof replayingSnapshot?.seq === 'number' ? replayingSnapshot.seq : null + let pendingData = '' + for (const chunk of pendingChunks) { + const sliced = + replayedSeq === null ? chunk.data : session.getChunkDataAfterSnapshot(chunk, replayedSeq) + pendingData += sliced ?? chunk.data + } + if (replayingSnapshot && replayedSeq !== null) { + session.setRestoredSnapshotBaseline( + expectedPtyId, + replayingSnapshot, + replayingSnapshot.paintsContent === true + ) + for (const chunk of pendingChunks) { + if (typeof chunk.seq === 'number' && session.restoredSnapshotExpectedStartSeq !== null) { + session.restoredSnapshotExpectedStartSeq = Math.max( + session.restoredSnapshotExpectedStartSeq, + chunk.seq + ) + } + } + } + if (pendingData) { + session.writePtyOutputToXterm(pendingData, true) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts new file mode 100644 index 00000000000..dd65ea50c12 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts @@ -0,0 +1,132 @@ +import type { PtyDataMeta } from '../pty-dispatcher' + +import { HIDDEN_OUTPUT_RESTORE_PENDING_CHARS } from './hidden-output-restore-limits' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { PendingHiddenOutputRestoreChunk } from './pending-hidden-output-restore-chunk' + +export function bindHiddenOutputRestoreChunk(session: ConnectPanePtySession): void { + session.queueLiveChunkDuringRestore = function (data: string, meta?: PtyDataMeta): void { + if (!data) { + return + } + const ptyId = session.transport.getPtyId() + if (!session.canUseHiddenOutputSnapshot(ptyId)) { + return + } + if (session.hiddenOutputRestorePtyId !== null && session.hiddenOutputRestorePtyId !== ptyId) { + session.clearHiddenOutputRestoreState() + } + session.hiddenOutputRestorePtyId = ptyId + session.hiddenOutputRestoreNeeded = true + if (session.hiddenOutputRestorePendingOverflow) { + // Why: the overflow latch discards everything queued at the next drain, so queueing more only grows the discard; salvage queries, drop content. + session.salvageRendererQueriesFromDiscardedRestoreData(data) + session.armHiddenOutputRestoreForegroundDeadline() + return + } + if ( + session.hiddenOutputRestorePendingChars + data.length > + HIDDEN_OUTPUT_RESTORE_PENDING_CHARS + ) { + const discardedChunks = session.hiddenOutputRestorePendingChunks + session.hiddenOutputRestorePendingChunks = [] + session.hiddenOutputRestorePendingChars = 0 + session.hiddenOutputRestorePendingOverflow = true + for (const chunk of discardedChunks) { + session.salvageRendererQueriesFromDiscardedRestoreData(chunk.data) + } + session.salvageRendererQueriesFromDiscardedRestoreData(data) + session.armHiddenOutputRestoreForegroundDeadline() + return + } + const pending: PendingHiddenOutputRestoreChunk = { data } + if (typeof meta?.seq === 'number') { + pending.seq = meta.seq + } + if (typeof meta?.rawLength === 'number') { + pending.rawLength = meta.rawLength + } + session.hiddenOutputRestorePendingChunks.push(pending) + session.hiddenOutputRestorePendingChars += data.length + session.armHiddenOutputRestoreForegroundDeadline() + } + + session.getChunkDataAfterSnapshot = function ( + chunk: PendingHiddenOutputRestoreChunk, + snapshotSeq: number | undefined + ): string | null { + if (typeof snapshotSeq !== 'number' || typeof chunk.seq !== 'number') { + return chunk.data + } + const rawLength = chunk.rawLength ?? chunk.data.length + const startSeq = chunk.seq - rawLength + if (snapshotSeq >= chunk.seq) { + return '' + } + if (snapshotSeq <= startSeq) { + return chunk.data + } + const offset = snapshotSeq - startSeq + if (rawLength !== chunk.data.length) { + return null + } + return chunk.data.slice(offset) + } + + type RestoredSnapshotReconciliation = + | { action: 'write'; data: string; meta: PtyDataMeta | undefined } + | { action: 'drop-duplicate' } + | { action: 'force-fresh-restore' } + + // Why: same slicing as session.getChunkDataAfterSnapshot but for post-restore live chunks, which main's ACK backlog can still deliver at/before the snapshot seq (and can trim seq ranges silently). + session.reconcileChunkAgainstRestoredSnapshot = function ( + data: string, + meta: PtyDataMeta | undefined + ): RestoredSnapshotReconciliation { + if (session.restoredSnapshotBaselineSeq === null) { + return { action: 'write', data, meta } + } + if (session.transport.getPtyId() !== session.restoredSnapshotBaselinePtyId) { + session.clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + if (typeof meta?.seq !== 'number') { + // Why: seq-less chunks (no runtime metering) can't be reconciled; pass them through like session.getChunkDataAfterSnapshot. + return { action: 'write', data, meta } + } + if ( + session.restoredSnapshotDeliveryWindowStartSeq !== null && + meta.seq <= session.restoredSnapshotDeliveryWindowStartSeq + ) { + // Why: all still-deliverable bytes started after this seq and delivery is in-order, so this can't be a backlog dup — it's a new seq domain; retire the baseline and write. + session.clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + const rawLength = meta.rawLength ?? data.length + const startSeq = meta.seq - rawLength + const expectedStartSeq = session.restoredSnapshotExpectedStartSeq + session.restoredSnapshotExpectedStartSeq = Math.max(expectedStartSeq ?? meta.seq, meta.seq) + if (expectedStartSeq !== null && startSeq > expectedStartSeq) { + // Why: the chunk starts past the continuity point — bytes between were dropped (pending-cap trim); only a fresh snapshot heals the gap. + return { action: 'force-fresh-restore' } + } + if (meta.seq <= session.restoredSnapshotBaselineSeq) { + return { action: 'drop-duplicate' } + } + if (startSeq >= session.restoredSnapshotBaselineSeq) { + return { action: 'write', data, meta } + } + if (rawLength !== data.length) { + // Why: renderer-only OSC stripping makes raw seq offsets unmappable onto cleaned text; refetch instead of risking duplicate output. + return { action: 'force-fresh-restore' } + } + const sliced = data.slice(session.restoredSnapshotBaselineSeq - startSeq) + return { + action: 'write', + data: sliced, + // Why: keep seq metadata consistent with the sliced payload so a later queue drain slices against accurate offsets. + meta: { ...meta, rawLength: sliced.length } + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts new file mode 100644 index 00000000000..81e26731783 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts @@ -0,0 +1,158 @@ +import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs' +import { redactPtyIdForDiagnostics } from '../../../../../shared/pty-delivery-diagnostics' +import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles' +import { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler' + +import { + HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS, + HIDDEN_OUTPUT_RESTORE_REMOTE_REARM_MAX +} from './hidden-output-restore-limits' +import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindHiddenOutputRestoreDrain(session: ConnectPanePtySession): void { + // 'drained' = painted all queued bytes; 'overflow' = queue blew its cap (stream outran fetch+replay); 'refetch' = offsets unmappable, need a fresher snapshot. + session.drainPendingLiveChunksAfterSnapshot = function ( + snapshotSeq: number | undefined + ): 'drained' | 'overflow' | 'refetch' { + if (session.hiddenOutputRestorePendingOverflow) { + session.hiddenOutputRestorePendingOverflow = false + discardPendingLiveChunksSalvagingQueries() + return 'overflow' + } + while (session.hiddenOutputRestorePendingChunks.length > 0) { + const chunks = session.hiddenOutputRestorePendingChunks + session.hiddenOutputRestorePendingChunks = [] + session.hiddenOutputRestorePendingChars = 0 + for (const [index, chunk] of chunks.entries()) { + const data = session.getChunkDataAfterSnapshot(chunk, snapshotSeq) + if (data === null) { + // Why: renderer-only OSC stripping makes raw seq offsets unmappable onto cleaned text; refetch instead of risking duplicate output. + for (const discarded of chunks.slice(index)) { + session.salvageRendererQueriesFromDiscardedRestoreData(discarded.data) + } + discardPendingLiveChunksSalvagingQueries() + return 'refetch' + } + // Why: advance the continuity point so reconciliation neither re-drops drained chunks as duplicates nor misreads the next live chunk as a gap. + if (typeof chunk.seq === 'number' && session.restoredSnapshotExpectedStartSeq !== null) { + session.restoredSnapshotExpectedStartSeq = Math.max( + session.restoredSnapshotExpectedStartSeq, + chunk.seq + ) + } + if (data) { + session.writePtyOutputToXterm(data, true) + session.recordRendererOrderedSeq(chunk) + } + } + if (session.hiddenOutputRestorePendingOverflow) { + session.hiddenOutputRestorePendingOverflow = false + discardPendingLiveChunksSalvagingQueries() + return 'overflow' + } + } + return 'drained' + } + + function discardPendingLiveChunksSalvagingQueries(): void { + const discarded = session.hiddenOutputRestorePendingChunks + session.hiddenOutputRestorePendingChunks = [] + session.hiddenOutputRestorePendingChars = 0 + for (const chunk of discarded) { + session.salvageRendererQueriesFromDiscardedRestoreData(chunk.data) + } + } + + session.clearPendingLiveChunksDuringRestore = function (): void { + session.hiddenOutputRestorePendingChunks = [] + session.hiddenOutputRestorePendingChars = 0 + session.hiddenOutputRestorePendingOverflow = false + session.hiddenOutputRestoreFreshSnapshotNeeded = false + session.hiddenOutputRestoreRetryDeferred = false + session.hiddenOutputRestoreScheduled = false + cancelScheduledHiddenOutputRestore(session.pane.terminal) + session.clearHiddenOutputRestoreDeferredRetryTimer() + session.clearHiddenOutputRestoreForegroundDeadlineTimer() + session.hiddenOutputRestoreDeferredRetryAttempts = 0 + } + + session.clearHiddenOutputRestoreDeferredRetryTimer = function (): void { + if (session.hiddenOutputRestoreDeferredRetryTimer === null) { + return + } + clearTimeout(session.hiddenOutputRestoreDeferredRetryTimer) + session.hiddenOutputRestoreDeferredRetryTimer = null + } + session.cleanupHiddenOutputRestoreDeferredRetry = + session.clearHiddenOutputRestoreDeferredRetryTimer + + session.clearHiddenOutputRestoreForegroundDeadlineTimer = function (): void { + if (session.hiddenOutputRestoreForegroundDeadlineTimer === null) { + return + } + clearTimeout(session.hiddenOutputRestoreForegroundDeadlineTimer) + session.hiddenOutputRestoreForegroundDeadlineTimer = null + } + session.cleanupHiddenOutputRestoreForegroundDeadline = + session.clearHiddenOutputRestoreForegroundDeadlineTimer + + session.armHiddenOutputRestoreForegroundDeadline = function (): void { + if ( + session.disposed || + session.hiddenOutputRestoreForegroundDeadlineTimer !== null || + !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) || + (isRemoteRuntimePtyId(session.hiddenOutputRestorePtyId) && + session.hiddenOutputRestoreLegacyPtyId !== session.hiddenOutputRestorePtyId && + typeof session.transport.serializeBufferOutcome === 'function') || + (session.hiddenOutputRestorePendingChunks.length === 0 && + !session.hiddenOutputRestorePendingOverflow) + ) { + return + } + const ptyId = session.hiddenOutputRestorePtyId + if (ptyId === null || session.transport.getPtyId() !== ptyId) { + return + } + const deadlineGeneration = session.hiddenOutputRestoreGeneration + // Why: only foreground output blocked behind recovery gets a deadline; hidden-time restore work has no user impact. + session.hiddenOutputRestoreForegroundDeadlineTimer = setTimeout(() => { + session.hiddenOutputRestoreForegroundDeadlineTimer = null + if ( + session.disposed || + session.hiddenOutputRestoreGeneration !== deadlineGeneration || + session.hiddenOutputRestorePtyId !== ptyId || + !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + ) { + return + } + session.abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId) + }, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS) + } + + // Trades the loss banner for one bounded post-suppression repaint from the + // host's authoritative buffer. Ordered before the state reset so the repaint + // timer arms against the live ptyId (mirrors the flood-abandon call sites). + session.rearmRemoteHiddenOutputRestoreInsteadOfWarning = function ( + ptyId: string, + reason: string + ): boolean { + if ( + !isRemoteRuntimePtyId(ptyId) || + session.hiddenOutputRestoreRemoteAbandonCycles >= HIDDEN_OUTPUT_RESTORE_REMOTE_REARM_MAX + ) { + return false + } + session.hiddenOutputRestoreRemoteAbandonCycles += 1 + recordTerminalFreezeBreadcrumb('restore-abandon-rearm', { + id: redactPtyIdForDiagnostics(ptyId), + reason, + cycle: session.hiddenOutputRestoreRemoteAbandonCycles + }) + session.noteHiddenOutputRestoreFloodBackpressure() + session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) + return true + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts new file mode 100644 index 00000000000..80ae6078f20 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts @@ -0,0 +1,37 @@ +export const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 +export const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 +export const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 +export const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750 +// Why (rc.7.perf DSR-timeout feedback loop): under a foreground flood the +// restore pipeline is its own bottleneck — each synchronous snapshot replay +// starves ACK processing, main pins at the in-flight cap, drops at the +// pending cap, and every drop marker re-armed another restore until the +// flood ended. Backpressure evidence opens this suppression window: drop +// markers inside it must not re-arm restores; live bytes write through and +// ONE deferred repaint (when the window closes) heals the visual gap. +export const HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS = 2000 +// Backstop for the same loop: a single in-flight restore task may re-iterate +// (fresh-snapshot marks, unmappable slices) only this many times before it +// abandons and lets live bytes flow. +export const HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS = 3 +// Why: remote-runtime PTYs have no local main fallback — the host transport is +// the only recovery and legitimately answers null while it resyncs, trims a +// flooded snapshot, or waits out link RTT. Those nulls are not proof of loss, +// so an abandoned restore re-arms one quiet post-suppression repaint instead of +// claiming the bytes are gone. Five cycles (~2.15s each) outlast both the +// multiplexer resync window and the remote snapshot request timeout (10s); +// past that the host really is unreachable and the loss banner is honest. +export const HIDDEN_OUTPUT_RESTORE_REMOTE_REARM_MAX = 5 +// Why: the host declined seven separate times; each one cost it a real serialize attempt, so stop asking. +export const HIDDEN_OUTPUT_RESTORE_REMOTE_OUTCOME_MAX_ATTEMPTS = 7 +// Why separate and larger: these causes are decided before any frame leaves the +// client (resync gate, occupied request lane, detached stream), so the host +// declined nothing and charging them to its budget would banner a healthy pane — +// the exact elapsed-time guess this change removes. They cost zero host traffic, +// so the only job of this cap is termination. At the ~2s post-abandon re-arm +// cadence, 30 outlasts several full 10s resync watchdog cycles. +export const HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS = 30 +// Why: this is only shown if hidden renderer output was skipped and main-owned +// terminal state is unavailable, so the user has an explicit loss signal. +export const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = + '\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts new file mode 100644 index 00000000000..f245c9c16f2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts @@ -0,0 +1,320 @@ +import { useAppStore } from '@/store' +import { isTerminalWritePipelineCertifiedDead } from '@/lib/pane-manager/terminal-write-pipeline-health' +import { requestTerminalPaneRecovery } from '../terminal-pane-recovery' +import { registerStaleDocumentVisibilityRecovery } from '../stale-document-visibility' +import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics' +import { registerTerminalBacklogRecovery } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { + cancelScheduledHiddenOutputRestore, + scheduleHiddenOutputRestore +} from '../hidden-output-restore-scheduler' +import { resolveHiddenRestoreScrollbackRows } from '../terminal-hidden-restore-scrollback' + +import { + HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS, + HIDDEN_OUTPUT_RESTORE_REMOTE_OUTCOME_MAX_ATTEMPTS, + HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS +} from './hidden-output-restore-limits' +import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { HiddenOutputSnapshotResult } from './hidden-output-snapshot-serialize' + +export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession): void { + session.requestHiddenOutputRestoreIfNeeded = function (opts?: { + bypassScheduler?: boolean + }): boolean { + // Why: once the write pipeline is probe-certified dead a restore can never parse; recovery owns the pane and the remount gets a fresh xterm + restore. + if (isTerminalWritePipelineCertifiedDead(session.pane.terminal)) { + // Why the re-kick: certification's recovery request can be budget-declined or cancelled by a sibling remount; without this, a revealed dead pane keeps the stale frame forever. + if (!session.certifiedDeadRestoreRecoveryRequested && !session.disposed) { + session.certifiedDeadRestoreRecoveryRequested = true + const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null + void requestTerminalPaneRecovery({ + tabId: session.deps.tabId, + ptyId: session.transport.getPtyId() ?? storePtyId, + reason: 'restore-blocked', + terminalRecoveryGeneration: session.terminalRecoveryGeneration, + terminalRecoveryInstanceId: session.terminalRecoveryInstance.id + }) + } + return false + } + session.resetHiddenOutputRestoreIfPtyChanged() + const ptyId = session.hiddenOutputRestorePtyId ?? session.transport.getPtyId() + if ( + !session.hiddenOutputRestoreNeeded && + session.hiddenOutputRestorePendingChunks.length === 0 + ) { + return false + } + if (!session.canUseHiddenOutputSnapshot(ptyId)) { + return false + } + session.hiddenOutputRestorePtyId = ptyId + if (session.hiddenOutputRestoreInFlight) { + session.armHiddenOutputRestoreForegroundDeadline() + return true + } + if (!opts?.bypassScheduler) { + const priority = session.isActiveSplitPane() ? 'active' : 'inactive' + if (priority === 'inactive') { + if (!session.hiddenOutputRestoreScheduled) { + session.hiddenOutputRestoreScheduled = true + const scheduledPtyId = ptyId + const scheduledGeneration = session.hiddenOutputRestoreGeneration + // Why: resume can reveal many split panes at once; spread inactive replays across frames so xterm scrollback replay doesn't block return. + scheduleHiddenOutputRestore( + session.pane.terminal, + () => { + session.hiddenOutputRestoreScheduled = false + if ( + session.disposed || + session.hiddenOutputRestoreGeneration !== scheduledGeneration || + session.hiddenOutputRestorePtyId !== scheduledPtyId || + session.transport.getPtyId() !== scheduledPtyId || + !session.canUseHiddenOutputSnapshot(scheduledPtyId) || + (!session.hiddenOutputRestoreNeeded && + session.hiddenOutputRestorePendingChunks.length === 0) || + !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + ) { + return + } + session.requestHiddenOutputRestoreIfNeeded({ bypassScheduler: true }) + }, + priority + ) + } + return true + } + cancelScheduledHiddenOutputRestore(session.pane.terminal) + session.hiddenOutputRestoreScheduled = false + } + session.clearHiddenOutputRestoreDeferredRetryTimer() + session.hiddenOutputRestoreRetryDeferred = false + + session.hiddenOutputRestoreInFlight = (async () => { + // Backstop (rc.7.perf loop): bound how many snapshot fetch+replay rounds one task burns before yielding to the live stream. + let restoreIterations = 0 + while (!session.disposed) { + const currentPtyId = session.hiddenOutputRestorePtyId + if (currentPtyId === null) { + session.clearHiddenOutputRestoreState() + return + } + if (!session.canUseHiddenOutputSnapshot(currentPtyId)) { + if (session.hiddenOutputRestorePtyId === currentPtyId) { + session.clearHiddenOutputRestoreState() + } + // Remote-only path: the transport swapped PTYs mid-restore, which is a + // stream change, not proof the hidden bytes are unrecoverable. + if ( + !session.rearmRemoteHiddenOutputRestoreInsteadOfWarning( + currentPtyId, + 'restore-pty-swapped' + ) + ) { + session.writeRestoreUnavailableWarning() + } + return + } + if (session.transport.getPtyId() !== currentPtyId) { + if (session.hiddenOutputRestorePtyId === currentPtyId) { + session.clearHiddenOutputRestoreState() + } + return + } + const restoreGeneration = session.hiddenOutputRestoreGeneration + session.hiddenOutputRestoreNeeded = false + let snapshotResult: HiddenOutputSnapshotResult + try { + snapshotResult = await session.serializeHiddenOutputSnapshot(currentPtyId, { + scrollbackRows: resolveHiddenRestoreScrollbackRows( + session.pane.terminal.options.scrollback + ) + }) + } catch { + snapshotResult = + !isRemoteRuntimePtyId(currentPtyId) || + session.hiddenOutputRestoreLegacyPtyId === currentPtyId || + typeof session.transport.serializeBufferOutcome !== 'function' + ? { kind: 'unavailable' } + : // Why 'host': the only reject here is the request timeout — the frame went out and the host stayed silent. + { kind: 'retry-worthy', source: 'host' } + } + if (session.disposed) { + return + } + const restoreGenerationChanged = session.hiddenOutputRestoreGeneration !== restoreGeneration + const restorePtyChanged = + session.transport.getPtyId() !== currentPtyId || + session.hiddenOutputRestorePtyId !== currentPtyId + if (restoreGenerationChanged || restorePtyChanged) { + // Why: the snapshot belongs to the requested PTY; after reattach it's stale, and a stale generation may be an abandoned timeout superseded by a newer restore. + if (restorePtyChanged && session.hiddenOutputRestorePtyId === currentPtyId) { + session.clearHiddenOutputRestoreState() + } + return + } + if (snapshotResult.kind === 'retry-worthy') { + let budgetExhausted: boolean + if (snapshotResult.source === 'host') { + session.hiddenOutputRestoreRemoteOutcomeAttempts += 1 + budgetExhausted = + session.hiddenOutputRestoreRemoteOutcomeAttempts >= + HIDDEN_OUTPUT_RESTORE_REMOTE_OUTCOME_MAX_ATTEMPTS + } else { + session.hiddenOutputRestoreLocalGateAttempts += 1 + budgetExhausted = + session.hiddenOutputRestoreLocalGateAttempts >= + HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS + } + if (budgetExhausted) { + session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { + rearmRemote: false + }) + return + } + session.hiddenOutputRestoreNeeded = true + session.hiddenOutputRestoreFreshSnapshotNeeded = false + session.noteHiddenOutputRestoreFloodBackpressure() + session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) + return + } + if (snapshotResult.kind === 'permanently-unavailable') { + session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { + rearmRemote: false + }) + return + } + if (snapshotResult.kind === 'unknown-legacy-host') { + session.hiddenOutputRestoreLegacyPtyId = currentPtyId + session.armHiddenOutputRestoreForegroundDeadline() + } + if (snapshotResult.kind !== 'snapshot') { + session.hiddenOutputRestoreNeeded = true + session.hiddenOutputRestoreFreshSnapshotNeeded = false + session.hiddenOutputRestoreRetryDeferred = true + session.scheduleHiddenOutputRestoreDeferredRetry() + return + } + const snapshot = snapshotResult.snapshot + session.hiddenOutputRestoreDeferredRetryAttempts = 0 + session.hiddenOutputRestoreRemoteAbandonCycles = 0 + session.hiddenOutputRestoreRemoteOutcomeAttempts = 0 + session.hiddenOutputRestoreLocalGateAttempts = 0 + restoreIterations += 1 + await session.applyMainBufferSnapshot(snapshot) + if ( + session.disposed || + session.hiddenOutputRestoreGeneration !== restoreGeneration || + session.hiddenOutputRestorePtyId !== currentPtyId || + session.transport.getPtyId() !== currentPtyId + ) { + return + } + // Why: everything at/before snapshot.seq is now painted; chunks still draining from main's ACK backlog below it are duplicates to suppress. + session.setRestoredSnapshotBaseline( + currentPtyId, + snapshot, + restoredSnapshotPaintsPrintableContent(snapshot) + ) + session.hiddenOutputRestoreReplayingSnapshot = null + const needsFreshSnapshot = session.hiddenOutputRestoreFreshSnapshotNeeded + session.hiddenOutputRestoreFreshSnapshotNeeded = false + const drainOutcome = session.drainPendingLiveChunksAfterSnapshot(snapshot.seq) + if (drainOutcome === 'drained' && !needsFreshSnapshot) { + session.hiddenOutputRestoreNeeded = false + session.hiddenOutputRestorePtyId = null + session.clearHiddenOutputRestoreForegroundDeadlineTimer() + return + } + if (!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) { + // Why: hidden bytes arriving during the snapshot aren't in renderer memory; leave recovery pending for reveal, don't loop snapshots in a throttled tab. + session.hiddenOutputRestoreNeeded = true + return + } + if (drainOutcome === 'overflow') { + // Cut 1 (rc.7.perf loop): a FOREGROUND queue overflow means the stream outruns fetch+replay; re-fetching starves ACKs, so abandon and heal with one post-flood repaint. + session.noteHiddenOutputRestoreFloodBackpressure() + session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) + return + } + if (restoreIterations >= HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS) { + // Backstop: re-looping this many times means the stream is winning the race. + warnTerminalLifecycleAnomaly('hidden output restore hit its iteration cap', { + tabId: session.deps.tabId, + worktreeId: session.deps.worktreeId, + leafId: session.pane.leafId, + paneId: session.pane.id, + ptyId: currentPtyId, + reason: drainOutcome + }) + session.noteHiddenOutputRestoreFloodBackpressure() + session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) + return + } + session.hiddenOutputRestoreNeeded = true + } + })() + const hiddenOutputRestoreTask = session.hiddenOutputRestoreInFlight + let trackedHiddenOutputRestore: Promise + trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => { + if (session.hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) { + session.hiddenOutputRestoreInFlight = null + } + // Why: after dispose the task body exits immediately, so re-arming here + // resolves instantly and re-enters this handler — an unbounded promise + // chain that eats the heap. The pane is gone; there is nothing to restore. + if (session.disposed) { + return + } + if ( + session.hiddenOutputRestorePendingChunks.length > 0 || + session.hiddenOutputRestorePendingOverflow + ) { + session.hiddenOutputRestoreNeeded = true + session.armHiddenOutputRestoreForegroundDeadline() + } + if ( + !session.hiddenOutputRestoreRetryDeferred && + session.hiddenOutputRestoreNeeded && + shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + ) { + session.requestHiddenOutputRestoreIfNeeded() + } + }) + session.hiddenOutputRestoreInFlight = trackedHiddenOutputRestore + return true + } + + session.unregisterBacklogRecovery = registerTerminalBacklogRecovery(session.pane.terminal, () => { + // Why: clear the hidden-delivery bit BEFORE the restore snapshot request; bytes arriving in between are reconciled by the seq guard. + session.syncHiddenRendererPtyDelivery() + return session.requestHiddenOutputRestoreIfNeeded() + }) + if ( + typeof document !== 'undefined' && + typeof document.addEventListener === 'function' && + typeof document.removeEventListener === 'function' + ) { + const onDocumentVisibilityChange = (): void => { + // Why: document hide/show flips the foreground predicate with no pane lifecycle event; re-sync the hidden-delivery gate both ways. + session.syncHiddenRendererPtyDelivery() + if (shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) { + session.requestHiddenOutputRestoreIfNeeded() + } + } + document.addEventListener('visibilitychange', onDocumentVisibilityChange) + // Why: on stale macOS occlusion (visibilityState wedged 'hidden'), user input forces a resync — no visibilitychange fires, else the gate drops bytes forever. + const unregisterStaleVisibilityRecovery = registerStaleDocumentVisibilityRecovery( + onDocumentVisibilityChange + ) + session.unregisterDocumentVisibilityRecovery = () => { + document.removeEventListener('visibilitychange', onDocumentVisibilityChange) + unregisterStaleVisibilityRecovery() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts new file mode 100644 index 00000000000..8714adbd84b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -0,0 +1,234 @@ +import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' +import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' +import { waitForTerminalReplayWritesParsed } from '../replay-guard' +import { + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + POST_REPLAY_LIVE_SNAPSHOT_RESET, + RESET_AFTER_BYTE_GAP +} from '../../../../../shared/terminal-mode-reset-profiles' +import { + discardTerminalOutput, + writeTerminalOutput +} from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' +import { + buildMainModelSnapshotReplayWrites, + hasPositiveTerminalDimensions, + readProposedTerminalCols, + shouldSkipAltFrameForWidthMismatch +} from '../terminal-snapshot-replay-paint' + +import { HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING } from './hidden-output-restore-limits' +import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): void { + session.writeRestoreUnavailableWarning = function (): void { + // The reset must parse before both the warning and any foreground drain. + session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) + if (!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) { + return + } + writeTerminalOutput(session.pane.terminal, HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING, { + foreground: true, + beforeWrite: session.beforeTerminalOutputWrite + }) + } + + session.applyMainBufferSnapshot = async function (snapshot: { + data: string + frameRestoreAnsi?: string + cols: number + rows: number + seq?: number + pendingDeliveryStartSeq?: number + alternateScreen?: boolean + scrollbackAnsi?: string + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number + }): Promise { + const restorePtyId = session.transport.getPtyId() + const restoreGeneration = session.hiddenOutputRestoreGeneration + if (session.hiddenOutputSnapshotScrollRestore) { + session.cancelSnapshotScrollRestore() + } + const scrollRestore = { + ptyId: restorePtyId, + generation: restoreGeneration, + valid: true, + started: false + } + session.hiddenOutputSnapshotScrollRestore = scrollRestore + const colsBeforeReplay = session.pane.terminal.cols + const rowsBeforeReplay = session.pane.terminal.rows + const hasSnapshotDimensions = hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) + let skippedAltFrame = false + try { + await session.structuralReplayCoordinator.run( + async () => { + if ( + !scrollRestore.valid || + session.disposed || + session.transport.getPtyId() !== scrollRestore.ptyId || + session.hiddenOutputRestoreGeneration !== scrollRestore.generation + ) { + return + } + scrollRestore.started = true + if (typeof snapshot.seq === 'number') { + session.hiddenOutputRestoreReplayingSnapshot = { + seq: snapshot.seq, + paintsContent: restoredSnapshotPaintsPrintableContent(snapshot), + ...(typeof snapshot.pendingDeliveryStartSeq === 'number' + ? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq } + : {}) + } + } + discardTerminalOutput(session.pane.terminal) + if ( + hasSnapshotDimensions && + (session.pane.terminal.cols !== snapshot.cols || + session.pane.terminal.rows !== snapshot.rows) + ) { + // Why: xterm parses writes later; hold snapshot dimensions until the FIFO sentinel completes so serialized wraps stay exact. + session.suppressStructuralReplayPtyResize = true + try { + session.pane.terminal.resize(snapshot.cols, snapshot.rows) + } finally { + session.suppressStructuralReplayPtyResize = false + } + } + // Why here and not from the writes below: the mirror tracks what the + // APPLICATION negotiated, so it must see the snapshot's own bytes + // before adopting the flags main proved for this boundary. + session.applySnapshotKittyKeyboardModes( + `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`, + { + kittyKeyboardFlags: snapshot.kittyKeyboardFlags, + snapshotSeq: snapshot.seq + } + ) + // Why shared: the SSH reattach model paint inlines the same + // choreography (coordinator nesting would deadlock there); one + // builder keeps the alt-screen branches from drifting. + skippedAltFrame = + snapshot.alternateScreen === true && + snapshot.frameRestoreAnsi !== undefined && + shouldSkipAltFrameForWidthMismatch( + snapshot.cols, + readProposedTerminalCols(session.pane) + ) + // Why: an imageless success is not proof the pane is empty; normal replay clears screen and scrollback. + const snapshotCarriesNoImage = + snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi + if (snapshotCarriesNoImage) { + // Why still ground: a restore only runs because bytes were dropped, so + // the gap's pen outlives a snapshot that cannot repaint over it. The + // live-path constant, not the replay baseline — nothing repaints here, + // so the pane is handed back to whatever is still running. + session.writeReplayData(RESET_AFTER_BYTE_GAP) + } else { + for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { + skipAltFrame: skippedAltFrame, + paneOnAlternateScreen: session.isPaneOnAlternateScreen() + })) { + session.writeReplayData(replayChunk) + } + } + // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). + session.writeReplayData( + session.hasLiveAgentReattachStatusOrTitleSignal() + ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET + : POST_REPLAY_LIVE_SNAPSHOT_RESET + ) + if (snapshot.pendingEscapeTailAnsi) { + // Why last: snapshot taken mid-escape; re-arm as the FINAL replay write (any later ESC aborts it) so the live tail completes it, not render literally (Bug E / #7329). + session.writeReplayData(snapshot.pendingEscapeTailAnsi) + } + session.hiddenRendererStateDirty = false + session.recordRendererOrderedSeq(snapshot) + recordTerminalOutput(session.pane.terminal) + await waitForTerminalReplayWritesParsed(session.pane.terminal) + }, + { + shouldRestore: () => + scrollRestore.valid && + !session.disposed && + session.transport.getPtyId() === scrollRestore.ptyId && + session.hiddenOutputRestoreGeneration === scrollRestore.generation, + afterRestore: async () => { + const isCurrentRestore = (): boolean => + scrollRestore.valid && + !session.disposed && + session.transport.getPtyId() === scrollRestore.ptyId && + session.hiddenOutputRestoreGeneration === scrollRestore.generation + if (!isCurrentRestore()) { + return + } + const currentPtyId = session.transport.getPtyId() + if (!currentPtyId) { + return + } + if (getFitOverrideForPty(currentPtyId)) { + safeFit(session.pane) + if (skippedAltFrame && !isRemoteRuntimePtyId(currentPtyId)) { + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } + return + } + const fit = safeFitAndThen( + session.pane, + 'hidden-snapshot-pty-resize', + () => { + if (!isCurrentRestore() || session.transport.getPtyId() !== currentPtyId) { + return + } + const replayChangedDimensions = hasSnapshotDimensions + ? session.pane.terminal.cols !== snapshot.cols || + session.pane.terminal.rows !== snapshot.rows + : session.pane.terminal.cols !== colsBeforeReplay || + session.pane.terminal.rows !== rowsBeforeReplay + if (skippedAltFrame) { + session.pulseVisibleLocalPtySizeForTuiRepaint(currentPtyId) + return + } + if (replayChangedDimensions && session.isRendererPtyResizeAuthoritative()) { + session.transport.resize(session.pane.terminal.cols, session.pane.terminal.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + // Why: redundant SIGWINCH makes alt-screen TUIs rebuild their scroll viewport to the top on tab return. + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } + } + }, + { + shouldContinue: isCurrentRestore, + retryIfUnmeasurable: true, + // A skipped frame is blank until this fit pushes the final + // grid/SIGWINCH, so carry the continuation through reveal. + deferIfHidden: true + } + ) + session.pendingHiddenSnapshotFit = fit + try { + await fit.completion + } finally { + if (session.pendingHiddenSnapshotFit === fit) { + session.pendingHiddenSnapshotFit = null + } + } + if (isCurrentRestore()) { + session.scheduleReattachIdleAgentCursorReset() + } + } + } + ) + } finally { + if (session.hiddenOutputSnapshotScrollRestore === scrollRestore) { + session.hiddenOutputSnapshotScrollRestore = null + } + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-seq-and-skip.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-seq-and-skip.ts new file mode 100644 index 00000000000..7b90c4a6c76 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-seq-and-skip.ts @@ -0,0 +1,241 @@ +import { useAppStore } from '@/store' +import { parseTerminalKittyKeyboardFlags } from '../../../../../shared/terminal-kitty-keyboard-flags' +import { + containsStatefulRendererQuery, + extractHiddenStartupRendererQueryData +} from '../../../../../shared/terminal-reply-query-extraction' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { + resolveTerminalColorSchemeMode, + scanMode2031ReplyDecision +} from '../../../../../shared/terminal-color-scheme-protocol' +import { installTerminalLiveScrollbackRestore } from '@/lib/pane-manager/terminal-live-scrollback-restore' +import type { PtyDataMeta } from '../pty-dispatcher' +import { sendTerminalOscColorQueryReplies } from '../terminal-capability-replies' + +import { shouldWritePtyOutputForeground } from './foreground-output-scan' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { bindWritePtyOutputToXterm } from './write-pty-output-to-xterm' + +import { bindAbandonHiddenOutputRestore } from './hidden-output-restore-abandon' +import { bindHiddenOutputRestoreChunk } from './hidden-output-restore-chunk' +import { bindHiddenStartupRendererQueryWrite } from './hidden-startup-renderer-query-write' + +import { bindHiddenOutputRestoreDrain } from './hidden-output-restore-drain' + +export function bindHiddenOutputSeqAndSkip(session: ConnectPanePtySession): void { + // Why record-only: DECSET 2031 subscribes to future color changes, it is not a query — + // the protocol's query is `CSI ?996n`. fish arms 2031 for the ~1ms it paints a prompt, so + // any reply lands after the withdrawal and paints `?997;1n` as literal text (#9993). + // Why here and not in xterm's CSI handler: xterm batches several PTY chunks into one + // synchronous parse, so a handler cannot tell where a chunk ended. One raw chunk in, + // one order-aware final state out. + session.observeLiveMode2031Chunk = function (data: string): void { + // Gate-managed PTYs never see these bytes; main's '2031-subscribe' fact records them. + if (session.isHiddenDeliveryGateManagedPty(session.transport.getPtyId())) { + return + } + const result = scanMode2031ReplyDecision(session.mode2031ReplyScanState, data) + session.mode2031ReplyScanState = result.state + if (result.decision === 'unsubscribed') { + session.deps.paneMode2031Ref.current.delete(session.pane.id) + session.deps.paneLastThemeModeRef.current.delete(session.pane.id) + } + if (result.decision !== 'subscribed') { + return + } + const settings = useAppStore.getState().settings + // Why seed the mode: maybePushMode2031Flip only pushes on a change, so an unseeded + // subscription would read as a flip on the next unrelated appearance re-apply. + session.deps.paneMode2031Ref.current.set(session.pane.id, true) + session.deps.paneLastThemeModeRef.current.set( + session.pane.id, + resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) + ) + } + + // Why installed here: the handler observes CSI 3 J inside xterm's parse, so a + // redraw split across PTY chunks is reported once, with the pane's rows for + // this write already applied. + session.liveScrollbackRestore?.dispose() + session.liveScrollbackRestore = installTerminalLiveScrollbackRestore(session.pane.terminal) + + // Why read it live: `?1049` is not a no-op when the pane is already on the + // target buffer — xterm still runs restoreCursor and swaps the kitty flag + // registers — so the replay prologue must only switch when it truly differs. + // + // Best-effort by design. xterm parses asynchronously, so this sees only + // PARSED writes; a `?1049` transition still queued reads stale. Draining + // first with a write sentinel was tried and reverted: it puts the repaint + // behind xterm's queue, so a wedged terminal stalls recovery, and it breaks + // the disposal/cancellation ordering the restore paths rely on. A stale read + // costs one mis-scoped buffer switch; the barrier costs the repaint. + session.isPaneOnAlternateScreen = function (): boolean { + return session.pane.terminal.buffer.active.type === 'alternate' + } + + bindWritePtyOutputToXterm(session) + session.markHiddenOutputRestoreNeeded = function (): void { + const ptyId = session.transport.getPtyId() + if (!session.canUseHiddenOutputSnapshot(ptyId)) { + return + } + if (session.hiddenOutputRestorePtyId !== null && session.hiddenOutputRestorePtyId !== ptyId) { + session.clearHiddenOutputRestoreState() + } + session.hiddenOutputRestorePtyId = ptyId + session.hiddenOutputRestoreNeeded = true + if (shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) { + session.requestHiddenOutputRestoreIfNeeded() + } + } + + session.shouldSkipHiddenRendererOutput = function (foreground: boolean, data: string): boolean { + const ptyId = session.transport.getPtyId() + if ( + foreground || + (!session.shouldSnapshotHiddenCodexOutput && session.remoteOutputGatedPtyId !== ptyId) || + !session.canUseHiddenOutputSnapshot(ptyId) + ) { + return false + } + // Why: CPR/DECRQM replies depend on ordered state; keep a clean stateful-query chunk live, but after skipped bytes avoid stale replies. + return session.hiddenRendererStateDirty || !containsStatefulRendererQuery(data) + } + + session.writeHiddenStartupRendererQueries = function (data: string): void { + const extracted = extractHiddenStartupRendererQueryData( + data, + session.hiddenStartupRendererQueryPending + ) + session.hiddenStartupRendererQueryPending = extracted.pending + if (extracted.oscColorQueryData) { + // Why: Codex's startup palette probe has a 100ms budget; answer hidden color queries immediately so scheduling/remote-input debounce (#7329) can't miss it. + sendTerminalOscColorQueryReplies( + extracted.oscColorQueryData, + session.pane.terminal, + session.sendDesktopQueryReplyImmediate + ) + } + if (extracted.statelessQueryData) { + session.writePtyOutputToXterm(extracted.statelessQueryData, false, { + hiddenStartupRendererQuery: true + }) + } + // Stateful hidden queries need ordered terminal state; if the hidden xterm is dirty, skip rather than send stale CPR/DECRQM. + } + + bindHiddenStartupRendererQueryWrite(session) + bindHiddenOutputRestoreChunk(session) + /** + * Apply an authoritative snapshot to the pane's kitty mirror in the order: + * unproven reset, replay-semantics scan of the snapshot + * bytes (so screen selection lands first), then the owner's proven flags. + * + * Why the reset only happens when the owner proved something: an old host + * omits the field, and downgrading a mirror that is already tracking live + * output would lose correct state instead of preserving it. + */ + session.applySnapshotKittyKeyboardModes = function ( + snapshotData: string, + snapshot: { kittyKeyboardFlags?: number; snapshotSeq?: number } + ): void { + const proven = + snapshot.snapshotSeq === undefined + ? undefined + : parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) + if (proven === undefined) { + // Why the demotion: a mirror grounded in this PTY's stream keeps its + // state, but a constructor-fresh tracker (window reload) holds a + // known-zero that was never proven for the reattached PTY — demote it + // so the serializer cannot republish it downstream as host-proven + // inactive. + if (!session.kittyKeyboardModes.hasProvenBaseline) { + session.kittyKeyboardModes.resetForSnapshot() + } + session.kittyKeyboardModes.scanReplay(snapshotData) + return + } + session.kittyKeyboardModes.resetForSnapshot() + session.kittyKeyboardModes.scanReplay(snapshotData) + session.kittyKeyboardModes.restoreSnapshotFlags(proven) + // Why in the same critical section: without the baseline a quiet pane + // could not publish a coherent snapshot until unrelated output arrived. + session.recordRendererOrderedSeq({ seq: snapshot.snapshotSeq }) + } + + session.recordRendererOrderedSeq = function (meta?: Pick): void { + if (typeof meta?.seq !== 'number') { + return + } + const ptyId = session.transport.getPtyId() + if (!ptyId) { + return + } + if (session.rendererOrderedPtyId !== ptyId) { + session.rendererOrderedPtyId = ptyId + session.rendererOrderedSeq = meta.seq + return + } + session.rendererOrderedSeq = Math.max(session.rendererOrderedSeq ?? 0, meta.seq) + } + + session.resetRendererOrderedSeqForPtyExit = (exitedPtyId: string): void => { + // Why: an exit ends this ptyId's seq domain; a revived id restarts main's counter, so both seq high-water marks (ordered + restored baseline) must reset here or they drop revived bytes as duplicates. + if (session.restoredSnapshotBaselinePtyId === exitedPtyId) { + session.clearRestoredSnapshotBaseline() + } + if (session.rendererOrderedPtyId === exitedPtyId) { + session.rendererOrderedPtyId = null + session.rendererOrderedSeq = null + } + if (session.rendererChannelSeqPtyId === exitedPtyId) { + session.rendererChannelSeqPtyId = null + session.rendererChannelSeq = null + } + } + + session.observeRendererOrderedSeqRegression = function (meta: PtyDataMeta | undefined): void { + if (typeof meta?.seq !== 'number') { + return + } + const ptyId = session.transport.getPtyId() + if (!ptyId) { + return + } + if (session.rendererChannelSeqPtyId !== ptyId) { + session.rendererChannelSeqPtyId = ptyId + session.rendererChannelSeq = meta.seq + return + } + if (session.rendererChannelSeq !== null && meta.seq < session.rendererChannelSeq) { + // Why: pty:data is FIFO, so seq regresses only when a session revived without an observed exit and restarted its counter; drop the stale baseline. + if (session.rendererOrderedPtyId === ptyId) { + session.rendererOrderedPtyId = null + session.rendererOrderedSeq = null + } + } + session.rendererChannelSeq = meta.seq + } + + session.getHiddenRendererDataAfterOrderedSeq = function ( + data: string, + meta: PtyDataMeta | undefined + ): string | null { + if ( + session.rendererOrderedPtyId === null || + session.rendererOrderedSeq === null || + session.transport.getPtyId() !== session.rendererOrderedPtyId + ) { + return data + } + return session.getChunkDataAfterSnapshot( + { data, seq: meta?.seq, rawLength: meta?.rawLength }, + session.rendererOrderedSeq + ) + } + + bindHiddenOutputRestoreDrain(session) + bindAbandonHiddenOutputRestore(session) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts new file mode 100644 index 00000000000..8629163d09e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts @@ -0,0 +1,142 @@ +import { isHostAnsweredSnapshotRetryCause } from '@/runtime/remote-runtime-terminal-multiplexer' +import { onTerminalScrollIntentFollowOutput } from '@/lib/pane-manager/terminal-scroll-intent' + +import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { readE2eHiddenSnapshotOverride } from './e2e-terminal-pty-harness' + +import type { PtyBufferSnapshot } from '../pty-transport' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export type HiddenOutputSnapshotResult = + | { kind: 'snapshot'; snapshot: PtyBufferSnapshot } + | { kind: 'retry-worthy'; source: 'host' | 'local' } + | { kind: 'permanently-unavailable' } + | { kind: 'unknown-legacy-host' } + | { kind: 'unavailable' } + +export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession): void { + session.serializeHiddenOutputSnapshot = async function ( + ptyId: string, + opts: { scrollbackRows?: number } + ): Promise { + const e2eSnapshot = readE2eHiddenSnapshotOverride(ptyId) + if (e2eSnapshot) { + const snapshot = await e2eSnapshot + return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unavailable' } + } + if (session.canUseMainBufferSnapshot(ptyId)) { + const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, opts) + return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unavailable' } + } + if ( + session.transport.getPtyId() !== ptyId || + typeof session.transport.serializeBuffer !== 'function' + ) { + return { kind: 'unavailable' } + } + if ( + session.hiddenOutputRestoreLegacyPtyId === ptyId || + typeof session.transport.serializeBufferOutcome !== 'function' + ) { + const snapshot = await session.transport.serializeBuffer(opts) + return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unknown-legacy-host' } + } + try { + const outcome = await session.transport.serializeBufferOutcome(opts) + if (outcome.availability.kind === 'snapshot') { + // A success frame with no image is still the host's own answer to a request it received. + return outcome.snapshot + ? { kind: 'snapshot', snapshot: outcome.snapshot } + : { kind: 'retry-worthy', source: 'host' } + } + if (outcome.availability.kind === 'retry-worthy') { + return { + kind: 'retry-worthy', + source: isHostAnsweredSnapshotRetryCause(outcome.availability.cause) ? 'host' : 'local' + } + } + if (outcome.availability.kind === 'permanently-unavailable') { + return { kind: 'permanently-unavailable' } + } + return { kind: 'unknown-legacy-host' } + } catch { + // Why 'host': the reject path is the request timeout — the frame went out and the host stayed silent. + return { kind: 'retry-worthy', source: 'host' } + } + } + + // Why: hidden/parked panes used to mark hidden only at the first + // session.dataCallback sync, leaving a spawn-time window where neither side + // answered queries (the spawn-time DA1 loss). Declaring hidden on the + // spawn IPC lets main mark the PTY before its first byte — including + // codex spawns: the model responder answers their startup probes from + // byte zero now that the 10s renderer query window is gone. + // Remote-runtime PTYs are never gate-markable (no local main transit). + session.shouldDeclareHiddenAtSpawn = function (): boolean { + return ( + session.hiddenDeliveryGateActive && + !session.runtimeEnvironmentId && + !session.disposed && + !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + ) + } + + // ── Hidden-delivery gate sync (Phase 4) ───────────────────────────── + // Why: marks this pane's PTY hidden in main while no visible view needs + // its bytes; main then drops delivery after model ingestion and reveal + // restores from the snapshot. The marked id is tracked locally so PTY + // changes (reattach/restart) can never leave a stale id gated. + session.hiddenDeliverySyncedPtyId = null + session.releaseHiddenDeliveryClaim = null + session.modelRestoreSubscribedPtyId = null + session.unregisterModelRestoreNeeded = null + + function isHiddenOutputRestoreFloodSuppressed(): boolean { + return Date.now() < session.hiddenOutputRestoreFloodSuppressedUntil + } + + // True when a drop/gap signal on a visible pane is attributable to this + // pane's OWN restore backpressure (a restore is replaying right now, or + // one was just cut off for outrunning the stream). Such signals must not + // re-arm restores — that is the rc.7.perf feedback loop. + session.isForegroundRestoreBackpressureContext = function (): boolean { + return ( + shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) && + (session.hiddenOutputRestoreInFlight !== null || isHiddenOutputRestoreFloodSuppressed()) + ) + } + + session.clearHiddenOutputRestoreFloodRepaintTimer = function (): void { + session.cancelHiddenOutputRestoreFloodRepaintPark?.() + session.cancelHiddenOutputRestoreFloodRepaintPark = null + if (session.hiddenOutputRestoreFloodRepaintTimer === null) { + return + } + clearTimeout(session.hiddenOutputRestoreFloodRepaintTimer) + session.hiddenOutputRestoreFloodRepaintTimer = null + } + session.cleanupHiddenOutputRestoreFloodRepaint = session.clearHiddenOutputRestoreFloodRepaintTimer + + // Why: the repaint discards the buffer and replays a full snapshot, which repositions the viewport; a user reading scrollback must not be yanked to the bottom, so hold it until their own scroll intent returns to follow-output. + session.repaintAfterFloodWhenFollowingOutput = function (ptyId: string): void { + session.cancelHiddenOutputRestoreFloodRepaintPark?.() + session.cancelHiddenOutputRestoreFloodRepaintPark = null + let repainted = false + const cancelPark = onTerminalScrollIntentFollowOutput(session.pane.terminal, () => { + repainted = true + session.cancelHiddenOutputRestoreFloodRepaintPark = null + if (session.disposed || session.transport.getPtyId() !== ptyId) { + return + } + session.markHiddenOutputRestoreNeeded() + }) + if (!repainted) { + session.cancelHiddenOutputRestoreFloodRepaintPark = cancelPark + } + } + + session.resetHiddenOutputRestoreFloodSuppression = function (): void { + session.hiddenOutputRestoreFloodSuppressedUntil = 0 + session.clearHiddenOutputRestoreFloodRepaintTimer() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state-and-ssh-probe.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state-and-ssh-probe.ts new file mode 100644 index 00000000000..7b5f656d396 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state-and-ssh-probe.ts @@ -0,0 +1,222 @@ +import { useAppStore } from '@/store' +import type { PtyBufferSnapshot } from '../pty-transport' +import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../../../../shared/terminal-color-scheme-protocol' +import { discardTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { cancelTerminalScrollIntentBufferRebuildCompletions } from '@/lib/pane-manager/terminal-scroll-intent-rebuild' +import { DeferredReattachLiveDataQueue } from '../deferred-reattach-live-data-queue' +import { resolveHiddenRestoreScrollbackRows } from '../terminal-hidden-restore-scrollback' +import { + decideSshReattachPaintSource, + memoizeSshReattachModelSnapshotProbe, + resolveSshReattachModelSnapshotWithTimeout, + shouldFetchSshReattachModelSnapshot +} from '../ssh-reattach-model-restore' + +import { + HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS, + HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX +} from './hidden-output-restore-limits' +import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { recordHiddenRendererSkip } from './e2e-terminal-pty-harness' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { bindHiddenOutputRestoreSnapshot } from './hidden-output-restore-snapshot' +import { bindHiddenOutputRestoreRequest } from './hidden-output-restore-request' +import { bindLiveDataCallback } from './live-data-callback' + +import { bindReattachLiveDataDeferral } from './reattach-live-data-deferral' + +export function bindHiddenRestoreStateAndSshProbe(session: ConnectPanePtySession): void { + session.scheduleHiddenOutputRestoreDeferredRetry = function (): void { + if ( + session.disposed || + session.hiddenOutputRestoreDeferredRetryTimer !== null || + !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + ) { + return + } + if ( + session.hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX + ) { + const ptyId = session.hiddenOutputRestorePtyId + if (ptyId !== null) { + session.abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId) + } else { + session.clearHiddenOutputRestoreState() + session.writeRestoreUnavailableWarning() + } + return + } + session.hiddenOutputRestoreDeferredRetryAttempts += 1 + // Why: a null snapshot usually means remote output was still mutating; retry after one quiet tick instead of spinning. + session.hiddenOutputRestoreDeferredRetryTimer = setTimeout(() => { + session.hiddenOutputRestoreDeferredRetryTimer = null + if (session.disposed || !session.hiddenOutputRestoreNeeded) { + return + } + session.hiddenOutputRestoreRetryDeferred = false + session.requestHiddenOutputRestoreIfNeeded() + }, HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS) + } + + session.clearHiddenOutputRestoreState = function (): void { + session.cancelSnapshotScrollRestore() + session.clearPendingLiveChunksDuringRestore() + // Re-arm budget is per PTY stream, like the rest of this state. + session.hiddenOutputRestoreRemoteAbandonCycles = 0 + session.hiddenOutputRestoreRemoteOutcomeAttempts = 0 + session.hiddenOutputRestoreLocalGateAttempts = 0 + session.hiddenStartupRendererQueryPending = '' + session.hiddenRendererStateDirty = false + session.hiddenOutputRestoreNeeded = false + session.hiddenOutputRestorePtyId = null + session.hiddenOutputRestoreReplayingSnapshot = null + session.hiddenOutputRestoreGeneration += 1 + } + + session.cancelSnapshotScrollRestore = function (): void { + session.pendingHiddenSnapshotFit?.cancel() + session.pendingHiddenSnapshotFit = null + const scrollRestore = session.hiddenOutputSnapshotScrollRestore + if (!scrollRestore) { + return + } + scrollRestore.valid = false + session.hiddenOutputSnapshotScrollRestore = null + if (scrollRestore.started) { + cancelTerminalScrollIntentBufferRebuildCompletions(session.pane.terminal) + } + // Why: invalidation suppresses restoration, but queued bytes still own the bracket until their FIFO sentinels prove parsing finished. + } + session.cancelHiddenOutputSnapshotScrollRestore = session.cancelSnapshotScrollRestore + + session.clearPaneMode2031State = function (): void { + session.deps.paneMode2031Ref.current.delete(session.pane.id) + session.deps.paneLastThemeModeRef.current.delete(session.pane.id) + // A partial CSI prefix belongs to the stream that produced it; carrying it into a + // replacement PTY would splice two unrelated byte ranges into one sequence. + session.mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE + } + + session.pulseVisibleLocalPtySizeForTuiRepaint = function (ptyId: string): void { + if ( + !session.isRendererPtyResizeAuthoritative() || + session.shouldSuppressDesktopPtyResize() || + isRemoteRuntimePtyId(ptyId) + ) { + return + } + const cols = session.pane.terminal.cols + const rows = session.pane.terminal.rows + if (cols <= 2 || rows <= 0) { + return + } + // Why: a hidden alt-screen TUI can miss the same-size restore SIGWINCH; a one-column pulse makes the repaint observable to the child. + session.transport.resize(cols - 1, rows) + session.transport.resize(cols, rows) + } + + session.skipBackgroundAlternateScreenOutput = function (data: string): void { + session.writeHiddenStartupRendererQueries(data) + session.hiddenRendererStateDirty = true + recordHiddenRendererSkip(data.length) + const ptyId = session.transport.getPtyId() + if (!ptyId || session.alternateScreenBackgroundRepaintTimer !== null) { + return + } + session.pulseVisibleLocalPtySizeForTuiRepaint(ptyId) + session.alternateScreenBackgroundRepaintTimer = setTimeout(() => { + session.alternateScreenBackgroundRepaintTimer = null + }, 100) + } + + session.resetHiddenOutputRestoreIfPtyChanged = function (): void { + if (session.hiddenOutputRestorePtyId === null) { + return + } + if (session.transport.getPtyId() !== session.hiddenOutputRestorePtyId) { + // Why: renderer backlog is tied to the old PTY stream; after reattach it must not delay or replay before the new PTY. + session.clearHiddenOutputRestoreState() + session.clearRestoredSnapshotBaseline() + session.clearPaneMode2031State() + // Why: flood-backpressure evidence is per PTY stream too. + session.resetHiddenOutputRestoreFloodSuppression() + discardTerminalOutput(session.pane.terminal) + } + } + + bindHiddenOutputRestoreSnapshot(session) + bindHiddenOutputRestoreRequest(session) + bindLiveDataCallback(session) + session.beginReattachLiveDataDeferral = ( + ownerGeneration = session.transportStreamGeneration + ): void => { + session.reattachLiveDataDeferralDepth += 1 + if (session.reattachLiveDataDeferralDepth === 1) { + session.deferredReattachLiveData = new DeferredReattachLiveDataQueue() + session.deferredReattachLiveDataOwners = new Map() + } + if (!session.deferredReattachLiveDataOwners.has(ownerGeneration)) { + session.deferredReattachLiveDataOwners.set(ownerGeneration, { failed: false }) + } + } + + session.beginReattachLiveDataDeferralIfUnowned = ( + ownerGeneration = session.transportStreamGeneration + ): void => { + if (!session.deferredReattachLiveDataOwners.has(ownerGeneration)) { + session.beginReattachLiveDataDeferral(ownerGeneration) + } + } + + bindReattachLiveDataDeferral(session) + session.isCapturedDirectSshReattachCurrent = (ptyId: string): boolean => + !session.directSshRetryAttempt || session.capturedDirectSshRetryStateMatches(ptyId) + session.rejectObsoleteDirectSshReattach = (ptyId: string | null | undefined): boolean => { + if ( + !session.directSshRetryAttempt || + (ptyId && session.claimCapturedDirectSshRetryPty(ptyId)) + ) { + return false + } + session.transport.detach?.({ preserveExitObserver: false }) + return true + } + + let parkedSshSnapshotPrefetch: { + ptyId: string + fetch: () => Promise + } | null = null + + const createSshMainModelSnapshotProbe = ( + ptyId: string + ): (() => Promise) => + memoizeSshReattachModelSnapshotProbe(async (): Promise => { + const sshParkingEnabled = useAppStore.getState().settings?.terminalSshViewParking !== false + if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) { + return null + } + const snapshot = await resolveSshReattachModelSnapshotWithTimeout( + window.api.pty.getMainBufferSnapshot(ptyId, { + scrollbackRows: resolveHiddenRestoreScrollbackRows( + session.pane.terminal.options.scrollback + ) + }) + ) + return snapshot && + decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) === + 'main-model-snapshot' + ? snapshot + : null + }) + + session.getSshMainModelSnapshotProbe = ( + ptyId: string + ): (() => Promise) => { + if (parkedSshSnapshotPrefetch?.ptyId !== ptyId) { + parkedSshSnapshotPrefetch = { ptyId, fetch: createSshMainModelSnapshotProbe(ptyId) } + } + return parkedSshSnapshotPrefetch.fetch + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query-write.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query-write.ts new file mode 100644 index 00000000000..082e6f8e7d7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query-write.ts @@ -0,0 +1,160 @@ +import { parseTerminalOscColorQuery } from '../../../../../shared/terminal-osc-color-reply' +import { + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS, + extractHiddenStartupRendererQueryData, + findCsiFinalByteIndex, + isStatefulRendererReplyCsiQuery, + isStatelessRendererReplyCsiQuery +} from '../../../../../shared/terminal-reply-query-extraction' +import type { PtyDataMeta } from '../pty-dispatcher' +import { + DEFAULT_DA1_RESPONSE, + sendTerminalOscColorQueryReplies +} from '../terminal-capability-replies' + +import { recordHiddenRendererSkip } from './e2e-terminal-pty-harness' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** Hidden-pane startup renderer query extraction, output skipping, and salvage from discarded restore data. */ +export function bindHiddenStartupRendererQueryWrite(session: ConnectPanePtySession): void { + session.takeHiddenStartupRendererQueryPendingForForeground = function (data: string): { + statelessQueryData: string + statefulQueryData: string + oscColorQueryData: string + remainingData: string + consumedCurrentChars: number + } { + const pending = session.hiddenStartupRendererQueryPending + session.hiddenStartupRendererQueryPending = '' + if (!pending) { + return { + statelessQueryData: '', + statefulQueryData: '', + oscColorQueryData: '', + remainingData: data, + consumedCurrentChars: 0 + } + } + + const input = pending + data + let statelessQueryData = '' + let statefulQueryData = '' + let oscColorQueryData = '' + let consumedInputChars = pending.length + let nextPending = '' + if (input.startsWith('\x1b[')) { + const finalByteIndex = findCsiFinalByteIndex(input, 2) + if (finalByteIndex === -1) { + nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS) + consumedInputChars = input.length + } else { + const sequence = input.slice(0, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence)) { + statelessQueryData = sequence + } else if (isStatefulRendererReplyCsiQuery(sequence)) { + statefulQueryData = sequence + } + consumedInputChars = finalByteIndex + 1 + } + } else if (input.startsWith('\x1b]')) { + const query = parseTerminalOscColorQuery(input, 0) + if (query.kind === 'partial') { + nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS) + consumedInputChars = input.length + } else if (query.kind === 'match') { + oscColorQueryData = input.slice(0, query.endIndex) + consumedInputChars = query.endIndex + } else { + consumedInputChars = pending.length + } + } else if (input.length === 1) { + nextPending = input + consumedInputChars = input.length + } else { + consumedInputChars = pending.length + } + + session.hiddenStartupRendererQueryPending = nextPending + const consumedCurrentChars = Math.max(0, consumedInputChars - pending.length) + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + remainingData: data.slice(consumedCurrentChars), + consumedCurrentChars + } + } + + session.metaAfterConsumingCurrentChars = function ( + meta: PtyDataMeta | undefined, + consumedCurrentChars: number + ): PtyDataMeta | undefined { + if (consumedCurrentChars === 0 || typeof meta?.rawLength !== 'number') { + return meta + } + return { + ...meta, + rawLength: Math.max(0, meta.rawLength - consumedCurrentChars) + } + } + + session.skipHiddenRendererOutput = function (data: string): void { + session.writeHiddenStartupRendererQueries(data) + session.markHiddenOutputRestoreNeeded() + session.hiddenRendererStateDirty = true + if (session.hiddenOutputRestoreInFlight) { + session.hiddenOutputRestoreFreshSnapshotNeeded = true + } + recordHiddenRendererSkip(data.length) + } + + // Why: discarding flood bytes must not swallow terminal queries (a lost DSR/CPR hangs the program); the snapshot repaint owns the content, so synthesize replies via the immediate input path, not xterm replay. + session.salvageRendererQueriesFromDiscardedRestoreData = function (data: string): void { + if (!data || !data.includes('\x1b')) { + return + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + if (extracted.oscColorQueryData) { + sendTerminalOscColorQueryReplies( + extracted.oscColorQueryData, + session.pane.terminal, + session.sendDesktopQueryReplyImmediate + ) + } + let unansweredQueryData = '' + for (const sequence of splitCsiSequences( + extracted.statefulQueryData + extracted.statelessQueryData + )) { + if (sequence === '\x1b[6n') { + // CPR from the live buffer; may be mid-repaint stale, but in a drop scenario liveness (unblock the reader) is the contract, not accuracy. + const buffer = session.pane.terminal.buffer.active + const row = Math.min(buffer.cursorY + 1, session.pane.terminal.rows) + const col = Math.min(buffer.cursorX + 1, session.pane.terminal.cols) + session.sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`) + } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { + session.sendDesktopQueryReplyImmediate(DEFAULT_DA1_RESPONSE) + } else { + unansweredQueryData += sequence + } + } + if (unansweredQueryData) { + // Best-effort for rarer queries (DECRQM, DA2, XTVERSION): replay into xterm so its handlers answer when no replay is active. + session.writePtyOutputToXterm(unansweredQueryData, true, { hiddenStartupRendererQuery: true }) + } + } + + function splitCsiSequences(queryData: string): string[] { + const sequences: string[] = [] + let offset = queryData.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(queryData, offset + 2) + if (finalByteIndex === -1) { + break + } + sequences.push(queryData.slice(offset, finalByteIndex + 1)) + offset = queryData.indexOf('\x1b[', finalByteIndex + 1) + } + return sequences + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts new file mode 100644 index 00000000000..c48730296c8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts @@ -0,0 +1,18 @@ +import { containsCsiRendererQuery } from '../../../../../shared/terminal-reply-query-extraction' +import { isKnownTuiAgentTerminalStartupCommand } from '../terminal-startup-command-classifier' +import type { PtyConnectionDeps } from '../pty-connection-types' + +export function shouldKeepHiddenStartupRendererQueriesLive( + startup: PtyConnectionDeps['startup'] +): boolean { + return ( + Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') || + isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '') + ) +} + +export function containsHiddenStartupRendererQuery(data: string): boolean { + // Why: hidden Codex startup must not live-render ordinary redraw floods, but + // query chunks still need xterm's built-in terminal replies to unblock TUIs. + return containsCsiRendererQuery(data) || data.includes('\x1b]10;?') || data.includes('\x1b]11;?') +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts b/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts new file mode 100644 index 00000000000..9a6147ab649 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts @@ -0,0 +1,180 @@ +import { detectAgentStatusFromTitle } from '@/lib/agent-status' +import { useAppStore } from '@/store' +import type { AgentStatusEntry } from '../../../../../shared/agent-status-types' +import { createAgentInterruptInference } from '../agent-interrupt-inference' +import { createAgentQuestionAnsweredInference } from '../agent-question-answered-inference' +import type { AgentInterruptInputIntent } from '../../../../../shared/agent-interrupt-intent' +import { markTerminalBracketedPasteInterrupted } from '../terminal-bracketed-paste' + +import { REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS } from './foreground-output-scan' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +import { installPaneAgentIdentity } from './pane-agent-identity' + +export function installInterruptInputIntent(session: ConnectPanePtySession): void { + session.scheduleReattachIdleAgentCursorReset = (): void => { + const status = detectAgentStatusFromTitle(session.getCurrentTerminalTitle() ?? '') + if (status !== 'idle' && status !== 'permission') { + return + } + session.clearReattachIdleAgentCursorResetTimer() + session.reattachIdleAgentCursorResetTimer = setTimeout(() => { + session.reattachIdleAgentCursorResetTimer = null + if (session.disposed) { + return + } + const latestStatus = detectAgentStatusFromTitle(session.getCurrentTerminalTitle() ?? '') + if (latestStatus !== 'idle' && latestStatus !== 'permission') { + return + } + // Why: restored idle agent TUIs can repaint after reattach SIGWINCH and + // reapply DECSCUSR steady-bar; the normal working→idle reset will not + // fire because the agent was already idle before Orca restarted. + session.queueAgentIdleTerminalModeReset() + }, REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS) + } + session.interruptInference = createAgentInterruptInference({ + paneKey: session.cacheKey, + getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[session.cacheKey], + inferInterrupt: (request) => { + // Why: the explicit hook row is the authority for an in-flight agent turn. + // Codex can reset its terminal title while handling Ctrl+C/Escape, so title + // state must not veto clearing the row's working state. + return window.api.agentStatus + .inferInterrupt(request) + .then((applied) => { + if (applied) { + session.clearInferredInterruptWorkingTitle() + } + return applied + }) + .catch((err) => { + console.warn('[agent-interrupt] inferInterrupt failed:', err) + return false + }) + } + }) + session.questionAnsweredInference = createAgentQuestionAnsweredInference({ + paneKey: session.cacheKey, + getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[session.cacheKey], + inferQuestionAnswered: (request) => + window.api.agentStatus.inferQuestionAnswered(request).catch((err) => { + console.warn('[agent-question] inferQuestionAnswered failed:', err) + return false + }) + }) + session.dropCommandFinishedStatusIfSameTurn = ( + entry: AgentStatusEntry | undefined, + options?: { allowInferredInterrupt?: boolean } + ): void => { + const state = useAppStore.getState() + if (!entry) { + // Why: an Orca-started agent can exit before its first hook status. The + // launch registry was still created up front, so clear it on command exit. + state.clearAgentLaunchConfig(session.cacheKey) + return + } + const current = state.agentStatusByPaneKey[session.cacheKey] + if (!current) { + state.clearAgentLaunchConfig(session.cacheKey) + return + } + const unchanged = + current.state === entry.state && + current.prompt === entry.prompt && + current.updatedAt === entry.updatedAt && + current.stateStartedAt === entry.stateStartedAt && + current.agentType === entry.agentType + const inferredFromEntry = + options?.allowInferredInterrupt === true && + current.state === 'done' && + current.interrupted === true && + current.prompt === entry.prompt && + current.agentType === entry.agentType && + current.stateHistory?.some( + (history) => + history.state === entry.state && + history.prompt === entry.prompt && + history.startedAt === entry.stateStartedAt + ) === true + if (!unchanged && !inferredFromEntry) { + return + } + state.dropAgentStatus(session.cacheKey) + } + session.pendingTerminalInputIntent = null + session.clearPendingTerminalInputIntentTimer = null + session.clearPendingTerminalInputIntent = (): void => { + session.pendingTerminalInputIntent = null + if (session.clearPendingTerminalInputIntentTimer !== null) { + clearTimeout(session.clearPendingTerminalInputIntentTimer) + session.clearPendingTerminalInputIntentTimer = null + } + } + session.setPendingTerminalInputIntent = (intent: AgentInterruptInputIntent): void => { + session.clearPendingTerminalInputIntent() + session.pendingTerminalInputIntent = intent + session.clearPendingTerminalInputIntentTimer = setTimeout(() => { + session.clearPendingTerminalInputIntent() + }, 0) + } + session.inputMatchesIntent = (intent: AgentInterruptInputIntent, data: string): boolean => { + return ( + (intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03') + ) + } + session.inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => { + if (data === '\x03') { + return 'ctrl-c' + } + if (data === '\x1b') { + return 'plain-escape' + } + return null + } + session.observeSentTerminalInputIntent = ( + data: string, + intent = session.pendingTerminalInputIntent + ): void => { + if (intent && session.inputMatchesIntent(intent, data)) { + session.interruptInference.observeInputIntent(intent) + session.observeTitleOnlyInterrupt() + } + } + session.observeAcceptedTerminalInput = ( + data: string, + intent: AgentInterruptInputIntent | null = null + ): void => { + if (intent === 'ctrl-c' || data === '\x03') { + markTerminalBracketedPasteInterrupted(session.pane.terminal) + } + // Why: every delivered-input path funnels through here, so this is where a + // submit keystroke into a waiting AskUserQuestion pane becomes the + // "question answered" signal no hook will ever deliver. + session.questionAnsweredInference.observeSentTerminalInput(data) + } + session.pendingTerminalInputWrite = null + // Why: an unset baseline must differ from the first `?? null` snapshot so the + // first acknowledged input still advances the sequence. + session.sequencedInterruptStatusBaseline = undefined + session.interruptStatusBaselineSequence = 0 + session.setPendingTerminalInputWrite = (promise: Promise): void => { + session.pendingTerminalInputWrite = promise + void promise.finally(() => { + if (session.pendingTerminalInputWrite === promise) { + session.pendingTerminalInputWrite = null + } + }) + } + session.flushPendingInterruptInference = (): boolean | Promise => { + const pendingWrite = session.pendingTerminalInputWrite + if (!pendingWrite) { + return session.interruptInference.flushPending() + } + return pendingWrite.then((immediateResult) => { + return immediateResult ?? session.interruptInference.flushPending() + }) + } + installPaneAgentIdentity(session) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts b/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts new file mode 100644 index 00000000000..90a31924b98 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts @@ -0,0 +1,189 @@ +import { useAppStore } from '@/store' +import { containsStatefulRendererQuery } from '../../../../../shared/terminal-reply-query-extraction' +import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-delivery-credit' +import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity' +import { observeTerminalBracketedPasteModeOutput } from '../terminal-bracketed-paste' +import type { PtyDataMeta } from '../pty-dispatcher' +import { sendTerminalOscColorQueryReplies } from '../terminal-capability-replies' + +import { shouldWritePtyOutputForeground } from './foreground-output-scan' +import { registerE2eTerminalPtyDataInjection } from './e2e-terminal-pty-harness' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindLiveDataCallback(session: ConnectPanePtySession): void { + session.dataCallback = ( + data: string, + meta?: PtyDataMeta, + streamGeneration = session.transportStreamGeneration + ): void => { + if (streamGeneration !== session.transportStreamGeneration) { + return + } + if (session.deferredReattachLiveData !== null) { + const ackCredit = takeCurrentTerminalDeliveryCredit() + session.deferredReattachLiveData.enqueue({ + data, + ptyId: session.transport.getPtyId(), + streamGeneration, + ...(meta ? { meta } : {}), + ...(ackCredit ? { ackCredit } : {}) + }) + return + } + if (data.length > 0) { + session.hasReceivedPtyOutput = true + recordAgentHibernationPaneOutput(session.cacheKey) + // Why: output is the agent-start signal that ends the relaxed no-evidence process-scan cadence (a starting agent always prints). + session.agentCompletionCoordinator.observeOutputActivity() + } + session.observeStartupDraftPasteReadiness(data) + session.resetHiddenOutputRestoreIfPtyChanged() + session.observeLiveMode2031Chunk(data) + if (meta?.droppedOutput === true) { + // Why gated (rc.7.perf loop): a visible pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel. + if (meta?.background !== true && session.isForegroundRestoreBackpressureContext()) { + session.noteHiddenOutputRestoreFloodBackpressure() + } else { + // Why: main dropped buffered output at the pending cap, so the stream has a gap; repaint from the main-owned snapshot instead of writing on. + session.markHiddenOutputRestoreNeeded() + if (data) { + // The sentinel can carry query bytes carved from the bulk drop (extractDroppedPtyQueryBytes in main); replies must still flow. + session.salvageRendererQueriesFromDiscardedRestoreData(data) + } + return + } + } + session.respondToTerminalPixelSizeQueries(data) + observeTerminalBracketedPasteModeOutput(session.pane.terminal, data) + // Why: under main side-effect authority these facts arrive via pty:sideEffect; byte-scanning here would double-fire. Remote PTYs / kill-switch-off keep this path. + if (!session.mainSideEffectAuthority) { + for (const link of session.observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(session.deps.worktreeId, link) + } + session.commandLifecycle.handlePtyData(data) + } + session.commandCodeOutputStatusDetector?.observe(data) + const codexBackfillNotice = session.codexBackfillErrorDetector?.observe(data) + if (codexBackfillNotice) { + session.reportError(codexBackfillNotice) + } + // Why: split panes have visible-but-inactive panes the user watches; throttle only when the pane or whole document is hidden. + const foreground = + shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) && meta?.background !== true + // Why: latch the hidden-delivery gate from the byte path too, covering a PTY id that arrives after the initial sync (no-op when current). + if (!foreground) { + session.syncHiddenRendererPtyDelivery() + } + // Post-restore reconciliation: drop chunks the snapshot covers, force a fresh restore for unmappable seq gaps; runs after byte observers, before any xterm write. + const reconciliation = session.reconcileChunkAgainstRestoredSnapshot(data, meta) + if (reconciliation.action === 'drop-duplicate') { + return + } + if (reconciliation.action === 'force-fresh-restore') { + // Why gated (rc.7.perf loop): foreground-flood seq gaps are our own backpressure drops; snapshot-per-gap IS the loop, so retire the baseline and heal post-flood. + if (foreground && session.isForegroundRestoreBackpressureContext()) { + session.noteHiddenOutputRestoreFloodBackpressure() + session.clearRestoredSnapshotBaseline() + // fall through with the ORIGINAL data/meta — post-gap bytes are new + } else { + // Why: capture in-flight BEFORE the mark — on a visible pane the mark starts the restore synchronously and must not flag itself. + const restoreWasInFlight = session.hiddenOutputRestoreInFlight !== null + session.markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + session.hiddenOutputRestoreFreshSnapshotNeeded = true + } + return + } + } else { + data = reconciliation.data + meta = reconciliation.meta + } + // Why: a hidden Codex query can split just before visibility flips; hand xterm the completed query while other bytes still follow restore. + const pendingForegroundQuery = foreground + ? session.takeHiddenStartupRendererQueryPendingForForeground(data) + : null + const rendererData = pendingForegroundQuery?.remainingData ?? data + const rendererMeta = session.metaAfterConsumingCurrentChars( + meta, + pendingForegroundQuery?.consumedCurrentChars ?? 0 + ) + session.observeRendererOrderedSeqRegression(meta) + const orderedRendererData = foreground + ? rendererData + : session.getHiddenRendererDataAfterOrderedSeq(rendererData, rendererMeta) + if (orderedRendererData === null) { + // Why: renderer filtering can't map cleaned text back to raw seq offsets; rebuild from main instead of risking stale bytes. + session.markHiddenOutputRestoreNeeded() + session.schedulePendingStartupCommandDelivery() + return + } + if (!foreground && orderedRendererData.length === 0) { + session.recordRendererOrderedSeq(rendererMeta) + session.schedulePendingStartupCommandDelivery() + return + } + if (pendingForegroundQuery?.statelessQueryData) { + session.writePtyOutputToXterm(pendingForegroundQuery.statelessQueryData, true, { + hiddenStartupRendererQuery: true + }) + } + if (pendingForegroundQuery?.oscColorQueryData) { + sendTerminalOscColorQueryReplies( + pendingForegroundQuery.oscColorQueryData, + session.pane.terminal, + // Why: OSC color reply sent immediately so the remote debounce can't delay it past the program's read window (#7329). + session.sendDesktopQueryReplyImmediate + ) + } + const restoreAppliesToCurrentPty = + session.hiddenOutputRestorePtyId !== null && + session.transport.getPtyId() === session.hiddenOutputRestorePtyId + const skipBackgroundAlternateScreenFrame = + meta?.background === true && + shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) && + session.pane.terminal.buffer.active.type === 'alternate' && + !containsStatefulRendererQuery(orderedRendererData) + if (skipBackgroundAlternateScreenFrame) { + session.skipBackgroundAlternateScreenOutput(orderedRendererData) + } else if (session.shouldSkipHiddenRendererOutput(foreground, orderedRendererData)) { + session.skipHiddenRendererOutput(orderedRendererData) + } else if ( + (session.hiddenOutputRestoreNeeded || session.hiddenOutputRestoreInFlight) && + restoreAppliesToCurrentPty + ) { + if (foreground) { + if (pendingForegroundQuery?.statefulQueryData) { + session.queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData) + } + session.queueLiveChunkDuringRestore(orderedRendererData, rendererMeta) + session.requestHiddenOutputRestoreIfNeeded() + } else if (session.hiddenOutputRestoreInFlight) { + session.hiddenOutputRestoreNeeded = true + session.hiddenOutputRestoreFreshSnapshotNeeded = true + } + // Why: hidden chunks with a restore already latched are dropped; the reveal snapshot covers their bytes. + } else { + // Why: hidden panes normally get no bytes (main drops post-ingestion); stragglers ride the bounded background queue, overflow latches restore. + if (pendingForegroundQuery?.statefulQueryData) { + session.writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, { + hiddenStartupRendererQuery: true + }) + } + session.writePtyOutputToXterm(orderedRendererData, foreground) + if (foreground) { + session.recordRendererOrderedSeq(rendererMeta) + } + } + + session.schedulePendingStartupCommandDelivery() + } + session.unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection( + session.cacheKey, + (data, meta) => { + if (!session.disposed) { + session.dataCallback(data, meta) + } + } + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/paired-parked-terminal-restore.ts b/src/renderer/src/components/terminal-pane/pty-connection/paired-parked-terminal-restore.ts new file mode 100644 index 00000000000..b010078b104 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/paired-parked-terminal-restore.ts @@ -0,0 +1,30 @@ +import { useAppStore } from '@/store' +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import { REMOTE_PTY_ID_PREFIX } from './pty-connect-limits' + +export function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX) +} + +export function canRestorePairedParkedTerminal(ptyId: string): boolean { + const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + return ( + environmentId !== null && + useAppStore + .getState() + .runtimeStatusByEnvironmentId.get(environmentId) + ?.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) === true + ) +} + +// Why: daemon session IDs use the format `${worktreeId}@@${shortUuid}`. +// This validates that a session ID actually belongs to the given worktree, +// preventing cross-workspace contamination during restore. +export function isSessionOwnedByWorktree(sessionId: string, worktreeId: string): boolean { + const separatorIdx = sessionId.lastIndexOf('@@') + if (separatorIdx === -1) { + return true + } + return sessionId.slice(0, separatorIdx) === worktreeId +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts new file mode 100644 index 00000000000..9f1b2b7ebe7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -0,0 +1,286 @@ +import { agentTypeToIconAgent } from '@/lib/agent-status' +import { useAppStore } from '@/store' +import { getConnectionId } from '@/lib/connection-context' +import { replayIntoTerminal } from '../replay-guard' +import { POST_REPLAY_REATTACH_RESET } from '../../../../../shared/terminal-mode-reset-profiles' +import { + isLocalNativeWindowsConpty, + resolveWindowsShellOverride +} from '@/lib/pane-manager/windows-pty-compatibility' +import { createTerminalCommandLifecycle } from '../terminal-command-lifecycle' +import { createPaneForegroundAgentTracker } from '../pane-foreground-agent-tracker' +import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id' +import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import type { TuiAgent } from '../../../../../shared/tui-agent' +import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config' + +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** Pane agent identity, foreground-agent sampling, and command lifecycle handling. */ +export function installPaneAgentIdentity(session: ConnectPanePtySession): void { + // Why: the 133;D confirmation guard and the visible-pane resampler both key off + // "does this pane expect an agent"; derive each signal once so the two callers + // can't drift and silently reintroduce the icon bug this fix closes. + session.paneHasLiveHookAgentIcon = (state: ReturnType): boolean => { + const entry = state.agentStatusByPaneKey[session.cacheKey] + return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType)) + } + // Why: one ladder for both launch-agent signals; a second copy could drift. + const resolveLaunchAgentCandidate = ( + state: ReturnType + ): string | undefined => { + const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (candidate) => candidate.id === session.deps.tabId + ) + const registeredLaunchAgent = + state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType + return ( + tab?.launchAgent ?? + session.paneStartup?.launchAgent ?? + session.paneStartup?.initialAgentStatus?.agent ?? + (isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined) + ) + } + session.paneExpectsLaunchAgent = (state: ReturnType): boolean => + Boolean(resolveLaunchAgentCandidate(state)) + // Why: the concrete TUI agent a fresh spawn is expected to launch, used to seed + // a command-start confirmation on no-OSC shells (Git Bash/cmd) that never emit + // one. Returns null when the expectation isn't a recognized TUI agent. + session.resolveExpectedLaunchTuiAgent = (): TuiAgent | null => { + const candidate = resolveLaunchAgentCandidate(useAppStore.getState()) + return isTuiAgent(candidate) ? candidate : null + } + // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a + // full-screen agent's leaked nested-shell 133;D can't clear its tab identity, + // even on a restore where no command-start read has recorded evidence yet. + session.paneHasKnownAgentIdentity = (): boolean => { + const state = useAppStore.getState() + const registeredLaunchAgent = + state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType + return ( + Boolean(state.paneForegroundAgentByPaneKey[session.cacheKey]?.agent) || + session.paneHasLiveHookAgentIcon(state) || + isTuiAgent(registeredLaunchAgent) + ) + } + // Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints + // it on exit, so a confirmed return-to-shell must clear a title that still + // names an agent — otherwise the tab reads "grok" over a bare prompt. Only + // reset an agent-named title; user/shell-set titles are left untouched. + session.clearStaleAgentTabTitleOnConfirmedShell = (): void => { + const state = useAppStore.getState() + const currentTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + ) + const title = currentTitle ?? tab?.title + if (!title || resolveCommittedTitleAgentType(title) === null) { + return + } + const neutralTitle = session.neutralTerminalTitle() + session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, neutralTitle) + if (session.manager.getActivePane()?.id === session.pane.id) { + session.deps.updateTabTitle(session.deps.tabId, neutralTitle) + } + } + session.deferredCommandFinishedStatusDrop = null + session.visibleForegroundSamplePending = false + session.visibleForegroundSampleSettled = false + session.settleDeferredCommandFinishedStatusDrop = (): void => { + const dropStatus = session.deferredCommandFinishedStatusDrop + session.deferredCommandFinishedStatusDrop = null + dropStatus?.() + } + session.isForegroundTrackingAllowed = (id: string): boolean => { + if (isRemoteRuntimePtyId(id) || parseAppSshPtyId(id) !== null) { + return false + } + if (!navigator.userAgent.includes('Windows')) { + return true + } + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (candidate) => candidate.id === session.deps.tabId + ) + // Why: WSL and remote-runtime panes can never authorize native ConPTY + // bytes, so do not pay for Windows process scans that cannot affect routing. + return isLocalNativeWindowsConpty({ + userAgent: navigator.userAgent, + connectionId: getConnectionId(session.deps.worktreeId) ?? null, + cwd: session.deps.cwd, + shellOverride: resolveWindowsShellOverride( + tab?.shellOverride, + state.settings?.terminalWindowsShell + ), + executionHostId: getExecutionHostIdForWorktree(state, session.deps.worktreeId) + }) + } + session.paneForegroundAgentTracker = createPaneForegroundAgentTracker({ + getPtyId: () => session.transport.getPtyId(), + isTrackablePtyId: session.isForegroundTrackingAllowed, + readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id), + confirmForegroundProcess: (id) => window.api.pty.confirmForegroundProcess(id), + publish: (entry) => useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry), + hasKnownAgentIdentity: session.paneHasKnownAgentIdentity, + onConfirmedShellForeground: (reason) => { + session.clearStaleAgentTabTitleOnConfirmedShell() + // Why: a hard-killed agent leaves mouse/focus/kitty modes armed, and the + // surviving shell then receives pointer moves as typed SGR reports; the + // replay guard keeps xterm's auto-replies from leaking to the shell. + replayIntoTerminal(session.pane, session.deps.replayingPanesRef, POST_REPLAY_REATTACH_RESET, { + breadcrumbIdentity: { + tabId: session.deps.tabId, + worktreeId: session.deps.worktreeId, + ptyId: session.transport.getPtyId() + }, + shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously + }) + if (reason === 'visible-pty') { + useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) + return + } + session.settleDeferredCommandFinishedStatusDrop() + }, + onCommandFinishedUnavailable: session.settleDeferredCommandFinishedStatusDrop, + onVisibleForegroundSettled: (outcome) => { + session.visibleForegroundSamplePending = false + session.visibleForegroundSampleSettled = outcome !== 'inconclusive' + if (outcome !== 'inconclusive') { + return + } + const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[session.cacheKey] + if (foreground?.routingConfirmationPending !== true) { + return + } + useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { + agent: foreground.agent, + routingRevoked: true, + shellForeground: foreground.shellForeground + }) + } + }) + // Why: one command-finished policy whether the signal arrives as bytes + // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — + // routing both through this handler keeps the drop/interrupt semantics + // identical across authority modes. + session.handleCommandFinished = (bestEffortExitCode: number | null): void => { + session.clearCommandInferredPaneAgentAfterPtySideEffects() + session.visibleForegroundSamplePending = false + const shouldDeferStatusDrop = session.paneForegroundAgentTracker.onCommandFinished() + // Why: the finished command may have moved HEAD or the index (e.g. + // `git checkout`); nudge git UI now instead of waiting for a poll. + dispatchTerminalCommandFinishedEvent(session.deps.worktreeId, bestEffortExitCode) + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[session.cacheKey] + const inferenceResult = session.flushPendingInterruptInference() + const dropStatus = (): void => { + if (inferenceResult === true) { + session.dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) + return + } + if (inferenceResult instanceof Promise) { + void inferenceResult.then((applied) => { + session.dropCommandFinishedStatusIfSameTurn(entry, { + allowInferredInterrupt: applied === true + }) + }) + return + } + session.dropCommandFinishedStatusIfSameTurn(entry) + } + if (shouldDeferStatusDrop) { + // Why: keep the concrete pane identity routable while the local process + // check distinguishes a leaked nested-shell D from a genuine agent exit. + session.deferredCommandFinishedStatusDrop = dropStatus + return + } + session.deferredCommandFinishedStatusDrop = null + dropStatus() + } + session.sampleVisiblePaneForegroundAgent = (forceRoutingConfirmation = false): void => { + if ( + !session.deps.isVisibleRef.current || + session.visibleForegroundSamplePending || + session.visibleForegroundSampleSettled + ) { + return + } + const state = useAppStore.getState() + const foreground = state.paneForegroundAgentByPaneKey[session.cacheKey] + // Why: a daemon reattach may restore display identity without current + // routing authority. Only fresh evidence can suppress its confirmation. + if (foreground?.agent && foreground.routingTrusted === true) { + return + } + if (!forceRoutingConfirmation && session.paneHasLiveHookAgentIcon(state)) { + return + } + const expectsAgent = session.paneExpectsLaunchAgent(state) + // Why: a completed local process ladder is stronger than stale tab/startup + // launch metadata. Command-start clears this mark if the pane becomes busy. + if (foreground?.shellForeground) { + return + } + // Why: tab launch metadata can leak across split panes; rebuild pane-scoped + // identity from local process state, with remote/SSH excluded by the tracker. + session.visibleForegroundSamplePending = + session.paneForegroundAgentTracker.onVisiblePtyBound(expectsAgent) + } + session.startAcceptedInferredCommand = (agent) => { + session.paneForegroundAgentTracker.onCommandStarted(agent) + } + session.requestKnownWindowsShiftEnterReconfirmation = () => { + const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[session.cacheKey] + // Why: daemon reattach/launch metadata is display-only until a live + // provider read confirms it. Submit/interrupt/title-exit evidence must + // revoke that launch-only hint too, otherwise Shift+Enter can route bytes + // to an agent that already exited before confirmation ever ran. + if ( + !foreground?.agent || + foreground.routingTrusted !== true || + TUI_AGENT_CONFIG[foreground.agent].windowsShiftEnterEncoding !== 'csi-u' + ) { + return + } + // Why: cmd.exe and Git Bash have no OSC command boundaries. Keep the icon + // as a hint, but revoke bytes until one current provider confirmation lands. + useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { + agent: foreground.agent, + routingRevoked: true, + shellForeground: false + }) + session.visibleForegroundSamplePending = false + session.visibleForegroundSampleSettled = false + // Why: hook rows can suppress display-only sampling, but cannot restore + // byte authority after this function explicitly revoked routing trust. + session.sampleVisiblePaneForegroundAgent(true) + if (session.paneForegroundAgentTracker.hasReadInFlight()) { + useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { + agent: foreground.agent, + routingRevoked: true, + shellForeground: false, + routingConfirmationPending: true + }) + } + } + session.commandLifecycle = createTerminalCommandLifecycle({ + onCommandStarted: () => { + // Why: a new command invalidates cleanup waiting on the previous D; only + // a later confirmed shell boundary may retire this pane's live identity. + session.deferredCommandFinishedStatusDrop = null + session.visibleForegroundSamplePending = false + session.visibleForegroundSampleSettled = false + // Why: typed commands can be aliases, so they only widen the bounded + // process-confirmation window; they never become routing evidence. + session.paneForegroundAgentTracker.onCommandStarted(session.commandInferredPaneAgent) + }, + onCommandFinished: session.handleCommandFinished + }) + // Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect — + // it stays attached in every authority mode. + session.commandLifecycle.attachXtermConsumer(session.pane.terminal) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts new file mode 100644 index 00000000000..326e2b345ed --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts @@ -0,0 +1,25 @@ +import type { IDisposable } from '@xterm/xterm' +import type { HasPty } from '../terminal-dead-session-reconcile' + +export type PanePtyBinding = IDisposable & { + syncProcessTracking: () => void + noteVisibilityResume: () => void + reassertPtySizeAfterWindowWake: () => void + /** Navigation-free hibernation wake: fires the armed cold-restore --resume + * without the size-reassert/foreground-sample side effects of a real reveal. + * Used by the mobile wake fanout so a hidden hibernated pane resumes with no + * desktop hidden→visible transition. Returns the sleeping record's provider + * session claim key when this pane started (or latched) the in-place wake, + * so the follow-up generic resume never launches the same session twice. */ + wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set) => string | null + /** Re-sample process identity when the pane gains intra-tab focus: the tab + * icon follows the active leaf, and a shell-marked entry on a still-running + * agent pane has no OSC boundary left to correct it. */ + sampleForegroundAgentOnFocus: () => void + /** Reconfirm after direct shortcut input, which bypasses PTY onData. */ + requestWindowsShiftEnterReconfirmation: () => void + /** Refresh interactive redraw scheduling after captured shortcut input. */ + markShortcutTerminalInputSent: () => void + reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void + reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts new file mode 100644 index 00000000000..fe449dadb47 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts @@ -0,0 +1,247 @@ +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { useAppStore } from '@/store' +// Why: a restored pane's stale-account prompt can only be raised once a PTY is +// actually attached — nothing is inspectable while the session hydrates. +import { notifyCodexPaneBoundForStaleSweep } from '@/lib/codex-stale-pane-sweep' +import { createTerminalGitHubPRLinkDetector } from '../../../../../shared/terminal-github-pr-link-detector' +import { setRendererPtyVisibilityClaim } from '../pty-renderer-delivery-claims' +import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS } from '../agent-task-complete-policy' + +import { + isAgentTaskCompleteNotificationEnabled, + isAgentTaskCompleteTrackingEnabled +} from './agent-task-complete-settings' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** PTY visibility reporting, active-PTY binding, and the spawn/rebind/bell handlers that follow it. */ +export function installPanePtyVisibilityBind(session: ConnectPanePtySession): void { + session.observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() + session.reportPanePtyVisibility = (ptyId: string | null | undefined, visible: boolean): void => { + if (!ptyId || isRemoteRuntimePtyId(ptyId)) { + // Why: remote-runtime PTYs use a relay path outside main's local + // renderer-visibility registry, so reporting them here is misleading. + return + } + setRendererPtyVisibilityClaim(session.transport, ptyId, visible) + } + session.bindActivePanePty = ( + ptyId: string, + options: { + seedInitialAgentStatus?: boolean + updateTabPtyId?: 'always' | 'if-missing' + replacePtyId?: string + sampleVisibleForegroundAgent?: boolean + } = {} + ): void => { + session.bindProcessExitState(ptyId, options.replacePtyId) + if (session.activePanePtyBinding && session.activePanePtyBinding !== ptyId) { + session.reportPanePtyVisibility(session.activePanePtyBinding, false) + } + session.setPanePtyFitBinding(ptyId) + session.activePanePtyBinding = ptyId + session.reportPanePtyVisibility(ptyId, session.deps.isVisibleRef.current) + // Why: record bind time on the spawn/attach chokepoint so the reconcile + // guard knows this binding is newer than any pre-bind snapshot. + session.activePanePtyBindingBoundAt = performance.now() + session.registerSideEffectFactConsumerForPty(ptyId) + session.syncHiddenRendererPtyDelivery() + session.deps.syncPanePtyLayoutBinding(session.pane.id, ptyId) + // A live bind proves this pane is current again after detach/reattach. + useAppStore.getState().restoreAgentPaneAuthority?.(session.cacheKey) + notifyCodexPaneBoundForStaleSweep(ptyId) + const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId] ?? [] + const directSshRetryAttemptId = + session.capturedDirectSshRetryPtyAccepted && session.directSshRetryAttempt + ? session.directSshRetryAttempt.attemptId + : undefined + if ( + directSshRetryAttemptId || + options.updateTabPtyId !== 'if-missing' || + !tabPtyIds.includes(ptyId) + ) { + if (directSshRetryAttemptId) { + session.deps.updateTabPtyId( + session.deps.tabId, + ptyId, + options.replacePtyId, + directSshRetryAttemptId + ) + } else if (options.replacePtyId) { + session.deps.updateTabPtyId(session.deps.tabId, ptyId, options.replacePtyId) + } else { + session.deps.updateTabPtyId(session.deps.tabId, ptyId) + } + } + if (options.seedInitialAgentStatus) { + session.applyInitialAgentStatus() + } + // Spawn/attach completion is when a pane gains a concrete PTY ID. The initial + // frame-level sync often runs before that async result arrives. + scheduleRuntimeGraphSync() + session.agentCompletionCoordinator.startProcessTracking() + // Why: fresh spawns normally rely on a future OSC 133 command-start read to + // identify the launched agent; only adopted or restored PTYs may already be + // inside Codex with no new foreground signal. But no-OSC shells (Git Bash, + // cmd.exe) never emit a command-start, so an expected-agent pane spawned into + // one would never gain fresh evidence authorizing trusted Windows CSI-u + // routing for the launched agent, so Shift+Enter could submit (#7620, #9703). + // Seed the SAME command-start confirmation the manually-typed launch path + // uses (onCommandStarted): its bounded retry ladder spans agent boot, and a + // miss publishes shellForeground:false — recoverable by later focus/reveal + // samples. A visible-pty sample would instead let a slow boot latch a + // known-agent shell-confirm that clears launch identity and blocks recovery. + // A real OSC 133;C, if it arrives, simply supersedes this. + if (options.sampleVisibleForegroundAgent === true) { + session.sampleVisiblePaneForegroundAgent() + } else if (options.seedInitialAgentStatus === true) { + const freshSpawnLaunchAgent = session.resolveExpectedLaunchTuiAgent() + if (freshSpawnLaunchAgent) { + session.paneForegroundAgentTracker.onCommandStarted(freshSpawnLaunchAgent) + } + } + } + + session.onPtySpawn = (ptyId: string): void => { + if (!session.claimCapturedDirectSshRetryPty(ptyId)) { + // Why: this callback proves a fresh process was created, so rejecting its obsolete lease must also retire it. + queueMicrotask(() => { + if (session.transport.getPtyId() === ptyId) { + session.transport.disconnect() + } + }) + return + } + // Why: record that this exact PTY was freshly spawned (not reattached), so a + // newborn shell that dies before any interaction (e.g. failing direnv on a + // just-created worktree) can be kept visible rather than tearing down the + // worktree. Reattach/coldRestore skip onPtySpawn (pty-transport.ts). + session.spawnedFreshPtyId = ptyId + // Why: Command Code has no prompt-start hook. Seed the visible working row + // once the PTY exists, then let real hook events refine or complete it. + session.bindActivePanePty(ptyId, { seedInitialAgentStatus: true }) + // Spend queued startup only after this pane owns a concrete PTY. + try { + session.deps.onQueuedStartupSpawned?.() + } catch { + // Do not strand a successful spawn because a delivery callback failed. + } + } + session.onPtyRebind = (ptyId: string, replacedPtyId: string): void => { + if (!session.canAdoptCapturedDirectSshRetryPty(ptyId)) { + return + } + // Why: provider handle rotation keeps the existing pane/session generation; + // replace its stale store identity without fresh-spawn exit semantics. + session.bindActivePanePty(ptyId, { replacePtyId: replacedPtyId }) + } + // ─── Attention signal: BEL ──────────────────────────────────────────── + // + // BEL (0x07) is the attention signal. A BEL raises tab- and worktree-level + // indicators, and fires an OS notification. The experimental pane marker + // clears when the user interacts with the exact pane. + // + // The one case where BEL falsely fires is when a crashed TUI left DEC + // private mode 1004 (focus event reporting) enabled — pane clicks then + // emit `\e[I`/`\e[O` into the shell, zsh treats them as unbound keys and + // rings the bell. This is specific to terminals with cross-restart + // persistence (as we have); our fix is to reset 1004 and friends after + // scrollback replay so the mode state matches the fresh shell + // underneath. See POST_REPLAY_MODE_RESET in shared/terminal-mode-reset-profiles.ts. + session.onBell = (): void => { + // Why: restored Claude Code sessions have been observed to emit a real + // standalone BEL some time after daemon snapshot reattach, even when Orca + // did not just forward focus/control input. Treat the BEL as authoritative + // PTY output here; any product-side suppression should be an explicit UX + // decision higher up, not a transport-layer guess. + session.deps.markWorktreeUnread(session.deps.worktreeId) + session.deps.markTerminalTabUnread(session.deps.tabId) + if (useAppStore.getState().settings?.experimentalTerminalAttention === true) { + session.deps.markTerminalPaneUnread(session.cacheKey) + } + // Why: agent CLIs often emit BEL in the same completion burst as their + // working->idle title change. Delay only the OS notification so the richer + // agent-complete notification can win the main-process worktree cooldown. + session.pendingTerminalBellNotification = true + if (!session.hasPendingAgentTaskCompleteNotification()) { + session.scheduleTerminalBellNotification() + } + } + + session.clearTerminalBellNotificationTimer = (): void => { + if (session.terminalBellNotificationTimer !== null) { + clearTimeout(session.terminalBellNotificationTimer) + session.terminalBellNotificationTimer = null + } + } + + session.scheduleTerminalBellNotification = (): void => { + if (session.terminalBellNotificationTimer !== null) { + return + } + session.terminalBellNotificationTimer = setTimeout(() => { + session.terminalBellNotificationTimer = null + if (session.disposed) { + session.pendingTerminalBellNotification = false + return + } + if (session.hasPendingAgentTaskCompleteNotification()) { + return + } + session.pendingTerminalBellNotification = false + session.deps.dispatchNotification({ source: 'terminal-bell', paneKey: session.cacheKey }) + }, AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + } + + session.hasPendingAgentTaskCompleteNotification = (): boolean => + isAgentTaskCompleteNotificationEnabled() && + (session.agentCompletionCoordinator.hasPendingHookDoneCompletion() || + session.agentTaskCompleteNotificationGraceTimer !== null || + session.agentTaskCompleteNotificationMaxTimer !== null || + session.agentTaskCompleteStatusUnsubscribe !== null) + + session.clearPendingAgentTaskCompleteNotification = (): void => { + if (session.agentTaskCompleteNotificationGraceTimer !== null) { + clearTimeout(session.agentTaskCompleteNotificationGraceTimer) + session.agentTaskCompleteNotificationGraceTimer = null + } + if (session.agentTaskCompleteNotificationMaxTimer !== null) { + clearTimeout(session.agentTaskCompleteNotificationMaxTimer) + session.agentTaskCompleteNotificationMaxTimer = null + } + if (session.agentTaskCompleteStatusUnsubscribe !== null) { + session.agentTaskCompleteStatusUnsubscribe() + session.agentTaskCompleteStatusUnsubscribe = null + } + } + + session.syncAgentTaskCompleteTrackingEnabled = (): boolean => { + const enabled = isAgentTaskCompleteTrackingEnabled() + const osNotificationsEnabled = isAgentTaskCompleteNotificationEnabled() + if ( + !osNotificationsEnabled && + session.wasAgentTaskCompleteOsNotificationEnabled && + session.pendingTerminalBellNotification + ) { + session.scheduleTerminalBellNotification() + } + if (!enabled && session.wasAgentTaskCompleteTrackingEnabled) { + // Why: disabling every completion consumer is an event-time boundary. + // Drop pending alerts while preserving accepted-hook lifecycle state. + session.agentTaskCompleteNotificationGeneration += 1 + session.requiresFreshWorkingForAgentTaskCompleteNotification = true + session.clearPendingAgentTaskCompleteNotification() + if (session.pendingTerminalBellNotification) { + session.scheduleTerminalBellNotification() + } + } else if (enabled && !session.wasAgentTaskCompleteTrackingEnabled) { + // Why: a pane may have observed work while all completion consumers were + // disabled. Re-enabling should not let the next idle event report old work. + session.requiresFreshWorkingForAgentTaskCompleteNotification = true + } + session.wasAgentTaskCompleteTrackingEnabled = enabled + session.wasAgentTaskCompleteOsNotificationEnabled = osNotificationsEnabled + return enabled + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts new file mode 100644 index 00000000000..ded2c9e208d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts @@ -0,0 +1,95 @@ +import { serializeWithAbsoluteCursor } from '../../../../../shared/terminal-serialize-absolute-cursor' +import { isTerminalWritePipelineCertifiedDead } from '@/lib/pane-manager/terminal-write-pipeline-health' +import { registerPtySerializer, registerPtyTitleSource } from '../pty-buffer-serializer' +import { + discardTerminalOutput, + waitForTerminalOutputParsed +} from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** Serializer and title-source registration for a bound PTY, plus the replay write queue. */ +export function bindRegisterPaneSerializer(session: ConnectPanePtySession): void { + session.registerPaneSerializerFor = (ptyId: string): void => { + // Why: StrictMode mounts panes twice; the first mount is session.disposed + // before the second runs, but its pty:spawn IPC may have resolved by + // the time `session.disposed` flips. Without this guard, the session.disposed first + // mount would register against a torn-down xterm and replace the live + // second-mount registration via owner-token shadowing. + if (session.disposed) { + return + } + const unregisterSerializer = registerPtySerializer( + ptyId, + async (opts) => { + try { + if (isTerminalWritePipelineCertifiedDead(session.pane.terminal)) { + return null + } + await waitForTerminalOutputParsed(session.pane.terminal) + // Certification can land while the serializer waits for an older + // write; never publish a fossil frame from a dead renderer. + if (isTerminalWritePipelineCertifiedDead(session.pane.terminal)) { + return null + } + // Why: alt-screen TUIs (vim, claude-code) hold transient state in + // the alternate screen. The hydration path requests + // altScreenForcesZeroRows so normal-buffer scrollback isn't bled + // into the seed when the user is mid-TUI; the read-fallback path + // omits it because it wants the user's currently-visible content. + const alt = session.pane.terminal.buffer.active.type === 'alternate' + // Why serializeWithAbsoluteCursor: SerializeAddon's relative + // cursor restore lands one column short when replay of a + // margin-filling final row leaves the target wrap-pending. + const data = + opts?.altScreenForcesZeroRows && alt + ? serializeWithAbsoluteCursor(session.pane.serializeAddon, session.pane.terminal, { + scrollback: 0 + }) + : serializeWithAbsoluteCursor(session.pane.serializeAddon, session.pane.terminal, { + scrollback: opts?.scrollbackRows + }) + const orderedSeq = + session.rendererOrderedPtyId === ptyId ? session.rendererOrderedSeq : null + // Why snapshotFlags and not `flags`: this pane may itself have + // consumed an old-host snapshot that proved nothing, and its + // conservative `0` fallback must not be republished downstream as + // a host-proven inactive protocol. + const provenKittyFlags = session.kittyKeyboardModes.hasProvenBaseline + ? session.kittyKeyboardModes.snapshotFlags + : undefined + return { + data, + cols: session.pane.terminal.cols, + rows: session.pane.terminal.rows, + ...(orderedSeq !== null ? { seq: orderedSeq } : {}), + ...(orderedSeq !== null && provenKittyFlags !== undefined + ? { kittyKeyboardFlags: provenKittyFlags } + : {}) + } + } catch { + return null + } + }, + () => { + session.clearHiddenOutputRestoreState() + discardTerminalOutput(session.pane.terminal) + clearTerminalScrollbackAndFollowOutput(session.pane.terminal) + } + ) + const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => + session.pane.terminal.onTitleChange(handler) + ) + const origOnDataDisposableDispose = session.onDataDisposable.dispose.bind( + session.onDataDisposable + ) + session.onDataDisposable.dispose = () => { + unregisterTitleSource() + unregisterSerializer() + origOnDataDisposableDispose() + } + } + + session.replayWriteQueue = Promise.resolve() +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts new file mode 100644 index 00000000000..9ca1202e9b1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -0,0 +1,226 @@ +import { useAppStore } from '@/store' +import type { SessionRestoredBannerReason } from '../session-restored-banner-pane-state' +import { hasPtySerializer } from '../pty-buffer-serializer' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import { isExpectedAgentProcess } from '../../../../../shared/agent-process-recognition' +import { resolveDraftPasteReadyTimeoutMs } from '../../../../../shared/draft-paste-ready-timeout' +import { createDraftPasteReadyScanner } from '../../../../../shared/draft-paste-ready-scanner' +import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' +import { writeTerminalPastePtyInput } from '../terminal-pty-paste-writer' + +import { STARTUP_DRAFT_PASTE_QUIET_MS } from './pty-connect-limits' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { + session.settlePaneSerializerAfterReplay = async ( + ptyId: string, + generation: number + ): Promise => { + try { + await session.replayWriteQueue + if (session.disposed || session.transport.getPtyId() !== ptyId) { + await window.api.pty + .clearPendingPaneSerializer(session.cacheKey, generation) + .catch(() => {}) + return + } + await waitForTerminalOutputParsed(session.pane.terminal) + if (!session.disposed && session.transport.getPtyId() === ptyId) { + await window.api.pty.settlePaneSerializer(session.cacheKey, generation) + return + } + } catch { + // Clear below so a failed parser/replay cannot leave the pane generation pending. + } + await window.api.pty.clearPendingPaneSerializer(session.cacheKey, generation).catch(() => {}) + } + session.reportRemoteRendererSerializerReady = (): void => { + const ptyId = session.transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + return + } + if (!hasPtySerializer(ptyId)) { + session.registerPaneSerializerFor(ptyId) + } + // Why: onSubscribed follows the snapshot callback, but replay drains + // asynchronously; join it and xterm's parser before reporting readiness. + void session.replayWriteQueue + .then(() => waitForTerminalOutputParsed(session.pane.terminal)) + .then(() => { + if (!session.disposed && session.transport.getPtyId() === ptyId) { + void window.api.pty.reportRendererSerializerReady?.(ptyId) + } + }) + .catch(() => {}) + } + + // Why: local and ordinary SSH startup commands are provider-owned so delivery + // survives renderer replacement. Only explicit terminal-paste stays renderer-owned. + session.pendingStartupCommand = session.shouldDeliverStartupViaTerminalPaste + ? session.paneStartup?.command + ? { command: session.paneStartup.command } + : null + : null + const startupDraftReadyScanner = session.ownsStartupDraftPaste + ? createDraftPasteReadyScanner( + session.startupDraftAgentConfig?.draftPasteReadySignal ?? + 'render-quiet-after-bracketed-paste' + ) + : null + let startupDraftReadinessArmed = false + let startupDraftPasteSettled = !session.ownsStartupDraftPaste + let startupDraftPasteInFlight = false + let startupDraftInputRecorded = false + let startupDraftQuietTimer: ReturnType | null = null + let startupDraftHardTimer: ReturnType | null = null + const clearStartupDraftPasteTimers = (): void => { + if (startupDraftQuietTimer !== null) { + clearTimeout(startupDraftQuietTimer) + startupDraftQuietTimer = null + } + if (startupDraftHardTimer !== null) { + clearTimeout(startupDraftHardTimer) + startupDraftHardTimer = null + } + } + session.cleanupStartupDraftPasteTimers = clearStartupDraftPasteTimers + const getStartupDraftPtyId = (): string | null => { + const ptyId = session.transport.getPtyId() + if ( + !ptyId || + session.disposed || + session.deps.paneTransportsRef.current.get(session.pane.id) !== session.transport + ) { + return null + } + return ptyId + } + const sendStartupDraftPaste = (): void => { + if ( + !session.startupDraftPrompt || + startupDraftPasteSettled || + startupDraftPasteInFlight || + !startupDraftReadinessArmed + ) { + return + } + const ptyId = getStartupDraftPtyId() + if (!ptyId) { + return + } + startupDraftPasteInFlight = true + startupDraftPasteSettled = true + session.startupDraftPasteAttempted = true + session.cleanupStartupDraftPasteTimers() + const settings = getSettingsForWorktreeRuntimeOwner( + useAppStore.getState(), + session.deps.worktreeId + ) + // Why: xterm focus reports share this transport queue. Bypassing it can + // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. + void sendAgentDraftPasteContent(settings, ptyId, session.startupDraftPrompt, async (data) => { + const accepted = await writeTerminalPastePtyInput(session.transport, data) + if (accepted && !startupDraftInputRecorded) { + // Why: this transport write bypasses xterm's user-input signal; keep + // the composed draft from being discarded by later hibernation. + startupDraftInputRecorded = true + session.recordTerminalInputForHibernation() + } + return accepted + }) + .catch(() => false) + .finally(() => { + startupDraftPasteInFlight = false + }) + } + const deliverStartupDraftIfAgentOwnsPty = async (): Promise => { + if (!session.startupDraftAgentConfig || startupDraftPasteSettled) { + return + } + const ptyId = getStartupDraftPtyId() + if (!ptyId) { + return + } + const settings = getSettingsForWorktreeRuntimeOwner( + useAppStore.getState(), + session.deps.worktreeId + ) + try { + const process = await inspectRuntimeTerminalProcess(settings, ptyId) + const foreground = process.foregroundProcess?.toLowerCase() ?? '' + if ( + getStartupDraftPtyId() === ptyId && + isExpectedAgentProcess(foreground, session.startupDraftAgentConfig.expectedProcess) + ) { + sendStartupDraftPaste() + } + } catch { + // Best-effort fallback; the primary path is the PTY readiness marker. + } + } + const armStartupDraftHardTimer = (): void => { + if (!startupDraftReadyScanner || startupDraftPasteSettled || startupDraftHardTimer !== null) { + return + } + startupDraftHardTimer = setTimeout(() => { + startupDraftHardTimer = null + void deliverStartupDraftIfAgentOwnsPty() + }, resolveDraftPasteReadyTimeoutMs(session.startupDraftAgent)) + } + const armStartupDraftQuietTimer = (): void => { + if (!startupDraftReadyScanner || startupDraftPasteSettled) { + return + } + if (startupDraftQuietTimer !== null) { + clearTimeout(startupDraftQuietTimer) + } + startupDraftQuietTimer = setTimeout(() => { + startupDraftQuietTimer = null + sendStartupDraftPaste() + }, STARTUP_DRAFT_PASTE_QUIET_MS) + } + session.armStartupDraftReadinessObservation = (): void => { + if (!startupDraftReadyScanner || startupDraftReadinessArmed) { + return + } + startupDraftReadinessArmed = true + armStartupDraftHardTimer() + } + session.observeStartupDraftPasteReadiness = (data: string): void => { + if (!startupDraftReadyScanner || !startupDraftReadinessArmed || startupDraftPasteSettled) { + return + } + const scanned = startupDraftReadyScanner.observe(data) + if (scanned.ready) { + sendStartupDraftPaste() + return + } + if (scanned.armQuietTimer) { + armStartupDraftQuietTimer() + } + } + if ( + session.ownsStartupDraftPaste && + !session.connectionId && + !session.shouldDeliverStartupViaTerminalPaste + ) { + session.armStartupDraftReadinessObservation() + } + let sessionRestoredBannerShown: SessionRestoredBannerReason | null = null + session.showSessionRestoredBanner = (reason: SessionRestoredBannerReason = 'restored'): void => { + // Why: a plain 'restored' banner must not latch out the later 'resume-unavailable' + // upgrade — the pane would keep claiming a session it never got back. + if ( + sessionRestoredBannerShown === reason || + sessionRestoredBannerShown === 'resume-unavailable' + ) { + return + } + sessionRestoredBannerShown = reason + session.deps.onShowSessionRestoredBanner(session.pane.id, reason) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts b/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts new file mode 100644 index 00000000000..086b894ebff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts @@ -0,0 +1,5 @@ +export type PendingHiddenOutputRestoreChunk = { + data: string + seq?: number + rawLength?: number +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/process-exit-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/process-exit-startup.ts new file mode 100644 index 00000000000..824a26cdba0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/process-exit-startup.ts @@ -0,0 +1,19 @@ +import type { PtyPaneStartup } from '../pty-connection-types' + +import type { ColdRestoreAgentResumeStartup, PendingStartupCommand } from './fresh-spawn-types' + +export function toProcessExitStartup( + startup: PendingStartupCommand | ColdRestoreAgentResumeStartup | null +): PtyPaneStartup { + return startup && 'launchConfig' in startup && 'agent' in startup + ? { + command: startup.command, + env: startup.env, + launchConfig: startup.launchConfig, + resumeProviderSession: startup.resumeProviderSession, + launchToken: startup.launchToken, + launchAgent: startup.agent, + showSessionRestoredBanner: true + } + : startup +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts new file mode 100644 index 00000000000..82e850eef19 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts @@ -0,0 +1,31 @@ +import { e2eConfig } from '@/lib/e2e-config' + +export const pendingSpawnByPaneKey = new Map>() +export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' +// Why: relay requests expire at 30s; leave one second for their fallback before re-arming locally. +export const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000 +export const REMOTE_PTY_ID_PREFIX = 'remote:' +export const PTY_CONNECT_DIAG_LIMIT = 200 +export const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 +export const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 +// Why a grace window instead of a plain flag: a connect that never settles +// (SSH RPC timeout class, wedged daemon call) would otherwise suppress +// input-triggered recovery FOREVER — and such a pane has no output flowing, +// so no other detector can fire. Past the grace, undeliverable input may +// recover again; the transport's destroyed-check no longer kills a +// pre-existing session when a late reattach resolves, so a remount racing +// a slow-but-alive connect costs a wasted view rebuild, not a shell. +export const TRANSPORT_CONNECT_SETTLE_GRACE_MS = 60_000 + +export function recordPtyConnectDiagnostic(message: string): void { + if (!e2eConfig.exposeStore) { + return + } + console.log(`[pty-connect] ${message}`) + const target = globalThis as Record + const diag = (target.__ptyConnectDiag ??= [] as string[]) as string[] + diag.push(message) + if (diag.length > PTY_CONNECT_DIAG_LIMIT) { + diag.splice(0, diag.length - PTY_CONNECT_DIAG_LIMIT) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts new file mode 100644 index 00000000000..0105c1c532d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts @@ -0,0 +1,356 @@ +import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { useAppStore } from '@/store' +import { + consumeCommittedPtyShutdownExit, + deferPtyShutdownExit, + isHostPtySleepPending +} from '../pty-shutdown-exit-deferral' +import { replayIntoTerminal } from '../replay-guard' +import { POST_REPLAY_MODE_RESET } from '../../../../../shared/terminal-mode-reset-profiles' +import { + getProviderSessionClaimKey, + isPassiveCompletedHibernationEvidence +} from '@/lib/sleeping-agent-pane-ownership' +import { + createGitBashConsoleCapacityDetector, + type GitBashConsoleCapacityDetector +} from '../git-bash-console-capacity' +import type { PtyPaneStartup } from '../pty-connection-types' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** PTY exit handling, hibernated-pane wake targets, and post-exit focus transfer. */ +export function installPtyExitHibernate(session: ConnectPanePtySession): void { + type ProcessExitState = { + startup: PtyPaneStartup + detector: GitBashConsoleCapacityDetector + } + session.createProcessExitState = (startup: PtyPaneStartup): ProcessExitState => ({ + startup, + detector: createGitBashConsoleCapacityDetector() + }) + session.currentProcessExitState = session.createProcessExitState(session.paneStartup) + session.processExitStateByPtyId = new Map() + session.bindProcessExitState = (ptyId: string, replacedPtyId?: string): void => { + const state = + (replacedPtyId ? session.processExitStateByPtyId.get(replacedPtyId) : undefined) ?? + session.currentProcessExitState + session.processExitStateByPtyId.set(ptyId, state) + if (replacedPtyId && replacedPtyId !== ptyId) { + session.processExitStateByPtyId.delete(replacedPtyId) + } + } + session.focusSurvivingPtyPaneAfterKeptExit = (): void => { + if (session.manager.getActivePane()?.id !== session.pane.id) { + return + } + const hasPtyBinding = (paneId: number): boolean => + Boolean(session.deps.paneTransportsRef.current.get(paneId)?.getPtyId()) + const repairedActiveLeafId = + useAppStore.getState().terminalLayoutsByTabId[session.deps.tabId]?.activeLeafId ?? null + const repairedActivePaneId = repairedActiveLeafId + ? session.manager.getNumericIdForLeaf(repairedActiveLeafId) + : null + const targetPaneId = + repairedActivePaneId !== null && + repairedActivePaneId !== session.pane.id && + hasPtyBinding(repairedActivePaneId) + ? repairedActivePaneId + : (session.manager + .getPanes() + .find((candidate) => candidate.id !== session.pane.id && hasPtyBinding(candidate.id)) + ?.id ?? null) + if (targetPaneId !== null) { + // Why: when a newborn split PTY dies before output/input, the pane stays + // mounted for diagnostics; move live focus to the sibling that still owns a PTY. + session.manager.setActivePane(targetPaneId, { + focus: session.deps.isActiveRef.current && session.deps.isVisibleRef.current + }) + } + } + + // Why: the transport's own exit handler (pty-transport.ts) normally makes + // onExit run-at-most-once by clearing connected/ptyId + unregistering BEFORE + // calling it. reconcileIfSessionDead drives onExit directly (bypassing that), + // so this guards the body so reconcile and any racing real/synthetic pty:exit + // for the same id close the pane exactly once. Scoped to the exiting ptyId + // (not a bare boolean): an intentional suppressed restart keeps the pane + // mounted and rebinds to a NEW ptyId, and that replacement's later real exit + // must still run — a one-shot boolean would strand the pane on rebind. + session.handledExitPtyId = null + // Why: tracks the ptyId of a genuine fresh spawn — onPtySpawn fires only for + // fresh spawns, never reattach/coldRestore (pty-transport.ts). Lets the + // sole-pane exit branch tell "this newborn shell died on its own" from "a + // reattached persisted session was already dead", so a failing .envrc/direnv + // on a brand-new worktree keeps its dead terminal visible instead of bouncing + // the user to Landing. + session.spawnedFreshPtyId = null + // Why: hibernation suppresses its kill's exit while the pane is hidden, so + // onExit must not tear the pane down — but the pane still owes the user a + // wake. Remember the hibernated PTY and exact record; the visibility-resume + // hook consumes both and cannot accidentally adopt a later stale record. + session.hibernatedWakeTarget = null + session.wakeHibernatedAgentPane = null + // Why: a mobile wake can land after the sleeping record is written but + // before the suppressed kill exit arms the wake target. The phone never + // reveals the desktop pane, so without a latch the edge-triggered wake would + // be dropped and the phone left on a frozen terminal. + session.pendingHibernatedWakeTarget = null + // Why: transport.connect settles asynchronously. Repeated mobile activation + // must keep claiming this provider session until the replacement PTY either + // exists (and clears the sleep record) or the spawn fails and can be retried. + session.hibernatedWakeInFlightClaimKey = null + // Why: reveal is the normal wake trigger, but a reveal that lands *during* the + // in-flight hibernation kill runs noteVisibilityResume before onExit arms the + // wake. Sharing the guarded consume lets both the reveal hook and the + // arm-time foreground check resume the pane exactly once. + session.consumeHibernatedAgentWake = (claimedProviderSessions?: Set): string | null => { + const target = session.hibernatedWakeTarget + if (!target || session.disposed) { + return null + } + if (session.deps.paneTransportsRef.current.get(session.pane.id) !== session.transport) { + return null + } + const currentRecord = session.getSleepingRecordForPane(useAppStore.getState())?.record + if (currentRecord !== target.record) { + session.hibernatedWakeTarget = null + session.pendingHibernatedWakeTarget = null + return null + } + const currentPtyId = session.transport.getPtyId() + // Why: a real pty:exit clears the transport's ptyId before onExit while a + // reconcile-driven exit leaves it bound; both mean "nothing respawned since + // hibernation". A different non-null id means another flow (e.g. an + // intentional restart) already rebound the pane — its spawn wins. + if (currentPtyId !== null && currentPtyId !== target.ptyId) { + session.hibernatedWakeTarget = null + session.pendingHibernatedWakeTarget = null + return null + } + if (!session.wakeHibernatedAgentPane) { + return null + } + const claimKey = getProviderSessionClaimKey(target.record) + if (claimedProviderSessions?.has(claimKey)) { + return null + } + // Why: one wake event can visit multiple mounted legacy/stable panes for + // the same provider session. Claim synchronously before any spawn starts. + claimedProviderSessions?.add(claimKey) + session.hibernatedWakeTarget = null + session.pendingHibernatedWakeTarget = null + session.hibernatedWakeInFlightClaimKey = claimKey + // Why: reveal is the wake signal for a hibernated pane. Resume the recorded + // agent session (or fall back to a fresh shell) instead of leaving the + // frozen frame with no PTY behind it. + void session + .wakeHibernatedAgentPane() + .then((spawnedPtyId) => { + if (!spawnedPtyId) { + // Why: a transient replacement-spawn failure leaves the passive + // record owned by this pane. Re-arm the exact target so a later + // mobile open can retry instead of stranding the frozen session; + // consume revalidates disposal, binding, PTY, and record identity. + session.hibernatedWakeTarget = target + } + }) + .finally(() => { + if (session.hibernatedWakeInFlightClaimKey === claimKey) { + session.hibernatedWakeInFlightClaimKey = null + } + }) + return claimKey + } + session.onExit = ( + ptyId: string, + exitCode = 0, + opts: { preserveRendererBinding?: boolean } = {} + ): void => { + if (session.handledExitPtyId === ptyId) { + return + } + if ( + session.deps.isPtyShutdownPending(ptyId) || + isHostPtySleepPending(ptyId, session.runtimeEnvironmentId) + ) { + // Why: the transport emits exit once; replay it only after a verified commit so rollback keeps renderer state retryable. + deferPtyShutdownExit(ptyId, (settlement) => { + if (settlement === 'committed') { + session.onExit(ptyId, exitCode, { preserveRendererBinding: true }) + } + }) + return + } + const preserveRendererBinding = + opts.preserveRendererBinding === true || + consumeCommittedPtyShutdownExit(ptyId, session.runtimeEnvironmentId) + session.resetRendererOrderedSeqForPtyExit(ptyId) + const currentPaneTransport = session.deps.paneTransportsRef.current.get(session.pane.id) + if (currentPaneTransport && currentPaneTransport !== session.transport) { + // Why: an old transport can deliver a late exit after this pane has + // rebound to a replacement PTY; only clear ownership for the exited id. + session.handledExitPtyId = ptyId + session.processExitStateByPtyId.delete(ptyId) + if (!preserveRendererBinding) { + session.deps.clearTabPtyId(session.deps.tabId, ptyId) + } + session.deps.consumeSuppressedPtyExit(ptyId) + scheduleRuntimeGraphSync() + return + } + session.handledExitPtyId = ptyId + const processExitState = + session.processExitStateByPtyId.get(ptyId) ?? session.currentProcessExitState + session.processExitStateByPtyId.delete(ptyId) + session.agentCompletionCoordinator.dispose() + session.dropSideEffectFactConsumer() + // Why: main clears gate state on PTY exit too; this only resets the + // pane-local marker so a reused pane cannot skip re-marking a new PTY. + session.releaseHiddenRendererPtyDelivery() + session.clearPanePtyFitBinding() + // Why: the negotiating application died with its PTY; any replacement + // session starts with kitty keyboard flags at zero. + session.kittyKeyboardModes.reset() + const isSuppressedExit = session.deps.consumeSuppressedPtyExit(ptyId) || preserveRendererBinding + if (!isSuppressedExit) { + session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, ptyId) + } + session.deps.clearRuntimePaneTitle(session.deps.tabId, session.pane.id) + if (!preserveRendererBinding) { + session.deps.clearTabPtyId(session.deps.tabId, ptyId) + } + // Why: if the PTY exits abruptly (Ctrl-D, crash, shell termination) without + // first emitting a non-agent title, the cache timer would persist as stale + // state. Clear it unconditionally on PTY exit. + session.deps.setCacheTimerStartedAt(session.cacheKey, null) + // Why: a dead terminal has no running agent — remove its explicit status + // entry so the hover UI only shows what is running *now*. + useAppStore.getState().removeAgentStatus(session.cacheKey) + useAppStore.getState().clearPaneForegroundAgent(session.cacheKey) + // The runtime graph is the CLI's source for live terminal bindings, so + // we must republish when a pane loses its PTY instead of waiting for a + // broader layout change that may never happen. + scheduleRuntimeGraphSync() + // Why: intentional restarts suppress the PTY exit ahead of time so the + // pane stays mounted and can reconnect in place. Without consuming the + // suppression here, split-pane Codex restarts would still close the pane + // because this handler runs before the tab-level close logic sees the exit. + if (isSuppressedExit) { + // Why: the action that suppressed the exit owns whether the leaf binding + // is a wake hint or should be discarded; runtime cleanup above is enough. + session.manager.setPaneGpuRendering(session.pane.id, true) + const sleepingRecordEntry = session.getSleepingRecordForPane(useAppStore.getState()) + if ( + sleepingRecordEntry && + isPassiveCompletedHibernationEvidence(sleepingRecordEntry.record) + ) { + // Why: hibernation killed this pane's PTY while hidden. The frozen TUI + // frame still has mouse-tracking/bracketed-paste armed, which silently + // eats every click and keystroke against a dead transport — disarm the + // modes now and arm the reveal-time wake. + replayIntoTerminal(session.pane, session.deps.replayingPanesRef, POST_REPLAY_MODE_RESET, { + breadcrumbIdentity: { + tabId: session.deps.tabId, + worktreeId: session.deps.worktreeId, + ptyId + }, + shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously + }) + session.hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record } + const pendingWakeMatches = + session.pendingHibernatedWakeTarget?.ptyId === ptyId && + session.pendingHibernatedWakeTarget.record === sleepingRecordEntry.record + if (session.pendingHibernatedWakeTarget && !pendingWakeMatches) { + session.pendingHibernatedWakeTarget = null + } + if (session.deps.isVisibleRef.current || pendingWakeMatches) { + // Why: a reveal (or a mobile wake) that raced this kill already ran + // before the exit landed, so it saw nothing armed. Consume the wake + // now (deferred off the exit handler) so the pane still resumes + // without needing a second hide/reveal or wake event. + queueMicrotask(() => { + session.consumeHibernatedAgentWake() + }) + } + } else if (session.pendingHibernatedWakeTarget?.ptyId === ptyId) { + session.pendingHibernatedWakeTarget = null + } + return + } + session.manager.setPaneGpuRendering(session.pane.id, true) + const failedLocalProcess = + !session.connectionId && session.runtimeEnvironmentId === null && exitCode !== 0 + if (failedLocalProcess && session.deps.onPaneProcessDied) { + const gitBashConsoleCapacityFailure = processExitState.detector.detected() + session.deps.onPaneProcessDied({ + paneId: session.pane.id, + exitCode, + startup: gitBashConsoleCapacityFailure ? processExitState.startup : null, + reason: gitBashConsoleCapacityFailure ? 'git-bash-console-capacity' : 'process-failed' + }) + return + } + const panes = session.manager.getPanes() + if (panes.length <= 1) { + // Why: a worktree's sole newborn terminal can die on shell startup — e.g. + // a PR branch ships an .envrc whose direnv command fails, so the login + // shell exits non-zero immediately. Routing that through onPtyExitRef + // closes the only tab, which deactivates the worktree (setActiveWorktree + // (null)) and strands the user on the Landing screen for a worktree that + // was just created. Keep the dead pane mounted instead (mirrors the + // freshly-split guard below) so the direnv error stays visible and the + // worktree stays active. Gated on a genuine fresh spawn (onPtySpawn fired + // for this ptyId — reattach/coldRestore skip it) that the user never typed + // into, so a reattached-dead session or an explicit `exit` still tears + // down as before. + if (session.spawnedFreshPtyId === ptyId && !Number.isFinite(session.lastTerminalInputAt)) { + return + } + session.deps.onPtyExitRef.current(ptyId) + return + } + if ( + session.deps.isVisibleRef.current && + session.hadExistingPaneTransportAtConnect && + !session.restoredPtyIdForTransport && + !Number.isFinite(session.lastTerminalInputAt) && + !session.hasReceivedPtyOutput + ) { + // Why: a freshly split pane can lose its newborn PTY during setup; keep + // the split visible so the failed session does not immediately collapse. + // Hidden panes must close instead: the hidden-delivery gate withholds + // their bytes, so "no output" is meaningless there, and keeping one + // strands a binding-less pane the exit path never revisits — it remounts + // as a permanently blank ghost on reveal. + session.focusSurvivingPtyPaneAfterKeptExit() + return + } + session.manager.closePane(session.pane.id) + } + + // Why: on app restart, restored Claude tabs may already be idle when we first + // see their title. The agent status tracker only fires onBecameIdle for + // working→idle transitions, so the cache timer would never start for these + // sessions. We only allow this one-time seed for reattached PTYs; fresh + // Claude launches also start idle, but they have no prompt cache yet. + session.hasConsideredInitialCacheTimerSeed = false + session.allowInitialIdleCacheSeed = false + + session.resolveCurrentAgentStatusRouting = () => { + const ptyId = session.activePanePtyBinding ?? session.transport.getPtyId() + const state = useAppStore.getState() + if (session.disposed || !ptyId) { + return undefined + } + return resolveLiveAgentStatusConnectionRouting({ + state, + paneKey: session.cacheKey, + ptyId, + expectedConnectionId: session.worktreeConnectionId, + runtimeEnvironmentId: + session.transport.getRuntimeEnvironmentId?.() ?? session.runtimeEnvironmentId + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts new file mode 100644 index 00000000000..1c53e318dab --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -0,0 +1,372 @@ +import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' +import { installTerminalImeCompositionRoute } from '../terminal-ime-composition-route' +import { useAppStore } from '@/store' +import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' +import { safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' +import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' +import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' +import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' +import { getAppliedSizeReadE2eDelayMs } from '../pty-applied-size-read-e2e-delay' +import { createPtySizeReassertion } from '../pty-size-reassertion' +import { isPaneReplaying } from '../replay-guard' +import { shouldDropQuarantinedTerminalInput } from '../terminal-input-quarantine' +import { + PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, + queuePanePtyResizeIfHeld, + type PanePtyResizeHoldFlushDetail +} from '@/lib/pane-manager/pane-pty-resize-hold' + +import { FOREGROUND_GRID_DRIFT_CHECK_MIN_MS } from './foreground-output-budgets' +import { TERMINAL_FOCUS_IN_SEQUENCE, TERMINAL_FOCUS_OUT_SEQUENCE } from './foreground-output-scan' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { isCodexPaneStale } from './codex-pane-stale' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function installPtyInputForward(session: ConnectPanePtySession): void { + session.forwardPtyInput = (data: string): void => { + // Why: xterm auto-replies to embedded query sequences (DA1, DECRQM, + // OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes + // into xterm for scrollback/cold-restore/snapshot, those queries would + // otherwise pipe replies into the freshly spawned shell as stray input + // ("?1;2c", "2026;2$y", OSC color fragments, ...). The replay sites + // engage the guard via replayIntoTerminal; here we drop everything + // xterm emits while the guard is active. See replay-guard.ts. + if (isPaneReplaying(session.deps.replayingPanesRef, session.pane.id)) { + return + } + const currentPtyId = session.transport.getPtyId() + // Why: after a Codex account switch, the runtime auth has already moved to + // the newly selected account. Stale panes must not keep sending input until + // they restart, or work can execute under the wrong account while the UI + // still says the pane is stale. Fall back to the tab's persisted PTY ID so + // the block still holds during reconnect races before the live transport has + // updated its local PTY binding. + if ( + isCodexPaneStale({ + tabId: session.deps.tabId, + worktreeId: session.deps.worktreeId, + panePtyId: currentPtyId + }) + ) { + session.clearPendingTerminalInputIntent() + return + } + // Why: presence-lock input drop. While mobile is the driver for this + // PTY, desktop keystrokes must not reach the shell; the visible overlay's + // explicit Take back action owns restoring desktop input and dimensions. + if (currentPtyId && isPtyLocked(currentPtyId)) { + session.clearPendingTerminalInputIntent() + return + } + if ( + session.isNativeWindowsConpty && + session.suppressNativeWindowsIdleCodexFocusReports && + (data === TERMINAL_FOCUS_IN_SEQUENCE || data === TERMINAL_FOCUS_OUT_SEQUENCE) + ) { + // Why: Codex can leave focus reporting armed after a Windows turn, but + // disabling the mode would permanently silence focus events on resume. + return + } + // Why: xterm answers CPR/DSR/DA queries natively through this same onData + // stream (mixed with keystrokes). Those replies are latency-critical — a + // querying program reads them in raw mode with a short timeout — so send + // them immediately, skipping the remote input debounce that would corrupt + // them (#7329). They are not user input, so they bypass intent inference and + // activity recording below. No pending-intent guard: the only intents are + // plain-escape (`\x1b`) and ctrl-c (`\x03`), neither of which can satisfy + // isTerminalQueryReply (it requires length >= 3 and a full reply grammar), + // so a real keystroke never reaches this branch. + if (isTerminalQueryReply(data)) { + session.sendDesktopQueryReplyImmediate(data) + return + } + // Why after the query-reply branch: device replies are not user input and + // must always reach the shell, or a program querying during reattach hangs. + // Why at all: a replaced endpoint reattaches to a fresh shell, so the tail + // of the interrupted line would be submitted by the user's own Enter and a + // compound command could run its surviving half (#10065 follow-up). + if (shouldDropQuarantinedTerminalInput(session.deps.tabId, data)) { + session.clearPendingTerminalInputIntent() + return + } + const intent = session.pendingTerminalInputIntent + // Why: real xterm can deliver the terminal byte even when our DOM keydown + // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to + // infer for local/remote acknowledged writes; SSH fire-and-forget remains + // excluded because those transports do not expose sendInputAccepted. + const acknowledgedIntent = intent ?? session.inferIntentFromExactTerminalInput(data) + if (acknowledgedIntent && session.transport.sendInputAccepted) { + const interruptStatusBaseline = + useAppStore.getState().agentStatusByPaneKey[session.cacheKey] ?? null + // Why: equal snapshots retain double-Escape semantics while older snapshots lose ack races. + if (session.sequencedInterruptStatusBaseline !== interruptStatusBaseline) { + session.sequencedInterruptStatusBaseline = interruptStatusBaseline + session.interruptStatusBaselineSequence += 1 + } + const capturedBaselineSequence = session.interruptStatusBaselineSequence + session.claimViewportForUserActivity() + if (acknowledgedIntent === 'ctrl-c') { + // Why: the accepted-write callback is async; let the next command be + // inferred if the user cancelled an oversized line and immediately typed. + session.cancelSuspendedShellCommandInference() + } + session.clearPendingTerminalInputIntent() + const writePromise = session.transport + .sendInputAccepted(data) + .then((accepted): boolean | Promise | null => { + if (accepted) { + // Why: rejected writes use transport recovery and must not arm a parser probe. + session.markAcceptedTerminalInputSent() + session.observeAcceptedShellCommandInput(data) + session.observeAcceptedTerminalInput(data, acknowledgedIntent) + const immediateResult = session.interruptInference.observeInputIntent( + acknowledgedIntent, + interruptStatusBaseline, + capturedBaselineSequence + ) + session.observeTitleOnlyInterrupt() + return immediateResult ?? null + } + // Why: Esc/Ctrl+C are the first keys users press on a frozen pane; + // an unbound-transport reject here must arm recovery too. + session.requestRecoveryForUndeliverableInput() + return null + }) + .catch((err) => { + console.warn('[agent-interrupt] acknowledged terminal input failed:', err) + return null + }) + session.setPendingTerminalInputWrite(writePromise) + return + } + if (intent) { + session.claimViewportForUserActivity() + if (session.transport.sendInput(data)) { + session.markAcceptedTerminalInputSent() + session.observeAcceptedShellCommandInput(data) + session.observeAcceptedTerminalInput(data, intent) + } else { + session.requestRecoveryForUndeliverableInput() + } + session.clearPendingTerminalInputIntent() + return + } + session.claimViewportForUserActivity() + if (session.transport.sendInput(data)) { + session.markAcceptedTerminalInputSent() + session.observeAcceptedShellCommandInput(data) + session.observeAcceptedTerminalInput(data) + session.observeSentTerminalInputIntent(data) + } else { + session.clearPendingTerminalInputIntent() + session.requestRecoveryForUndeliverableInput() + } + } + session.onDataDisposable = session.pane.terminal.onData((data) => { + if (session.deps.deferPtyInput) { + session.deps.deferPtyInput(session.pane.id, data, session.forwardPtyInput) + return + } + session.forwardPtyInput(data) + }) + session.imeCompositionRouteDisposable = installTerminalImeCompositionRoute({ + terminalElement: session.pane.terminal.element, + terminal: session.pane.terminal, + capturedTransport: session.transport, + getCurrentTransport: () => session.deps.paneTransportsRef.current.get(session.pane.id) + }) + + session.shouldSuppressDesktopPtyResize = (): boolean => { + const currentPtyId = session.transport.getPtyId() + return Boolean( + currentPtyId && (getFitOverrideForPty(currentPtyId) || isPtyLocked(currentPtyId)) + ) + } + + session.isRendererPtyResizeAuthoritative = (): boolean => { + if (session.deps.isVisibleRef.current) { + return true + } + // Why: hidden-tab layout churn is not authoritative; visible resume + // owns correction, and hidden SIGWINCH can reset full-screen TUIs. + return false + } + + session.forwardPtyResize = (cols: number, rows: number): void => { + if (!session.isRendererPtyResizeAuthoritative()) { + return + } + // Why: when a mobile-fit override is active OR mobile is currently the + // driver of this PTY, the PTY is already at phone dims and any desktop + // resize is wrong. Suppress resize forwarding to avoid spurious SIGWINCH + // signals (TUI flicker / wrap corruption). Both checks are needed: + // - getFitOverrideForPty covers the "phone-fit dims" state. + // - isPtyLocked covers the broader "mobile driving" state, including + // transitions where override may not be set (e.g. legacy code paths). + // The pty:resize IPC has a defense-in-depth twin. See + // docs/mobile-presence-lock.md. + if (session.shouldSuppressDesktopPtyResize()) { + return + } + if (queuePanePtyResizeIfHeld(session.pane.container, cols, rows)) { + return + } + session.transport.resize(cols, rows, { claim: true }) + } + + session.onHeldPtyResizeFlush = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail) { + return + } + session.forwardPtyResize(detail.cols, detail.rows) + } + session.pane.container.addEventListener( + PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, + session.onHeldPtyResizeFlush + ) + + session.onResizeDisposable = session.pane.terminal.onResize(({ cols, rows }) => { + if (session.suppressStructuralReplayPtyResize || session.suppressViewportClaimTerminalResize) { + return + } + session.forwardPtyResize(cols, rows) + }) + + // Why: renderer resize forwarding is fire-and-forget. A visible pane can + // finish with xterm at the right grid while the PTY silently kept an older + // grid, so Codex keeps composing against stale columns. Fit first so xterm's + // normal onResize can send, then read applied PTY size and repair only drift. + session.ptySizeReassertion = createPtySizeReassertion({ + isDisposed: () => session.disposed, + getPtyId: () => session.transport.getPtyId(), + isRemotePtyId: isRemoteRuntimePtyId, + shouldSuppressDesktopResize: () => session.shouldSuppressDesktopPtyResize(), + fitAndRun: (continuation) => safeFitAndThen(session.pane, 'pty-size-reassertion', continuation), + getTerminalDimensions: () => ({ + cols: session.pane.terminal.cols, + rows: session.pane.terminal.rows + }), + getAppliedSize: async (ptyId) => { + // Why: e2e seam — delays the read past the reveal fit to reproduce the + // busy-daemon/SSH ordering; returns 0 outside e2e builds. + const delayMs = getAppliedSizeReadE2eDelayMs() + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + return window.api.pty.getSize(ptyId) + }, + forwardResize: session.forwardPtyResize + }) + // Why built here and not inside session.handleReattachResult: a hidden pane parks this until it is + // revealed, and a closure created in that scope would pin the whole reattach payload + // (snapshot/replay/coldRestore bytes) for as long as the pane stays hidden. Taking the + // generation and pty id by value keeps only connection-level state alive. + session.createReattachGridPush = ( + attemptGeneration: number, + reattachPtyId: string + ): { shouldContinue: () => boolean; continuation: () => void } => { + const isCurrent = (): boolean => + !session.disposed && + attemptGeneration === session.transportStreamGeneration && + session.transport.getPtyId() === reattachPtyId + return { + shouldContinue: isCurrent, + continuation: () => { + if (!isCurrent()) { + return + } + // Why re-checked at fire time: the caller's pre-check cannot see a mobile takeover that + // lands while the pane waits for a box, and transport.resize here bypasses + // forwardPtyResize's own suppression. + if (session.shouldSuppressDesktopPtyResize()) { + return + } + const reattachCols = session.pane.terminal.cols + const reattachRows = session.pane.terminal.rows + if (reattachCols > 0 && reattachRows > 0) { + session.transport.resize(reattachCols, reattachRows) + } + // Why: POSIX only sends SIGWINCH on an actual dimension change; signal explicitly so restored TUIs repaint at the correct cursor after replay. + if (!isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') + } + // Why here: a deferred reveal resolves the fit handle as incomplete, so an awaited + // reassertion at the call site would never run for that path. + if (session.deps.isVisibleRef.current) { + session.ptySizeReassertion.request({ fit: false }) + } + } + } + } + session.pendingForegroundGridDriftCheckRaf = null + session.lastForegroundGridDriftCheckAt = Number.NEGATIVE_INFINITY + session.readProposedTerminalGrid = (): { cols: number; rows: number } | null => { + try { + const proposed = session.pane.fitAddon.proposeDimensions() + if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) { + return null + } + return proposed + } catch { + return null + } + } + session.terminalGridDriftedFromFit = (): boolean => { + const proposed = session.readProposedTerminalGrid() + return Boolean( + proposed && + (session.pane.terminal.cols !== proposed.cols || session.pane.terminal.rows !== proposed.rows) + ) + } + session.scheduleForegroundGridDriftCheck = (): void => { + // Why: mobile-owned PTYs intentionally keep a non-desktop grid; drift + // healing would refit xterm even if resize forwarding is later suppressed. + if ( + session.disposed || + !session.deps.isVisibleRef.current || + session.shouldSuppressDesktopPtyResize() || + session.pendingForegroundGridDriftCheckRaf !== null + ) { + return + } + const now = performance.now() + if (now - session.lastForegroundGridDriftCheckAt < FOREGROUND_GRID_DRIFT_CHECK_MIN_MS) { + return + } + session.lastForegroundGridDriftCheckAt = now + session.pendingForegroundGridDriftCheckRaf = requestAnimationFrame(() => { + session.pendingForegroundGridDriftCheckRaf = null + if ( + session.disposed || + !session.deps.isVisibleRef.current || + session.shouldSuppressDesktopPtyResize() || + !session.terminalGridDriftedFromFit() + ) { + return + } + // Why: xterm cell metrics can settle after the DOM box stops resizing, so + // ResizeObserver never fires even though FitAddon now proposes more cols. + requestStablePaneFit(session.pane as ManagedPaneInternal, () => + session.ptySizeReassertion.request({ fit: false }) + ) + }) + } + + // Why: observe the outer pane as the layout signal for both desktop drift + // healing and mobile take-back. Normal desktop panes compare xterm against + // the PTY's applied size; mobile-fit panes only report desktop geometry so + // the parked phone-sized PTY is not resized. See docs/mobile-fit-hold.md. + session.pendingGeometryReportRaf = null + session.lastObservedDesktopGrid = null + session.readPaneSize = (): { width: number; height: number } | null => { + if (typeof session.pane.container.getBoundingClientRect !== 'function') { + return null + } + const rect = session.pane.container.getBoundingClientRect() + return { width: rect.width, height: rect.height } + } + session.lastObservedPaneSize = session.readPaneSize() + session.pendingPaneGeometryChanged = false +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts new file mode 100644 index 00000000000..ba00c1d13c2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -0,0 +1,335 @@ +import { useAppStore } from '@/store' +import { createIpcPtyTransport } from '../pty-transport' +import { createRemoteRuntimePtyTransport } from '../remote-runtime-pty-transport' +import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation' +import { createUnresolvedOwnerPtyTransport } from '../unresolved-owner-pty-transport' +import { recordTerminalTabParkedOnUnresolvedHost } from '@/lib/parked-terminal-host-hydration' +import { getFitOverrideForPty, onOverrideChange } from '@/lib/pane-manager/mobile-fit-overrides' +import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' +import { isPaneReplaying } from '../replay-guard' +import { registerUndeliverableWriteHandler } from '@/lib/pane-manager/terminal-write-pipeline-health' +import { requestTerminalPaneRecovery } from '../terminal-pane-recovery' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { resolveTerminalColorSchemeMode } from '../../../../../shared/terminal-color-scheme-protocol' +import { discardTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { + CONPTY_DA1_RESPONSE, + createTerminalPixelSizeQueryResponder, + installTerminalCapabilityReplyHandlers +} from '../terminal-capability-replies' + +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { TRANSPORT_CONNECT_SETTLE_GRACE_MS } from './pty-connect-limits' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** Transport creation, terminal capability replies, viewport claims, and undeliverable-input recovery. */ +export function installPtyInputRecovery(session: ConnectPanePtySession): void { + session.markAcceptedTerminalInputSent = (): void => { + session.markTerminalInputSent() + session.recordTerminalInputForHibernationFallback() + } + session.terminalTheme = session.pane.terminal.options.theme + session.terminalColorQueryReplies = session.terminalTheme + ? { foreground: session.terminalTheme.foreground, background: session.terminalTheme.background } + : undefined + session.agentLaunchPreferences = toAgentLaunchPreferences(session.paneStartup?.sessionOptions) + session.transportOptions = { + cwd: session.deps.cwd, + // Why: only fresh local IPC spawns may recover from a saved startup cwd + // whose directory was deleted (#7239); remote-runtime and SSH spawns + // resolve cwd on another host and must keep exact cwd semantics. + ...(session.runtimeEnvironmentId === null && !session.connectionId + ? { cwdFallback: 'worktree' as const } + : {}), + env: session.paneEnv, + ...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}), + command: session.shouldDeliverStartupViaTerminalPaste + ? undefined + : session.paneStartup?.command, + ...(session.shouldUseProviderSshStartupDelivery + ? { commandDelivery: 'provider' as const } + : {}), + startupCommandDelivery: session.shouldDeliverStartupViaTerminalPaste + ? undefined + : session.connectionId && session.paneStartup?.command + ? 'shell-ready' + : session.paneStartup?.startupCommandDelivery, + connectionId: session.connectionId, + executionHostId: session.executionHostId, + worktreeId: session.deps.worktreeId, + // Why: closes the SIGKILL race documented in INVESTIGATION.md by letting + // main sync-flush the (worktreeId, tabId, leafId → ptyId) binding before + // pty:spawn returns. Daemon-host-only: SSH path leaves these undefined + // and the main-side guard short-circuits. + tabId: session.deps.tabId, + leafId: session.pane.leafId, + activate: session.deps.isActiveRef.current && session.deps.isVisibleRef.current, + ...(session.shellOverride ? { shellOverride: session.shellOverride } : {}), + ...(session.projectRuntime ? { projectRuntime: session.projectRuntime } : {}), + ...(session.terminalColorQueryReplies + ? { terminalColorQueryReplies: session.terminalColorQueryReplies } + : {}), + ...(session.paneStartup?.launchConfig + ? { launchConfig: session.paneStartup.launchConfig } + : {}), + ...(session.paneStartup?.resumeProviderSession + ? { resumeProviderSession: session.paneStartup.resumeProviderSession } + : {}), + ...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt) + ? { + agentPrompt: + session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt + } + : {}), + ...(session.paneStartup?.initialAgentStatus?.prompt + ? { agentPromptDelivery: 'auto-submit' as const } + : session.paneStartup?.draftPrompt + ? { agentPromptDelivery: 'draft' as const } + : {}), + ...(session.paneStartup?.agentArgsOverride !== undefined + ? { agentArgsOverride: session.paneStartup.agentArgsOverride } + : {}), + ...(session.agentLaunchPreferences + ? { agentLaunchPreferences: session.agentLaunchPreferences } + : {}), + ...(session.launchToken ? { launchToken: session.launchToken } : {}), + ...(session.paneStartup?.launchAgent ? { launchAgent: session.paneStartup.launchAgent } : {}), + ...(session.paneStartup?.telemetry ? { telemetry: session.paneStartup.telemetry } : {}), + onPtyExit: session.onExit, + onPtySpawn: session.onPtySpawn, + onPtyRebind: session.onPtyRebind, + ...(session.mainSideEffectAuthority + ? {} + : { + onTitleChange: session.onTitleChange, + onBell: session.onBell, + onAgentBecameIdle: session.onAgentBecameIdle, + onAgentBecameWorking: session.onAgentBecameWorking, + onAgentExited: session.onAgentExited + }), + // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService + // parses OSC 9999 before renderer delivery and forwards through the hook + // server with local/SSH identity. Remote-runtime streams do not pass through + // local main, so the renderer remains their status owner for now. + ...(session.shouldOwnAgentStatusInRenderer + ? { onAgentStatus: session.handleRendererOwnedAgentStatus } + : {}) + } + if (session.connectionOwnerHydrating) { + // Why: this pane holds an inert transport until its host resolves; register it so + // the repos:changed handler remounts it instead of leaving the terminal blank. + recordTerminalTabParkedOnUnresolvedHost(session.deps.worktreeId, session.deps.tabId) + } + session.transport = + session.terminalOwnerUnresolved || session.connectionOwnerHydrating + ? createUnresolvedOwnerPtyTransport( + session.terminalOwnerUnresolved + ? 'Workspace identity is ambiguous across hosts. Refresh projects and try again.' + : 'Workspace host is still loading. Retry when the project finishes hydrating.' + ) + : session.runtimeEnvironmentId + ? createRemoteRuntimePtyTransport(session.runtimeEnvironmentId, session.transportOptions) + : createIpcPtyTransport(session.transportOptions) + session.canSendDesktopQueryReply = (): boolean => { + const ptyId = session.transport.getPtyId() + return !ptyId || !isPtyLocked(ptyId) + } + // Why: parser/capability handlers bypass the ordinary onData guard. Keep + // desktop silent while the elected mobile xterm owns query replies. + session.sendDesktopQueryReplyImmediate = (data: string): boolean => + session.canSendDesktopQueryReply() && session.transport.sendInputImmediate(data) + // Why (gate mode only): gate-managed PTYs never see the subscribe bytes, so this fact is + // their only cue to record the subscription — without the registry entry a later theme + // flip never pushes the CSI 997 update and the TUI keeps a stale theme after reveal. + // Record-only: a subscribe is not a query (see session.observeLiveMode2031Chunk, #9993). + session.handleHiddenMode2031SubscribeFact = (): void => { + const ptyId = session.transport.getPtyId() + if ( + session.disposed || + (!session.isHiddenDeliveryGateManagedPty(ptyId) && session.remoteOutputGatedPtyId !== ptyId) + ) { + return + } + const mode = resolveTerminalColorSchemeMode( + useAppStore.getState().settings, + getSystemPrefersDark() + ) + session.deps.recordPaneMode2031Subscription?.(session.pane.id, mode) + } + // Why (gate mode only): the counterpart to the subscribe fact. These panes never + // receive the withdrawal bytes — main drops them before delivery — and the chunk + // scanner is disabled for them, so this fact is the ONLY observer that can retire + // the subscription. Without it a TUI that exits while hidden leaves paneMode2031 + // set, and the next theme flip pushes CSI 997 into the shell that replaced it + // (#9993 via maybePushMode2031Flip). + session.handleHiddenMode2031UnsubscribeFact = (): void => { + const ptyId = session.transport.getPtyId() + if ( + session.disposed || + (!session.isHiddenDeliveryGateManagedPty(ptyId) && session.remoteOutputGatedPtyId !== ptyId) + ) { + return + } + session.deps.paneMode2031Ref.current.delete(session.pane.id) + session.deps.paneLastThemeModeRef.current.delete(session.pane.id) + } + session.deps.paneTransportsRef.current.set(session.pane.id, session.transport) + session.terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({ + terminal: session.pane.terminal, + parser: session.pane.terminal.parser, + // Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode + // read window; the remote transport's input debounce would corrupt them + // (#7329), so send immediately. + sendInput: session.sendDesktopQueryReplyImmediate, + isReplaying: () => isPaneReplaying(session.deps.replayingPanesRef, session.pane.id), + ...(session.isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {}) + }) + session.respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder( + session.pane.terminal, + session.sendDesktopQueryReplyImmediate + ) + + session.claimViewportForUserActivity = (): void => { + const currentPtyId = session.transport.getPtyId() + if (!currentPtyId || getFitOverrideForPty(currentPtyId)?.mode !== 'remote-desktop-fit') { + return + } + let proposed: { cols: number; rows: number } | undefined + try { + proposed = session.pane.fitAddon.proposeDimensions() + } catch { + proposed = undefined + } + const cols = proposed?.cols ?? session.pane.terminal.cols + const rows = proposed?.rows ?? session.pane.terminal.rows + if (cols > 0 && rows > 0) { + // Why: queuing a claim is not convergence. Keep the pane parked until the + // runtime confirms desktop-fit so a transient resize failure retries. + session.transport.claimViewport?.(cols, rows) + } + } + session.claimPendingVisibleRemoteViewport = (): void => { + if ( + !session.pendingVisibleRemoteViewportClaim || + !session.deps.isVisibleRef.current || + typeof document === 'undefined' || + document.visibilityState === 'hidden' || + typeof document.hasFocus !== 'function' || + !document.hasFocus() + ) { + return + } + session.claimViewportForUserActivity() + } + session.armVisibleRemoteViewportClaim = (): void => { + const ptyId = session.transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + session.visibleRemoteViewportClaimPtyId = null + session.pendingVisibleRemoteViewportClaim = false + return + } + if ( + session.visibleRemoteViewportClaimPtyId !== ptyId || + session.pendingVisibleRemoteViewportClaim || + getFitOverrideForPty(ptyId)?.mode === 'remote-desktop-fit' + ) { + session.visibleRemoteViewportClaimPtyId = ptyId + session.pendingVisibleRemoteViewportClaim = true + } + } + session.unsubscribeRemoteDesktopActivationClaim = onOverrideChange((event) => { + if (event.ptyId !== session.transport.getPtyId() || !isRemoteRuntimePtyId(event.ptyId)) { + return + } + if (event.mode === 'desktop-fit') { + session.visibleRemoteViewportClaimPtyId = event.ptyId + session.pendingVisibleRemoteViewportClaim = false + return + } + if (event.mode === 'remote-desktop-fit') { + if ( + session.deps.isVisibleRef.current && + session.visibleRemoteViewportClaimPtyId !== event.ptyId + ) { + session.visibleRemoteViewportClaimPtyId = event.ptyId + session.pendingVisibleRemoteViewportClaim = true + } + session.claimPendingVisibleRemoteViewport() + } + }) + + // Why: an unbound transport (detached during a remount/move and never + // rebound) silently rejects every keystroke while the PTY stays alive and + // the last frame stays painted — the pane looks healthy and eats input + // (issue #8104 class). None of the dead-session reconciles cover it because + // the PTY is live; recover by remounting the tab over the live PTY. + session.transportConnectInFlightSince = null + session.requestRecoveryForUndeliverableInput = (providerRejected = false): void => { + if ( + !providerRejected && + session.transport.isConnected?.() && + session.transport.getPtyId() !== null + ) { + return + } + // Why: input rejected while a connect/reattach is still settling is "not + // deliverable YET", not a dead binding. Remounting here destroys the + // unbound transport, and pty-transport's destroyed check then kills the + // PTY the in-flight reattach resolves to — the live shell recovery exists + // to preserve. The fossil case this detector targets has no pending + // connect, so it still recovers. Same for a late async reject landing + // after dispose: the successor pane owns the tab now. + const connectStillSettling = + session.transportConnectInFlightSince !== null && + Date.now() - session.transportConnectInFlightSince < TRANSPORT_CONNECT_SETTLE_GRACE_MS + if (connectStillSettling || session.disposed) { + return + } + const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null + const undeliverablePtyId = session.transport.getPtyId() ?? storePtyId + // Why the split: for a local (daemon/app-SSH) id main's registry can answer, + // and a `false` there means the shell really died — the dead-session + // reconcile owns that teardown and a remount would race it. For a `remote:` + // id main owns no registry entry, so `pty:hasPty` routes to the local + // provider and fabricates "dead"; that answer blocked every recovery this + // signal exists to trigger (STA-2830). The host's own rejection replaces it. + const hostRejectedRemoteInput = providerRejected && isRemoteRuntimePtyId(undeliverablePtyId) + void requestTerminalPaneRecovery({ + tabId: session.deps.tabId, + ptyId: undeliverablePtyId, + reason: hostRejectedRemoteInput ? 'input-rejected-by-host' : 'input-undeliverable', + terminalRecoveryGeneration: session.terminalRecoveryGeneration, + terminalRecoveryInstanceId: session.terminalRecoveryInstance.id, + // Why: pty:hasPty answers null for ids the local registry doesn't own, + // and a disconnected remote pane would otherwise remount-churn on every + // cooldown window while typing. Local panes keep the lenient gate. + requireAuthoritativeLiveness: + Boolean(session.transport.getConnectionId?.()) || isRemoteRuntimePtyId(undeliverablePtyId), + // Why only the rejected path: it is the only one whose remount can land on + // a fresh shell. A stalled-pipeline remount always reattaches to the same + // shell, so its half-typed line is still on screen and intact. + endpointReplaced: providerRejected + }) + } + // Why: the write-pipeline health watch (scheduler stall probe, replay-guard + // wedge certification) detects a dead xterm pipeline; route its verdict to + // the same tab remount. Registered per xterm instance — recovery replaces + // the instance, which resets certification naturally. + session.unregisterUndeliverableWriteHandler = registerUndeliverableWriteHandler( + session.pane.terminal, + (reason) => { + // Certification can arrive while this terminal still owns queued or + // detached scheduler work; release its delivery credits immediately. + discardTerminalOutput(session.pane.terminal) + const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null + void requestTerminalPaneRecovery({ + tabId: session.deps.tabId, + ptyId: session.transport.getPtyId() ?? storePtyId, + reason, + terminalRecoveryGeneration: session.terminalRecoveryGeneration, + terminalRecoveryInstanceId: session.terminalRecoveryInstance.id + }) + } + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts new file mode 100644 index 00000000000..62dcb4b7a83 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts @@ -0,0 +1,251 @@ +import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' +import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' +import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' +import { reconcilePtySizeAcrossFrames } from '../pty-size-reconcile' +import { shouldClaimRemoteDesktopViewport } from '../remote-desktop-viewport-claim' +import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild' +import { waitForStableStartupGrid } from '../terminal-startup-grid-settle' + +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import { isSetupSplitGeometryReady } from './setup-split-geometry' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function installPtyResizeGeometry(session: ConnectPanePtySession): void { + session.handleObservedPaneGeometry = (): void => { + session.pendingGeometryReportRaf = null + if (session.disposed) { + return + } + if ( + deferTerminalGeometryMutationDuringRebuild( + session.pane.terminal, + 'observed-pane-geometry', + session.handleObservedPaneGeometry + ) + ) { + return + } + const paneGeometryChanged = session.pendingPaneGeometryChanged + session.pendingPaneGeometryChanged = false + const currentPtyId = session.transport.getPtyId() + if (!currentPtyId) { + // Why: ResizeObserver may deliver its initial measurement before the + // remote binding completes; retain that passive baseline for the first + // real focused resize instead of swallowing the user's first claim. + const proposed = session.readProposedTerminalGrid() + if (proposed) { + session.lastObservedDesktopGrid = proposed + } + return + } + const fitOverride = getFitOverrideForPty(currentPtyId) + if (!fitOverride) { + if (session.pane.terminal.cols > 0 && session.pane.terminal.rows > 0) { + // Why: record the local grid before a later remote hold parks xterm; + // the first real window/split resize can then claim immediately. + session.lastObservedDesktopGrid = { + cols: session.pane.terminal.cols, + rows: session.pane.terminal.rows + } + } + if (session.shouldSuppressDesktopPtyResize()) { + return + } + requestStablePaneFit(session.pane as ManagedPaneInternal, () => + session.ptySizeReassertion.request({ fit: false }) + ) + return + } + let proposed: { cols: number; rows: number } | undefined + try { + proposed = session.pane.fitAddon.proposeDimensions() + } catch { + proposed = undefined + } + if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) { + return + } + const priorProposed = session.lastObservedDesktopGrid + session.lastObservedDesktopGrid = proposed + if (fitOverride.mode === 'remote-desktop-fit') { + if ( + shouldClaimRemoteDesktopViewport({ + holdMode: fitOverride.mode, + prior: priorProposed, + current: proposed, + paneGeometryChanged, + paneVisible: session.deps.isVisibleRef.current, + documentVisible: document.visibilityState !== 'hidden', + documentFocused: document.hasFocus() + }) + ) { + // Why: a focused, visible layout change is genuine activity; release + // the park and update xterm before claiming so the owner does not keep + // rendering the prior owner's stale grid. + session.suppressViewportClaimTerminalResize = true + try { + session.pane.terminal.resize(proposed.cols, proposed.rows) + } finally { + session.suppressViewportClaimTerminalResize = false + } + session.transport.resize(proposed.cols, proposed.rows, { claim: true }) + } + return + } + if (isRemoteRuntimePtyId(currentPtyId)) { + session.transport.resize(proposed.cols, proposed.rows) + } else { + window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows) + } + } + session.geometryReportObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => { + const paneSize = session.readPaneSize() + if ( + paneSize && + session.lastObservedPaneSize && + (paneSize.width !== session.lastObservedPaneSize.width || + paneSize.height !== session.lastObservedPaneSize.height) + ) { + session.pendingPaneGeometryChanged = true + } + session.lastObservedPaneSize = paneSize + if (session.pendingGeometryReportRaf !== null) { + return + } + session.pendingGeometryReportRaf = requestAnimationFrame( + session.handleObservedPaneGeometry + ) + }) + // Why: pane.xtermContainer is created later in pane-lifecycle's + // attachWebgl/initial-fit path; pane.container is always present at the + // moment connectPanePty runs (it's the .pane element). Both report the + // same layout signal — when the outer pane resizes, the inner xterm + // container resizes too — so this is the safe element to observe. + if (session.geometryReportObserver && session.pane.container instanceof Element) { + session.geometryReportObserver.observe(session.pane.container) + } + + // Why: the deferred-rAF fit can spawn the PTY at a stale width when the pane's + // real (e.g. split/narrower) layout has not settled by the first frame — the + // PTY is born at the wide window width while xterm later reflows to the pane + // width. The corrective onResize is then dropped (isRendererPtyResizeAuthoritative() + // is false mid-mount), pinning process.stdout.columns forever and garbling + // TUIs. The reconcile re-fits across frames until the grid settles and forces + // the PTY to xterm's dimensions; the spawn-time sync is authoritative by + // definition so it bypasses the visibility gate (but not the mobile-fit + // override, which legitimately parks the PTY at phone dims). See + // pty-size-reconcile.ts for the convergence loop. + session.ptySizeReconcileHandle = null + session.liveScrollbackRestore = null + session.reconcilePtySizeAfterSpawn = ( + ptyId: string, + spawnCols: number, + spawnRows: number + ): void => { + session.ptySizeReconcileHandle?.cancel() + session.ptySizeReconcileHandle = reconcilePtySizeAcrossFrames({ + spawnCols, + spawnRows, + isAlive: () => !session.disposed && session.transport.getPtyId() === ptyId, + // Mobile legitimately parks the PTY at phone dims; skip those frames + // (neither fit nor forward) instead of cancelling the reconcile window. + isParked: () => Boolean(getFitOverrideForPty(ptyId)) || isPtyLocked(ptyId), + // Once the renderer resize is authoritative (pane visible), the live + // onResize owns future corrections, so the reconcile can hand off after + // the grid stabilizes. While hidden it keeps watching for a late settle. + isAuthoritative: () => session.isRendererPtyResizeAuthoritative(), + measure: () => { + if (!safeFit(session.pane)) { + return null + } + const cols = session.pane.terminal.cols + const rows = session.pane.terminal.rows + return cols > 0 && rows > 0 ? { cols, rows } : null + }, + resize: (cols, rows) => { + if (!session.shouldSuppressDesktopPtyResize()) { + session.transport.resize(cols, rows) + } + }, + // Why: confirm the PTY actually applied the size we forwarded before the + // reconcile hands off. transport.resize is fire-and-forget for daemon/SSH + // PTYs, so the loop can otherwise settle on a size the PTY dropped, leaving + // it pinned wide while xterm shows narrow — the mount-time desync. Skip + // remote-runtime PTYs (separate viewport channel; pty:getSize never tracks + // them) so they fall back to the grid-stable handoff. + getAppliedSize: isRemoteRuntimePtyId(ptyId) ? undefined : () => window.api.pty.getSize(ptyId), + requestFrame: (callback) => requestAnimationFrame(callback), + cancelFrame: (handle) => { + if (typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(handle) + } + } + }) + } + + // Defer PTY spawn/attach to next frame so FitAddon has time to calculate + // the correct terminal dimensions from the laid-out container. + session.cancelScheduledConnectFrame = (): void => { + if (session.connectFrame !== null) { + if (typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(session.connectFrame) + } + session.connectFrame = null + } + } + session.measureStartupGrid = (): { cols: number; rows: number } | null => { + if (!safeFit(session.pane)) { + return null + } + const cols = session.pane.terminal.cols + const rows = session.pane.terminal.rows + return cols > 0 && rows > 0 ? { cols, rows } : null + } + session.shouldSettleStartupGridBeforeConnect = (): boolean => + Boolean(session.paneStartup?.command) && + session.deps.isVisibleRef.current && + !session.connectionId && + session.runtimeEnvironmentId === null + session.isStartupGridReadyForConnect = (): boolean => { + const setupSplitDirection = session.paneStartup?.waitForSetupSplitDirection + if (!setupSplitDirection) { + return true + } + // Why: the setup split reparents the main pane before its xterm grid + // necessarily reflects the new flex geometry; wait for both to agree. + return isSetupSplitGeometryReady(session.pane, session.manager, setupSplitDirection) + } + session.settleStartupGridBeforeConnect = (connect: () => void): void => { + session.startupGridSettleHandle?.cancel() + let settledSynchronously = false + // Why: local startup commands can launch a TUI before the split-pane grid + // has settled; spawn from a briefly stable grid so the TUI paints cleanly. + const handle = waitForStableStartupGrid({ + isAlive: () => !session.disposed, + isReadyToSettle: session.paneStartup?.waitForSetupSplitDirection + ? session.isStartupGridReadyForConnect + : undefined, + measure: session.measureStartupGrid, + onSettled: () => { + settledSynchronously = true + session.startupGridSettleHandle = null + connect() + }, + requestFrame: (callback) => requestAnimationFrame(callback), + cancelFrame: (handle) => { + if (typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(handle) + } + } + }) + if (!settledSynchronously) { + session.startupGridSettleHandle = handle + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-live-data-deferral.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-live-data-deferral.ts new file mode 100644 index 00000000000..05db564e3ec --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-live-data-deferral.ts @@ -0,0 +1,74 @@ +import { deliverTerminalDataWithDeferredCredit } from '@/lib/pane-manager/terminal-delivery-credit' +import { waitForTerminalReplayWritesParsed } from '../replay-guard' +import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent' +import { MAX_DEFERRED_REATTACH_LIVE_CHARS } from '../deferred-reattach-live-data-queue' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindReattachLiveDataDeferral(session: ConnectPanePtySession): void { + session.finishReattachLiveDataDeferral = ( + deliver: boolean, + acceptedGeneration = session.transportStreamGeneration + ): void => { + if (session.reattachLiveDataDeferralDepth <= 0) { + return + } + if (!deliver) { + const owner = session.deferredReattachLiveDataOwners.get(acceptedGeneration) + if (owner) { + owner.failed = true + } + } + session.reattachLiveDataDeferralDepth -= 1 + if (session.reattachLiveDataDeferralDepth > 0) { + return + } + const queue = session.deferredReattachLiveData + session.deferredReattachLiveData = null + const currentPtyId = session.transport.getPtyId() + const currentGeneration = session.transportStreamGeneration + const currentOwner = session.deferredReattachLiveDataOwners.get(currentGeneration) + session.deferredReattachLiveDataOwners = new Map() + if (session.disposed || !queue) { + queue?.discard() + return + } + const chunks = queue.takeAll() + // Why: paint the authoritative replay first, then admit deferred live chunks so the replay clear can't erase newer output. + let deliveredDeferredChunks = 0 + for (const chunk of chunks) { + if ( + chunk.ptyId !== currentPtyId || + chunk.streamGeneration !== currentGeneration || + currentOwner?.failed === true + ) { + chunk.ackCredit?.() + continue + } + if (chunk.ackCredit) { + deliverTerminalDataWithDeferredCredit(chunk.ackCredit, () => { + session.dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) + }) + } else { + session.dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) + } + deliveredDeferredChunks += 1 + } + if (deliveredDeferredChunks > 0) { + // Why: replay restores the viewport before these newer bytes parse; settle the deferred slice, then apply the latest user intent. + flushTerminalOutput(session.pane.terminal, { maxChars: MAX_DEFERRED_REATTACH_LIVE_CHARS }) + void waitForTerminalReplayWritesParsed(session.pane.terminal).then(() => { + if ( + session.disposed || + !session.deps.isVisibleRef.current || + session.transport.getPtyId() !== currentPtyId || + session.transportStreamGeneration !== currentGeneration + ) { + return + } + enforceTerminalCurrentScrollIntent(session.pane.terminal) + }) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts new file mode 100644 index 00000000000..51041decf04 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPane } from '../pty-connection-test-pane-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from '../pty-connection-test-environment' +import { createReattachPayloadHandlers } from './apply-reattach-payload' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { ReattachPayloadContext } from './reattach-payload-context' +import type { ReattachPayloadSession } from './reattach-payload-session' + +function createSession(overrides: Record = {}): ReattachPayloadSession { + return { + pane: createPane(1), + rememberReattachPayloadAgentSignal: vi.fn(), + writeReplayData: vi.fn(), + reattachReplayResetSequence: vi.fn(() => ''), + sendFocusedReattachFocusInAfterReplay: vi.fn(), + kittyKeyboardModes: { + hasProvenBaseline: true, + reset: vi.fn(), + resetForSnapshot: vi.fn(), + scanReplay: vi.fn() + }, + ...overrides + } as unknown as ReattachPayloadSession +} + +function createContext(replay: string, attemptGeneration: number): ReattachPayloadContext { + return { + isCurrentReattachPayload: () => true, + connectResult: { id: 'pty-1', replay }, + ptyId: 'pty-1', + attemptGeneration, + prefetchedParkModelSnapshot: null, + revealFollowsTerminalPark: false, + fetchSshMainModelReattachSnapshot: async () => null, + hasStructuralReplay: true, + coldRestoreStartup: undefined, + reattachPayloadApplied: false + } +} + +describe('reattach payload context', () => { + beforeEach(() => installTerminalTestGlobals()) + + afterEach(async () => restoreTerminalTestGlobals()) + + it('keeps overlapping payload handlers bound to their own attempt', async () => { + const session = createSession() + const attemptA = createReattachPayloadHandlers(session, createContext('PAYLOAD-A', 1)) + const attemptB = createReattachPayloadHandlers(session, createContext('PAYLOAD-B', 2)) + + await attemptB.applyReattachPayload() + await attemptA.applyReattachPayload() + + expect(session.rememberReattachPayloadAgentSignal.mock.calls).toEqual([ + ['PAYLOAD-B', { fullScreenReplay: true }], + ['PAYLOAD-A', { fullScreenReplay: true }] + ]) + expect(session.sendFocusedReattachFocusInAfterReplay.mock.calls).toEqual([ + ['pty-1', 2], + ['pty-1', 1] + ]) + }) + + it('uses the connect startup plan without rebuilding or scheduling paste delivery', async () => { + const startup = { + command: 'codex resume provider-session', + agent: 'codex', + resumeProviderSession: { key: 'session_id', id: 'provider-session' }, + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + launchToken: 'transport-launch-token', + useLiveEntry: false, + hasSleepingRecord: false, + sleepingRecordEntry: null + } as ColdRestoreAgentResumeStartup + const buildColdRestoreAgentResumeStartup = vi.fn() + const applyColdRestoreAgentResumeStartup = vi.fn(() => true) + const schedulePendingStartupCommandDelivery = vi.fn() + const session = createSession({ + buildColdRestoreAgentResumeStartup, + applyColdRestoreAgentResumeStartup, + schedulePendingStartupCommandDelivery, + showSessionRestoredBanner: vi.fn(), + clearSleepingRecordAfterColdRestoreSpawn: vi.fn(), + consumeRestoredViewportBlankingMarker: vi.fn(), + writeFreshShellViewportBlanking: vi.fn() + }) + const context: ReattachPayloadContext = { + ...createContext('', 1), + connectResult: { + id: 'pty-1', + coldRestore: { scrollback: 'restored scrollback', cwd: '/workspace' } + }, + coldRestoreStartup: startup + } + + await createReattachPayloadHandlers(session, context).applyReattachPayload() + + expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledWith(startup) + expect(buildColdRestoreAgentResumeStartup).not.toHaveBeenCalled() + expect(schedulePendingStartupCommandDelivery).not.toHaveBeenCalled() + expect(context.reattachPayloadApplied).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts new file mode 100644 index 00000000000..559bde05215 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts @@ -0,0 +1,16 @@ +import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' + +// Per-call locals for applyReattachPayload so overlapping reattaches stay isolated. +export type ReattachPayloadContext = { + isCurrentReattachPayload: () => boolean + connectResult: PtyConnectResult | null + ptyId: string + attemptGeneration: number + prefetchedParkModelSnapshot: PtyBufferSnapshot | null + revealFollowsTerminalPark: boolean + fetchSshMainModelReattachSnapshot: () => Promise + hasStructuralReplay: boolean + coldRestoreStartup: ColdRestoreAgentResumeStartup | null | undefined + reattachPayloadApplied: boolean +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts new file mode 100644 index 00000000000..6bd4d78886d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts @@ -0,0 +1,27 @@ +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export type ReattachPayloadSession = Pick< + ConnectPanePtySession, + | 'applyColdRestoreAgentResumeStartup' + | 'applySnapshotKittyKeyboardModes' + | 'buildColdRestoreAgentResumeStartup' + | 'clearSleepingRecordAfterColdRestoreSpawn' + | 'consumeRestoredViewportBlankingMarker' + | 'createReattachGridPush' + | 'isPaneOnAlternateScreen' + | 'kittyKeyboardModes' + | 'pane' + | 'pendingReattachFit' + | 'reattachReplayResetSequence' + | 'recordRendererOrderedSeq' + | 'rememberReattachPayloadAgentSignal' + | 'schedulePendingStartupCommandDelivery' + | 'sendFocusedReattachFocusInAfterReplay' + | 'setRestoredSnapshotBaseline' + | 'shouldPreserveAgentReattachModes' + | 'showSessionRestoredBanner' + | 'suppressStructuralReplayPtyResize' + | 'transport' + | 'writeFreshShellViewportBlanking' + | 'writeReplayData' +> diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts new file mode 100644 index 00000000000..028e33d9992 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -0,0 +1,265 @@ +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport' +import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics' +// Why: a restored pane's stale-account prompt can only be raised once a PTY is +// actually attached — nothing is inspectable while the session hydrates. +import { notifyCodexPaneBoundForStaleSweep } from '@/lib/codex-stale-pane-sweep' +import { useAppStore } from '@/store' +import { isPassiveCompletedHibernationEvidence } from '@/lib/sleeping-agent-pane-ownership' +import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id' +import { resolveHiddenRestoreScrollbackRows } from '../terminal-hidden-restore-scrollback' + +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +import type { ReattachPayloadContext } from './reattach-payload-context' +import { createReattachPayloadHandlers } from './apply-reattach-payload' +import type { ReattachPayloadSession } from './reattach-payload-session' +import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery' + +type ReattachResultSession = ReattachPayloadSession & + Pick< + ConnectPanePtySession, + | 'agentCompletionCoordinator' + | 'authoritativeReattachGeneration' + | 'capturedDirectSshRetryPtyAccepted' + | 'cacheKey' + | 'connectionId' + | 'deps' + | 'directSshRetryAttempt' + | 'disposed' + | 'getSshMainModelSnapshotProbe' + | 'handleReattachResult' + | 'mountFollowsTerminalPark' + | 'registerEffectiveLaunchConfig' + | 'registerPaneSerializerFor' + | 'registerSideEffectFactConsumerForPty' + | 'rejectObsoleteDirectSshReattach' + | 'reportPanePtyVisibility' + | 'sampleVisiblePaneForegroundAgent' + | 'scheduleReattachIdleAgentCursorReset' + | 'serializeHiddenOutputSnapshot' + | 'setPanePtyFitBinding' + | 'startFreshColdRestoreAgentResume' + | 'structuralReplayCoordinator' + | 'syncHiddenRendererPtyDelivery' + | 'transportStreamGeneration' + > + +export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): void { + const session = sessionBag as unknown as ReattachResultSession + session.handleReattachResult = async ( + result: PtyConnectResult | string | void, + staleSessionId?: string | null, + coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, + attemptGeneration = session.transportStreamGeneration + ): Promise => { + if (session.disposed) { + return false + } + if (attemptGeneration !== session.transportStreamGeneration) { + return false + } + // Why: bump only once this attempt owns the stream, or a superseded result + // would cancel the current attempt's in-flight snapshot prepaint. + session.authoritativeReattachGeneration += 1 + const connectResult = + result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null + + if (connectResult?.exitedBeforeAttach) { + // Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach. + return true + } + + const retryPtyId = + connectResult?.id ?? + (typeof result === 'string' ? result : (staleSessionId ?? session.transport.getPtyId())) + if (session.rejectObsoleteDirectSshReattach(retryPtyId)) { + // Why: an obsolete reattach must stop consuming frames without killing the durable PTY a newer lease may adopt. + return false + } + const ptyId = + connectResult?.id ?? (typeof result === 'string' ? result : session.transport.getPtyId()) + if (!ptyId) { + warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', { + tabId: session.deps.tabId, + worktreeId: session.deps.worktreeId, + leafId: session.deps.restoredLeafId ?? session.pane.leafId, + paneId: session.pane.id, + ptyId: staleSessionId ?? null + }) + if (session.connectionId) { + recoverUnverifiableDirectSshReattach(sessionBag, staleSessionId) + return false + } + // Why: a stale restored session can fail reattach after mount; don't leave xterm alive without a backing PTY. + if (staleSessionId) { + session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId) + } else { + session.deps.syncPanePtyLayoutBinding(session.pane.id, null) + } + if (staleSessionId) { + session.deps.clearTabPtyId(session.deps.tabId, staleSessionId) + } + session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) + return false + } + session.registerEffectiveLaunchConfig(connectResult?.launchConfig, { + ...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}), + ...(connectResult?.launchAgent + ? { launchAgent: connectResult.launchAgent } + : coldRestoreStartup + ? { launchAgent: coldRestoreStartup.agent } + : {}) + }) + if (connectResult?.sessionExpired) { + if (staleSessionId) { + session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId) + } else { + session.deps.syncPanePtyLayoutBinding(session.pane.id, null) + } + if (staleSessionId) { + session.deps.clearTabPtyId(session.deps.tabId, staleSessionId) + } + // Why: SSH sleep/reconnect can invalidate the relay PTY while the tab stays mounted; replace the dead lease in-place, not a stale overlay. + session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) + return false + } + const isCurrentReattachPayload = (): boolean => { + const currentPtyId = session.transport.getPtyId() + return ( + !session.disposed && + attemptGeneration === session.transportStreamGeneration && + currentPtyId === ptyId + ) + } + if (!isCurrentReattachPayload()) { + return false + } + // Strict precedence snapshot > replay > coldRestore: paint exactly one, else overlapping tails duplicate TUI output on worktree switch. + const hasStructuralReplay = Boolean( + connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore + ) + const resumeComesFromPassiveHibernation = Boolean( + coldRestoreStartup && + !coldRestoreStartup.useLiveEntry && + coldRestoreStartup.sleepingRecordEntry && + isPassiveCompletedHibernationEvidence(coldRestoreStartup.sleepingRecordEntry.record) + ) + // Why: reattach drops startup commands; only passive hibernation is authority to retire an empty adopted shell and resume its provider session. + if (!hasStructuralReplay && connectResult?.isReattach && resumeComesFromPassiveHibernation) { + session.transport.disconnect() + if (staleSessionId) { + session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId) + session.deps.clearTabPtyId(session.deps.tabId, staleSessionId) + } else { + session.deps.syncPanePtyLayoutBinding(session.pane.id, null) + } + session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) + return false + } + session.setPanePtyFitBinding(ptyId) + session.reportPanePtyVisibility(ptyId, session.deps.isVisibleRef.current) + session.registerSideEffectFactConsumerForPty(ptyId) + session.syncHiddenRendererPtyDelivery() + session.deps.syncPanePtyLayoutBinding(session.pane.id, ptyId) + useAppStore.getState().restoreAgentPaneAuthority?.(session.cacheKey) + notifyCodexPaneBoundForStaleSweep(ptyId) + if (session.capturedDirectSshRetryPtyAccepted && session.directSshRetryAttempt) { + session.deps.updateTabPtyId( + session.deps.tabId, + ptyId, + undefined, + session.directSshRetryAttempt.attemptId + ) + } else { + session.deps.updateTabPtyId(session.deps.tabId, ptyId) + } + session.agentCompletionCoordinator.startProcessTracking() + session.sampleVisiblePaneForegroundAgent() + + // Why: mobile streaming needs xterm's exact screen state; install the serializer + lastTitle source for main-process hydration parity. + session.registerPaneSerializerFor(ptyId) + + // Why (C1 SSH parking): main's headless model holds ~5k rows for SSH ptys + // while the relay replay is a 100KiB raw-byte tail; prefer the model on + // reveal. Only a non-empty 'headless'-sourced snapshot qualifies — the + // renderer-serializer fallback has no mounted xterm after a park. The + // paint happens inline in the snapshot-branch style: session.applyMainBufferSnapshot + // would nest session.structuralReplayCoordinator.run inside the reattach task and + // deadlock on the coordinator's tail chain. + // Memoized: the prefetch and the payload task share one probe result, so a + // null prefetch can never buy a second timeout before the relay paint. + const fetchSshMainModelReattachSnapshot = session.getSshMainModelSnapshotProbe(ptyId) + // Why consume-once: only the first reattach of a reveal remount may pay + // the probe; a later in-place reconnect on this same mount must not buy a + // second timeout before the relay paint. + const revealFollowsTerminalPark = + session.mountFollowsTerminalPark && + (connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId)) + session.mountFollowsTerminalPark = false + // Why: ordinary parking destroys xterm. Rebuild from the authoritative + // host snapshot before releasing queued live bytes; null falls back to + // the subscribe screen without keeping the old xterm mounted. + let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null + if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) { + if (parseAppSshPtyId(ptyId)) { + prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot() + } else { + try { + const result = await session.serializeHiddenOutputSnapshot(ptyId, { + scrollbackRows: resolveHiddenRestoreScrollbackRows( + session.pane.terminal.options.scrollback + ) + }) + prefetchedParkModelSnapshot = result.kind === 'snapshot' ? result.snapshot : null + } catch { + prefetchedParkModelSnapshot = null + } + } + if (!isCurrentReattachPayload()) { + return false + } + } + const reattachPayload: ReattachPayloadContext = { + isCurrentReattachPayload, + connectResult, + ptyId, + attemptGeneration, + prefetchedParkModelSnapshot, + revealFollowsTerminalPark, + fetchSshMainModelReattachSnapshot, + hasStructuralReplay, + coldRestoreStartup, + reattachPayloadApplied: !hasStructuralReplay && prefetchedParkModelSnapshot === null + } + const { applyReattachPayload, fitAfterReattachRestore } = createReattachPayloadHandlers( + session, + reattachPayload + ) + if (hasStructuralReplay || prefetchedParkModelSnapshot) { + await session.structuralReplayCoordinator.run(applyReattachPayload, { + shouldRestore: isCurrentReattachPayload, + afterRestore: fitAfterReattachRestore + }) + } else { + await applyReattachPayload() + await fitAfterReattachRestore() + } + if (!isCurrentReattachPayload() || !reattachPayload.reattachPayloadApplied) { + return false + } + session.scheduleReattachIdleAgentCursorReset() + + scheduleRuntimeGraphSync() + return true + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts new file mode 100644 index 00000000000..33ec09d79f6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -0,0 +1,188 @@ +import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler' + +import { + CURSOR_SHOW_SEQUENCE, + TERMINAL_FOCUS_IN_SEQUENCE, + FOCUS_REPORTING_DISABLE_SEQUENCE +} from './foreground-output-scan' +import { + parsedViewportShowsParkedCursorAgentScreen, + terminalHasFocusReportingEnabled +} from './cursor-agent-reattach-screen' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindReplayDataDrain(session: ConnectPanePtySession): void { + session.sendFocusedReattachFocusInAfterReplay = ( + expectedPtyId: string | null = session.transport.getPtyId(), + expectedStreamGeneration = session.transportStreamGeneration + ): void => { + const scheduledGeneration = session.reattachReplayPayloadSignalGeneration + void waitForTerminalOutputParsed(session.pane.terminal).then(() => { + const currentPtyId = session.transport.getPtyId() + if ( + session.disposed || + expectedStreamGeneration !== session.transportStreamGeneration || + currentPtyId !== expectedPtyId + ) { + return + } + // Why: a newer replay frame owns the judgment; its own post-parse + // callback will re-evaluate against its own viewport. + if (scheduledGeneration !== session.reattachReplayPayloadSignalGeneration) { + return + } + // Why: the replay-byte signal also matches a dead run's screen — in + // scrollback or still painted above a fresh shell prompt. The parsed + // viewport is the ground truth; unless it shows a parked-cursor + // cursor-agent screen and no status/title corroborates, downgrade to + // the plain-shell behavior (drop focus reporting, skip focus-in). + if ( + !session.hasLiveAgentReattachStatusOrTitleSignal() && + session.reattachReplayPayloadHasCursorAgentSignal + ) { + if (parsedViewportShowsParkedCursorAgentScreen(session.pane.terminal) === false) { + session.reattachReplayPayloadHasCursorAgentSignal = false + // Why: the live-agent reset preserved the payload's ?25l; a plain + // shell never re-shows the cursor itself. + session.writeReplayData(`${CURSOR_SHOW_SEQUENCE}${FOCUS_REPORTING_DISABLE_SEQUENCE}`) + return + } + } + // Why: a live TUI such as cursor-agent parks the real terminal cursor off + // its own input caret and moves it back only on a focus-in. Reattach + // reuses the same live PTY and the xterm textarea already holds DOM + // focus, so xterm never emits the focus-in the agent needs and the parked + // cursor anchors the IME/caret to the wrong cell. Gated on ?1004h so a + // bare shell never receives a stray \x1b[I. + const sendFocusMode = terminalHasFocusReportingEnabled(session.pane.terminal) + if (!session.shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { + return + } + session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) + }) + } + + session.pendingReplayData = null + session.replayPayloadGeneration = 0 + let replayDrainQueued = false + const drainReplayDataQueue = async ( + expectedPtyId: string | null, + expectedStreamGeneration: number + ): Promise => { + let appliedCurrentPayload = false + while (session.pendingReplayData !== null) { + if ( + session.pendingReplayData.ptyId !== expectedPtyId || + session.pendingReplayData.streamGeneration !== expectedStreamGeneration + ) { + return false + } + if ( + session.transport.getPtyId() !== expectedPtyId || + session.transportStreamGeneration !== expectedStreamGeneration + ) { + session.pendingReplayData = null + return false + } + const payload = session.pendingReplayData + const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload + session.pendingReplayData = null + const isCurrentPayload = (): boolean => + !session.disposed && + payload.generation === session.replayPayloadGeneration && + payload.streamGeneration === session.transportStreamGeneration && + session.transport.getPtyId() === payload.ptyId + if (!isCurrentPayload()) { + continue + } + // Relay replay buffers may overlap with content already rendered in + // xterm. Local eager replay decides this earlier so metadata-only frames + // can keep restored scrollback while still using the replay guard. + if (clearBeforeReplay) { + await session.writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') + if (!isCurrentPayload()) { + continue + } + } + if (clearBeforeReplay || data.length > 0) { + // Why: an empty clearing frame is still an authoritative repaint and + // must clear a stale agent signal from an earlier payload. + session.rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay }) + } + // Why: replayed application bytes carry the live TUI's kitty keyboard + // negotiation; the mirror must re-arm from them after a reload. Replay + // semantics: relay reconnects redeliver the same window, so pushes + // apply as sets to keep the mirrored stack from accumulating frames. + session.applySnapshotKittyKeyboardModes(data, payload) + await session.writeReplayDataAsync(data) + if (!isCurrentPayload()) { + continue + } + if (clearBeforeReplay || data.length > 0) { + await session.writeReplayDataAsync(session.reattachReplayResetSequence(data)) + if (!isCurrentPayload()) { + continue + } + session.sendFocusedReattachFocusInAfterReplay(payload.ptyId, payload.streamGeneration) + } + // Why: the daemon could not serialize a PTY read that ended mid-escape, + // so the emulator shipped the dangling partial separately. Write it LAST + // — after the reset, whose ESC would otherwise abort it — so the next + // live chunk completes the sequence instead of rendering literally + // (#7329). Guarded so a later ESC cannot leave the parser wedged. + if (pendingEscapeTailAnsi) { + await session.writeReplayDataAsync(pendingEscapeTailAnsi) + } + if (!isCurrentPayload()) { + continue + } + // Why: remote-runtime snapshots can arrive after WebGL attached to an + // empty buffer; rebuilding after replay parses seeds the glyph atlas + // from the now-populated xterm state. + session.manager.rebuildPaneWebgl(session.pane.id) + appliedCurrentPayload = true + } + return appliedCurrentPayload + } + session.scheduleReplayDataDrain = (): void => { + if (replayDrainQueued) { + return + } + const scheduledPtyId = session.pendingReplayData?.ptyId ?? null + replayDrainQueued = true + // Why: live bytes are newer than the authoritative replay frame. Hold + // them until clear + replay + reset have all parsed, or replay can erase them. + const scheduledStreamGeneration = + session.pendingReplayData?.streamGeneration ?? session.transportStreamGeneration + session.beginReattachLiveDataDeferral(scheduledStreamGeneration) + let replayCompleted = false + session.replayWriteQueue = session.replayWriteQueue + .catch(() => undefined) + .then(() => + session.structuralReplayCoordinator.run( + async () => { + replayCompleted = await drainReplayDataQueue(scheduledPtyId, scheduledStreamGeneration) + }, + { + shouldRestore: () => + !session.disposed && + session.transport.getPtyId() === scheduledPtyId && + session.transportStreamGeneration === scheduledStreamGeneration + } + ) + ) + .then(() => { + replayCompleted &&= !session.disposed && session.transport.getPtyId() === scheduledPtyId + }) + .finally(() => { + replayDrainQueued = false + if (session.pendingReplayData !== null) { + // Why: preserve the PTY identity captured when the callback fired; + // re-reading it here could retag stale bytes for a replacement PTY. + session.scheduleReplayDataDrain() + } + session.finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration) + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts b/src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts new file mode 100644 index 00000000000..db1d277dcc3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts @@ -0,0 +1,30 @@ +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindAttachRetainedLegacyPty(session: ConnectPanePtySession): void { + session.attachRetainedLegacyPty = (ptyId: string): boolean => { + try { + session.authoritativeReattachGeneration += 1 + session.clearPaneMode2031State() + session.clearHiddenOutputRestoreState() + const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null) + session.transport.attach({ + existingPtyId: ptyId, + callbacks: outputCallbacks.callbacks + }) + const attachedPtyId = session.transport.getPtyId() ?? ptyId + session.bindActivePanePty(attachedPtyId, { + updateTabPtyId: 'if-missing', + sampleVisibleForegroundAgent: true + }) + if (isRemoteRuntimePtyId(attachedPtyId)) { + session.registerPaneSerializerFor(attachedPtyId) + } + return true + } catch (err) { + session.reportError(err instanceof Error ? err.message : String(err)) + return false + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts new file mode 100644 index 00000000000..cf58a43ab09 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -0,0 +1,131 @@ +import { createTerminalZeroDimensionsMessage } from '../../../../../shared/terminal-zero-dimensions-diagnostic' +import { isWorktreeRemovalFenceError } from '../../../../../shared/worktree/removal-fence-error' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { createCodexBackfillErrorDetector } from '../codex-backfill-error-detector' + +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { bindBuildColdRestoreAgentResumeStartup } from './cold-restore-resume-startup' + +import { bindPrepaintParkedSshSnapshot } from './ssh-snapshot-prepaint' +import { bindForegroundOutputRefresh } from './foreground-output-refresh' +import { bindRegisterPaneSerializer } from './pane-serializer-register' +import { bindHandleReattachResult } from './reattach-result-handler' +import { bindAttachRetainedLegacyPty } from './retained-legacy-pty-attach' +import { runDeferredSessionAttach } from './deferred-session-attach' + +import { bindSerializeHiddenOutputSnapshot } from './hidden-output-snapshot-serialize' +import { bindSettlePaneSerializer } from './pane-serializer-settle' + +import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot' +import { bindHiddenOutputSeqAndSkip } from './hidden-output-seq-and-skip' +import { bindHiddenRestoreStateAndSshProbe } from './hidden-restore-state-and-ssh-probe' + +export function installRunDeferredConnect(session: ConnectPanePtySession): void { + session.runDeferredConnect = (): void => { + if (session.connectStarted) { + return + } + if (!session.startupGridSettledForConnect && session.shouldSettleStartupGridBeforeConnect()) { + session.cancelScheduledConnectFrame() + if (session.connectFallbackTimer !== null) { + clearTimeout(session.connectFallbackTimer) + session.connectFallbackTimer = null + } + session.settleStartupGridBeforeConnect(() => { + session.startupGridSettledForConnect = true + session.runDeferredConnect() + }) + return + } + session.connectStarted = true + session.cancelScheduledConnectFrame() + if (session.connectFallbackTimer !== null) { + clearTimeout(session.connectFallbackTimer) + session.connectFallbackTimer = null + } + if (session.disposed) { + return + } + safeFit(session.pane) + session.cols = session.pane.terminal.cols + session.rows = session.pane.terminal.rows + + // Why: if fitAddon resolved to 0×0, the container likely has no layout + // dimensions (display:none, unmounted, or zero-size parent). Surface a + // diagnostic so the user sees something instead of a blank pane. + // Gate on visibility: background/hidden tabs (orchestration workers, CLI + // `terminal create` without --focus) legitimately connect at 0×0 because + // safeFit skips fitting unmeasurable panes; they refit via the pane resize + // observer once shown, so the diagnostic must not fire while hidden. + if ((session.cols === 0 || session.rows === 0) && session.deps.isVisibleRef.current) { + session.deps.onPtyErrorRef?.current?.( + session.pane.id, + createTerminalZeroDimensionsMessage(session.cols, session.rows) + ) + } + + session.reportError = (message: string): void => { + // Why: the transport connect can reject asynchronously after the pane has been + // disposed (e.g. its workspace was deleted) — dropping a late error avoids a toast + // racing the unmount. Mirrors the connect scheduler's disposed guard above. + if (session.disposed) { + return + } + if (isWorktreeRemovalFenceError(message)) { + // Why: main fences a spawn/reattach whose worktree (or an overlapping + // parent/child root) is being deleted. That is expected teardown, not a + // user-facing failure — the pane unmounts once removal completes, so never + // surface the raw fence error. Covers the parent-removal-fences-child case + // that startFreshSpawn's own-worktree isDeleting skip cannot see. + return + } + session.deps.onPtyErrorRef?.current?.(session.pane.id, message) + } + session.codexBackfillErrorDetector = + session.paneStartup?.launchAgent === 'codex' || session.tab?.launchAgent === 'codex' + ? createCodexBackfillErrorDetector() + : null + + // Why: shared registration so both fresh-spawn and reattach paths install + // the same SerializeAddon-backed serializer plus the onTitleChange wrapper + // that drives lastTitle parity for mobile subscribers. Wires the resulting + // unregister into onDataDisposable.dispose so disposal stays a single + // teardown point. See docs/mobile-prefer-renderer-scrollback.md. + bindRegisterPaneSerializer(session) + bindSettlePaneSerializer(session) + bindBuildColdRestoreAgentResumeStartup(session) + bindDeferredColdRestoreAndSnapshot(session) + session.canUseMainBufferSnapshot = function (ptyId: string | null): ptyId is string { + return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) + } + + session.canUseHiddenOutputSnapshot = function (ptyId: string | null): ptyId is string { + if (!ptyId) { + return false + } + if (session.canUseMainBufferSnapshot(ptyId)) { + return true + } + return ( + session.transport.getPtyId() === ptyId && + typeof session.transport.serializeBuffer === 'function' + ) + } + + bindSerializeHiddenOutputSnapshot(session) + bindForegroundOutputRefresh(session) + bindHiddenOutputSeqAndSkip(session) + bindHiddenRestoreStateAndSshProbe(session) + + bindPrepaintParkedSshSnapshot(session) + bindHandleReattachResult(session) + bindAttachRetainedLegacyPty(session) + runDeferredSessionAttach(session) + } + + // Why: Wayland/CI compositors can starve rAF while timers/CDP stay responsive; the terminal must still start its PTY once. + session.connectFallbackTimer = setTimeout(session.runDeferredConnect, 250) + session.connectFrame = requestAnimationFrame(session.runDeferredConnect) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts new file mode 100644 index 00000000000..657a01169bc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts @@ -0,0 +1,303 @@ +import { useAppStore } from '@/store' +import { + shouldReconcileDeadSession, + shouldReconcileMissingSession, + type HasPty +} from '../terminal-dead-session-reconcile' +import { cancelPendingSafeFitContinuations } from '@/lib/pane-manager/pane-tree-ops' +import { PANE_PTY_RESIZE_HOLD_FLUSH_EVENT } from '@/lib/pane-manager/pane-pty-resize-hold' +import { discardTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { + getProviderSessionClaimKey, + isPassiveCompletedHibernationEvidence +} from '@/lib/sleeping-agent-pane-ownership' +import { releaseRendererPtyVisibilityClaim } from '../pty-renderer-delivery-claims' + +import { REMOTE_PTY_ID_PREFIX } from './pty-connect-limits' +import { SHIFT_ENTER_RECONFIRM_IDLE_MS } from './foreground-output-scan' +import type { PanePtyBinding } from './pane-pty-binding' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function installSessionReconcileDispose(session: ConnectPanePtySession): PanePtyBinding { + session.reconcileIfSessionDead = ( + liveSessionIds: Set, + snapshotRequestedAt?: number + ): void => { + if (session.disposed) { + return + } + const currentPtyId = session.transport.getPtyId() + if ( + !currentPtyId || + // Why: this exit was already handled — onExit guards it too, but skipping here avoids a redundant shouldReconcile evaluation. + session.handledExitPtyId === currentPtyId || + !shouldReconcileDeadSession({ + ptyId: currentPtyId, + connectionId: session.transport.getConnectionId?.(), + liveSessionIds, + ptyBoundAt: session.activePanePtyBindingBoundAt, + snapshotRequestedAt + }) + ) { + return + } + session.onExit(currentPtyId) + } + + session.reconcileIfSessionMissing = ( + hasPty: HasPty, + livenessRequestedAt = performance.now() + ): void => { + const requestedPtyId = session.transport.getPtyId() + if ( + !requestedPtyId || + requestedPtyId === session.handledExitPtyId || + requestedPtyId.startsWith(REMOTE_PTY_ID_PREFIX) || + session.transport.getConnectionId?.() != null + ) { + return + } + + let livenessPromise: Promise + try { + livenessPromise = Promise.resolve(hasPty(requestedPtyId)) + } catch { + return + } + + void livenessPromise + .then((isLive) => { + if (session.disposed) { + return + } + const currentPtyId = session.transport.getPtyId() + if ( + !currentPtyId || + currentPtyId !== requestedPtyId || + session.handledExitPtyId === currentPtyId || + !shouldReconcileMissingSession({ + ptyId: currentPtyId, + connectionId: session.transport.getConnectionId?.(), + isLive, + ptyBoundAt: session.activePanePtyBindingBoundAt, + livenessRequestedAt + }) + ) { + return + } + session.onExit(currentPtyId) + }) + .catch(() => {}) + } + + return { + syncProcessTracking() { + session.agentCompletionCoordinator.startProcessTracking() + // Why: the hidden-delivery gate must follow every pane visibility flip. + session.syncHiddenRendererPtyDelivery() + if (!session.deps.isVisibleRef.current) { + session.pendingVisibleRemoteViewportClaim = false + } + }, + // Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback. + noteVisibilityResume() { + session.armVisibleRemoteViewportClaim() + session.claimPendingVisibleRemoteViewport() + session.ptySizeReassertion.request({ fit: false }) + session.consumeHibernatedAgentWake() + session.requestKnownWindowsShiftEnterReconfirmation() + session.sampleVisiblePaneForegroundAgent() + }, + reassertPtySizeAfterWindowWake() { + session.armVisibleRemoteViewportClaim() + session.claimPendingVisibleRemoteViewport() + session.ptySizeReassertion.request({ fit: false }) + }, + // Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads. + wakeHibernatedAgentIfArmed(claimedProviderSessions) { + if (session.hibernatedWakeInFlightClaimKey) { + if (claimedProviderSessions?.has(session.hibernatedWakeInFlightClaimKey)) { + return null + } + claimedProviderSessions?.add(session.hibernatedWakeInFlightClaimKey) + return session.hibernatedWakeInFlightClaimKey + } + const consumedClaimKey = session.consumeHibernatedAgentWake(claimedProviderSessions) + if (consumedClaimKey) { + return consumedClaimKey + } + // Why: wake arrived mid-hibernation-kill before onExit armed the wake target (transport still bound to the dying PTY). + // Only the exact PTY marked for suppressed shutdown may latch — never a stale/manual record beside an ordinary live PTY. + const state = useAppStore.getState() + const recordEntry = session.getSleepingRecordForPane(state) + const currentPtyId = session.transport.getPtyId() + if ( + recordEntry && + isPassiveCompletedHibernationEvidence(recordEntry.record) && + currentPtyId !== null && + state.suppressedPtyExitIds[currentPtyId] === true && + !session.disposed && + session.hibernatedWakeTarget === null && + session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport && + session.transport.getPtyId() === currentPtyId + ) { + const claimKey = getProviderSessionClaimKey(recordEntry.record) + if (claimedProviderSessions?.has(claimKey)) { + return null + } + claimedProviderSessions?.add(claimKey) + session.pendingHibernatedWakeTarget = { ptyId: currentPtyId, record: recordEntry.record } + return claimKey + } + return null + }, + sampleForegroundAgentOnFocus() { + session.requestKnownWindowsShiftEnterReconfirmation() + session.sampleVisiblePaneForegroundAgent() + }, + requestWindowsShiftEnterReconfirmation() { + if (session.shiftEnterReconfirmTimer !== null) { + clearTimeout(session.shiftEnterReconfirmTimer) + } + // Why: confirm the composer only after the Shift+Enter burst goes idle, preserving rapid multiline input. + session.shiftEnterReconfirmTimer = setTimeout(() => { + session.shiftEnterReconfirmTimer = null + session.requestKnownWindowsShiftEnterReconfirmation() + session.sampleVisiblePaneForegroundAgent() + }, SHIFT_ENTER_RECONFIRM_IDLE_MS) + }, + markShortcutTerminalInputSent() { + session.markInteractiveRedrawInput() + }, + reconcileIfSessionDead: session.reconcileIfSessionDead, + reconcileIfSessionMissing: session.reconcileIfSessionMissing, + dispose() { + session.disposed = true + // Why: a detached client stops observing the pane's bytes, so it must cede + // agent-status authority back to the host on the next mirrored snapshot. + session.releaseRendererOwnedAgentStatusPane?.() + session.directSshPaneRetrySettlementCancelled = true + for (const timer of session.directSshPaneRetrySettlementTimers) { + clearTimeout(timer) + } + session.directSshPaneRetrySettlementTimers.clear() + // Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream. + const queue = session.deferredReattachLiveData + session.deferredReattachLiveData = null + queue?.discard() + session.reattachLiveDataDeferralDepth = 0 + session.deferredReattachLiveDataOwners = new Map() + cancelPendingSafeFitContinuations(session.pane) + session.pendingHiddenSnapshotFit = null + session.pendingReattachFit = null + // Why: park/reconnect/remount doesn't advance the recovery epoch, so invalidate this xterm or its delayed retry could hit the next instance. + session.terminalRecoveryInstance.unregister() + session.unregisterUndeliverableWriteHandler() + session.unsubscribeRemoteDesktopActivationClaim() + session.cancelHiddenOutputSnapshotScrollRestore() + session.liveScrollbackRestore?.dispose() + session.liveScrollbackRestore = null + session.structuralReplayCoordinator.dispose() + session.cancelFreshSpawnFollowReset() + // Why: cancel the post-spawn reconcile's pending rAF so a torn-down pane can't keep fitting/resizing after disposal. + session.ptySizeReconcileHandle?.cancel() + session.ptySizeReconcileHandle = null + session.startupGridSettleHandle?.cancel() + session.startupGridSettleHandle = null + session.ptySizeReassertion.dispose() + if (session.pendingForegroundGridDriftCheckRaf !== null) { + cancelAnimationFrame(session.pendingForegroundGridDriftCheckRaf) + session.pendingForegroundGridDriftCheckRaf = null + } + // Why: a pane unmount must never leave its PTY delivery gated — the parked watcher or remounted pane re-decides. + session.releaseHiddenRendererPtyDelivery() + if (session.terminalKeyTargetSupportsEvents) { + session.terminalKeyTarget.removeEventListener('keydown', session.onTerminalKeyDown, { + capture: true + }) + } + session.clearPendingTerminalInputIntent() + session.pendingTerminalInputWrite = null + session.interruptInference.dispose() + session.clearTitleOnlyInterruptTimer() + // Why release, not cancel: the pending settle belongs to the turn, not to + // this pane — a park mid-settle hands it to the parked watcher instead. + session.releaseCommandCodeDoneSettleExecutor() + if (session.shiftEnterReconfirmTimer !== null) { + clearTimeout(session.shiftEnterReconfirmTimer) + session.shiftEnterReconfirmTimer = null + } + // Why: resolve in-flight passphrase-gate waits so their zustand subscribers + async IIFEs don't hang when the pane is torn down before SSH state changes. + while (session.waitTeardowns.length > 0) { + const teardown = session.waitTeardowns.pop() + teardown?.() + } + if (session.startupInjectTimer !== null) { + clearTimeout(session.startupInjectTimer) + session.startupInjectTimer = null + } + session.cleanupStartupDraftPasteTimers() + session.releaseUnattemptedStartupDraftPasteDelivery() + session.unregisterAgentHookTerminalLifecycle() + session.clearSuppressedTitleSideEffects() + session.clearPendingAgentTaskCompleteNotification() + session.pendingTerminalBellNotification = false + session.clearTerminalBellNotificationTimer() + session.clearReattachIdleAgentCursorResetTimer() + if (session.alternateScreenBackgroundRepaintTimer !== null) { + clearTimeout(session.alternateScreenBackgroundRepaintTimer) + session.alternateScreenBackgroundRepaintTimer = null + } + session.cleanupHiddenOutputRestoreDeferredRetry() + session.cleanupHiddenOutputRestoreForegroundDeadline() + session.cleanupHiddenOutputRestoreFloodRepaint() + session.unregisterBacklogRecovery?.() + session.unregisterBacklogRecovery = null + session.unregisterDocumentVisibilityRecovery?.() + session.unregisterDocumentVisibilityRecovery = null + releaseRendererPtyVisibilityClaim(session.transport) + // Why: the pane's fact consumer must be gone before a parked-tab watcher takes over this PTY's facts in the same effect flush. + session.dropSideEffectFactConsumer() + session.clearPanePtyFitBinding() + discardTerminalOutput(session.pane.terminal) + session.unregisterE2ePtyDataInjection() + if (session.agentTaskCompleteSettingsUnsubscribe !== null) { + session.agentTaskCompleteSettingsUnsubscribe() + session.agentTaskCompleteSettingsUnsubscribe = null + } + if (session.unsubscribeWindowsDoneTerminalModeReset !== null) { + session.unsubscribeWindowsDoneTerminalModeReset() + session.unsubscribeWindowsDoneTerminalModeReset = null + } + if (session.connectFrame !== null) { + // Why: cancel the queued connect frame so a disposed pane (StrictMode/split-group remount) can't reattach the PTY and steal the live pane's handler wiring. + session.cancelScheduledConnectFrame() + } + if (session.connectFallbackTimer !== null) { + clearTimeout(session.connectFallbackTimer) + session.connectFallbackTimer = null + } + session.imeCompositionRouteDisposable.dispose() + session.onDataDisposable.dispose() + session.userInputActivityDisposable?.dispose() + session.terminalCapabilityRepliesDisposable.dispose() + session.onResizeDisposable.dispose() + session.pane.container.removeEventListener( + PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, + session.onHeldPtyResizeFlush + ) + session.geometryReportObserver?.disconnect() + if (session.pendingGeometryReportRaf !== null) { + cancelAnimationFrame(session.pendingGeometryReportRaf) + session.pendingGeometryReportRaf = null + } + session.commandLifecycle.dispose() + session.deferredCommandFinishedStatusDrop = null + session.visibleForegroundSamplePending = false + session.visibleForegroundSampleSettled = false + session.paneForegroundAgentTracker.dispose() + session.agentCompletionCoordinator.dispose() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts b/src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts new file mode 100644 index 00000000000..fc65e447b22 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts @@ -0,0 +1,75 @@ +import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import type { SetupSplitDirection } from '../../../../../shared/worktree/launch-types' + +const SPLIT_GEOMETRY_EPSILON_PX = 1 + +export function readElementRect(element: HTMLElement | null | undefined): DOMRect | null { + try { + return element?.getBoundingClientRect?.() ?? null + } catch { + return null + } +} + +export function hasVisibleRect(rect: DOMRect | null): rect is DOMRect { + return Boolean( + rect && rect.width > SPLIT_GEOMETRY_EPSILON_PX && rect.height > SPLIT_GEOMETRY_EPSILON_PX + ) +} + +export function readProposedPaneGrid(pane: ManagedPane): { cols: number; rows: number } | null { + try { + const dimensions = pane.fitAddon.proposeDimensions() + if (!dimensions || dimensions.cols <= 0 || dimensions.rows <= 0) { + return null + } + return dimensions + } catch { + return null + } +} + +export function isPaneGridAlignedWithFit(pane: ManagedPane): boolean { + const proposed = readProposedPaneGrid(pane) + return Boolean( + proposed && pane.terminal.cols === proposed.cols && pane.terminal.rows === proposed.rows + ) +} + +export function isSetupSplitGeometryReady( + pane: ManagedPane, + manager: PaneManager, + direction: SetupSplitDirection +): boolean { + const splitElement = pane.container.parentElement + const directionClass = direction === 'vertical' ? 'is-vertical' : 'is-horizontal' + if ( + !splitElement?.classList?.contains('pane-split') || + !splitElement.classList.contains(directionClass) + ) { + return false + } + + const sibling = manager + .getPanes() + .find( + (candidate) => candidate.id !== pane.id && candidate.container.parentElement === splitElement + ) + const splitRect = readElementRect(splitElement) + const paneRect = readElementRect(pane.container) + const siblingRect = readElementRect(sibling?.container) + if (!hasVisibleRect(splitRect) || !hasVisibleRect(paneRect) || !hasVisibleRect(siblingRect)) { + return false + } + + const splitAxis = direction === 'vertical' ? splitRect.width : splitRect.height + const paneAxis = direction === 'vertical' ? paneRect.width : paneRect.height + const siblingAxis = direction === 'vertical' ? siblingRect.width : siblingRect.height + return ( + paneAxis > SPLIT_GEOMETRY_EPSILON_PX && + siblingAxis > SPLIT_GEOMETRY_EPSILON_PX && + splitAxis - paneAxis > SPLIT_GEOMETRY_EPSILON_PX && + splitAxis - siblingAxis > SPLIT_GEOMETRY_EPSILON_PX && + isPaneGridAlignedWithFit(pane) + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts new file mode 100644 index 00000000000..83cc0a0301b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts @@ -0,0 +1,261 @@ +import { detectAgentStatusFromTitle } from '@/lib/agent-status' +import { useAppStore } from '@/store' +import type { AgentType } from '../../../../../shared/agent-status-types' +import { AGENT_INTERRUPT_SETTLE_MS } from '../../../../../shared/agent-interrupt-intent' +import { resolvePaneAgentOwner } from '../../../../../shared/pane-agent-owner' + +import { MANUAL_AGENT_COMMAND_MAX_CHARS } from './pty-connect-limits' +import { + CURSOR_AGENT_REATTACH_HEADER, + terminalOwnsDomFocus, + hasCursorAgentReattachPayloadScreenSignal +} from './cursor-agent-reattach-screen' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function installShellCommandInference(session: ConnectPanePtySession): void { + session.observeAcceptedShellCommandInput = (data: string): void => { + if ( + data.includes('\r') || + data.includes('\n') || + data.includes('\x03') || + data.includes('\x04') + ) { + // Why: shells without OSC 133 give no command/exit boundary. An accepted + // submit or interrupt revokes stale trusted Shift+Enter routing and confirms once. + session.requestKnownWindowsShiftEnterReconfirmation() + } + if (session.commandInferredPaneAgent) { + return + } + // Why: bytes typed inside a live agent TUI are prompt text, not shell + // commands, even if they spell another agent binary name. + if (session.hasFreshPaneAgentSurface()) { + session.resetPendingShellCommandLine() + return + } + if (session.shellCommandInferenceSuspendedUntilCommandEnd) { + if (data.includes('\x03') || data.includes('\x15')) { + session.shellCommandInferenceSuspendedUntilCommandEnd = false + session.resetPendingShellCommandLine() + } + if (data.includes('\r') || data.includes('\n')) { + session.shellCommandInferenceSuspendedUntilCommandEnd = false + } + return + } + if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { + session.resetPendingShellCommandLine() + session.shellCommandInferenceSuspendedUntilCommandEnd = + !data.includes('\r') && !data.includes('\n') + return + } + for (let index = 0; index < data.length; index += 1) { + const char = data[index]! + if (char === '\r' || char === '\n') { + session.shellCommandInferenceSuspendedUntilCommandEnd = false + session.rememberCommandInferredPaneAgent() + if (session.commandInferredPaneAgent) { + return + } + continue + } + if (char === '\x7f' || char === '\b') { + session.deletePendingShellCommandCharacter() + continue + } + if (char === '\x17') { + session.deletePendingShellCommandWord() + continue + } + if (char === '\x03' || char === '\x15') { + session.resetPendingShellCommandLine() + continue + } + if (char === '\x1b') { + const nextIndex = session.consumeShellCommandCsiSequence(data, index) + if (nextIndex !== null) { + index = nextIndex - 1 + continue + } + session.resetPendingShellCommandLine() + continue + } + if (char < ' ') { + session.resetPendingShellCommandLine() + continue + } + if (char >= ' ') { + session.appendPendingShellCommandInput(char) + if (session.shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + } + } + } + /** + * Resolves the authoritative owner agent type for this pane, checking tab launch, + * pane startup, typed command ownership, and store state configuration. + * + * Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an + * OMP-owned pane back to Pi; command ownership covers manually typed `omp` + * in generic terminals where launch metadata does not exist. + */ + session.getAuthoritativePaneAgent = (): AgentType | undefined => { + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + ) + return ( + resolvePaneAgentOwner({ + launchAgent: tab?.launchAgent, + startupLaunchAgent: session.paneStartup?.launchAgent, + initialStatusAgent: session.paneStartup?.initialAgentStatus?.agent, + commandInferredAgent: session.commandInferredPaneAgent, + hookAgent: state.agentStatusByPaneKey[session.cacheKey]?.agentType + }) ?? undefined + ) + } + // Why: the renderer veto (owner evidence beating a Gemini-looking title) must + // use only pane-scoped, CURRENT ownership. getAuthoritativePaneAgent leads + // with the tab-shared `tab.launchAgent` and a never-cleared + // `paneStartup.launchAgent`, which would let a sibling split pane or a reused + // pane keep WebGL for a genuine Gemini terminal (#7428 regression class). + // Launch identity is excluded, and the never-clearing startup seed + // (`paneStartup.initialAgentStatus`) too; a stale or `done` explicit row is + // ignored via the freshness predicate so a reused pane cannot inherit a prior + // agent's veto. Only live foreground command inference and a fresh, active + // hook row count. A genuine OMP/Pi pane stays protected owner-independently by + // the isPiAgentTitle guard inside isGeminiTerminalTitle. + session.getPaneScopedRendererOwner = (): AgentType | undefined => { + const entry = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + return ( + session.commandInferredPaneAgent ?? + (session.isFreshActivePaneAgentEntry(entry) ? entry.agentType : undefined) + ) + } + session.clearInferredInterruptWorkingTitle = (): void => { + const state = useAppStore.getState() + const currentTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + const statusTitle = state.agentStatusByPaneKey[session.cacheKey]?.terminalTitle + const title = currentTitle ?? statusTitle + if (!title) { + return + } + const neutralTitle = session.neutralTerminalTitle() + // Why: inferred interrupts update the explicit hook row, but many CLIs leave + // their OSC title stuck on a working spinner. Replace only this fallback + // title signal with a neutral terminal label so the existing process tracker + // can still decide whether an agent TUI is truly alive. + session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, neutralTitle) + if (session.manager.getActivePane()?.id === session.pane.id) { + session.deps.updateTabTitle(session.deps.tabId, neutralTitle) + } + } + session.titleOnlyInterruptTimer = null + session.clearTitleOnlyInterruptTimer = (): void => { + if (session.titleOnlyInterruptTimer !== null) { + clearTimeout(session.titleOnlyInterruptTimer) + session.titleOnlyInterruptTimer = null + } + } + session.observeTitleOnlyInterrupt = (): void => { + const state = useAppStore.getState() + if (state.agentStatusByPaneKey[session.cacheKey]) { + return + } + const runtimeTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + const tabTitle = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + )?.title + const baselineTitle = runtimeTitle ?? tabTitle + if (detectAgentStatusFromTitle(baselineTitle ?? '') !== 'working') { + return + } + session.clearTitleOnlyInterruptTimer() + session.titleOnlyInterruptTimer = setTimeout(() => { + session.titleOnlyInterruptTimer = null + if (useAppStore.getState().agentStatusByPaneKey[session.cacheKey]) { + return + } + const currentState = useAppStore.getState() + const currentRuntimeTitle = + currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + const currentTabTitle = (currentState.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + )?.title + const currentTitle = currentRuntimeTitle ?? currentTabTitle + if ( + currentTitle === baselineTitle && + detectAgentStatusFromTitle(currentTitle ?? '') === 'working' + ) { + // Why: title-only agents such as Pi can miss their own idle title after + // Ctrl+C. Clear only an unchanged, acknowledged working title. + session.clearInferredInterruptWorkingTitle() + } + }, AGENT_INTERRUPT_SETTLE_MS) + } + session.clearReattachIdleAgentCursorResetTimer = (): void => { + if (session.reattachIdleAgentCursorResetTimer !== null) { + clearTimeout(session.reattachIdleAgentCursorResetTimer) + session.reattachIdleAgentCursorResetTimer = null + } + } + session.getCurrentTerminalTitle = (): string | null => { + const state = useAppStore.getState() + const runtimeTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + const tabTitle = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + )?.title + return runtimeTitle ?? tabTitle ?? null + } + session.reattachReplayPayloadHasCursorAgentSignal = false + // Why: post-parse veto callbacks must only judge the latest replay frame; a + // newer frame bumps the generation so a stale callback stands down. + session.reattachReplayPayloadSignalGeneration = 0 + session.rememberReattachPayloadAgentSignal = ( + data: string, + opts: { fullScreenReplay: boolean } + ): void => { + session.reattachReplayPayloadSignalGeneration += 1 + // Why: ordinary scrollback can mention agent names. Treat replay bytes as + // a live Cursor Agent signal only when they look like its restored screen. + const signal = hasCursorAgentReattachPayloadScreenSignal(data) + // Why: incremental (non-clearing) replay frames repaint only part of the + // screen, so their bytes can only add evidence — a full-screen replay is + // the authoritative repaint that may clear the flag. + session.reattachReplayPayloadHasCursorAgentSignal = opts.fullScreenReplay + ? signal + : session.reattachReplayPayloadHasCursorAgentSignal || signal + } + session.isCursorAgentNativeTitle = (title: string): boolean => { + return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase() + } + session.hasLiveAgentReattachStatusOrTitleSignal = (): boolean => { + // Why: launch ownership (tab.launchAgent) never decays after the agent + // exits, so it must not count as liveness here — only live status, live + // titles, and the replayed screen shape do. + if (useAppStore.getState().agentStatusByPaneKey[session.cacheKey]) { + return true + } + const title = session.getCurrentTerminalTitle() ?? '' + // Why: broad token matching (getAgentLabel) fires on titles like + // "ssh devin@host"; that surface is too loose to gate mode preservation + // and PTY byte injection, so only exact/status titles count here. + return detectAgentStatusFromTitle(title) !== null || session.isCursorAgentNativeTitle(title) + } + session.hasLiveAgentReattachSignal = (): boolean => { + return ( + session.hasLiveAgentReattachStatusOrTitleSignal() || + session.reattachReplayPayloadHasCursorAgentSignal + ) + } + session.shouldPreserveAgentReattachModes = (): boolean => { + // Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes. + // Preserve those modes only when reattach still looks agent-owned. + return session.hasLiveAgentReattachSignal() + } + session.shouldSendFocusedAgentReattachFocusIn = (): boolean => { + return terminalOwnsDomFocus(session.pane.terminal) && session.shouldPreserveAgentReattachModes() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts new file mode 100644 index 00000000000..0777dead431 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -0,0 +1,198 @@ +import { useAppStore } from '@/store' +import type { PtyConnectResult } from '../pty-transport' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { parseLegacyNumericPaneKey } from '../../../../../shared/stable-pane-id' +import { getProviderSessionClaimKey } from '@/lib/sleeping-agent-pane-ownership' +import { + agentProviderSessionsEqual, + type SleepingAgentSessionRecord +} from '../../../../../shared/agent-session-resume' +import { recognizeAgentProcessFromCommandLine } from '../../../../../shared/agent-process-recognition' +import type { TuiAgent } from '../../../../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config' +import { + beginAgentStartupDeliveryAttempt, + releaseAgentStartupDeliveryAttempt +} from '@/lib/agent-startup-delayed-delivery' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +import { installCommandInferredPaneAgent } from './command-inferred-pane-agent' + +export function installSleepingRecordAccess(session: ConnectPanePtySession): void { + session.getSleepingRecordForPane = ( + state: ReturnType + ): { paneKey: string; record: SleepingAgentSessionRecord } | null => { + const stableRecord = state.sleepingAgentSessionsByPaneKey[session.cacheKey] + if (stableRecord) { + return { paneKey: session.cacheKey, record: stableRecord } + } + const legacyMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter( + ([paneKey, record]) => { + const legacy = parseLegacyNumericPaneKey(paneKey) + return ( + legacy?.tabId === session.deps.tabId && + record.worktreeId === session.deps.worktreeId && + (!record.tabId || record.tabId === session.deps.tabId) + ) + } + ) + const exactLegacyMatch = legacyMatches.find(([paneKey]) => { + const legacy = parseLegacyNumericPaneKey(paneKey) + return legacy?.numericPaneId === String(session.pane.id) + }) + const providerSessionKeys = new Set( + legacyMatches.map(([, record]) => getProviderSessionClaimKey(record)) + ) + const oldestLegacyMatch = legacyMatches + .slice() + .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] + // Why: duplicate legacy aliases can point at one provider session; consume + // the oldest capture as canonical and clear its aliases after resume. + const selectedLegacyMatch = + exactLegacyMatch ?? + (providerSessionKeys.size === 1 + ? legacyMatches.length === 1 + ? legacyMatches[0] + : oldestLegacyMatch + : null) + if (!selectedLegacyMatch) { + return null + } + const [paneKey, record] = selectedLegacyMatch + return { paneKey, record } + } + session.isLegacyWorkerAutomaticResumeBlocked = (): boolean => + session.getSleepingRecordForPane(useAppStore.getState())?.record.automaticResumeBlockedBy === + 'legacy-orchestration-worker' + session.clearSleepingRecordProviderDuplicates = ( + state: ReturnType, + consumed: { paneKey: string; record: SleepingAgentSessionRecord } + ): void => { + state.clearSleepingAgentSession(consumed.paneKey) + for (const [paneKey, record] of Object.entries(state.sleepingAgentSessionsByPaneKey)) { + if ( + paneKey !== consumed.paneKey && + record.worktreeId === consumed.record.worktreeId && + record.agent === consumed.record.agent && + agentProviderSessionsEqual( + record.agent, + record.providerSession, + consumed.record.providerSession + ) + ) { + // Why: legacy pane aliases can leave multiple sleeping rows for one + // provider session; once this pane resumes it, every alias is stale. + state.clearSleepingAgentSession(paneKey) + } + } + } + session.launchToken = session.paneStartup?.launchConfig + ? (session.paneStartup.launchToken ?? createBrowserUuid()) + : undefined + session.startupDraftAgent = + session.paneStartup?.launchAgent ?? session.paneStartup?.initialAgentStatus?.agent + session.startupDraftAgentConfig = session.startupDraftAgent + ? TUI_AGENT_CONFIG[session.startupDraftAgent] + : null + session.startupDraftPrompt = + typeof session.paneStartup?.draftPrompt === 'string' && session.paneStartup.draftPrompt.trim() + ? session.paneStartup.draftPrompt + : null + session.startupDraftPromptNeedsPaste = + session.startupDraftPrompt !== null && + !session.startupDraftAgentConfig?.draftPromptFlag && + !session.startupDraftAgentConfig?.draftPromptEnvVar + session.startupDraftDeliveryClaimed = false + session.startupDraftPasteAttempted = false + session.claimStartupDraftPasteDelivery = (): boolean => { + if (!session.startupDraftPromptNeedsPaste || session.launchToken === undefined) { + return false + } + if (session.startupDraftDeliveryClaimed) { + return true + } + // Why: launch-bound draft paste needs a launch token; all current + // draftPrompt startup callers pair it with launchConfig so this can safely + // fence off delayed sidecar delivery before Codex's first composer frame. + session.startupDraftDeliveryClaimed = beginAgentStartupDeliveryAttempt({ + worktreeId: session.deps.worktreeId, + tabId: session.deps.tabId, + launchToken: session.launchToken + }) + return session.startupDraftDeliveryClaimed + } + session.releaseUnattemptedStartupDraftPasteDelivery = (): void => { + if ( + !session.startupDraftDeliveryClaimed || + session.startupDraftPasteAttempted || + session.launchToken === undefined + ) { + return + } + releaseAgentStartupDeliveryAttempt({ + worktreeId: session.deps.worktreeId, + tabId: session.deps.tabId, + launchToken: session.launchToken + }) + session.startupDraftDeliveryClaimed = false + } + // Why: reserve before deferred connect so the creation sidecar cannot time out during setup and strand this pane's live scanner. + session.ownsStartupDraftPaste = session.claimStartupDraftPasteDelivery() + if (session.paneStartup?.launchConfig) { + useAppStore + .getState() + .registerAgentLaunchConfig(session.cacheKey, session.paneStartup.launchConfig, { + agentType: session.paneStartup.launchAgent ?? session.paneStartup.initialAgentStatus?.agent, + ...(session.launchToken ? { launchToken: session.launchToken } : {}), + tabId: session.deps.tabId, + leafId: session.pane.leafId + }) + } else if (session.paneStartup) { + useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) + } + session.registerEffectiveLaunchConfig = ( + effectiveLaunchConfig: PtyConnectResult['launchConfig'] | undefined, + metadata?: { launchToken?: string; launchAgent?: TuiAgent } + ): void => { + if (!effectiveLaunchConfig) { + if (metadata?.launchAgent) { + // Why: daemon launch identity can outlive the process while Orca is + // closed. Use it to request confirmation, never as current byte authority. + useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { + agent: metadata.launchAgent, + shellForeground: false + }) + } + return + } + // Why: daemon reattach preserves the pane's exact launch command but not + // renderer metadata; recover only allowlisted command identity from it. + const persistedLaunchAgent = recognizeAgentProcessFromCommandLine( + effectiveLaunchConfig.agentCommand + )?.agent + useAppStore.getState().registerAgentLaunchConfig(session.cacheKey, effectiveLaunchConfig, { + agentType: + metadata?.launchAgent ?? + session.paneStartup?.launchAgent ?? + session.paneStartup?.initialAgentStatus?.agent ?? + persistedLaunchAgent, + ...((metadata?.launchToken ?? session.launchToken) + ? { launchToken: metadata?.launchToken ?? session.launchToken } + : {}), + tabId: session.deps.tabId, + leafId: session.pane.leafId + }) + } + session.clearRegisteredStartupLaunchConfig = (): void => { + useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) + } + session.neutralTerminalTitle = (): string => { + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + ) + return tab?.defaultTitle?.trim() || 'Terminal' + } + installCommandInferredPaneAgent(session) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts new file mode 100644 index 00000000000..f1985cedfe5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts @@ -0,0 +1,118 @@ +import { useAppStore } from '@/store' +import { SSH_SESSION_EXPIRED_ERROR } from './pty-connect-limits' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +// Why: when multiple panes/tabs need the same deferred SSH connection, +// the first one calls ssh.connect() and subsequent ones must wait for it +// rather than returning early (which would leave them disconnected). This +// helper either connects or waits for an in-flight connect to finish. +export type SshConnectResult = { connected: true } | { connected: false; error: string } +type UserInitiatedSshConnectOutcome = 'connected' | 'cancelled' | 'failed' + +const sshConnectPromises = new Map>() + +export function isSshSessionExpiredError(err: unknown): boolean { + return (err instanceof Error ? err.message : String(err)).includes(SSH_SESSION_EXPIRED_ERROR) +} + +function sshPromptConnectOutcomeForStatus( + status: string | undefined, + sawNonDisconnected: boolean +): UserInitiatedSshConnectOutcome | null { + if (status === 'connected') { + return 'connected' + } + if (status === 'auth-failed' || status === 'error' || status === 'reconnection-failed') { + return 'failed' + } + // Why: this only counts after a real connect attempt; the entry-time + // disconnected state just means the user still needs to initiate auth. + if (sawNonDisconnected && status === 'disconnected') { + return 'cancelled' + } + return null +} + +export function waitForUserInitiatedSshConnect( + session: ConnectPanePtySession +): Promise { + return new Promise((resolve) => { + // Entry-time disconnected means authentication has not started; it only cancels after another status was observed. + let sawNonDisconnected = !['disconnected', undefined].includes( + useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status + ) + let settled = false + const finish = (outcome: UserInitiatedSshConnectOutcome): void => { + if (settled) { + return + } + settled = true + unsubscribe() + const index = session.waitTeardowns.indexOf(teardown) + if (index !== -1) { + session.waitTeardowns.splice(index, 1) + } + resolve(outcome) + } + const teardown = (): void => finish('cancelled') + // Disposal must resolve the wait even if the SSH store never emits again. + session.waitTeardowns.push(teardown) + const readOutcome = (status: string | undefined): UserInitiatedSshConnectOutcome | null => { + if (status && status !== 'disconnected') { + sawNonDisconnected = true + } + return sshPromptConnectOutcomeForStatus(status, sawNonDisconnected) + } + const unsubscribe = useAppStore.subscribe((state) => { + if (session.disposed) { + finish('cancelled') + return + } + const outcome = readOutcome(state.sshConnectionStates.get(session.connectionId)?.status) + if (outcome) { + finish(outcome) + } + }) + if (session.disposed) { + finish('cancelled') + return + } + // Catch a status change that landed between the caller's check and this subscription. + const currentOutcome = readOutcome( + useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status + ) + if (currentOutcome) { + finish(currentOutcome) + } + }) +} + +export async function waitForSshConnection(connectionId: string): Promise { + const state = useAppStore.getState().sshConnectionStates.get(connectionId) + if (state?.status === 'connected') { + return { connected: true } + } + + const existing = sshConnectPromises.get(connectionId) + if (existing) { + return existing + } + + const promise: Promise = (async (): Promise => { + try { + await window.api.ssh.connect({ targetId: connectionId }) + return { connected: true } + } catch (err) { + console.warn(`Deferred SSH reconnect failed for ${connectionId}:`, err) + return { + connected: false, + error: err instanceof Error ? err.message : String(err) + } + } finally { + sshConnectPromises.delete(connectionId) + } + })() + + sshConnectPromises.set(connectionId, promise) + return promise +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts b/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts new file mode 100644 index 00000000000..8dfe10e8a6e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts @@ -0,0 +1,78 @@ +import { waitForTerminalReplayWritesParsed } from '../replay-guard' +import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' +import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id' +import { + buildMainModelSnapshotReplayWrites, + hasPositiveTerminalDimensions +} from '../terminal-snapshot-replay-paint' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function bindPrepaintParkedSshSnapshot(session: ConnectPanePtySession): void { + session.prepaintParkedSshSnapshot = (ptyId: string | null): void => { + const parsedPtyId = ptyId ? parseAppSshPtyId(ptyId) : null + if ( + !ptyId || + !session.mountFollowsTerminalPark || + parsedPtyId?.connectionId !== session.connectionId || + !session.capturedDirectSshRetryLeaseMatches() + ) { + return + } + const capturedGeneration = session.authoritativeReattachGeneration + const isCurrent = (): boolean => + !session.disposed && + session.mountFollowsTerminalPark && + session.authoritativeReattachGeneration === capturedGeneration && + session.capturedDirectSshRetryLeaseMatches() + const fetchSnapshot = session.getSshMainModelSnapshotProbe(ptyId) + void fetchSnapshot() + .then(async (snapshot) => { + if (!snapshot || !isCurrent()) { + return + } + await session.structuralReplayCoordinator.run( + async () => { + if (!isCurrent()) { + return + } + const modelData = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` + session.rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true }) + if ( + hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) && + (session.pane.terminal.cols !== snapshot.cols || + session.pane.terminal.rows !== snapshot.rows) + ) { + session.suppressStructuralReplayPtyResize = true + try { + session.pane.terminal.resize(snapshot.cols, snapshot.rows) + } finally { + session.suppressStructuralReplayPtyResize = false + } + } + session.applySnapshotKittyKeyboardModes(modelData, { + kittyKeyboardFlags: snapshot.kittyKeyboardFlags, + snapshotSeq: snapshot.seq + }) + // Why keep a too-wide frame: preconnect SSH has no live repaint owner or post-restore fit. + for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { + paneOnAlternateScreen: session.isPaneOnAlternateScreen() + })) { + session.writeReplayData(replayChunk) + } + session.writeReplayData(session.reattachReplayResetSequence(modelData)) + if (snapshot.pendingEscapeTailAnsi) { + session.writeReplayData(snapshot.pendingEscapeTailAnsi) + } + recordTerminalOutput(session.pane.terminal) + await waitForTerminalReplayWritesParsed(session.pane.terminal) + if (isCurrent()) { + session.manager.rebuildPaneWebgl(session.pane.id) + } + }, + { shouldRestore: isCurrent } + ) + }) + .catch(() => {}) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts b/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts new file mode 100644 index 00000000000..649aabb8b39 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts @@ -0,0 +1,4 @@ +// Why: the notice deliberately omits the rejected path — saved cwds can +// contain private repo/user names; the terminal itself shows where it opened. +export const STARTUP_CWD_FALLBACK_NOTICE = + '\r\n[Orca opened this terminal at the workspace root because its saved start folder no longer exists.]\r\n' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts new file mode 100644 index 00000000000..3fc57968edd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -0,0 +1,260 @@ +import { useAppStore } from '@/store' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { bindPanePtyId, getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id' +import { isFreshNonDoneAgentStatus } from '../../../../../shared/agent-status-types' +import { isCtrlCKeyEvent, isPlainEscapeKeyEvent } from '../agent-interrupt-inference' +import { createAgentCompletionCoordinator } from '../agent-completion-coordinator' +import { dispatchAgentHookTerminalLifecycle } from '../agent-hook-terminal-lifecycle' +import { createCodexAutoApprovalHookCompletionSuppressor } from '../codex-auto-approval-notification-suppression' +import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner' +import { registerTerminalSideEffectFactConsumer } from '../terminal-side-effect-facts-handler' + +import { isAgentTaskCompleteTrackingEnabled } from './agent-task-complete-settings' +import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** Keydown intent, PTY fit binding, side-effect fact consumption, and the agent completion coordinator. */ +export function installTerminalKeydownFit(session: ConnectPanePtySession): void { + session.onTerminalKeyDown = (event: KeyboardEvent): void => { + if (isPlainEscapeKeyEvent(event)) { + session.setPendingTerminalInputIntent('plain-escape') + // Why: plain Escape produces real terminal input (\x1b), so it is a + // genuine "user is here" signal and must still dismiss attention before + // the early return for interrupt-intent inference. + session.deps.clearTerminalTabUnread(session.deps.tabId) + session.deps.clearTerminalPaneUnread(session.cacheKey) + session.deps.clearWorktreeUnread(session.deps.worktreeId) + return + } + if (isCtrlCKeyEvent(event)) { + if (!navigator.userAgent.includes('Mac') && session.pane.terminal.hasSelection()) { + return + } + session.setPendingTerminalInputIntent('ctrl-c') + } + // Why: only treat keydowns that will produce real terminal input as the + // "user is here" signal. Modifier-only presses, autorepeat, and Cmd/Ctrl+C + // copy chords with an active selection must not dismiss attention on a + // sibling pane before the user has seen it. + if ( + event.repeat || + event.key === 'Alt' || + event.key === 'AltGraph' || + event.key === 'Control' || + event.key === 'Meta' || + event.key === 'Shift' + ) { + return + } + if ( + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === 'c' && + session.pane.terminal.hasSelection() + ) { + return + } + // Why: user shell frameworks (bash-preexec/iTerm2) can replace Orca's + // OSC 133;C hook, so a manually launched agent produces no command-start + // signal at all. Enter at a shell-foreground prompt is the user-side + // equivalent; the sample is gated to panes with no live agent identity + // and publishes nothing for an idle shell. + if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey) { + session.sampleVisiblePaneForegroundAgent() + } + session.deps.clearTerminalTabUnread(session.deps.tabId) + session.deps.clearTerminalPaneUnread(session.cacheKey) + session.deps.clearWorktreeUnread(session.deps.worktreeId) + } + // Why: infer only from focused xterm key events. Raw PTY bytes cannot + // distinguish plain Escape from Alt/meta sequences, and programmatic writes + // should not clear agent status. + session.terminalKeyTarget = session.pane.terminal.element ?? session.pane.container + session.terminalKeyTargetSupportsEvents = + typeof session.terminalKeyTarget?.addEventListener === 'function' && + typeof session.terminalKeyTarget?.removeEventListener === 'function' + if (session.terminalKeyTargetSupportsEvents) { + session.terminalKeyTarget.addEventListener('keydown', session.onTerminalKeyDown, { + capture: true + }) + } + + session.visibleRemoteViewportClaimPtyId = null + session.pendingVisibleRemoteViewportClaim = false + session.setPanePtyFitBinding = (ptyId: string): void => { + bindPanePtyId(session.pane.id, ptyId, session.deps.tabId) + session.pane.container.dataset.ptyId = ptyId + if ( + session.deps.isVisibleRef.current && + isRemoteRuntimePtyId(ptyId) && + session.visibleRemoteViewportClaimPtyId !== ptyId + ) { + // Why: the initial fit event consumes this activation arm before later peer ownership changes. + session.visibleRemoteViewportClaimPtyId = ptyId + session.pendingVisibleRemoteViewportClaim = true + } + // Why: override hydration can arrive before this pane knows its PTY. Once + // data-pty-id is bound, safeFit can park xterm at the authoritative grid. + if (getFitOverrideForPty(ptyId)) { + safeFit(session.pane) + } + session.claimPendingVisibleRemoteViewport() + } + session.activePanePtyBinding = null + // Why: bind time lets async liveness reconcile ignore a request started + // before this PTY bound (newborn race). Null disables the guard (fail-safe). + session.activePanePtyBindingBoundAt = null + + // Why: with main side-effect authority on, the pane's title/bell/agent + // policy callbacks consume pty:sideEffect facts instead of transport byte + // parsers (which stay unregistered) — same policy code, single consumer. + // restoreTitleOnRegister replaces the eager-replay title restore: main's + // title-only snapshot carries the no-attention-replay rule. + session.unregisterSideEffectFactConsumer = null + session.registerSideEffectFactConsumerForPty = ( + ptyId: string, + remoteOutputPaused = false + ): void => { + if ((!session.mainSideEffectAuthority && !remoteOutputPaused) || session.disposed) { + return + } + session.unregisterSideEffectFactConsumer?.() + session.unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({ + ptyId, + callbacks: { + onTitleChange: session.onTitleChange, + onBell: session.onBell, + onAgentBecameIdle: session.onAgentBecameIdle, + onAgentBecameWorking: session.onAgentBecameWorking, + onAgentExited: session.onAgentExited, + onCommandFinished: session.handleCommandFinished, + onPrLink: (link) => + useAppStore + .getState() + .observeTerminalGitHubPullRequestLink(session.deps.worktreeId, link), + // Why: the Command Code settle policy stays here — the done settle + // timer must consult the live store row (which hook events and + // renderer seeds also write), so main only emits scrape facts. + onCommandCodeWorking: session.seedCommandCodeOutputWorkingStatus, + onCommandCodeDone: session.scheduleCommandCodeOutputDoneStatus, + ...(session.shouldOwnAgentStatusInRenderer + ? { onAgentStatus: (payload) => session.handleRendererOwnedAgentStatus(payload) } + : {}), + // Why: gated hidden panes never see the subscribe bytes; the fact + // replaces the byte scan (and the old post-latch subscribe drop). + ...(session.hiddenDeliveryGateActive || remoteOutputPaused + ? { + onMode2031Subscribe: session.handleHiddenMode2031SubscribeFact, + onMode2031Unsubscribe: session.handleHiddenMode2031UnsubscribeFact + } + : {}) + }, + restoreTitleOnRegister: true + }) + } + session.dropSideEffectFactConsumer = (): void => { + session.unregisterSideEffectFactConsumer?.() + session.unregisterSideEffectFactConsumer = null + session.remoteOutputFactConsumerPtyId = null + } + session.clearPanePtyFitBinding = (): void => { + // Why: fit bindings live in a module-level map, so pane teardown must + // clear them explicitly instead of relying on DOM removal. + bindPanePtyId(session.pane.id, null, session.deps.tabId) + session.visibleRemoteViewportClaimPtyId = null + session.pendingVisibleRemoteViewportClaim = false + session.activePanePtyBinding = null + session.activePanePtyBindingBoundAt = null + delete session.pane.container.dataset.ptyId + delete session.pane.container.dataset.ptyRecoveryState + } + + session.agentCompletionCoordinator = createAgentCompletionCoordinator({ + paneKey: session.cacheKey, + statusLane: 'pty', + getPtyId: () => session.transport.getPtyId(), + getSettings: () => useAppStore.getState().settings, + inspectProcess: inspectRuntimeTerminalProcess, + dispatchHookLifecycle: (payload) => + dispatchAgentHookTerminalLifecycle(session.cacheKey, payload), + shouldSuppressProcessReplacementCompletion: (_exited, replacement) => { + const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + const currentAgentForReplacement = resolveCompatibleAgentTypeForOwner( + currentStatus?.agentType, + replacement.agent + ) + return ( + isFreshNonDoneAgentStatus(currentStatus) && currentAgentForReplacement === replacement.agent + ) + }, + shouldSuppressConfirmedProcessExitCompletion: (exited) => { + const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + const currentAgentForExited = resolveCompatibleAgentTypeForOwner( + currentStatus?.agentType, + exited.agent + ) + // Why: a replacement hook can lead process visibility by one cadence; + // only a different known active owner can veto confirmed old-process exit. + return Boolean( + isFreshNonDoneAgentStatus(currentStatus) && + currentStatus.agentType && + currentStatus.agentType !== 'unknown' && + currentAgentForExited !== exited.agent + ) + }, + dispatchCompletion: (title, meta) => { + if (meta?.source === 'process-exit') { + session.clearSuppressedTitleSideEffects() + } + if (meta?.terminalIdleConfirmed === true) { + // Why: an agent can crash before its done hook; confirmed process death + // must still restore cursor and native Windows Kitty keyboard modes. + const currentAgentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + if (!isFreshNonDoneAgentStatus(currentAgentStatus)) { + session.setFocusReportSuppressionForAgentCompletion(title, meta.agentStatus?.agentType) + } + session.queueAgentIdleTerminalModeReset() + } + session.scheduleAgentTaskCompleteNotification(title, { + allowDoneDetailAfterGrace: meta?.quietedHookDone, + ...(meta?.source === 'process-exit' ? { agentCompletionSource: meta.source } : {}), + ...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {}) + }) + }, + dispatchAttention: (title, meta) => + session.scheduleAgentTaskCompleteNotification(title, { + agentStatusSnapshot: meta.agentStatus + }), + shouldPollProcessCadence: () => + isAgentTaskCompleteTrackingEnabled() && session.deps.isVisibleRef.current, + isProcessInspectionCostly: () => { + // Why: local Windows inspection forks a powershell.exe whole-process-table + // CIM scan per poll (~10-40x heavier than POSIX `ps`); SSH/remote PTYs run + // their scans on the remote host, so only local Windows panes relax the + // no-evidence cadence. + if (!navigator.userAgent.includes('Windows')) { + return false + } + const ptyId = session.transport.getPtyId() + return ptyId !== null && !isRemoteRuntimePtyId(ptyId) && parseAppSshPtyId(ptyId) === null + }, + isLive: () => { + if (session.disposed) { + return false + } + if (session.transport.getPtyId()) { + return true + } + return (useAppStore.getState().ptyIdsByTabId[session.deps.tabId] ?? []).length > 0 + }, + shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor( + session.cacheKey, + () => ({ + tabId: session.deps.tabId, + ...(session.launchToken ? { launchToken: session.launchToken } : {}) + }) + ) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts new file mode 100644 index 00000000000..d035a2366a9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts @@ -0,0 +1,224 @@ +import { resolvePaneTitleDecision } from '../terminal-title-evidence' +import { useAppStore } from '@/store' +import { shouldSeedCacheTimerOnInitialTitle } from '../cache-timer-seeding' +import { shouldSuppressCodexAutoApprovalSyntheticTitle } from '../codex-auto-approval-notification-suppression' +import { + cancelCommandCodeDoneSettle, + openCommandCodeDoneSettle, + setCommandCodeDoneSettleExecutor +} from '../command-code-done-settle' +import { canCommandCodeOutputOwnPane } from '../command-code-output-ownership' +import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner' +import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +import { installPanePtyVisibilityBind } from './pane-pty-visibility-bind' + +export function installTitleSpawnBell(session: ConnectPanePtySession): void { + session.onTitleChange = ( + title: string, + rawTitle: string, + meta?: { staleWorkingTitleClear?: boolean } + ): void => { + // Why: one owner-aware decision drives the display label, the runtime/tab + // title, task-completion tracking, and the renderer gate, so raw title text + // can no longer disable GPU behind stronger owner evidence (#7428/#7447). + const decision = resolvePaneTitleDecision({ + normalizedTitle: title, + rawTitle, + displayOwnerAgentType: session.getAuthoritativePaneAgent(), + rendererOwnerAgentType: session.getPaneScopedRendererOwner(), + userGpuMode: useAppStore.getState().settings?.terminalGpuAcceleration ?? 'auto' + }) + const paneTitle = decision.displayTitle + if ( + shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, { + paneKey: session.cacheKey, + tabId: session.deps.tabId, + ...(session.launchToken ? { launchToken: session.launchToken } : {}) + }) + ) { + return + } + session.manager.setPaneGpuRendering(session.pane.id, decision.rendererPolicy.gpuEnabled) + session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, paneTitle) + // Why: a stale-derived cleared title comes from main's unthrottled 3s + // timer, not agent output. It must update the visible title but never + // feed completion tracking — observeTitle would classify the cleared + // title as idle and mint a task-complete for a merely-paused agent. + if (!meta?.staleWorkingTitleClear && session.syncAgentTaskCompleteTrackingEnabled()) { + const activeHookStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + if (!session.shouldSuppressTitleCompletionForFreshHook(decision.rawTitle, activeHookStatus)) { + // Why: display titles still update while hooks are active, but a stale + // idle frame must not complete the coordinator turn before hook `done`. + session.agentCompletionCoordinator.observeTitle(decision.rawTitle) + } + } + // Why: only the focused pane should drive the tab title — otherwise two + // agents in split panes cause rapid title flickering as each emits OSC + // sequences. Only the active split's title propagates to the tab. When + // focus changes, onActivePaneChange syncs the newly active pane's stored + // title to the tab. + if (session.manager.getActivePane()?.id === session.pane.id) { + session.deps.updateTabTitle(session.deps.tabId, paneTitle) + } + + if (!session.hasConsideredInitialCacheTimerSeed) { + session.hasConsideredInitialCacheTimerSeed = true + const state = useAppStore.getState() + if ( + shouldSeedCacheTimerOnInitialTitle({ + rawTitle, + allowInitialIdleSeed: session.allowInitialIdleCacheSeed, + existingTimerStartedAt: state.cacheTimerByKey[session.cacheKey], + promptCacheTimerEnabled: state.settings?.promptCacheTimerEnabled ?? null + }) + ) { + session.deps.setCacheTimerStartedAt(session.cacheKey, Date.now()) + } + } + } + + session.applyInitialAgentStatus = (terminalTitle?: string): void => { + const initialStatus = session.paneStartup?.initialAgentStatus + const routing = session.resolveCurrentAgentStatusRouting() + if (!initialStatus || !routing) { + return + } + const statusPayload = { + state: 'working' as const, + prompt: initialStatus.prompt, + agentType: resolveCompatibleAgentTypeForOwner( + initialStatus.agent, + session.getAuthoritativePaneAgent() + ), + observation: rendererAgentStatusObservations.observe(session.cacheKey, { + origin: 'launch', + observedAt: Date.now(), + kind: 'transition' + }) + } + if (session.paneStartup.launchConfig) { + useAppStore + .getState() + .setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing, { + launchConfig: session.paneStartup.launchConfig, + ...(session.launchToken ? { launchToken: session.launchToken } : {}) + }) + return + } + useAppStore + .getState() + .setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing) + } + + session.canApplyCommandCodeOutputStatus = (): boolean => { + const state = useAppStore.getState() + const foreground = state.paneForegroundAgentByPaneKey[session.cacheKey] + return canCommandCodeOutputOwnPane({ + foregroundAgent: foreground?.agent, + shellForeground: foreground?.shellForeground, + paneOwnerAgent: session.getAuthoritativePaneAgent(), + retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[session.cacheKey]?.agentType + }) + } + + session.seedCommandCodeOutputWorkingStatus = (prompt: string): void => { + if (!session.canApplyCommandCodeOutputStatus()) { + return + } + session.clearCommandCodeOutputDoneTimer() + const routing = session.resolveCurrentAgentStatusRouting() + if (!routing) { + return + } + const currentState = useAppStore.getState() + const currentEntry = currentState.agentStatusByPaneKey[session.cacheKey] + const currentTitle = + currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + const normalizedPrompt = prompt.trim() + if ( + currentEntry?.agentType === 'command-code' && + currentEntry.state === 'done' && + (!normalizedPrompt || normalizedPrompt === currentEntry.prompt.trim()) + ) { + return + } + currentState.setAgentStatus( + session.cacheKey, + { + state: 'working', + prompt: normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''), + agentType: 'command-code', + observation: rendererAgentStatusObservations.observe(session.cacheKey, { + origin: 'process', + observedAt: Date.now(), + kind: 'transition' + }) + }, + currentTitle, + undefined, + routing + ) + } + + // Why the settle window lives outside this binding: park unmounts the pane + // mid-settle, so a pane-owned timer would be cancelled with nothing left to + // complete the turn — the row would stick at 'working'. Only the row write + // (routing + title slot) is pane-local; the deadline transfers to whichever + // owner (parked watcher or remounted pane) holds the pane next. + session.releaseCommandCodeDoneSettleExecutor = setCommandCodeDoneSettleExecutor( + session.cacheKey, + (normalizedPrompt) => { + const routing = session.resolveCurrentAgentStatusRouting() + if (!routing) { + return + } + const currentState = useAppStore.getState() + const currentEntry = currentState.agentStatusByPaneKey[session.cacheKey] + if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') { + return + } + const currentPrompt = currentEntry.prompt.trim() + if (currentPrompt && currentPrompt !== normalizedPrompt) { + return + } + const currentTitle = + currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + currentState.setAgentStatus( + session.cacheKey, + { + state: 'done', + prompt: currentPrompt || normalizedPrompt, + agentType: 'command-code', + observation: rendererAgentStatusObservations.observe(session.cacheKey, { + origin: 'process', + observedAt: Date.now(), + kind: 'transition' + }) + }, + currentTitle, + undefined, + routing + ) + } + ) + session.clearCommandCodeOutputDoneTimer = (): void => + cancelCommandCodeDoneSettle(session.cacheKey) + session.scheduleCommandCodeOutputDoneStatus = (prompt: string): void => { + if (!session.canApplyCommandCodeOutputStatus()) { + return + } + const normalizedPrompt = prompt.trim() + if (!normalizedPrompt) { + cancelCommandCodeDoneSettle(session.cacheKey) + return + } + // Why: Command Code keeps rendering the composer while tools run. Only + // complete the row if no active status repaint arrives during this window. + openCommandCodeDoneSettle(session.cacheKey, normalizedPrompt) + } + + installPanePtyVisibilityBind(session) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts new file mode 100644 index 00000000000..73b97c1cb17 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts @@ -0,0 +1,126 @@ +import type { PtyReplayDataMeta } from '../pty-transport' +import type { PtyTransportRecoveryState } from '../pty-transport-types' +import type { PtyDataMeta } from '../pty-dispatcher' +import type { PtyPaneStartup } from '../pty-connection-types' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** Per-generation transport output callbacks and the hidden-output restore state they reset. */ +export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySession): void { + session.captureTransportOutputCallbacks = ( + onError: (message: string) => void, + startup: PtyPaneStartup + ) => { + // Why: a new stream generation cannot inherit an old replay's pending + // destination-grid fit or keep its live-data waiter open. + session.pendingHiddenSnapshotFit?.cancel() + session.pendingHiddenSnapshotFit = null + session.pendingReattachFit?.cancel() + session.pendingReattachFit = null + const generation = (session.transportStreamGeneration += 1) + const processExitState = session.createProcessExitState(startup) + session.currentProcessExitState = processExitState + const isCurrent = (): boolean => + !session.disposed && generation === session.transportStreamGeneration + return { + generation, + callbacks: { + onReattachDetermined: (): void => { + if (isCurrent()) { + session.beginReattachLiveDataDeferralIfUnowned(generation) + } + }, + onConnect: (): void => { + if (isCurrent()) { + session.reportRemoteRendererSerializerReady() + // Re-derive the pause bit after a rebind; visibility can change while no PTY is bound. + session.syncHiddenRendererPtyDelivery() + } + }, + onStreamRecovered: (): void => { + if (isCurrent()) { + session.markHiddenOutputRestoreNeeded() + } + }, + onData: (data: string, meta?: PtyDataMeta): void => { + if (isCurrent()) { + processExitState.detector.observe(data) + session.dataCallback(data, meta, generation) + } + }, + onReplayData: (data: string, meta?: PtyReplayDataMeta): void => { + if (isCurrent()) { + session.replayDataCallback(data, meta, generation) + } + }, + onError: (message: string): void => { + if (isCurrent()) { + onError(message) + } + }, + onWriteUnavailable: (): void => { + if (isCurrent()) { + session.requestRecoveryForUndeliverableInput(true) + } + }, + onRecoveryStateChange: (state: PtyTransportRecoveryState): void => { + if (isCurrent()) { + // Why: cached pixels remain visible while detached; expose transport truth for diagnostics and recovery UI. + session.pane.container.dataset.ptyRecoveryState = state.phase + session.deps.onPtyRecoveryStateRef?.current?.(session.pane.id, state) + } + }, + onOutputPauseChanged: (paused: boolean, supported: boolean): void => { + if (isCurrent()) { + session.handleRemoteOutputPauseChanged(paused, supported) + } + } + } + } + } + + session.hiddenOutputRestoreNeeded = false + session.hiddenOutputRestoreInFlight = null + session.hiddenOutputRestorePendingChunks = [] + session.hiddenOutputRestorePendingChars = 0 + session.hiddenOutputRestorePendingOverflow = false + session.hiddenOutputRestoreFreshSnapshotNeeded = false + session.hiddenOutputRestoreRetryDeferred = false + session.hiddenOutputRestoreScheduled = false + session.hiddenOutputRestoreDeferredRetryTimer = null + session.hiddenOutputRestoreForegroundDeadlineTimer = null + session.hiddenOutputRestoreDeferredRetryAttempts = 0 + session.hiddenOutputRestoreRemoteOutcomeAttempts = 0 + session.hiddenOutputRestoreLocalGateAttempts = 0 + session.hiddenOutputRestoreLegacyPtyId = null + // Bounded remote re-arms spent instead of the loss banner (per PTY stream). + session.hiddenOutputRestoreRemoteAbandonCycles = 0 + session.hiddenOutputSnapshotScrollRestore = null + // Why: hidden recovery state belongs to one PTY stream. Reattach/restart + // can reuse the pane object for a different session before visibility. + session.hiddenOutputRestorePtyId = null + // One recovery re-kick per xterm instance. Generation-aware cooldown and + // window-cap retries keep a fresh-but-wedged replacement from fossilizing. + session.certifiedDeadRestoreRecoveryRequested = false + session.hiddenOutputRestoreGeneration = 0 + // Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS). + session.hiddenOutputRestoreFloodSuppressedUntil = 0 + // Why: queued replay writes still paint after deadline abandonment; the + // fallback drain must not write snapshot-covered live bytes a second time. + session.hiddenOutputRestoreReplayingSnapshot = null + session.hiddenOutputRestoreFloodRepaintTimer = null + session.cancelHiddenOutputRestoreFloodRepaintPark = null + // Why: after a snapshot restore, main can still drain ACK-backlog chunks + // whose bytes the snapshot already covers — writing them unguarded + // duplicates visible output. Track the restored baseline seq (per PTY) + // and the expected next chunk start so session.dataCallback can drop/slice + // overlaps and detect seq gaps from main-side pending-cap trims whose + // one-shot marker was already consumed. + session.restoredSnapshotBaselineSeq = null + session.restoredSnapshotBaselinePtyId = null + session.restoredSnapshotExpectedStartSeq = null + // Why: main samples its pending renderer-delivery queue with the snapshot. + // Chunks at or below this seq can never be backlog duplicates (delivery is + // once-and-in-order), so the dedupe window is (windowStart, baseline]. + session.restoredSnapshotDeliveryWindowStartSeq = null +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts new file mode 100644 index 00000000000..28498734a05 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts @@ -0,0 +1,113 @@ +import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-delivery-credit' +import { nativeWindowsRewriteNeedsFollowupRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' +import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' + +import { FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS } from './foreground-output-budgets' +import { + shouldWritePtyOutputForeground, + scanSynchronizedForegroundOutput, + containsCursorRestore +} from './foreground-output-scan' +import { containsHiddenStartupRendererQuery } from './hidden-startup-renderer-query' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** The xterm write path for PTY output, including the queued agent-idle mode reset. */ +export function bindWritePtyOutputToXterm(session: ConnectPanePtySession): void { + session.writePtyOutputToXterm = function ( + data: string, + foreground: boolean, + opts?: { hiddenStartupRendererQuery?: boolean } + ): void { + // Why: every application byte funnels through here, so it's the one place the kitty keyboard mirror observes the pane's protocol negotiation. + session.kittyKeyboardModes.scan(data) + if (foreground) { + session.resetHiddenOutputRestoreIfPtyChanged() + } + const parseHiddenStartupOutput = + !foreground && + session.canUseHiddenOutputSnapshot(session.transport.getPtyId()) && + session.shouldSnapshotHiddenCodexOutput && + (opts?.hiddenStartupRendererQuery === true || containsHiddenStartupRendererQuery(data)) + const synchronizedForegroundScan = + session.shouldProtectNativeWindowsSynchronizedOutput && foreground + ? scanSynchronizedForegroundOutput( + data, + session.synchronizedForegroundMarkerTail, + session.synchronizedForegroundOutputActive + ) + : null + const synchronizedOutputStarted = synchronizedForegroundScan?.started === true + const synchronizedOutputEnded = synchronizedForegroundScan?.ended === true + const synchronizedForegroundOutput = + synchronizedForegroundScan !== null && + (session.synchronizedForegroundOutputActive || + synchronizedOutputStarted || + synchronizedOutputEnded) + const nextSynchronizedForegroundOutputActive = synchronizedForegroundScan?.active === true + // Why: xterm's DOM renderer draws the cursor as row content, so Windows cursor-only restores need row invalidation even outside DEC 2026. + const nativeWindowsCursorRestore = + session.shouldProtectNativeWindowsSynchronizedOutput && + foreground && + containsCursorRestore(data) + const foregroundOutput = foreground || parseHiddenStartupOutput + if (foreground) { + session.scheduleForegroundGridDriftCheck() + } + const renderRefreshDecision = foregroundOutput + ? session.shouldForceForegroundRenderRefresh(data) + : { refresh: false, inPlaceRewrite: false } + const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh + // Why: Claude Code's in-place prompt redraws on Windows ConPTY can paint one frame late; a follow-up repaint fixes the column desync without a resize. + const nativeWindowsInPlaceRewriteFollowup = nativeWindowsRewriteNeedsFollowupRenderRefresh({ + isNativeWindowsConpty: session.shouldApplyNativeWindowsRewriteRefresh, + isForeground: foreground, + isInPlaceRewrite: renderRefreshDecision.inPlaceRewrite + }) + // Why: recompute the latch on every synchronized START so each frame's interactivity is judged by its own open time and can't leak across a same-chunk close+open; clear only on leaving synchronized output. + if (synchronizedForegroundOutput && synchronizedOutputStarted) { + session.synchronizedForegroundFrameInteractive = + performance.now() - session.lastTerminalInputAt <= + FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS + } else if (!nextSynchronizedForegroundOutputActive && !synchronizedOutputEnded) { + session.synchronizedForegroundFrameInteractive = false + } + // Why: ConPTY can split a submit repaint's closing chunk past the 150ms window, so treat a keystroke-opened frame as latency-sensitive to drain it fast (~16-32ms) not the 1s coalesce fallback. + const synchronizedFrameLatencySensitive = + synchronizedForegroundOutput && session.synchronizedForegroundFrameInteractive + session.synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive + session.synchronizedForegroundMarkerTail = synchronizedForegroundScan?.markerTail ?? '' + writeTerminalOutput(session.pane.terminal, data, { + foreground: foregroundOutput, + beforeWrite: session.beforeTerminalOutputWrite, + // Why: every scheduler write claims one child so a split delivery is credited only after all children parse or discard. + ackCredit: takeCurrentTerminalDeliveryCredit() ?? undefined, + onBackgroundBacklogDropped: session.markHiddenOutputRestoreNeeded, + latencySensitive: + !foreground || parseHiddenStartupOutput + ? true + : synchronizedFrameLatencySensitive || session.isLatencySensitiveForegroundOutput(data), + forceForegroundRefresh: + foregroundOutput && + (synchronizedForegroundOutput || + nativeWindowsCursorRestore || + foregroundRenderRefreshNeeded), + followupForegroundRefresh: nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, + // Why: xterm already queued a WebGL frame parsing this chunk; merge the repair into it instead of rendering the grid twice. + shouldRefreshForegroundSynchronously: session.shouldRefreshForegroundSynchronously, + stripTransientCursorShows: session.shouldProtectNativeWindowsSynchronizedOutput && foreground, + coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, + holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive + }) + } + + session.queueAgentIdleTerminalModeReset = (): void => { + if (session.disposed) { + return + } + session.writePtyOutputToXterm( + session.idleAgentTerminalModeReset, + shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) + ) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index f288521ae8a..d45be73c63e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -6,6 +6,7 @@ import { } from '../terminal/terminal-provider-snapshot-capability' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, + getTerminalWorktreeParkingInputsKey, hasPendingRetentionSpawnWork, isEvictionExemptTerminalPty, selectForceParkEvictableTabIds, @@ -13,6 +14,47 @@ import { type TerminalWorktreeRetentionCandidate } from './terminal-hidden-worktree-retention' +describe('getTerminalWorktreeParkingInputsKey', () => { + const tabs = { + 'repo::/worktree': [ + { + id: 'tab-1', + ptyId: 'repo::/worktree@@pty-1', + pendingActivationSpawn: undefined, + title: 'Original title' + } + ] + } + + it('ignores display-only tab changes', () => { + const retitledTabs = { + 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], title: 'Updated title' }] + } + expect(getTerminalWorktreeParkingInputsKey(retitledTabs)).toBe( + getTerminalWorktreeParkingInputsKey(tabs) + ) + }) + + it('changes for inputs consumed by the parking policy', () => { + const original = getTerminalWorktreeParkingInputsKey(tabs) + expect( + getTerminalWorktreeParkingInputsKey({ + 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], ptyId: null }] + }) + ).not.toBe(original) + expect( + getTerminalWorktreeParkingInputsKey({ + 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], pendingActivationSpawn: true }] + }) + ).not.toBe(original) + expect( + getTerminalWorktreeParkingInputsKey({ + 'repo::/worktree': [{ ...tabs['repo::/worktree'][0], id: 'tab-2' }] + }) + ).not.toBe(original) + }) +}) + describe('hasPendingRetentionSpawnWork', () => { const remoteTab = { id: 'tab-remote', diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 8473d07ffee..2024002bfa4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -30,6 +30,19 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 4 export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 15 * 60_000 +type TerminalWorktreeParkingTab = Pick + +export function getTerminalWorktreeParkingInputsKey( + tabsByWorktree: Readonly> +): string { + return JSON.stringify( + Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn]) + ]) + ) +} + export function hasPendingRetentionSpawnWork( tab: Pick, pendingStartupByTabId: Readonly> diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts index 50d3d693f7d..da14fb4b9b6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts @@ -62,6 +62,19 @@ describe('requestTerminalPaneRecovery', () => { expect(mocks.hasPty).not.toHaveBeenCalled() }) + it('remounts an unverifiable reattach without requiring host-death evidence', async () => { + const result = await requestTerminalPaneRecovery({ + tabId: 'tab-ssh', + ptyId: 'ssh:target@@pty-1', + reason: 'reattach-unverifiable' + }) + + expect(result).toBe(true) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-ssh') + expect(mocks.hasPty).not.toHaveBeenCalled() + expect(isTerminalInputQuarantined('tab-ssh')).toBe(false) + }) + it('records a breadcrumb when the tab cannot be remounted, without consuming budget', async () => { mocks.remountTerminalTabForRecovery.mockReturnValue(false) @@ -151,6 +164,23 @@ describe('requestTerminalPaneRecovery', () => { expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(4) }) + it('does not restart an unverifiable SSH reattach chain after its incident cap', async () => { + vi.useFakeTimers() + for (let attempt = 0; attempt < 4; attempt += 1) { + vi.setSystemTime(attempt * 20_000) + await requestTerminalPaneRecovery({ + tabId: 'tab-ssh', + ptyId: 'ssh:target@@pty-1', + reason: 'reattach-unverifiable', + terminalRecoveryGeneration: captureTerminalPaneRecoveryGeneration('tab-ssh') + }) + } + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(600_000) + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3) + }) + it('does not retry a cooldown decline from the xterm replaced by the remount', async () => { vi.useFakeTimers() vi.setSystemTime(0) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts index 9800e95d2ac..3db158d805a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts @@ -27,6 +27,7 @@ export type TerminalPaneRecoveryReason = // rejection frame is the evidence instead — it came from the process that // owns the PTY, over a connection that is by construction still up. | 'input-rejected-by-host' + | 'reattach-unverifiable' // A restore was requested for a certified-dead pipeline (reveal path). | 'restore-blocked' @@ -83,6 +84,15 @@ type RecoveryBudget = | { allowed: false; declinedBy: 'window-cap'; retryInMs: number } | { allowed: false; declinedBy: 'cooldown'; retryInMs: number } +function shouldScheduleRecoveryRetry(request: RecoveryRequest, budget: RecoveryBudget): boolean { + return ( + !budget.allowed && + (budget.declinedBy === 'cooldown' + ? request.terminalRecoveryGeneration !== undefined + : request.reason !== 'reattach-unverifiable') + ) +} + function recoveryBudget(tabId: string, now: number): RecoveryBudget { const timestamps = recoveryTimestampsByTabId.get(tabId) ?? [] const recent = timestamps.filter((t) => now - t < RECOVERY_WINDOW_MS) @@ -202,10 +212,7 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro } const budget = recoveryBudget(request.tabId, Date.now()) if (!budget.allowed) { - if ( - budget.declinedBy === 'window-cap' || - (budget.declinedBy === 'cooldown' && request.terminalRecoveryGeneration !== undefined) - ) { + if (shouldScheduleRecoveryRetry(request, budget)) { scheduleRecoveryRetry(request, budget.retryInMs) } return false @@ -239,10 +246,7 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro } const recheck = recoveryBudget(request.tabId, Date.now()) if (!recheck.allowed) { - if ( - recheck.declinedBy === 'window-cap' || - (recheck.declinedBy === 'cooldown' && request.terminalRecoveryGeneration !== undefined) - ) { + if (shouldScheduleRecoveryRetry(request, recheck)) { scheduleRecoveryRetry(request, recheck.retryInMs) } return false diff --git a/src/renderer/src/lib/github-work-item-workspace-attachment.test.ts b/src/renderer/src/lib/github-work-item-workspace-attachment.test.ts index f52808dc8e0..c3d3870f803 100644 --- a/src/renderer/src/lib/github-work-item-workspace-attachment.test.ts +++ b/src/renderer/src/lib/github-work-item-workspace-attachment.test.ts @@ -3,9 +3,7 @@ import { describe, expect, it } from 'vitest' import { findGithubIssueWorkspaceAttachment, findGithubPrWorkspaceAttachment, - findGithubWorkItemWorkspaceAttachment, - getGithubWorkItemWorkspaceAttachmentLabel, - getGithubPrWorkspaceAttachmentLabel + findGithubWorkItemWorkspaceAttachment } from './github-work-item-workspace-attachment' import type { Worktree } from '../../../shared/worktree/types' @@ -95,20 +93,4 @@ describe('GitHub work-item workspace attachment', () => { expect(findGithubPrWorkspaceAttachment([gitlabOnly], 'repo-1', 42)).toBeNull() expect(findGithubIssueWorkspaceAttachment([gitlabOnly], 'repo-1', 42)).toBeNull() }) - - it('labels attachments without exposing a full path when display or branch is available', () => { - expect( - getGithubWorkItemWorkspaceAttachmentLabel(worktree({ displayName: ' Named GH ' })) - ).toBe('Named GH') - expect( - getGithubWorkItemWorkspaceAttachmentLabel( - worktree({ displayName: '', branch: 'refs/heads/fix-ci' }) - ) - ).toBe('fix-ci') - expect( - getGithubPrWorkspaceAttachmentLabel( - worktree({ displayName: '', branch: '', path: 'C:\\repo\\workspace-tail' }) - ) - ).toBe('workspace-tail') - }) }) diff --git a/src/renderer/src/lib/github-work-item-workspace-attachment.ts b/src/renderer/src/lib/github-work-item-workspace-attachment.ts index 283260913d8..e8baf7f05e9 100644 --- a/src/renderer/src/lib/github-work-item-workspace-attachment.ts +++ b/src/renderer/src/lib/github-work-item-workspace-attachment.ts @@ -1,6 +1,5 @@ import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { Worktree } from '../../../shared/worktree/types' -import { getWorktreeAttachmentLabel } from './worktree-attachment-label' type GitHubWorkItemType = GitHubWorkItem['type'] @@ -40,11 +39,3 @@ export function findGithubIssueWorkspaceAttachment( ): Worktree | null { return findGithubWorkItemWorkspaceAttachment(worktrees, repoId, 'issue', issueNumber) } - -export function getGithubWorkItemWorkspaceAttachmentLabel(worktree: Worktree): string { - return getWorktreeAttachmentLabel(worktree) -} - -export function getGithubPrWorkspaceAttachmentLabel(worktree: Worktree): string { - return getWorktreeAttachmentLabel(worktree) -} diff --git a/src/renderer/src/lib/linear-issue-workspace-attachment.test.ts b/src/renderer/src/lib/linear-issue-workspace-attachment.test.ts index 9e44943421d..2273b6a267a 100644 --- a/src/renderer/src/lib/linear-issue-workspace-attachment.test.ts +++ b/src/renderer/src/lib/linear-issue-workspace-attachment.test.ts @@ -3,8 +3,7 @@ import { describe, expect, it } from 'vitest' import { buildLinearIssueWorkspaceAttachmentIndex, findLinearIssueWorkspaceAttachment, - findLinearIssueWorkspaceAttachmentInIndex, - getLinearIssueWorkspaceAttachmentLabel + findLinearIssueWorkspaceAttachmentInIndex } from './linear-issue-workspace-attachment' import type { Worktree } from '../../../shared/worktree/types' @@ -181,20 +180,4 @@ describe('Linear issue workspace attachment', () => { expect(findLinearIssueWorkspaceAttachmentInIndex(index, issue)?.id).toBe('match') expect(findLinearIssueWorkspaceAttachmentInIndex(index, { identifier: 'STA-9999' })).toBeNull() }) - - it('labels attachments without exposing a full path when display or branch is available', () => { - expect( - getLinearIssueWorkspaceAttachmentLabel(worktree({ displayName: ' Named Linear ' })) - ).toBe('Named Linear') - expect( - getLinearIssueWorkspaceAttachmentLabel( - worktree({ displayName: '', branch: 'refs/heads/fix-ci' }) - ) - ).toBe('fix-ci') - expect( - getLinearIssueWorkspaceAttachmentLabel( - worktree({ displayName: '', branch: '', path: 'C:\\repo\\workspace-tail' }) - ) - ).toBe('workspace-tail') - }) }) diff --git a/src/renderer/src/lib/linear-issue-workspace-attachment.ts b/src/renderer/src/lib/linear-issue-workspace-attachment.ts index e931b619aab..d9a9472b1dc 100644 --- a/src/renderer/src/lib/linear-issue-workspace-attachment.ts +++ b/src/renderer/src/lib/linear-issue-workspace-attachment.ts @@ -4,7 +4,6 @@ import { getLinearOrganizationUrlKeyFromIssueUrl, parseLinearIssueInput } from '../../../shared/linear/links' -import { getWorktreeAttachmentLabel } from './worktree-attachment-label' export type LinearIssueAttachmentRef = Pick & Partial> @@ -125,7 +124,3 @@ export function findLinearIssueWorkspaceAttachmentInIndex( const candidates = index.get(identifier) return candidates ? findScopedAttachment(candidates, issue) : null } - -export function getLinearIssueWorkspaceAttachmentLabel(worktree: Worktree): string { - return getWorktreeAttachmentLabel(worktree) -} diff --git a/src/renderer/src/lib/scroll-cache.test.ts b/src/renderer/src/lib/scroll-cache.test.ts index 98f5021541a..18ecd33bfdc 100644 --- a/src/renderer/src/lib/scroll-cache.test.ts +++ b/src/renderer/src/lib/scroll-cache.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach } from 'vitest' import { - cursorPositionCache, + editorSelectionCache, diffViewStateCache, pdfViewPositionCache, setWithLRU, @@ -9,7 +9,7 @@ import { beforeEach(() => { scrollTopCache.clear() - cursorPositionCache.clear() + editorSelectionCache.clear() diffViewStateCache.clear() pdfViewPositionCache.clear() }) @@ -120,6 +120,29 @@ describe('scrollTopCache', () => { }) }) +describe('editorSelectionCache', () => { + it('keeps each pane selection independent', () => { + const firstSelection = { + selectionStartLineNumber: 2, + selectionStartColumn: 3, + positionLineNumber: 4, + positionColumn: 5 + } + const secondSelection = { + selectionStartLineNumber: 8, + selectionStartColumn: 2, + positionLineNumber: 6, + positionColumn: 1 + } + + setWithLRU(editorSelectionCache, '/path/to/file.ts', [firstSelection]) + setWithLRU(editorSelectionCache, '/path/to/file.ts::tab-2', [secondSelection]) + + expect(editorSelectionCache.get('/path/to/file.ts')).toEqual([firstSelection]) + expect(editorSelectionCache.get('/path/to/file.ts::tab-2')).toEqual([secondSelection]) + }) +}) + describe('pdfViewPositionCache', () => { it('is an empty Map on import', () => { expect(pdfViewPositionCache).toBeInstanceOf(Map) diff --git a/src/renderer/src/lib/scroll-cache.ts b/src/renderer/src/lib/scroll-cache.ts index fba5eec01f7..de28893ebaa 100644 --- a/src/renderer/src/lib/scroll-cache.ts +++ b/src/renderer/src/lib/scroll-cache.ts @@ -1,4 +1,4 @@ -import type { editor } from 'monaco-editor' +import type { editor, ISelection } from 'monaco-editor' // Why: 20 entries covers a typical working set of open/recently-viewed files. // Eviction only means losing a scroll position (user sees top of file), not a @@ -37,8 +37,8 @@ export function setWithLRU( export const scrollTopCache = new Map() // Why: Same rationale as scrollTopCache — module-scoped avoids Zustand -// re-renders on every cursor move. -export const cursorPositionCache = new Map() +// re-renders on every cursor or selection change. +export const editorSelectionCache = new Map() // Why: PDFs store a pdf.js location in PDF user space, not a scrollTop — page // layout is rebuilt at a scale that depends on container width, so a pixel diff --git a/src/renderer/src/lib/sidebar-worktree-activation.test.ts b/src/renderer/src/lib/sidebar-worktree-activation.test.ts index 4d0b34da2e0..a33f91565a2 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.test.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ activateAndRevealFolderWorkspace: vi.fn(), @@ -18,6 +18,10 @@ describe('sidebar worktree activation', () => { mocks.activateAndRevealFolderWorkspace.mockClear() }) + afterEach(() => { + vi.unstubAllGlobals() + }) + it('activates a clicked worktree without sidebar reveal', async () => { await activateWorktreeFromSidebar('wt-live') @@ -38,6 +42,29 @@ describe('sidebar worktree activation', () => { }) }) + it('switches immediately while an ephemeral runtime wake is pending', async () => { + let resolveResume: ((value: null) => void) | undefined + const resumeWorkspace = vi.fn( + () => + new Promise((resolve) => { + resolveResume = resolve + }) + ) + vi.stubGlobal('window', { + api: { ephemeralVm: { resumeWorkspace } } + }) + + const activation = activateWorktreeFromSidebar('wt-vm') + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-vm', { + revealInSidebar: false + }) + expect(resumeWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-vm' }) + + resolveResume?.(null) + await activation + }) + it('routes folder workspace activation through the guarded folder path', async () => { await activateWorktreeFromSidebar('folder:folder-workspace-1') diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts index 664895013b3..dd4eec3f0df 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -22,6 +22,11 @@ export async function activateWorktreeFromSidebar( } return } + // Keep navigation independent from an optional runtime wake IPC. + activateAndRevealWorktree(worktreeId, { + revealInSidebar: false, + ...(executionHostId ? { executionHostId } : {}) + }) if (typeof window !== 'undefined' && window.api?.ephemeralVm?.resumeWorkspace) { try { @@ -41,14 +46,6 @@ export async function activateWorktreeFromSidebar( description: error instanceof Error ? error.message : String(error) } ) - return } } - - // Why: sidebar clicks already happen on a visible row; revealing again can - // jump duplicate pinned/canonical entries back to the first mounted copy. - activateAndRevealWorktree(worktreeId, { - revealInSidebar: false, - ...(executionHostId ? { executionHostId } : {}) - }) } diff --git a/src/renderer/src/lib/worktree-attachment-label.test.ts b/src/renderer/src/lib/worktree-attachment-label.test.ts new file mode 100644 index 00000000000..c54bd34b09f --- /dev/null +++ b/src/renderer/src/lib/worktree-attachment-label.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import type { Worktree } from '../../../shared/worktree/types' +import { getWorktreeAttachmentLabel } from './worktree-attachment-label' + +function worktree(overrides: Partial = {}): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + path: '/tmp/repo-1/wt-1', + head: 'abc123', + branch: 'refs/heads/feature/workspace-attachment', + isBare: false, + isMainWorktree: false, + displayName: 'Workspace', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +describe('getWorktreeAttachmentLabel', () => { + it('prefers a trimmed display name', () => { + expect(getWorktreeAttachmentLabel(worktree({ displayName: ' Named workspace ' }))).toBe( + 'Named workspace' + ) + }) + + it('falls back to a branch without the local ref prefix', () => { + expect( + getWorktreeAttachmentLabel(worktree({ displayName: '', branch: 'refs/heads/fix-ci' })) + ).toBe('fix-ci') + }) + + it('falls back to the cross-platform path basename', () => { + expect( + getWorktreeAttachmentLabel( + worktree({ displayName: '', branch: '', path: 'C:\\repo\\workspace-tail' }) + ) + ).toBe('workspace-tail') + }) +}) diff --git a/src/shared/agent-title-agy-gemini-collision.test.ts b/src/shared/agent-title-agy-gemini-collision.test.ts new file mode 100644 index 00000000000..cd8c1e8714f --- /dev/null +++ b/src/shared/agent-title-agy-gemini-collision.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { getAgentLabel } from './agent-title-identity' +import { resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type' +import { GEMINI_IDLE, GEMINI_WORKING } from './agent-title-core' + +// Why: Antigravity's models are named "Gemini " (see the real `agy models` +// output parsed in commit-message-agent-spec.test.ts), so an agy pane's own title carries a +// whole `gemini` token. Gemini CLI is checked before Antigravity in getAgentLabel, so the +// model name used to win and an agy pane read as Gemini CLI. +describe('agy titles carrying a Gemini model name', () => { + const AGY_TITLES = [ + '⠋ agy · Gemini 3.7 Flash · high', + 'Antigravity · Gemini 3.7 Flash', + 'agy · Gemini 3.5 Flash (High)' + ] + + it.each(AGY_TITLES)('labels %s as Antigravity', (title) => { + expect(getAgentLabel(title)).toBe('Antigravity') + }) + + // Why: the two chain copies are reached by different surfaces — the sidebar goes through + // getAgentLabel, the tab through resolveExplicitTerminalTitleAgentType. A fix in one only + // moves the disagreement rather than removing it. + it.each(AGY_TITLES)('resolves %s as antigravity on the tab path too', (title) => { + expect(resolveExplicitTerminalTitleAgentType(title)).toBe('antigravity') + }) +}) + +describe('titles that must keep their existing label', () => { + // Why: a real recorded pane title from terminal history. A Grok pane whose TASK TEXT + // contains the token "Antigravity" — it resolves correctly only because grok is checked + // before antigravity, which is why this fix narrows a token instead of reordering. + it('keeps a grok pane whose task text names Antigravity', () => { + expect(getAgentLabel('STA-4011 Linux Antigravity Commit Messages - grok')).toBe('Grok') + }) + + // Why: the four Gemini OSC glyphs are decisive and stay so; agy emits none of them. + it('still labels a glyph-bearing Gemini CLI title', () => { + expect(getAgentLabel(`${GEMINI_WORKING} Gemini CLI`)).toBe('Gemini CLI') + expect(getAgentLabel(`${GEMINI_IDLE} Gemini CLI`)).toBe('Gemini CLI') + }) + + // Why: a Gemini CLI title with no agy token must be unaffected by the narrowing. + it('still labels a bare gemini token title', () => { + expect(getAgentLabel('gemini ready')).toBe('Gemini CLI') + }) +}) diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index c96121b432c..cc773ce164e 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -67,6 +67,14 @@ export function isGeminiTerminalTitle(title: string): boolean { if (isPiAgentTitle(title)) { return false } + // Why: Antigravity's models are named "Gemini ", so an agy pane's own + // title carries a whole `gemini` token. Gemini CLI is checked before Antigravity in + // getAgentLabel, so without this the model name wins and an agy pane reads as Gemini + // CLI. Only the token path defers — the four Gemini OSC glyphs stay decisive, and agy + // emits none of them. + if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { + return false + } return titleHasAgentName(title, 'gemini') } diff --git a/src/shared/codex-config-sync-types.ts b/src/shared/codex-config-sync-types.ts index 92284dcccbd..682d5869127 100644 --- a/src/shared/codex-config-sync-types.ts +++ b/src/shared/codex-config-sync-types.ts @@ -13,4 +13,10 @@ export type CodexConfigSyncStallReason = export type CodexConfigSyncStatus = | { state: 'synced'; reason: null; systemConfigPath: string } - | { state: 'stalled'; reason: CodexConfigSyncStallReason; systemConfigPath: string } + | { + state: 'stalled' + reason: CodexConfigSyncStallReason + systemConfigPath: string + /** Optional for compatibility with status producers that cannot resolve the managed path. */ + managedStatePath?: string + } diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index c679933c88e..03a979132b9 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -48,6 +48,14 @@ export function isGeminiTerminalTitle(title: string): boolean { if (isPiAgentTitle(title)) { return false } + // Why: Antigravity's models are named "Gemini ", so an agy pane's own + // title carries a whole `gemini` token. Gemini CLI is checked before Antigravity in + // getAgentLabel, so without this the model name wins and an agy pane reads as Gemini + // CLI. Only the token path defers — the four Gemini OSC glyphs stay decisive, and agy + // emits none of them. + if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { + return false + } return titleHasAgentName(title, 'gemini') } diff --git a/tests/e2e/editor-tab-selection-restore.spec.ts b/tests/e2e/editor-tab-selection-restore.spec.ts new file mode 100644 index 00000000000..df2ad678890 --- /dev/null +++ b/tests/e2e/editor-tab-selection-restore.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from './helpers/orca-app' +import { + activateGoldenWorktree, + cleanupGoldenWorktree, + createGoldenWorktree +} from './helpers/golden-source-control' +import { waitForSessionReady } from './helpers/store' + +test('preserves highlighted editor text across worktree tab switches', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}) => { + const fixture = createGoldenWorktree(testRepoPath, 'editor-selection') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + + await waitForSessionReady(orcaPage) + await activateGoldenWorktree(orcaPage, testRepoPath, fixture.worktreePath) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('explorer') + state?.setRightSidebarOpen(true) + }) + + const explorer = orcaPage.locator('[data-orca-explorer-shell]') + const rowNamed = (name: string) => + explorer.locator('[data-file-explorer-row]').filter({ + has: orcaPage.locator('[data-file-explorer-row-name]').getByText(name, { exact: true }) + }) + + await rowNamed('package.json').dblclick() + const monaco = orcaPage.locator('.monaco-editor').first() + await expect(monaco).toBeVisible({ timeout: 25_000 }) + await monaco.click() + await orcaPage.keyboard.press('ControlOrMeta+f') + const findInput = monaco.locator('.find-widget .input[aria-label="Find"]') + await expect(findInput).toBeVisible() + await findInput.fill('orca-e2e-test') + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.press('Escape') + + await expect + .poll(() => orcaPage.evaluate(() => window.__monacoEditorE2E?.snapshot().selection ?? null), { + message: 'Monaco did not select the searched text' + }) + .not.toBeNull() + const selectedRange = await orcaPage.evaluate( + () => window.__monacoEditorE2E?.snapshot().selection ?? null + ) + if (!selectedRange) { + throw new Error('Monaco selection disappeared before the tab switch') + } + expect([selectedRange.selectionStartLineNumber, selectedRange.selectionStartColumn]).not.toEqual([ + selectedRange.positionLineNumber, + selectedRange.positionColumn + ]) + if (process.env.ORCA_E2E_RECORD_VIDEO === '1') { + await orcaPage.waitForTimeout(700) + } + + await rowNamed('src').click() + await rowNamed('index.ts').click() + await expect(orcaPage.locator('.editor-header-path').first()).toContainText('index.ts', { + timeout: 20_000 + }) + + await orcaPage.locator('[data-tab-id]').filter({ hasText: 'package.json' }).last().click() + await expect(orcaPage.locator('.editor-header-path').first()).toContainText('package.json', { + timeout: 20_000 + }) + await expect + .poll(() => orcaPage.evaluate(() => window.__monacoEditorE2E?.snapshot().selection ?? null)) + .toEqual(selectedRange) + await expect(monaco.locator('.selected-text').first()).toBeVisible() + if (process.env.ORCA_E2E_RECORD_VIDEO === '1') { + await orcaPage.waitForTimeout(700) + } +}) diff --git a/tests/e2e/helpers/startup-exec-readiness-oracle.ts b/tests/e2e/helpers/startup-exec-readiness-oracle.ts index 48b2928ed6d..0ef9a7eff67 100644 --- a/tests/e2e/helpers/startup-exec-readiness-oracle.ts +++ b/tests/e2e/helpers/startup-exec-readiness-oracle.ts @@ -83,15 +83,24 @@ async function expectSingleOwningPty( terminal: string, ptyId: string ): Promise { - const listed = await callStartupExecRuntime(page, 'terminal.list', { - worktree: `id:${worktreeId}`, - requireFreshPtyLiveness: true - }) - expect( - listed.terminals - .filter((candidate) => candidate.tabId === tabId) - .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) - ).toEqual([{ handle: terminal, ptyId }]) + await expect + .poll( + async () => { + const listed = await callStartupExecRuntime( + page, + 'terminal.list', + { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + } + ) + return listed.terminals + .filter((candidate) => candidate.tabId === tabId) + .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) + }, + { timeout: 30_000 } + ) + .toEqual([{ handle: terminal, ptyId }]) } export async function callStartupExecRuntime(