From 677718c4a5239ac7095c854e4b19579884566234 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:52:18 -0700 Subject: [PATCH 1/9] fix(codex): stop rebuilding shared Codex state from a read that failed (STA-4823) (#15417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): stop rebuilding shared Codex state from a read that failed (STA-4823) Six shared files were rebuilt, erased or reported healthy after a read that had only failed. Batch A of the STA-4606 split: every one of these is reachable and testable on the host lane, so none of them wait on the WSL work. - `config-toml-trust.ts` upsertHookTrustEntries: `existsSync` reported a locked config.toml as absent, so the base content became '' and the upsert rewrote the file from the trust entries alone — a trust-only stub, with the user's model, provider, MCP servers, approvals and comments gone. It refuses now; every hook-service caller already turns that into "trust entries could not be written. Run /hooks in Codex to approve." - `codex-trust-grant-ledger.ts`: an unreadable ledger degraded to empty and the next write persisted a file holding only the home being written, dropping every other home's grants. The write paths refuse; the read path still degrades, and a corrupt ledger is still rebuilt. - `codex-pane-account-registry.ts`: an unreadable registry erased every pane's attribution AND cached that erasure, so it survived the file recovering. The failure is no longer cached, and both write sites refuse rather than persist a registry derived from an empty stand-in. - `hooks-json-read.ts`: the read arm already separated "no hooks" from "could not read", but the `existsSync` arm in front of it returned a valid empty config for a file that could not be opened. One read now classifies both. - `config-settings-baseline.ts`: absent, unparseable and unreadable all collapsed into `null`, so the snapshot rebuilt a baseline it could not read — recording an in-Codex edit as Orca's own write, after which promotion skips it forever. - `config-sync-stall.ts`: an unreadable runtime config read as absent and the status reported `synced` while the mirror was refusing. It reports `managed-home-unavailable`, the existing reason for exactly this, rather than borrowing a source-side one and blaming the wrong path. Absent and malformed still rebuild throughout — resetting corrupt state is the intent, and conflating it with unreadable would wedge a user on a broken file. * fix(codex): close shared state read-denial gaps * fix(codex): recover oversized settings baselines * fix(codex): name the stalled managed config * fix(codex): preserve hooks after failed source reads * test(codex): correct what the denyExistence rig actually models MEASURED on both platforms: a file-permission denial leaves existsSync TRUE and fails only the content read — chmod 000 gives EACCES on macOS, icacls /deny (R) gives EPERM errno -4048 on Windows, with stat/lstat succeeding in both. The rig's docblock claimed this mode modelled that denial. It does not. What it models is the UNC / \\wsl$ transport, where an unreachable distro reports errno UNKNOWN at every level and existsSync folds it to false. The distinction decides what the D29 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. No behaviour change; the comment was wrong, not the code. * test(codex): exercise live baseline read denial * fix(codex): retry pane attribution writes * fix(codex): retry reconciliation registry writes * fix(codex): report unreadable sync baselines --- src/main/agent-hooks/hooks-json-read.ts | 20 +- src/main/codex/codex-config-mirror.ts | 8 +- ...ex-pane-account-registry-mutations.test.ts | 95 ++++ .../codex-pane-account-registry-mutations.ts | 157 ++++++ .../codex-pane-account-registry-types.ts | 29 + src/main/codex/codex-pane-account-registry.ts | 191 +++---- .../codex-real-home-hook-install.test.ts | 17 + .../codex/codex-real-home-hook-install.ts | 7 +- src/main/codex/codex-stale-pane-accounts.ts | 5 +- src/main/codex/codex-trust-grant-ledger.ts | 33 +- src/main/codex/config-settings-baseline.ts | 65 ++- src/main/codex/config-settings-promotion.ts | 21 +- src/main/codex/config-sync-stall.test.ts | 29 + src/main/codex/config-sync-stall.ts | 48 +- src/main/codex/config-toml-trust.ts | 17 +- .../hook-service-user-hook-mirroring.test.ts | 74 ++- src/main/codex/hook-service.ts | 27 +- ...hared-state-survives-a-failed-read.test.ts | 506 ++++++++++++++++++ src/main/ipc/codex-config-sync.test.ts | 17 + src/shared/codex-config-sync-types.ts | 8 +- 20 files changed, 1231 insertions(+), 143 deletions(-) create mode 100644 src/main/codex/codex-pane-account-registry-mutations.test.ts create mode 100644 src/main/codex/codex-pane-account-registry-mutations.ts create mode 100644 src/main/codex/codex-pane-account-registry-types.ts create mode 100644 src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts 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/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 + } From 7a72f341f72346b9b6eee7b7411a0a81a041459d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:55:20 -0700 Subject: [PATCH 2/9] Split pty-connection.ts into focused modules under 400 lines (#15166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: split pty-connection.ts under 400 lines * rm design doc * refactor(pty-connection): extract reattach payload handlers as factories - Replace bindApplyReattachPayload with createReattachPayloadHandlers factory that returns handlers instead of mutating session directly, enabling better composability and testing - Extract waitForUserInitiatedSshConnect as standalone function for reuse across deferred session attach flows - Create ReattachPayloadSession type to document and isolate required session capabilities - Add test coverage for overlapping reattach payload attempts - Clean up comments to remove redundant prefixes (session.pane → pane, session.transport → transport) * fix(pty-connection): correct sequencing and state bugs in spawn and reat - Fix terminal tail slice to take prefix instead of suffix, preserving escape sequence markers needed by next scan - Clear pending pane serializer when direct SSH retry PTY is unclaimed - Initialize interrupt status baseline to undefined so first input advances sequence counter - Bump reattach generation only after confirming current attempt owns the stream, preventing superseded results from canceling in-flight prepaint * fix(pty-connection): correct sequencing and state bugs in spawn and reat - Fix terminal tail slice to take prefix instead of suffix, preserving escape sequence markers needed by next scan - Clear pending pane serializer when direct SSH retry PTY is unclaimed - Initialize interrupt status baseline to undefined so first input advances sequence counter - Bump reattach generation only after confirming current attempt owns the stream, preventing superseded results from canceling in-flight prepaint * fix(test): increase poll iterations to prevent Node 26 test leakage Increase event loop turns from 40 to 200 in the timer settlement loop. Node 26's libuv poll phase can briefly starve when concurrent workers transform tests, causing cleanup to leak into the next test. The higher iteration count ensures async operations complete before returning. * fix(foreground-output-budgets): use >= for budget window boundary check At the exact window boundary, the budget should roll over. Change the comparison from > to >= so the window resets when now equals windowStart + FOREGROUND_BUDGET_WINDOW_MS, not just after. Add tests to verify budget rejection and rollover behavior. * refactor(pty-connection): add status observations and routing improvemen - Track agent status observations with origin and transition metadata - Separate interactive redraw input timing from general terminal input - Restore pane authority on bind and reattach - Refine routing trust and confirmation state handling - Invoke queued startup callbacks when PTY is bound - Resolve Windows shell overrides with user settings * refactor: extract resolveLaunchAgentCandidate helper Consolidate duplicated launch-agent resolution logic into a shared helper to prevent future divergence between paneExpectsLaunchAgent and resolveExpectedLaunchTuiAgent. * refactor(pty-connection): use model snapshot for direct SSH reconnects Direct SSH reconnects now restore from the full SSH model snapshot (complete scrollback) when dimensions are compatible, instead of the bounded relay tail. Falls back gracefully when incompatible or alternate-screen was exited. * refactor(pty): retry unverifiable SSH reattaches via preserved bindings Preserve deferred SSH session IDs longer when they serve as the only retry binding, allowing the system to attempt recovery through direct SSH retries or PTY remounts when reattach fails in an unverifiable way. Simplify reconnect model restoration by removing the conditional model snapshot probe and using relay replay directly. * test: poll terminal readiness in expectSingleOwningPty Retry the terminal list assertion with polling to account for timing delays in PTY state reporting from the runtime. --- config/max-lines-baseline.txt | 1 - ...updater.linux-root-package-install.test.ts | 4 +- ...ty-connection-agent-session-resume.test.ts | 11 +- ...connection-deferred-ssh-passphrase.test.ts | 55 + ...nnection-direct-ssh-reattach-retry.test.ts | 72 + .../pty-connection-reattach-binding.test.ts | 72 + .../terminal-pane/pty-connection.ts | 9746 +---------------- .../pty-connection/agent-done-started-at.ts | 22 + .../agent-idle-working-handlers.ts | 237 + .../agent-task-complete-notify.ts | 192 + .../agent-task-complete-settings.ts | 54 + .../pty-connection/apply-reattach-payload.ts | 320 + .../pty-connection/codex-pane-stale.ts | 48 + .../cold-restore-resume-startup.ts | 102 + .../command-inferred-pane-agent.ts | 269 + .../connect-pane-pty-session.ts | 12 + .../pty-connection/connect-pane-pty.ts | 177 + .../cursor-agent-reattach-screen.ts | 89 + .../deferred-cold-restore-and-snapshot.ts | 217 + .../pty-connection/deferred-session-attach.ts | 304 + .../deferred-session-reattach-choice.ts | 218 + .../deferred-session-reattach-connect.ts | 164 + .../direct-ssh-reattach-recovery.test.ts | 46 + .../direct-ssh-reattach-recovery.ts | 19 + .../pty-connection/direct-ssh-retry-lease.ts | 6 + .../pty-connection/direct-ssh-retry-status.ts | 305 + .../e2e-terminal-pty-harness.ts | 148 + .../foreground-output-budgets.test.ts | 34 + .../foreground-output-budgets.ts | 39 + .../foreground-output-refresh.ts | 298 + .../pty-connection/foreground-output-scan.ts | 124 + .../fresh-spawn-follow-reset.ts | 173 + .../pty-connection/fresh-spawn-start.ts | 264 + .../pty-connection/fresh-spawn-types.ts | 25 + .../hidden-output-restore-abandon.ts | 96 + .../hidden-output-restore-chunk.ts | 132 + .../hidden-output-restore-drain.ts | 158 + .../hidden-output-restore-limits.ts | 37 + .../hidden-output-restore-request.ts | 320 + .../hidden-output-restore-snapshot.ts | 234 + .../hidden-output-seq-and-skip.ts | 241 + .../hidden-output-snapshot-serialize.ts | 142 + .../hidden-restore-state-and-ssh-probe.ts | 222 + .../hidden-startup-renderer-query-write.ts | 160 + .../hidden-startup-renderer-query.ts | 18 + .../pty-connection/interrupt-input-intent.ts | 180 + .../pty-connection/live-data-callback.ts | 189 + .../paired-parked-terminal-restore.ts | 30 + .../pty-connection/pane-agent-identity.ts | 286 + .../pty-connection/pane-pty-binding.ts | 25 + .../pane-pty-visibility-bind.ts | 247 + .../pane-serializer-register.ts | 95 + .../pty-connection/pane-serializer-settle.ts | 226 + .../pending-hidden-output-restore-chunk.ts | 5 + .../pty-connection/process-exit-startup.ts | 19 + .../pty-connection/pty-connect-limits.ts | 31 + .../pty-connection/pty-exit-hibernate.ts | 356 + .../pty-connection/pty-input-forward.ts | 372 + .../pty-connection/pty-input-recovery.ts | 335 + .../pty-connection/pty-resize-geometry.ts | 251 + .../reattach-live-data-deferral.ts | 74 + .../reattach-payload-context.test.ts | 106 + .../reattach-payload-context.ts | 16 + .../reattach-payload-session.ts | 27 + .../pty-connection/reattach-result-handler.ts | 265 + .../pty-connection/replay-data-drain.ts | 188 + .../retained-legacy-pty-attach.ts | 30 + .../pty-connection/run-deferred-connect.ts | 131 + .../session-reconcile-dispose.ts | 303 + .../pty-connection/setup-split-geometry.ts | 75 + .../pty-connection/shell-command-inference.ts | 261 + .../pty-connection/sleeping-record-access.ts | 198 + .../pty-connection/ssh-session-connect.ts | 118 + .../pty-connection/ssh-snapshot-prepaint.ts | 78 + .../startup-cwd-fallback-notice.ts | 4 + .../pty-connection/terminal-keydown-fit.ts | 260 + .../pty-connection/title-spawn-bell.ts | 224 + .../transport-output-callbacks.ts | 126 + .../write-pty-output-to-xterm.ts | 113 + .../terminal-pane-recovery.test.ts | 30 + .../terminal-pane/terminal-pane-recovery.ts | 20 +- .../helpers/startup-exec-readiness-oracle.ts | 27 +- 82 files changed, 11184 insertions(+), 9764 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/agent-done-started-at.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-settings.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/codex-pane-stale.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/cursor-agent-reattach-screen.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-lease.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/e2e-terminal-pty-harness.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-seq-and-skip.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state-and-ssh-probe.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query-write.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/paired-parked-terminal-restore.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/process-exit-startup.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-live-data-deferral.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts 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/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/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 53d248666bc..047ab764814 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 @@ -413,6 +413,78 @@ describe('connectPanePty', () => { expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-pty') }) + 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 e1927dd6ece..fbc51fc9c9d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,9744 +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' -// 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 - const coldRestoreStartup = buildColdRestoreAgentResumeStartup() - const outputCallbacks = captureTransportOutputCallbacks( - (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - 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 && 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 - } - 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..9de53ef07ec --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -0,0 +1,164 @@ +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' + +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 + const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup() + const outputCallbacks = session.captureTransportOutputCallbacks( + (message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + 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 && 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 + } + 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-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/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( From 8af02d658c5d60a46ae94764fe3b1183bfba3e5c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:25:03 -0700 Subject: [PATCH 3/9] Preserve code editor selections across tab switches (#16132) * Preserve editor selections across tab switches * Defer editor selection caching to tab lifecycle --- .../monaco-view-state-persistence.test.ts | 89 +++++++++++++++++++ .../editor/monaco-view-state-persistence.ts | 23 ++--- .../editor/useClosedEditorTabCleanup.ts | 6 +- src/renderer/src/lib/scroll-cache.test.ts | 27 +++++- src/renderer/src/lib/scroll-cache.ts | 6 +- .../e2e/editor-tab-selection-restore.spec.ts | 78 ++++++++++++++++ 6 files changed, 206 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/components/editor/monaco-view-state-persistence.test.ts create mode 100644 tests/e2e/editor-tab-selection-restore.spec.ts 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/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/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) + } +}) From da57e10dd9318ecbc55437f4fad775634f1858fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:40:42 -0700 Subject: [PATCH 4/9] fix(agent-status): stop an Antigravity pane reading as Gemini CLI (#15535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antigravity's models are named "Gemini " — the real `agy models` output is already parsed in commit-message-agent-spec.test.ts — so an agy pane's own title carries a whole `gemini` token. getAgentLabel checks Gemini CLI before Antigravity, first match wins, so the model name won and the pane read as Gemini CLI. Measured on '⠋ agy · Gemini 3.7 Flash · high': geminiGlyphs false, geminiToken true, agyToken true, label 'Gemini CLI'. Even 'Antigravity · Gemini 3.7 Flash' resolved to Gemini CLI. This surfaced as the tab bar and the sidebar disagreeing about the same pane, because the two reach different copies of the chain and apply different precedence to its result. Defer only the token path: if a title carries an agy/antigravity token, the bare-`gemini` branch declines. The four Gemini OSC glyphs stay decisive, and agy emits none of them. Same shape as the existing isPiAgentTitle veto directly above, which exists because substring matching made paths like 'gemini-project' masquerade as Gemini CLI. Narrowing the token rather than reordering the chain, deliberately: a real recorded pane title from local terminal history is 'STA-4011 Linux Antigravity Commit Messages - grok' — a Grok pane whose task text contains the token Antigravity. It resolves correctly only because grok is checked before antigravity, so hoisting the Antigravity branch would break it. That title ships as a regression case. Both copies of the chain are fixed; the sidebar reaches one and the tab the other, so fixing one alone would only move the disagreement. --- .../agent-title-agy-gemini-collision.test.ts | 47 +++++++++++++++++++ src/shared/agent-title-core.ts | 8 ++++ src/shared/terminal-title-agent-type.ts | 8 ++++ 3 files changed, 63 insertions(+) create mode 100644 src/shared/agent-title-agy-gemini-collision.test.ts 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/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') } From 21c134bb66f556813e8bdff0b84bc3a11100185f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:45:31 -0700 Subject: [PATCH 5/9] refactor(mobile): reuse repository color helper (#16131) --- mobile/src/components/NewWorktreeModal.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) 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') } From 9814813b2e082bc24b0a3dae1c949d3230cd4dfb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:45:57 -0700 Subject: [PATCH 6/9] refactor(skills): centralize observation matching (#16134) --- src/main/skills/skill-bundle-creation.ts | 25 ++++++----------------- src/main/skills/skill-package-creation.ts | 25 ++++++----------------- src/main/skills/skill-package-identity.ts | 20 ++++++++++++++++++ 3 files changed, 32 insertions(+), 38 deletions(-) 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 From f0c718ecd5283bea749b9775715fb4021a0ca3a9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:46:22 -0700 Subject: [PATCH 7/9] refactor(renderer): use canonical attachment labels (#16135) --- .../src/components/LinearIssueWorkspace.tsx | 8 ++- .../src/components/LinearItemDrawer.tsx | 8 ++- src/renderer/src/components/TaskPage.tsx | 15 +++--- .../open-dialog/github-item-dialog.tsx | 8 ++- .../pull-request-page/page/surface.tsx | 8 ++- ...hub-work-item-workspace-attachment.test.ts | 20 +------- .../github-work-item-workspace-attachment.ts | 9 ---- .../linear-issue-workspace-attachment.test.ts | 19 +------ .../lib/linear-issue-workspace-attachment.ts | 5 -- .../src/lib/worktree-attachment-label.test.ts | 49 +++++++++++++++++++ 10 files changed, 69 insertions(+), 80 deletions(-) create mode 100644 src/renderer/src/lib/worktree-attachment-label.test.ts 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 (
{ 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/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') + }) +}) From b89b58bf774525db4e47289d9abe67a8e4a7df69 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:07:36 -0700 Subject: [PATCH 8/9] fix(renderer): keep navigation responsive under terminal load (#16138) --- src/renderer/src/components/Terminal.tsx | 16 ++-- .../src/components/settings/Settings.tsx | 3 - .../settings/SettingsSidebar.test.tsx | 2 - .../components/settings/SettingsSidebar.tsx | 77 +++++++++++-------- .../sleep-worktree-activation-race.test.ts | 6 +- ...terminal-hidden-worktree-retention.test.ts | 42 ++++++++++ .../terminal-hidden-worktree-retention.ts | 13 ++++ .../lib/sidebar-worktree-activation.test.ts | 29 ++++++- .../src/lib/sidebar-worktree-activation.ts | 13 ++-- 9 files changed, 146 insertions(+), 55 deletions(-) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 82116fb6392..49bc620914a 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -119,6 +119,7 @@ import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, countEvictionExemptTabRoutes, formatEvictionExemptRouteCounts, + getTerminalWorktreeParkingInputsKey, hasPendingRetentionSpawnWork, selectForceParkEvictableTabIds, selectRetentionForceParkedTerminalWorktrees, @@ -325,6 +326,10 @@ function Terminal(): React.JSX.Element | null { ) const activeView = useAppStore((s) => 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/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/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/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 } : {}) - }) } From fe6f942d1f6aca13509b4f6d3ade47e54b9569de Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:18:08 -0700 Subject: [PATCH 9/9] Fix Ubuntu release dependency lockfile gate (STA-5109) (#16055) --- .../install-node-dependencies/action.yml | 3 +- .../install-node-dependencies-action.test.mjs | 69 +++++++++++++++++++ .../scripts/pr-workflow-parallelism.test.mjs | 4 +- 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 config/scripts/install-node-dependencies-action.test.mjs 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/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=')