mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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=')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 +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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.`
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
+9
-2
@@ -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
|
||||
})
|
||||
|
||||
+55
@@ -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')
|
||||
})
|
||||
|
||||
+72
@@ -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
|
||||
}
|
||||
+237
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
+192
@@ -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)
|
||||
}
|
||||
+54
@@ -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
|
||||
}
|
||||
+102
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+269
@@ -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)
|
||||
}
|
||||
+89
@@ -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} `)
|
||||
}
|
||||
+217
@@ -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)
|
||||
}
|
||||
+218
@@ -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()
|
||||
}
|
||||
+182
@@ -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)
|
||||
}
|
||||
+46
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
+19
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+298
@@ -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
|
||||
}
|
||||
+96
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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'
|
||||
+320
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+234
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+241
@@ -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)
|
||||
}
|
||||
+142
@@ -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()
|
||||
}
|
||||
}
|
||||
+222
@@ -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
|
||||
}
|
||||
}
|
||||
+160
@@ -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
|
||||
}
|
||||
}
|
||||
+18
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+30
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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
Reference in New Issue
Block a user