Merge remote-tracking branch 'origin/main' into debug-session-disappear

# Conflicts:
#	src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts
#	src/renderer/src/components/terminal-pane/pty-connection.ts
This commit is contained in:
Jinjing
2026-08-23 15:23:18 -07:00
137 changed files with 13024 additions and 10131 deletions
@@ -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.
-1
View File
@@ -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
@@ -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 })
}
})
})
@@ -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=')
+1 -9
View File
@@ -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')
}
+11 -9
View File
@@ -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<string, unknown> {
@@ -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 {
+4 -4
View File
@@ -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 }
@@ -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)
})
})
@@ -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<string, CodexPaneAccountRecord | null>()
private retryTimer: ReturnType<typeof setTimeout> | 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]
}
}
@@ -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:<distro>' — 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<string, CodexPaneAccountRecord>
}
+99 -92
View File
@@ -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:<distro>' — 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<string, CodexPaneAccountRecord>
}
// 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<RegistryFile>).panes
const panes = (parsed as Partial<CodexPaneAccountRegistryFile>).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<string, string> {
const registry = readRegistry()
const registry = readRegistryOrThrow()
const lanesByPtyId: Record<string, string> = {}
for (const ptyId of ptyIds) {
const record = registry.panes[ptyId]
@@ -254,6 +240,21 @@ export function listRecordedCodexPaneLanes(ptyIds: readonly string[]): Record<st
return lanesByPtyId
}
/** Reads restart-authoritative records without mapping an unavailable file to no attribution. */
export function listRecordedCodexPaneAccounts(
ptyIds: readonly string[]
): ReadonlyMap<string, CodexPaneAccountRecord> {
const registry = readRegistryOrThrow()
const records = new Map<string, CodexPaneAccountRecord>()
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()
}
}
@@ -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' }
@@ -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())
+3 -2
View File
@@ -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
}
+27 -6
View File
@@ -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)
}
+57 -8
View File
@@ -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<string, CodexSettingsConflict>
}
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)
}
+14 -7
View File
@@ -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<string, string>()
@@ -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
+29
View File
@@ -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.
+40 -8
View File
@@ -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.`
)
+15 -2
View File
@@ -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
@@ -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<string, { hooks?: { command?: string }[] }[]>
}
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')
+25 -2
View File
@@ -299,13 +299,18 @@ function getRuntimeHooksWithSystemUserHooks(
): {
hooks: Record<string, HookDefinition[]>
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<string>(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)
@@ -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<string>(),
readOnlyPaths: new Set<string>(),
existenceDeniedPaths: new Set<string>(),
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<typeof NodeFs>()
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<string, unknown> = {
...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<typeof NodeOs>('node:os')
return { ...actual, homedir: homedirMock }
})
const realFs = await vi.importActual<typeof NodeFs>('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()
})
})
+17
View File
@@ -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')
+6 -19
View File
@@ -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(
+6 -19
View File
@@ -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<void>
}
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'))
+20
View File
@@ -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
@@ -183,7 +183,9 @@ describe('updater', () => {
const settleQuitAndInstall = async (): Promise<void> => {
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)
@@ -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 => {
@@ -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 => {
+6 -9
View File
@@ -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 (
<div
@@ -11768,7 +11765,7 @@ export default function TaskPage(): React.JSX.Element {
issue
)
const attachedWorkspaceLabel = attachedWorkspace
? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace)
? getWorktreeAttachmentLabel(attachedWorkspace)
: null
return (
<div
+11 -5
View File
@@ -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<string>()
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,
@@ -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)
})
})
@@ -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)
}
}
}
@@ -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
@@ -2,11 +2,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useAllWorktrees } from '@/store/selectors'
import { useAppStore } from '@/store'
import {
findGithubIssueWorkspaceAttachment,
getGithubWorkItemWorkspaceAttachmentLabel
} from '@/lib/github-work-item-workspace-attachment'
import { findGithubIssueWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label'
import { translate } from '@/i18n/i18n'
import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types'
import type { GitHubItemDialogProps } from '../load-item-details/github-item-dialog-types'
@@ -45,7 +43,7 @@ export default function GitHubItemDialog({
[allWorktrees, effectiveRepoId, workItem]
)
const issueAttachedWorkspaceLabel = issueAttachedWorkspace
? getGithubWorkItemWorkspaceAttachmentLabel(issueAttachedWorkspace)
? getWorktreeAttachmentLabel(issueAttachedWorkspace)
: null
const handleOpenOrUseIssueWorkspace = useCallback(
@@ -10,11 +10,9 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useAllWorktrees } from '@/store/selectors'
import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context'
import {
findGithubPrWorkspaceAttachment,
getGithubPrWorkspaceAttachmentLabel
} from '@/lib/github-work-item-workspace-attachment'
import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label'
import {
clearGitHubLinkCopied,
createGitHubLinkCopyState,
@@ -72,7 +70,7 @@ export default function PullRequestPage({
[allWorktrees, effectiveRepoId, workItem]
)
const attachedWorkspaceLabel = attachedWorkspace
? getGithubPrWorkspaceAttachmentLabel(attachedWorkspace)
? getWorktreeAttachmentLabel(attachedWorkspace)
: null
// Why: key must include issue source preference so origin/upstream toggles for the same issue number don't read back the wrong repo's details.
@@ -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}
/>
@@ -111,9 +111,7 @@ function renderSidebar(
]}
repoSections={[]}
hasRepos={false}
searchQuery=""
onBack={vi.fn()}
onSearchChange={vi.fn()}
onSelectSection={vi.fn()}
/>
</TooltipProvider>
@@ -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<HTMLInputElement | null>
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<SettingsSidebarProps, 'searchInputRef' | 'searchAutoFocus'>): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchInputQuery)
const onSearchChange = useAppStore((state) => state.setSettingsSearchQuery)
const searchShortcutCombos = useShortcutKeyComboDetails('settings.search')
return (
<div className="border-b border-worktree-sidebar-border px-3 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
autoFocus={searchAutoFocus}
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={translate(
'auto.components.settings.SettingsSidebar.dbceaa8840',
'Search settings'
)}
className="bg-background/60 pl-9 pr-14 text-[13px]"
/>
{searchQuery === '' ? (
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
{searchShortcutCombos.map((combo) => (
<ShortcutKeyCombo
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
))}
</span>
) : null}
</div>
</div>
)
}
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({
</Button>
</div>
<div className="border-b border-worktree-sidebar-border px-3 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
autoFocus={searchAutoFocus}
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={translate(
'auto.components.settings.SettingsSidebar.dbceaa8840',
'Search settings'
)}
className="bg-background/60 pl-9 pr-14 text-[13px]"
/>
{searchQuery === '' ? (
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
{searchShortcutCombos.map((combo) => (
<ShortcutKeyCombo
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
))}
</span>
) : null}
</div>
</div>
<SettingsSearchField searchInputRef={searchInputRef} searchAutoFocus={searchAutoFocus} />
{showSetupGuideTopRow ? (
<div className="border-b border-worktree-sidebar-border px-3 py-3">
@@ -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' })
@@ -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<string, string> | 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
})
@@ -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<typeof connectPanePty> | 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')
})
@@ -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')
@@ -458,6 +458,78 @@ describe('connectPanePty', () => {
})
})
it.each([
['rejects', 'reject'],
['returns no PTY', 'empty']
] as const)(
'preserves a direct SSH binding and schedules another retry when reattach %s',
async (_description, outcome) => {
const { connectPanePty } = await import('./pty-connection')
const restoredPtyId = 'ssh:conn-1@@restored-session'
const transport = createMockTransport()
if (outcome === 'reject') {
transport.connect.mockRejectedValueOnce(new Error('relay attach timed out'))
} else {
transport.connect.mockResolvedValueOnce(undefined)
}
transportFactoryQueue.push(transport)
const pendingRetry = {
attemptId: 'attempt-generic-reattach-failure',
authority: {
targetId: 'conn-1',
providerEpoch: 'epoch-1',
connectionGeneration: 3
},
tabGeneration: 7,
startedAt: 1
}
const settleDirectSshPaneRetry = vi.fn()
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }]
},
ptyIdsByTabId: { 'tab-1': [] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
sshConnectionStates: new Map([
[
'conn-1',
{
targetId: 'conn-1',
status: 'connected',
providerEpoch: 'epoch-1',
connectionGeneration: 3
}
]
]),
deferredSshSessionIdsByTabId: { 'tab-1': restoredPtyId },
directSshPaneRetryByTabId: { 'tab-1': pendingRetry },
settleDirectSshPaneRetry
} as StoreState
const deps = createDeps()
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
await flushAsyncTicks(12)
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
expect(deps.updateTabPtyId).not.toHaveBeenCalled()
expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled()
expect(settleDirectSshPaneRetry).toHaveBeenCalledExactlyOnceWith({
status: 'failed',
tabId: 'tab-1',
attemptId: pendingRetry.attemptId,
authority: pendingRetry.authority,
tabGeneration: pendingRetry.tabGeneration
})
expect(mockStoreState.tabsByWorktree['wt-1']).toEqual([
{ id: 'tab-1', ptyId: null, generation: 7 }
])
expect(mockStoreState.deferredSshSessionIdsByTabId['tab-1']).toBe(restoredPtyId)
}
)
it('reattaches via the tab-level SSH pty id when deferred bookkeeping missed the tab', async () => {
// Why: restore can miss the deferred maps; the tab's SSH pty id must still drive connect-then-reattach, not a fresh spawn into a missing provider.
const { connectPanePty } = await import('./pty-connection')
File diff suppressed because it is too large Load Diff
@@ -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
}
@@ -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<ReturnType<typeof setTimeout>>()
session.directSshPaneRetryTimedPromises = new WeakSet<object>()
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
})
}
}
@@ -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)
}
@@ -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<typeof useAppStore.getState>
): 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
}
}
}
@@ -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<void>
fitAfterReattachRestore: () => Promise<void>
} {
const applyReattachPayload = async (): Promise<void> => {
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<void> => {
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 }
}
@@ -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<string, { previousAccountLabel: string; nextAccountLabel: string }>
): 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
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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
}
@@ -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)
}
@@ -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} `)
}
@@ -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<string, string> | undefined
): Record<string, string> | 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<string | null> => {
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<boolean> => {
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
}
@@ -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<void> => {
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<string, unknown>).sessionExpired,
replay: !!(result as Record<string, unknown>).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)
}
@@ -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()
}
@@ -0,0 +1,182 @@
import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics'
import { recordPtyConnectDiagnostic } from './pty-connect-limits'
import { isSshSessionExpiredError } from './ssh-session-connect'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
import { toProcessExitStartup } from './process-exit-startup'
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
const PANE_OWNER_UNVERIFIED_ERROR = 'terminal_pane_owner_unverified'
export function startDeferredSessionReattach(
session: ConnectPanePtySession,
deferredReattachSessionId: string
): void {
session.allowInitialIdleCacheSeed = true
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> REATTACH ${deferredReattachSessionId}`)
session.prepaintParkedSshSnapshot(deferredReattachSessionId)
// Why: pre-signal (declare) before the reattach connect so the cooperation gate suppresses the daemon seed for this paneKey; Electron preserves IPC order.
// See docs/mobile-prefer-renderer-scrollback.md (Renderer-side prerequisite requirement #4).
const preSignalPromise =
session.runtimeEnvironmentId || isRemoteRuntimePtyId(deferredReattachSessionId)
? Promise.resolve(null)
: window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null)
let expiredReattachError = false
let paneOwnerUnverified = false
const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup()
const outputCallbacks = session.captureTransportOutputCallbacks(
(message) => {
if (isSshSessionExpiredError(message)) {
expiredReattachError = true
return
}
if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) {
paneOwnerUnverified = true
}
if (!session.isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) {
return
}
session.reportError(message)
},
toProcessExitStartup(coldRestoreStartup ?? session.paneStartup)
)
session.beginReattachLiveDataDeferral(outputCallbacks.generation)
session.transportConnectInFlightSince = Date.now()
const reattachPromise = session.transport.connect({
url: '',
cols: session.cols,
rows: session.rows,
sessionId: deferredReattachSessionId,
...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}),
...(coldRestoreStartup?.env
? { env: session.mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) }
: {}),
...(coldRestoreStartup?.launchConfig ? { launchConfig: coldRestoreStartup.launchConfig } : {}),
...(coldRestoreStartup?.resumeProviderSession
? { resumeProviderSession: coldRestoreStartup.resumeProviderSession }
: {}),
...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}),
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
...(session.directSshRetryAttempt
? { admitPtyId: session.claimCapturedDirectSshRetryPty }
: {}),
callbacks: outputCallbacks.callbacks
})
void Promise.resolve(reattachPromise)
.catch(() => null)
.finally(() => {
session.transportConnectInFlightSince = null
})
const trackedReattachPromise = Promise.resolve(reattachPromise)
.then(async (result) => {
if (outputCallbacks.generation !== session.transportStreamGeneration) {
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
}
return
}
if (!result && paneOwnerUnverified) {
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
}
return
}
if (!result && expiredReattachError) {
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
}
if (session.disposed) {
return
}
if (session.rejectObsoleteDirectSshReattach(deferredReattachSessionId)) {
return
}
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId)
session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId)
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
const accepted = await session.handleReattachResult(
result,
deferredReattachSessionId,
coldRestoreStartup,
outputCallbacks.generation
)
session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation)
const gen = await preSignalPromise
if (typeof gen === 'number') {
if (!accepted) {
await window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
} else if (!isRemoteRuntimePtyId(deferredReattachSessionId)) {
const settledPtyId =
result && typeof result === 'object' && 'id' in result
? result.id
: (session.transport.getPtyId() ?? deferredReattachSessionId)
const hasRestorePayload =
result &&
typeof result === 'object' &&
('snapshot' in result || 'replay' in result || 'coldRestore' in result)
await (hasRestorePayload
? session.settlePaneSerializerAfterReplay(settledPtyId, gen)
: window.api.pty.settlePaneSerializer(session.cacheKey, gen))
}
}
})
.catch(async (err) => {
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
}
const message = err instanceof Error ? err.message : String(err)
if (outputCallbacks.generation !== session.transportStreamGeneration) {
return
}
if (session.rejectObsoleteDirectSshReattach(deferredReattachSessionId)) {
return
}
if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) {
session.reportError(message)
return
}
warnTerminalLifecycleAnomaly('restored PTY reattach threw', {
tabId: session.deps.tabId,
worktreeId: session.deps.worktreeId,
leafId: session.deps.restoredLeafId ?? session.pane.leafId,
paneId: session.pane.id,
ptyId: deferredReattachSessionId,
reason: message
})
if (session.connectionId && isSshSessionExpiredError(err)) {
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId)
session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId)
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
session.reportError(message)
if (session.connectionId) {
recoverUnverifiableDirectSshReattach(session, deferredReattachSessionId)
return
}
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId)
session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId)
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
})
session.armDirectSshPaneRetryTimeout(trackedReattachPromise, session.directSshRetryAttempt)
}
@@ -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
})
})
})
@@ -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
})
}
@@ -0,0 +1,6 @@
import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery'
export type DirectSshRetryLease = Pick<
DirectSshPaneRetryAttempt,
'attemptId' | 'authority' | 'tabGeneration'
>
@@ -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<unknown>,
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<number, { failed: boolean }>()
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()
}
}
}
@@ -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<string, (data: string, meta?: PtyDataMeta) => void>()
type E2eTerminalHiddenSnapshotOverrideApi = {
setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void
resolve: (ptyId: string) => void
clear: (ptyId: string) => void
}
type E2eTerminalHiddenSnapshotOverride = {
promise: Promise<PtyBufferSnapshot | null>
resolve: () => void
}
const e2eTerminalHiddenSnapshotOverrides = new Map<string, E2eTerminalHiddenSnapshotOverride>()
// 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<void>((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<PtyBufferSnapshot | null> | 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)
}
})
}
@@ -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)
})
})
@@ -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
}
@@ -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
}
}
}
@@ -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
)
}
@@ -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<void> => {
// 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()
}
}
@@ -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<string | null> => {
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<string | null> = 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
}
}
@@ -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<string, string>
}
export type FreshSpawnOptions = {
forceBlankRestoredViewport?: boolean
}
export type ColdRestoreAgentResumeStartup = PendingStartupCommand & {
agent: ResumableTuiAgent
resumeProviderSession: AgentProviderSessionMetadata
launchConfig: NonNullable<ReturnType<typeof buildAgentResumeStartupPlan>>['launchConfig']
launchToken: string
useLiveEntry: boolean
hasSleepingRecord: boolean
sleepingRecordEntry: { paneKey: string; record: SleepingAgentSessionRecord } | null
}
@@ -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)
}
}
}
@@ -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 }
}
}
}
@@ -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
}
}
@@ -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'
@@ -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<void>
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()
}
}
}
@@ -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<void> {
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
}
}
}
}
@@ -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<PtyDataMeta, 'seq'>): 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)
}
@@ -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<HiddenOutputSnapshotResult> {
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()
}
}
@@ -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<PtyBufferSnapshot | null>
} | null = null
const createSshMainModelSnapshotProbe = (
ptyId: string
): (() => Promise<PtyBufferSnapshot | null>) =>
memoizeSshReattachModelSnapshotProbe(async (): Promise<PtyBufferSnapshot | null> => {
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<PtyBufferSnapshot | null>) => {
if (parkedSshSnapshotPrefetch?.ptyId !== ptyId) {
parkedSshSnapshotPrefetch = { ptyId, fetch: createSshMainModelSnapshotProbe(ptyId) }
}
return parkedSshSnapshotPrefetch.fetch
}
}
@@ -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
}
}
@@ -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;?')
}
@@ -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<boolean | null>): void => {
session.pendingTerminalInputWrite = promise
void promise.finally(() => {
if (session.pendingTerminalInputWrite === promise) {
session.pendingTerminalInputWrite = null
}
})
}
session.flushPendingInterruptInference = (): boolean | Promise<boolean> => {
const pendingWrite = session.pendingTerminalInputWrite
if (!pendingWrite) {
return session.interruptInference.flushPending()
}
return pendingWrite.then((immediateResult) => {
return immediateResult ?? session.interruptInference.flushPending()
})
}
installPaneAgentIdentity(session)
}
@@ -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)
}
}
)
}
@@ -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
}
@@ -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<typeof useAppStore.getState>): 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<typeof useAppStore.getState>
): 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<typeof useAppStore.getState>): 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)
}
@@ -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>) => 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<string>, snapshotRequestedAt?: number) => void
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
}
@@ -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
}
}
@@ -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()
}
@@ -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<void> => {
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<typeof setTimeout> | null = null
let startupDraftHardTimer: ReturnType<typeof setTimeout> | 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<void> => {
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)
}
}
@@ -0,0 +1,5 @@
export type PendingHiddenOutputRestoreChunk = {
data: string
seq?: number
rawLength?: number
}
@@ -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
}
@@ -0,0 +1,31 @@
import { e2eConfig } from '@/lib/e2e-config'
export const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
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<string, unknown>
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)
}
}
@@ -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<string, ProcessExitState>()
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>): 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
})
}
}
@@ -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<boolean> | 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<PanePtyResizeHoldFlushDetail>).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
}
@@ -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
})
}
)
}
@@ -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
}
}
}
@@ -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)
})
}
}
}

Some files were not shown because too many files have changed in this diff Show More