mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(terminal): stop a hidden pane's unmeasured 80x24 from overwriting a live PTY's size on reattach A pane that mounts while display:none (app relaunch or update with the floating terminal panel closed, a non-active floating tab, any background tab) cannot fit its container, so it reattaches with xterm's default 80x24. Main wrote those placeholder dims into `ptySizes` unconditionally, both before and after the attach. A daemon attach never resizes the live session, so the real PTY stayed at its wide grid while main's hidden headless model was created (or reflowed after renderer hydration) at 80 columns. Every byte the agent emitted while hidden was parsed 80 wide; reveal restored that image into the pane: rows clamped at column 80 with CHA fill, the status bar interleaved into response text, and for alt-screen TUIs the whole screen stuck in an 80x24 corner until a real resize forced a repaint. Scrollback damage was permanent. Fix, main-side only: - Pre-attach: seed `ptySizes` only for a genuinely fresh session id, or a measured request with nothing cached. A hidden reattach writes nothing. - Commit: on reattach, record the provider's proven grid (`attachedGrid`, set only by the local provider whose attach really resizes), then the reply's `snapshotCols/Rows` (the daemon emulator's grid), then the size main already held, and only then the request. - Reflow an already-created model to that grid after the seed block, so bytes that arrived before the reply no longer leave an 80x24 model. Both the ipc and runtime spawn paths take the same authority module. Renderer and wire formats are unchanged; `PtySpawnResult` is main-internal. Reproduced deterministically: close the floating panel with Claude Code streaming at 211x57, kill only the Electron main process so the daemon survives, relaunch. Main's cache read 80x24 against an applied 211x57 and the reveal snapshot was 80 columns wide; replaying the recorded bytes through an 80-column emulator reproduced the field screenshot. Relaunch with the panel open, and a fresh spawn, keep the wide grid. * fix(terminal): commit the adopted-claim reattach grid and reject non-integer provider grids Review follow-ups. The runtime spawn path's adopted-claim branch returned before the size commit, so an adoption attaching to a live session kept whatever the caller requested; it now commits and reflows like every other reattach. The grid validator requires integers so a malformed provider grid falls through to the cached size instead of reaching xterm. * fix(terminal): reflow main's headless model onto the committed grid for every spawn, not only reattaches A hidden attach whose daemon restarted comes back as a fresh session, and the pre-attach seed is now withheld for unmeasured attaches, so a live byte that created the model at 80x24 before the reply would have kept it there forever. * refactor(terminal): let the provider's reattach flag pick the adopted-claim grid source * fix(terminal): derive the adopted-claim reattach flag once for the size commit and the reservation The SSH relay's adopted reply carries no isReattach, so the size commit would have taken the request while the reservation was told it was an attach. Normalize once so both agree.
312 lines
13 KiB
TypeScript
312 lines
13 KiB
TypeScript
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
|
|
import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
|
|
import { ptySizes } from '../delivery/visibility-state'
|
|
import { commitRuntimePtySize } from './spawn-commit-pty-size'
|
|
import {
|
|
shouldSkipCodexHomeEnvForWindowsShell,
|
|
recordCodexPaneAccountForSpawn,
|
|
codexReattachedHomeRouteField
|
|
} from '../host-env/codex-home'
|
|
import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate'
|
|
import { registerPty } from '../../../memory/pty-registry'
|
|
import { rememberPaneKeyForPty } from '../pane/key-state'
|
|
import {
|
|
pendingByPaneKey,
|
|
pendingPtyIdBySerializerGeneration,
|
|
rendererSerializerReadiness
|
|
} from '../pane/serializer-state'
|
|
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
|
|
import { track } from '../../../telemetry/client'
|
|
import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
|
|
import {
|
|
agentKindSchema,
|
|
launchSourceSchema,
|
|
requestKindSchema
|
|
} from '../../../../shared/telemetry-events'
|
|
import { persistAdmittedStablePaneBinding } from '../pane/stable-owner'
|
|
import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim'
|
|
import {
|
|
isNativeWindowsLocalPtySpawn,
|
|
markNativeWindowsConptyPty
|
|
} from '../../../runtime/terminal-model-query-authority'
|
|
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
|
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
|
|
import { clearProviderPtyState } from '../provider/state-cleanup'
|
|
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
|
|
import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
|
|
import type { RuntimePtySpawnState } from './spawn-state'
|
|
|
|
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
|
const args = ctx.args
|
|
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
|
|
try {
|
|
ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
|
|
store: ctx.hostSessionBinding?.store,
|
|
owner: ctx.stablePaneOwner,
|
|
result: ctx.result,
|
|
worktreeId: ctx.hostSessionBinding?.worktreeId,
|
|
startupCwd: ctx.cwd,
|
|
connectionId: args.connectionId
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'terminal_pane_owner_changed') {
|
|
throw error
|
|
}
|
|
console.error('[pty] failed to persist runtime PTY binding after attach:', error)
|
|
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
|
|
agentSessionOperationOutcome: 'unknown' as const
|
|
})
|
|
}
|
|
if (ctx.result.agentSessionEnsure?.disposition === 'adopted') {
|
|
// Why: an adoption is an attach to a live owner by definition, but the SSH relay's adopted
|
|
// reply omits isReattach; derive it once so the size commit and the reservation agree.
|
|
const adoptedResult = { ...ctx.result, isReattach: true }
|
|
const owner = ctx.result.agentSessionEnsure.owner
|
|
ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null)
|
|
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle)
|
|
if (ctx.result.incarnationId) {
|
|
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
|
|
}
|
|
ctx.deps.runtime?.registerPty(
|
|
ctx.result.id,
|
|
owner.surface.worktreeId,
|
|
args.connectionId ?? null,
|
|
{
|
|
tabId: owner.surface.tabId,
|
|
leafId: owner.surface.leafId,
|
|
terminalHandle: owner.surface.terminalHandle,
|
|
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
|
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
|
|
}
|
|
)
|
|
if (!args.connectionId) {
|
|
ctx.deps.options?.onCodexHomePtySpawned?.({
|
|
id: ctx.result.id,
|
|
codexHomePath: ctx.selectedCodexHomePath,
|
|
reattached: true,
|
|
startedAt: ctx.codexHomeLaunchStartedAt,
|
|
startedSequence: ctx.codexHomeLaunchStartedSequence,
|
|
...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true),
|
|
...(ctx.env ? { launchEnv: ctx.env } : {})
|
|
})
|
|
}
|
|
// Why: this branch returns before the normal commit site; without this the cache keeps
|
|
// whatever the caller requested.
|
|
commitRuntimePtySize(ctx, adoptedResult)
|
|
// Why: the adopted branch returns before the normal settle site, so the
|
|
// reservation must be resolved here or every later spawn for this pane
|
|
// awaits a promise that never settles.
|
|
resolvePaneSpawnReservation(
|
|
ctx.paneSpawnReservationKey,
|
|
ctx.paneSpawnReservation,
|
|
adoptedResult
|
|
)
|
|
return {
|
|
id: ctx.result.id,
|
|
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
|
agentSessionEnsure: ctx.result.agentSessionEnsure
|
|
}
|
|
}
|
|
ptyOwnership.set(ctx.result.id, args.connectionId ?? null)
|
|
if (ctx.result.incarnationId) {
|
|
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
|
|
}
|
|
// Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero.
|
|
if (
|
|
isNativeWindowsLocalPtySpawn({
|
|
connectionId: args.connectionId,
|
|
cwd: args.cwd,
|
|
shellOverride: ctx.daemonShellOverride
|
|
})
|
|
) {
|
|
markNativeWindowsConptyPty(ctx.result.id)
|
|
}
|
|
const persistSshLease = (): void =>
|
|
claimSshPaneLease({
|
|
store: ctx.deps.store,
|
|
connectionId: args.connectionId,
|
|
ptyId: ctx.result.id,
|
|
worktreeId: args.worktreeId,
|
|
tabId: args.tabId,
|
|
leafId: args.leafId
|
|
})
|
|
if (!ctx.hostSessionBinding) {
|
|
persistSshLease()
|
|
}
|
|
commitRuntimePtySize(ctx, ctx.result)
|
|
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
|
|
ptySizes.delete(ctx.effectiveSessionAppId)
|
|
}
|
|
recordCodexPaneAccountForSpawn({
|
|
ptyId: ctx.result.id,
|
|
isDaemonHostSpawn: ctx.isDaemonHostSpawn,
|
|
isReattach: ctx.result.isReattach === true,
|
|
pinnedByResume: ctx.codexResumeHomeSelected,
|
|
launchCodexHomePath: ctx.selectedCodexHomePath,
|
|
launchEnv: args.env,
|
|
target: ctx.codexSelectionTarget,
|
|
settings: ctx.deps.getSettings?.()
|
|
})
|
|
if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) {
|
|
try {
|
|
const binding = {
|
|
worktreeId: ctx.hostSessionBinding.worktreeId,
|
|
tabId: ctx.hostSessionBinding.tabId,
|
|
leafId: ctx.hostSessionBinding.leafId,
|
|
ptyId: ctx.result.id,
|
|
hostAdmittedMembership: true,
|
|
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
|
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
|
|
...(ctx.hostSessionBinding.expectedSourceBinding
|
|
? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding }
|
|
: {})
|
|
}
|
|
const persisted = args.connectionId
|
|
? ctx.hostSessionBinding.store.persistPtyBinding(
|
|
binding,
|
|
toSshExecutionHostId(args.connectionId)
|
|
)
|
|
: ctx.hostSessionBinding.store.persistPtyBinding(binding)
|
|
if (persisted === false) {
|
|
throw new Error('terminal_split_source_not_found')
|
|
}
|
|
} catch (err) {
|
|
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
|
|
if (!ctx.result.isReattach) {
|
|
deletePtyOwnership(ctx.result.id)
|
|
try {
|
|
await ctx.provider.shutdown(ctx.result.id, { immediate: true })
|
|
} catch (shutdownErr) {
|
|
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
|
|
}
|
|
clearProviderPtyState(ctx.result.id)
|
|
}
|
|
if (err instanceof Error && err.message === 'terminal_split_source_not_found') {
|
|
throw err
|
|
}
|
|
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
|
|
agentSessionOperationOutcome: 'unknown' as const
|
|
})
|
|
}
|
|
persistSshLease()
|
|
}
|
|
if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) {
|
|
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle)
|
|
}
|
|
if (args.worktreeId) {
|
|
ctx.deps.runtime?.registerPty(
|
|
ctx.result.id,
|
|
args.worktreeId,
|
|
args.connectionId ?? null,
|
|
// Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587).
|
|
typeof args.tabId === 'string' &&
|
|
isValidTerminalTabId(args.tabId) &&
|
|
args.tabId.length <= 512 &&
|
|
ctx.metadataLeafId !== null
|
|
? {
|
|
tabId: args.tabId,
|
|
leafId: ctx.metadataLeafId,
|
|
...(args.preAllocatedHandle ? { terminalHandle: args.preAllocatedHandle } : {}),
|
|
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
|
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
|
|
}
|
|
: undefined,
|
|
!args.connectionId
|
|
? shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd)
|
|
: undefined
|
|
)
|
|
} else {
|
|
// Why: non-worktree PTYs have no later surface-registration phase to clear admission intent.
|
|
ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId)
|
|
}
|
|
// Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank.
|
|
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
|
|
// Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity).
|
|
if (!ctx.stablePaneOwner) {
|
|
ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null)
|
|
}
|
|
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) {
|
|
markClaudePtySpawned(ctx.result.id)
|
|
}
|
|
if (args.telemetry && !ctx.stablePaneOwner) {
|
|
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
|
|
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
|
|
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
|
|
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
|
|
track('agent_started', {
|
|
agent_kind: agentKindParse.data,
|
|
launch_source: launchSourceParse.data,
|
|
request_kind: requestKindParse.data,
|
|
...getCohortAtEmit()
|
|
})
|
|
}
|
|
}
|
|
// Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup.
|
|
const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY)
|
|
const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined
|
|
const inheritRendererReadiness =
|
|
ctx.result.isReattach === true &&
|
|
!pendingSerializer &&
|
|
rendererSerializerReadiness.has(ctx.result.id)
|
|
rendererSerializerReadiness.beginIncarnation(ctx.result.id, inheritRendererReadiness)
|
|
if (paneKey && pendingSerializer) {
|
|
pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, ctx.result.id)
|
|
}
|
|
if (!args.connectionId) {
|
|
registerPty({
|
|
ptyId: ctx.result.id,
|
|
worktreeId: args.worktreeId ?? null,
|
|
sessionId: ctx.sessionId ?? null,
|
|
paneKey,
|
|
pid:
|
|
typeof ctx.result.pid === 'number' && Number.isFinite(ctx.result.pid) && ctx.result.pid > 0
|
|
? ctx.result.pid
|
|
: null
|
|
})
|
|
}
|
|
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
|
|
ctx.deps.sendPtySpawnedToRenderer(ctx.result.id)
|
|
if (!args.connectionId) {
|
|
ctx.deps.options?.onCodexHomePtySpawned?.({
|
|
id: ctx.result.id,
|
|
codexHomePath: ctx.selectedCodexHomePath,
|
|
startedAt: ctx.codexHomeLaunchStartedAt,
|
|
startedSequence: ctx.codexHomeLaunchStartedSequence,
|
|
...codexReattachedHomeRouteField(
|
|
ctx.reattachedCodexHomeRoutes,
|
|
ctx.result.id,
|
|
ctx.result.isReattach === true
|
|
),
|
|
...(ctx.result.isReattach === true
|
|
? { reattached: true }
|
|
: ctx.env
|
|
? { launchEnv: ctx.env }
|
|
: {})
|
|
})
|
|
}
|
|
const response = {
|
|
id: ctx.result.id,
|
|
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
|
...(ctx.stablePaneOwner && (ctx.stablePaneOwner.handle || args.preAllocatedHandle)
|
|
? {
|
|
stablePaneOwner: {
|
|
handle: ctx.stablePaneOwner.handle ?? args.preAllocatedHandle!,
|
|
tabId: ctx.stablePaneOwner.tabId,
|
|
leafId: ctx.stablePaneOwner.leafId
|
|
}
|
|
}
|
|
: {}),
|
|
...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {})
|
|
}
|
|
resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, {
|
|
...ctx.result,
|
|
...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' &&
|
|
ctx.reconciledSnapshotSeq !== null &&
|
|
ctx.snapshotKittyFlagsCoverReconciledSeq
|
|
? { snapshotSeq: ctx.reconciledSnapshotSeq }
|
|
: { snapshotKittyKeyboardFlags: undefined }),
|
|
isReattach: true
|
|
})
|
|
return response
|
|
}
|