fix(terminal): restore only pane-less streams after endpoint loss; one attach state

- Endpoint loss re-attaches in main only sessions no renderer view reports; a pane restores its own.
- The attach hide skips views mid-attach and leaves a pane's own hidden claim alone on failure.
- One per-session stream state (attaching, attached, lost) replaces three sets and is forgotten on exit.
- Waits retry the attach before failing, including after a failed re-attach.
- The output clock starts wherever a reattach result is seeded, so pane reattaches get it too.
This commit is contained in:
Jinwoo-H
2026-10-26 18:37:38 -04:00
parent 72b3b79fd3
commit 7de8a1be03
20 changed files with 183 additions and 110 deletions
+1
View File
@@ -971,6 +971,7 @@
1 test-no-wall-clock:src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts
2 test-no-wall-clock:src/main/runtime/terminal/surface/pty-pane-copies-untouched-main.test.ts
2 test-no-wall-clock:src/main/runtime/tui-idle-claude-task-wakeup.test.ts
12 test-no-wall-clock:src/main/runtime/tui-idle-evidence.test.ts
18 test-no-wall-clock:src/main/runtime/tui-idle-hook-lane-runtime.test.ts
10 test-no-wall-clock:src/main/runtime/tui-idle-hook-lane.test.ts
4 test-no-wall-clock:src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts
@@ -367,9 +367,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
)
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 })
try {
await expect(legacy.attach('legacy-session')).resolves.not.toHaveProperty(
'providerSequence'
)
const attached = await legacy.attach('legacy-session')
expect(attached).toMatchObject({
id: 'legacy-session',
isReattach: true,
incarnationId: 'legacy-attach-incarnation'
})
expect(attached.providerSequence).toBeUndefined()
} finally {
legacy.dispose()
request.mockRestore()
@@ -10,6 +10,11 @@ export const visibleRendererPtys = new Set<string>()
export const rendererVisibilityKnownPtys = new Set<string>()
// Visible attaches suppress main-start hiding; remember it so failed attaches can hide the PTY again.
export const attachingRendererViewPtys = new Map<string, { views: number; skippedHide: boolean }>()
/** A visible view, or one mid-attach, owns delivery; hiding it would drop output it is about to paint. */
export function isRendererViewShowingPty(id: string): boolean {
return visibleRendererPtys.has(id) || attachingRendererViewPtys.has(id)
}
// Keep null-initialized wrapper bridges to avoid Rolldown no-op folding (STA-5661; see debug.ts).
let invalidatePendingPtyDrainPriorityImpl: ((id?: string, schedule?: boolean) => void) | null = null
let invalidatePendingPtyDrainPolicyImpl: ((id?: string, schedule?: boolean) => void) | null = null
@@ -39,6 +39,9 @@ export function seedTerminalRestoreRecordsFromSpawnResult(
...(lastTitle !== undefined ? { lastTitle } : {})
})
}
if (result.isReattach === true) {
runtime?.startReattachedOutputClock?.(result.id)
}
}
export function assertSpawnReplyWasLive(result: PtySpawnResult): void {
+9 -3
View File
@@ -12,7 +12,11 @@ import {
import { localProvider } from './registry'
import { clearProviderPtyState } from './state-cleanup'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { providerSnapshotRequiredPtys, visibleRendererPtys } from '../delivery/visibility-state'
import {
providerSnapshotRequiredPtys,
rendererVisibilityKnownPtys,
visibleRendererPtys
} from '../delivery/visibility-state'
import type { PtyIpcSession } from '../session'
import { publishPtyEndpointLoss } from '../../../runtime/terminal/pty-endpoint-loss-signal'
@@ -27,8 +31,10 @@ export function bindProviderListeners(session: PtyIpcSession): void {
localProvider.onWriteUnavailable?.((payload) => {
// Before the window check: a headless host has no window, only remote streams.
publishPtyEndpointLoss(payload.id)
// Panes remount and re-attach themselves; main must restore its own stream for unmounted ones.
session.runtime?.reattachPtyAfterEndpointLoss(payload.id)
// A pane remounts and re-attaches itself; main restores only streams no pane will.
if (!rendererVisibilityKnownPtys.has(payload.id)) {
session.runtime?.reattachPtyAfterEndpointLoss(payload.id)
}
if (
!session.mainWindow ||
session.mainWindow.isDestroyed() ||
@@ -3,6 +3,7 @@ import { setupPtyIpcSuite } from '../pty-ipc-test-harness'
import { PtyWriteUnavailableError } from '../../providers/pty-write-unavailable-error'
import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../../shared/terminal-input'
import { registerPtyHandlers } from './register-handlers'
import { rendererVisibilityKnownPtys } from './delivery/visibility-state'
import { subscribeToPtyEndpointLoss } from '../../runtime/terminal/pty-endpoint-loss-signal'
vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
@@ -114,7 +115,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => {
})
describe('the daemon-death fan-out', () => {
function installProviderCapturingFanout(): (payload: { id: string }) => void {
function installProviderCapturingFanout(runtime?: unknown): (payload: { id: string }) => void {
let fanout: ((payload: { id: string }) => void) | null = null
installDaemonTestProvider({
onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => {
@@ -122,7 +123,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => {
return () => {}
})
})
registerPtyHandlers(mainWindow as never)
registerPtyHandlers(mainWindow as never, runtime as never)
if (!fanout) {
throw new Error('registerPtyHandlers did not subscribe to onWriteUnavailable')
}
@@ -139,6 +140,23 @@ describe('pty:writeUnavailable renderer-liveness guard', () => {
})
})
it('re-attaches in main only the sessions no mounted pane will restore', () => {
const reattachPtyAfterEndpointLoss = vi.fn()
const fanout = installProviderCapturingFanout({
setPtyController: vi.fn(),
reattachPtyAfterEndpointLoss
})
rendererVisibilityKnownPtys.add('pane-1')
try {
fanout({ id: 'pane-1' })
fanout({ id: 'unmounted-1' })
} finally {
rendererVisibilityKnownPtys.delete('pane-1')
}
expect(reattachPtyAfterEndpointLoss.mock.calls).toEqual([['unmounted-1']])
})
it('stays silent when the WebContents is destroyed under a live window', () => {
const fanout = installProviderCapturingFanout()
mainWindow.webContents.isDestroyed.mockReturnValue(true)
+14 -9
View File
@@ -3,7 +3,8 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import type { IPtyProvider } from '../../../providers/pty-provider-contract'
import { LocalPtyProvider } from '../../../providers/local-pty-provider'
import { ptyOwnership } from '../provider/ownership-state'
import { ptySizes, visibleRendererPtys } from '../delivery/visibility-state'
import { isRendererViewShowingPty, ptySizes } from '../delivery/visibility-state'
import { isHiddenRendererPty } from '../delivery/pty-hidden-delivery-gate'
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
import { hideRuntimeOwnedRendererDelivery } from './spawn-hidden-delivery'
@@ -128,18 +129,22 @@ export async function attachPtyFromRuntimeController(
if (provider !== localProvider || provider instanceof LocalPtyProvider) {
return false
}
const hidden = !visibleRendererPtys.has(ptyId) && hideRuntimeOwnedRendererDelivery(deps, ptyId)
// Only a mark this attach set may be undone; a pane's own hidden claim stays.
const hidden =
!isRendererViewShowingPty(ptyId) &&
!isHiddenRendererPty(ptyId) &&
hideRuntimeOwnedRendererDelivery(deps, ptyId)
try {
const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0
const attachResult = await provider.attach(ptyId)
if (attachResult?.providerSequence) {
deps.runtime?.synchronizePtyOutputSequenceFromProvider?.(
ptyId,
attachResult.providerSequence,
sequenceBeforeProviderAttach
)
}
if (attachResult) {
if (attachResult.providerSequence) {
deps.runtime?.synchronizePtyOutputSequenceFromProvider?.(
ptyId,
attachResult.providerSequence,
sequenceBeforeProviderAttach
)
}
// A restarted runtime has never seen this screen; an idle agent may print nothing after attach.
seedHeadlessTerminalFromSpawnResult(deps.runtime, attachResult, null)
seedTerminalRestoreRecordsFromSpawnResult(deps.runtime, attachResult)
@@ -1,5 +1,5 @@
import { markRuntimeOwnedHiddenRendererPty } from '../delivery/pty-hidden-delivery-gate'
import { attachingRendererViewPtys, visibleRendererPtys } from '../delivery/visibility-state'
import { attachingRendererViewPtys, isRendererViewShowingPty } from '../delivery/visibility-state'
import { closeStartupQueryAuthorityForPty } from '../provider/registry'
import type { RuntimePtySpawnState } from './spawn-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
@@ -43,7 +43,7 @@ export function commitRuntimeSpawnHiddenDelivery(ctx: RuntimePtySpawnState): voi
if (attaching) {
attaching.skippedHide = true
}
if (visibleRendererPtys.has(id) || attaching) {
if (isRendererViewShowingPty(id)) {
releaseRuntimeSpawnPreSpawnHiddenMark(ctx)
ctx.deps.syncPtyBackgroundedDelivery?.(id, 'spawn')
return
@@ -13,7 +13,6 @@ export class RuntimeTerminalViewSubscribers {
constructor(private readonly deps: RuntimeTerminalViewSubscriberDependencies) {}
// Why: exit drops subscribers only; attach/publish state survives until the respawn advances the generation.
clearSubscribers(ptyId: string): void {
this.remoteCounts.delete(ptyId)
this.silenceableRemoteViews.delete(ptyId)
@@ -453,7 +453,18 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
expect(model.emitData(PTY_ID, 'after loss\r\n')).toBe(true)
})
it('fails a wait at once when main could not attach the surviving session', async () => {
it('fails a wait at once when main cannot attach the surviving session', async () => {
const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
// The daemon refuses an attach-only request for a session it does not own.
model.sessions.delete(PTY_ID)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 })
).rejects.toThrow('terminal_not_connected_since_restart')
expect(model.attachCalls).toEqual([PTY_ID])
})
it('retries a refused attach when a wait arrives, instead of failing the wait', async () => {
const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
const refuse = model.deferNextAttach(false)
await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null)
@@ -463,8 +474,10 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 })
).rejects.toThrow('terminal_not_connected_since_restart')
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 50 })
).rejects.toThrow('timeout')
expect(model.attachCalls).toEqual([PTY_ID, PTY_ID])
expect(model.emitData(PTY_ID, 'attached on retry\r\n')).toBe(true)
})
it('does not subscriber-attach or provider-read a session this app already spawned', async () => {
@@ -138,13 +138,21 @@ export abstract class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRun
launchReadiness?: boolean
}
): Promise<RuntimeTerminalWait> {
if (options?.condition !== 'exit') {
try {
this.assertTerminalOutputReachesMain(handle)
} catch (error) {
return Promise.reject(error)
}
const attaching = options?.condition === 'exit' ? null : this.attachTerminalOutputToMain(handle)
return attaching
? attaching.then(() => this.waitForReachableTerminal(handle, options))
: this.waitForReachableTerminal(handle, options)
}
private waitForReachableTerminal(
handle: string,
options?: {
condition?: RuntimeTerminalWaitCondition
timeoutMs?: number
signal?: AbortSignal
launchReadiness?: boolean
}
): Promise<RuntimeTerminalWait> {
// Switch point: a saved pane main holds with no terminal yet starts first (once, shared).
const starting = ORCA_LAYOUT_OWNER_IS_MAIN
? this.startRestoredHandlePane(handle, options ?? {})
@@ -68,16 +68,17 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext
return this.mainDaemonAttachments.isKnownUnattachedLocal(ptyId)
}
/** A failed attach leaves a surviving session silent to main; waiting on it could only time out. */
protected assertTerminalOutputReachesMain(handle: string): void {
/** Null when main already receives the terminal; else attaches it, rejecting if it cannot, since a wait could only time out. */
protected attachTerminalOutputToMain(handle: string): Promise<void> | null {
const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId
if (
ptyId &&
this.isKnownUnattachedLocalDaemonPty(ptyId) &&
!this.mainDaemonAttachments.hasPendingAttach(ptyId)
) {
throw new Error('terminal_not_connected_since_restart')
}
const attaching = ptyId ? this.mainDaemonAttachments.ensureAttached(ptyId) : null
return (
attaching?.then((attached) => {
if (!attached) {
throw new Error('terminal_not_connected_since_restart')
}
}) ?? null
)
}
protected reconcileMainDaemonAttach(ptyId: string): void {
@@ -3,38 +3,41 @@ type MainDaemonAttachmentDependencies = {
attachProvider: (ptyId: string) => Promise<boolean> | null
}
/** Who holds a session's stream: an attach in flight, a stream this run holds, or one an endpoint drop detached. */
type StreamState =
| { kind: 'attaching'; restoring: boolean; attempt: Promise<boolean> }
| { kind: 'attached' }
| { kind: 'lost' }
// Why capped: 100 concurrent snapshot attaches delayed the visible terminal's first echo by ~0.7 s.
const MAX_CONCURRENT_ATTACHES = 4
/** Main holds the stream for every live local daemon session it adopts, so its records fill without a mounted pane. */
export class MainDaemonAttachments {
private readonly attaches = new Map<string, Promise<boolean>>()
private readonly states = new Map<string, StreamState>()
private readonly inventoryWaiters = new Set<string>()
private readonly spawnPublishedPtys = new Set<string>()
// Endpoint loss detached these; they need a re-attach although main already holds their model.
private readonly lostPtys = new Set<string>()
private readonly queued: (() => void)[] = []
private running = 0
constructor(private readonly deps: MainDaemonAttachmentDependencies) {}
/** This run's spawn or a pane's attach already carries the stream. */
markSpawnPublished(ptyId: string): void {
this.spawnPublishedPtys.add(ptyId)
this.states.set(ptyId, { kind: 'attached' })
}
resetGeneration(ptyId: string): void {
this.attaches.delete(ptyId)
/** Respawn or exit ends the generation whose stream this state described. */
forget(ptyId: string): void {
this.states.delete(ptyId)
this.inventoryWaiters.delete(ptyId)
this.spawnPublishedPtys.delete(ptyId)
this.lostPtys.delete(ptyId)
}
isKnownUnattachedLocal(ptyId: string): boolean {
return !this.spawnPublishedPtys.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId)
return !this.states.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId)
}
hasPendingAttach(ptyId: string): boolean {
return this.attaches.has(ptyId)
return this.states.get(ptyId)?.kind === 'attaching'
}
inventoryWaiterIds(): string[] {
@@ -43,8 +46,8 @@ export class MainDaemonAttachments {
/** Inventory proved the session live; attach it, or retry once an in-flight refusal settles. */
reconcile(ptyId: string): void {
const pending = this.attaches.get(ptyId)
if (!pending) {
const state = this.states.get(ptyId)
if (state?.kind !== 'attaching') {
this.ensure(ptyId)
return
}
@@ -52,47 +55,50 @@ export class MainDaemonAttachments {
return
}
this.inventoryWaiters.add(ptyId)
void pending.then((attached) => {
void state.attempt.then(() => {
this.inventoryWaiters.delete(ptyId)
if (attached) {
return
}
if (this.attaches.get(ptyId) === pending) {
this.attaches.delete(ptyId)
}
this.ensure(ptyId)
})
}
/** A dropped daemon endpoint detaches every stream; panes remount, sessions without one re-attach here. */
/** An endpoint drop detached a stream no pane will restore; re-attach it once the daemon answers. */
reattachAfterEndpointLoss(ptyId: string): void {
this.attaches.delete(ptyId)
this.lostPtys.add(ptyId)
this.states.set(ptyId, { kind: 'lost' })
this.ensure(ptyId)
}
ensure(ptyId: string): void {
if (
this.attaches.has(ptyId) ||
(!this.lostPtys.has(ptyId) && !this.isKnownUnattachedLocal(ptyId))
) {
return
}
const attempt = this.attach(ptyId)
this.attaches.set(ptyId, attempt)
void attempt.then((attached) => {
if (this.attaches.get(ptyId) !== attempt) {
return
}
if (attached) {
this.lostPtys.delete(ptyId)
} else {
this.attaches.delete(ptyId)
}
})
void this.ensureAttached(ptyId)
}
private async attach(ptyId: string): Promise<boolean> {
/** Null when the stream needs nothing from main; otherwise whether an attach (or retry) got it. */
ensureAttached(ptyId: string): Promise<boolean> | null {
const state = this.states.get(ptyId)
if (state?.kind === 'attaching') {
return state.attempt
}
const restoring = state?.kind === 'lost'
if (state?.kind === 'attached' || (!restoring && !this.isKnownUnattachedLocal(ptyId))) {
return null
}
const attaching: StreamState = { kind: 'attaching', restoring, attempt: Promise.resolve(false) }
this.states.set(ptyId, attaching)
attaching.attempt = this.attach(ptyId, attaching).then((attached) => {
if (this.states.get(ptyId) === attaching) {
if (attached) {
this.states.set(ptyId, { kind: 'attached' })
} else if (restoring) {
this.states.set(ptyId, { kind: 'lost' })
} else {
this.states.delete(ptyId)
}
}
return attached
})
return attaching.attempt
}
private async attach(ptyId: string, attaching: StreamState): Promise<boolean> {
if (this.running >= MAX_CONCURRENT_ATTACHES) {
// The finishing attach hands its slot over without releasing it.
await new Promise<void>((resolve) => this.queued.push(resolve))
@@ -100,9 +106,9 @@ export class MainDaemonAttachments {
this.running += 1
}
try {
// A pane may have attached, or the session respawned, while this waited.
if (!this.lostPtys.has(ptyId) && !this.isKnownUnattachedLocal(ptyId)) {
return false
// A pane's attach or a respawn replaced this state while it waited.
if (this.states.get(ptyId) !== attaching) {
return this.states.get(ptyId)?.kind === 'attached'
}
return (await this.deps.attachProvider(ptyId)) ?? false
} catch {
@@ -164,6 +164,7 @@ export abstract class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDi
advertisedUrlWatcher.unbindPty(ptyId)
this.mobileSubscribers.delete(ptyId)
this.terminalViewSubscribers.clearSubscribers(ptyId)
this.mainDaemonAttachments.forget(ptyId)
this.mobileDisplayModes.delete(ptyId)
this.resizeListeners.delete(ptyId)
this.lastRendererSizes.delete(ptyId)
@@ -162,18 +162,7 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit
},
attachProvider: (ptyId) => {
const attach = this.ptyController?.attach
if (!attach) {
return null
}
return (async () => {
const attached = await attach(ptyId)
const pty = attached ? this.ptysById.get(ptyId) : undefined
// An idle agent may print nothing after attach; from now on its silence is observed.
if (pty && pty.lastOutputAt === null) {
pty.outputObservedSince = Date.now()
}
return attached
})()
return attach ? (async () => attach(ptyId))() : null
}
})
@@ -225,6 +225,14 @@ export abstract class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRun
})
}
/** A reattached live process may print nothing; from now on its silence is observed quiet. */
startReattachedOutputClock(ptyId: string): void {
const pty = this.ptysById.get(ptyId)
if (pty && pty.lastOutputAt === null) {
pty.outputObservedSince ??= Date.now()
}
}
// Restore RPC bytes bypass onPtyData; seed read/list state without waiters, orchestration events or fresh-output clocks.
seedTerminalRestoreTail(ptyId: string, restore: { text?: string; lastTitle?: string }): void {
const seed = restore.text ? buildRestoredTerminalTailSeed(restore.text) : null
@@ -96,7 +96,7 @@ export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends Orc
this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now())
this.legacyWorkerRecovery.deleteRecoveredPty(ptyId)
// Why: a respawn under the same session id needs its own main attach.
this.mainDaemonAttachments.resetGeneration(ptyId)
this.mainDaemonAttachments.forget(ptyId)
const pty = this.ptysById.get(ptyId)
if (pty) {
pty.outputObservedSince = null
+1 -13
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { detectAgentStatusFromTitle } from '../../shared/agent-title-status'
import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
@@ -63,18 +63,6 @@ describe('hasQuietReadyScreen', () => {
).toBe(false)
})
it('measures quiet from when main began receiving a stream that has printed nothing since', () => {
vi.useFakeTimers({ now: 100_000 })
try {
const silent = record({ lastOutputAt: null, outputObservedSince: 100_000 })
expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(false)
vi.advanceTimersByTime(QUIESCENCE_MS)
expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('refuses without a ready screen', () => {
expect(hasQuietReadyScreen(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false)
})
@@ -0,0 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hasQuietReadyScreen } from './tui-idle-evidence'
const QUIESCENCE_MS = 3000
afterEach(() => {
vi.useRealTimers()
})
describe('output clock for a stream main attached after the process last printed', () => {
it('measures quiet from when main began receiving a stream that has printed nothing since', () => {
vi.useFakeTimers({ now: 100_000 })
const silent = { lastAgentStatus: null, lastOutputAt: null, outputObservedSince: 100_000 }
expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(false)
vi.advanceTimersByTime(QUIESCENCE_MS)
expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(true)
})
})
@@ -221,7 +221,7 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime
signal
}: { requireComposerMarker?: boolean; stopOnDialog?: boolean; signal?: AbortSignal } = {}
): Promise<RuntimeTerminalWait> {
this.assertTerminalOutputReachesMain(handle)
await this.attachTerminalOutputToMain(handle)
const initialPtyId =
this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId
const stop = new AbortController()