diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 3023236de0..52b983c51f 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -27,6 +27,7 @@ type IpcPtyConnectContext = { /** True only for the one buffered exit consumed by this connect attempt. */ isExpectedExitCurrent: () => boolean ownsPtyId: (id: string) => boolean + handleExplicitlyClosedConnect?: (id: string) => boolean bind: (id: string) => void isCurrent: (id: string) => boolean setCallbacks: (callbacks: PtyConnectOptions['callbacks']) => void @@ -89,6 +90,9 @@ export async function connectIpcPty( const priorIncarnationFence = currentPreHandlerPtySequence() const spawnResult = await spawnIpcPty(transportOptions, options, admittedSessionId) const retireFreshSpawn = async (): Promise => { + if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) { + return + } // A newer generation may already own a recycled id; an id-only kill would retire its PTY. if ( !spawnResult.isReattach && diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 4d7eaf7358..2f6ed734bb 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -232,7 +232,10 @@ export type PtyTransport = { * it also drops the transport's output processor from the pty side-effect memory census, * so a reattached one would run untracked. Create a new transport instead. */ detach?: (options?: { preserveExitObserver?: boolean }) => void - destroy?: () => void | Promise + destroy?: (options?: { + /** Explicit close can retain retirement intent until an unbound connect settles. */ + onAbandonedConnect?: (ptyId: string) => boolean + }) => void | Promise } export type IpcPtyTransportOptions = { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f794b9a1e4..7731e75eac 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -44,6 +44,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } = opts let connected = false let destroyed = false + let onAbandonedConnect: ((ptyId: string) => boolean) | undefined let ptyId: string | null = null let lifecycleGeneration = 0 let lastExitGeneration: number | null = null @@ -137,6 +138,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra lastExitGeneration === lifecycleGeneration && lifecycleGeneration === connectGeneration + 1, ownsPtyId: (id) => !destroyed && connected && ptyId === id, + handleExplicitlyClosedConnect: (id) => destroyed && (onAbandonedConnect?.(id) ?? false), bind, isCurrent: (id) => lifecycleGeneration === connectGeneration && connected && ptyId === id, setCallbacks, @@ -268,7 +270,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra : { ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(shellOverride ? { shellOverride } : {}) }, resetCrossChunkParserState: outputProcessor.resetAgentStatusCarry, - destroy() { + destroy(options) { + onAbandonedConnect ??= options?.onAbandonedConnect destroyed = true try { this.disconnect() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index 330b42166c..f3966b1f75 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -13,6 +13,7 @@ import { } from './terminal-pane-tab-detach' import { clearPaneTerminalError } from './terminal-error-accumulation' import type { TerminalPaneBindingController } from './use-terminal-pane-layout-bindings' +import { retireUnboundIpcTerminalPane } from './retire-unbound-ipc-terminal-pane' export function useTerminalPaneCloseActions(controller: TerminalPaneBindingController) { const { @@ -46,6 +47,13 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr clearSessionRestoredBannerForPane(paneId) const leafId = manager.getLeafId(paneId) if (leafId) { + retireUnboundIpcTerminalPane({ + getState: useAppStore.getState, + tabId, + leafId, + transport: paneTransportsRef.current.get(paneId), + getTransports: () => paneTransportsRef.current + }) useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null) useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true }) } diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.ts b/src/renderer/src/store/slices/terminal-tab-retirement.ts index 80e6782469..e2e034fa6a 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts @@ -135,6 +135,30 @@ export function isTerminalTabPresent( return locateTerminalTab(state.tabsByWorktree, tabId) !== null } +export function hasTerminalPtyOwnerOutsidePane( + state: TerminalTabRetirementState, + identity: string, + tabId: string, + excludedLeafId?: string +): boolean { + for (const [ownerTabId, owner] of collectLiveTerminalTabs(state)) { + const ids = + ownerTabId === tabId + ? Object.entries(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}) + .filter(([leafId]) => leafId !== excludedLeafId) + .map(([, ptyId]) => ptyId) + : collectPtyIdsForTab(state, ownerTabId, owner.rowPtyId) + if ( + ids.some( + (ptyId) => getTerminalPtyOwnershipIdentity(state, ptyId, owner.worktreeId) === identity + ) + ) { + return true + } + } + return false +} + export function buildTerminalTabRetirementPlan( state: TerminalTabRetirementState, tabId: string