refactor: split pty-connection.ts under 400 lines

This commit is contained in:
Jinjing
2026-08-22 14:44:50 -07:00
parent 02bee48e1d
commit 9b51be9507
69 changed files with 11089 additions and 9649 deletions
+383
View File
@@ -0,0 +1,383 @@
# Split `pty-connection.ts` under 400 lines
## Goal
- Source `src/renderer/src/components/terminal-pane/pty-connection.ts` ≤ 400 `wc -l` (already 4).
- Every dest file under `src/renderer/src/components/terminal-pane/pty-connection/` ≤ 400 `wc -l`.
- New `.ts` dest files also stay under oxlint `max-lines`: **300 counted** (`skipBlankLines` + `skipComments`). **No max-lines disables. No budget bumps.**
- Zero intentional behavior change. Cut/paste + imports. Keep why-comments.
- Public import path stays `./pty-connection` (`connectPanePty`, `STARTUP_CWD_FALLBACK_NOTICE`).
- Name dest files after concrete domain concepts. Never helpers/utils/common/misc/shared-stuff.
Current state (this worktree already finished the module-level extract):
| File | `wc -l` | Status |
| --- | --- | --- |
| `pty-connection.ts` | 4 | Thin barrel. Done. |
| 16 dest files listed in §1 | all well under 400/300 | Module-level extract. Done. |
| `pty-connection/connect-pane-pty.ts` | **8793** | Still the giant closure. This is the remaining split. |
`connectPanePty` lives at `connect-pane-pty.ts:425–8793` (8368 lines). `runDeferredConnect` is `3970–8519` (4550 lines).
Leave headroom: dest **bodies** should land around **180–220 counted lines**. Import blocks add 30–80 lines and count toward both `wc -l` and oxlint. If a dest is ≥260 counted after paste+imports, split it again before finishing.
---
## 1. Inventory
### Public API (barrel only)
| Symbol | Owner after split | Consumers |
| --- | --- | --- |
| `connectPanePty` | `pty-connection/connect-pane-pty.ts` | `TerminalPane.tsx`, `use-terminal-pane-lifecycle.ts`, `pty-connection.test.ts` (`await import('./pty-connection')`) |
| `STARTUP_CWD_FALLBACK_NOTICE` | `pty-connection/startup-cwd-fallback-notice.ts` | barrel + `startFreshSpawn` + one test |
`pty-connection-types.ts` stays a sibling (`PtyConnectionDeps`). Do not move it into the dest folder.
### 1a. Module-level — already extracted (keep, do not re-cut)
| Dest | Symbols | Est. wc |
| --- | --- | --- |
| `startup-cwd-fallback-notice.ts` | `STARTUP_CWD_FALLBACK_NOTICE` | 5 |
| `pty-connect-limits.ts` | `pendingSpawnByPaneKey`, SSH/retry/remote/diag/draft-paste constants, `recordPtyConnectDiagnostic` | 25 |
| `hidden-output-restore-limits.ts` | hidden-restore caps + unavailable warning | 38 |
| `foreground-output-budgets.ts` | foreground throughput/budget/drift constants | 16 |
| `foreground-output-scan.ts` | DEC 2026/cursor/focus sequences, `shouldWritePtyOutputForeground`, `scanSynchronizedForegroundOutput`, cursor helpers, inactive budget | 133 |
| `cursor-agent-reattach-screen.ts` | focus-mode types, parked cursor-agent screen, payload screen signal | 90 |
| `e2e-terminal-pty-harness.ts` | e2e injectors, hidden-snapshot override, output-debug | 160 |
| `hidden-startup-renderer-query.ts` | `shouldKeepHiddenStartupRendererQueriesLive`, `containsHiddenStartupRendererQuery` | 20 |
| `pane-pty-binding.ts` | `PanePtyBinding` | 24 |
| `agent-task-complete-settings.ts` | notification/tracking getters + subscribe | 50 |
| `ssh-session-connect.ts` | connect/outcome types, `waitForSshConnection`, prompt outcome | 70 |
| `paired-parked-terminal-restore.ts` | `isRemoteRuntimePtyId`, `canRestorePairedParkedTerminal`, `isSessionOwnedByWorktree` | 35 |
| `codex-pane-stale.ts` | restart-notice presence + `isCodexPaneStale` | 55 |
| `setup-split-geometry.ts` | rect/grid/setup-split readiness | 80 |
| `agent-done-started-at.ts` | `resolveLatestAgentDoneStartedAt` | 20 |
| `fresh-spawn-types.ts` | `PendingStartupCommand`, `FreshSpawnOptions`, `ColdRestoreAgentResumeStartup` | 26 |
### 1b. `connectPanePty` groups still inside `connect-pane-pty.ts`
Line numbers are **current** (`connect-pane-pty.ts`).
**Spine (425–3969)** — indent-2 state + handlers created at `connectPanePty` call time:
| Lines | Group | Est. body |
| --- | --- | --- |
| 425–542 | identity, timers, no-op slots, kitty tracker, startup capture | 118 |
| 543–664 | sleeping record + draft-paste claim | 122 |
| 665–716 | launch-config register + `neutralTerminalTitle` | 52 |
| 717–844 | pending shell command / CSI consume | 128 |
| 845–957 | title-completion suppress + hook lifecycle | 113 |
| 958–1043 | `observeAcceptedShellCommandInput` | 86 |
| 1044–1135 | authoritative/scoped owner + title-only interrupt | 92 |
| 1136–1218 | reattach payload signal + idle cursor reset | 83 |
| 1219–1358 | interrupt/question inference + pending intent | 140 |
| 1359–1593 | command-finished + visible foreground sample | 235 |
| 1594–1655 | `onTerminalKeyDown` | 62 |
| 1657–1743 | fit binding + side-effect fact consumer | 87 |
| 1744–1824 | `createAgentCompletionCoordinator` | 81 |
| 1825–1946 | surviving-pane focus + hibernate wake consume | 122 |
| 1947–2097 | `onExit` | 151 |
| 2098–2177 | `onTitleChange` + cache-timer seed | 80 |
| 2178–2300 | Command Code working/done status | 123 |
| 2301–2416 | `bindActivePanePty` / `onPtySpawn` / `onPtyRebind` | 116 |
| 2418–2632 | BEL + task-complete notification | 215 |
| 2633–2710 | idle / working / exited title callbacks | 78 |
| 2711–2972 | pane env, host route, direct-SSH retry, ConPTY mode | 262 |
| 2973–3256 | renderer-owned status, transport create, capability replies | 284 |
| 3257–3552 | viewport claim, undeliverable-input recovery, `forwardPtyInput` | 296 |
| 3553–3912 | resize, grid drift, observed geometry, spawn-size reconcile | 360 |
| 3913–3969 | startup-grid settle + `runDeferredConnect` scheduler prelude | 57 |
**`runDeferredConnect` (3970–8519)** — locals are created only after `connectStarted = true` (not on the startup-grid early return). Preserve that timing.
Largest inners:
| Symbol | Lines | Est. body |
| --- | --- | --- |
| `reportError` + serializer + draft paste + cold restore | 4010–4538 | 529 |
| `startFreshSpawn` | 4539–4764 | 226 |
| replay write / drain / `replayDataCallback` | 4765–5085 | 321 |
| `captureTransportOutputCallbacks` | 5086–5148 | 63 |
| hidden-restore state + serialize + flood + delivery sync | 5149–5550 | 402 |
| write/refresh/`writePtyOutputToXterm` | 5551–5804 | 254 |
| skip/salvage/queue/reconcile/seq | 5805–6560 | 756 |
| `applyMainBufferSnapshot` | 6561–6753 | 193 |
| `requestHiddenOutputRestoreIfNeeded` | 6754–7023 | 270 |
| `dataCallback` | 7024–7193 | 170 |
| reattach live-data deferral + SSH prepaint | 7194–7388 | 195 |
| `handleReattachResult` | 7389–7843 | **455** |
| `attachRetainedLegacyPty` (fn only) | 7844–7867 | 24 |
| SSH deferred connect + session resolve + attach/spawn | 7868–8518 | **651** |
| reconcile + binding + dispose | 8520–8793 | 274 |
`attachRetainedLegacyPty` itself is 24 lines. The 676-line span cited in the prompt is that function **plus** the rest of `runDeferredConnect`. Cut those as separate dests.
---
## 2. Giant-closure strategy: `ConnectPanePtySession` + `install*`
Do **not** try to keep nested closures by passing 30 arguments. Do **not** add max-lines disables.
### Mutable session bag
```ts
export type ConnectPanePtySession =
ConnectPanePtySessionCore &
ConnectPanePtySessionAgent &
ConnectPanePtySessionTransport &
ConnectPanePtySessionOutput &
ConnectPanePtySessionFnSlots
```
- One flat object. Shared `let`/`const` becomes `session.field`.
- Locals used by only one dest stay local in that dest.
- Function slots (`session.onExit`, `session.dataCallback`, …) replace `let foo = () => {}` / later reassignment.
- `transport` is a late-assigned slot. Installers that *define* functions before create-transport may close over `session.transport` and read it at **call** time only — same TDZ-safe pattern as today’s `const transport` declared later.
Split the type across dests so the type files themselves stay under 300 counted:
| Dest | Contents | Est. wc |
| --- | --- | --- |
| `connect-pane-pty-session-core.ts` | pane/manager/deps/cacheKey, disposed, connect timers, recovery ids | 160 |
| `connect-pane-pty-session-agent.ts` | shell-inference, title/hook, interrupt, hibernate, completion | 160 |
| `connect-pane-pty-session-transport.ts` | transport, SSH retry, resize/geometry, input marks | 160 |
| `connect-pane-pty-session-output.ts` | hidden-restore, replay queue, seq baselines, draft-paste | 180 |
| `connect-pane-pty-session-fn.ts` | function-slot types only | 180 |
| `connect-pane-pty-session.ts` | intersection alias | 20 |
| `create-connect-pane-pty-session.ts` | `createConnectPanePtySession` + four `init*Session*` slice fillers | 220 |
`createConnectPanePtySession` initializes every scalar/timer/no-op slot. It does **not** run definition-time side effects (no `addEventListener`, no transport create, no rAF).
### `install*` contract
```ts
export function installOnExit(session: ConnectPanePtySession): void {
session.onExit = (ptyId, opts = {}) => {
if (session.handledExitPtyId === ptyId) return
// original body; identifiers → session.identifier
}
}
```
Mechanical rewrite rules:
1. Cut/paste the original body. Keep every why-comment next to the same statement.
2. Shared state: `foo` → `session.foo`. Do not rename fields.
3. Late reassignment (`queueAgentIdleTerminalModeReset = …`, `wakeHibernatedAgentPane = …`) becomes `session.queueAgentIdleTerminalModeReset = …` at the same source location.
4. Do not invert `if`/`return` order. Do not merge branches. Do not “clean up” `let`.
5. If a dest exceeds 300 counted after imports, extract the next sequential named step. Do not add a disable.
### Two-phase install (preserve `runDeferredConnect` timing)
Today many functions and almost all hidden-restore/replay state are **created inside** `runDeferredConnect` only after `connectStarted = true`. Outer binding methods can run first (`syncProcessTracking` today still hits the no-op `syncHiddenRendererPtyDelivery`). Hoisting those installs to `connectPanePty` time would change pre-connect behavior.
| Phase | When | What |
| --- | --- | --- |
| A | `connectPanePty` body | identity + outer handlers + transport + resize + scheduler |
| B | first `runDeferredConnect` pass after `connectStarted = true` | serializer, draft paste, spawn/reattach, replay, hidden restore, `dataCallback`, SSH deferred flow |
```ts
session.runDeferredConnect = () => {
if (session.connectStarted) return
if (!session.startupGridSettledForConnect && shouldSettleStartupGridBeforeConnect(session)) {
// same early return as today — Phase B must NOT run
…
return
}
session.connectStarted = true
installDeferredConnectInners(session) // Phase B, once
resolveDeferredConnectTarget(session) // SSH / reattach / attach / spawn
}
```
`installDeferredConnectInners` is a thin sequencer (install calls only) so it stays under 400/300.
### Oversized inners — sequential named steps, not a 400-line dest
- `handleReattachResult` (455): outer bind/expiry stays in `install-handle-reattach-result.ts`; paint branches become `apply-reattach-daemon-snapshot.ts`, `apply-reattach-replay-or-model.ts`, `apply-reattach-cold-restore.ts`; fit continuation in `fit-after-reattach-restore.ts`.
- SSH/session/spawn tail (651): `install-ssh-deferred-connect-gate.ts`, `install-ssh-deferred-passphrase-wait.ts`, `install-ssh-deferred-reattach.ts`, `install-resolve-deferred-reattach-session.ts`, `install-deferred-reattach-connect.ts`, `install-deferred-attach-or-spawn.ts`.
- `requestHiddenOutputRestoreIfNeeded` (270 + imports): kickoff/scheduler vs async loop as two dests.
- `startFreshSpawn` (226 + imports): connect call vs `.then` settle as two dests (`install-start-fresh-spawn.ts`, `settle-fresh-spawn-result.ts`).
- `applyMainBufferSnapshot` (193 + imports): snapshot write vs `afterRestore` fit as two dests if imports push it over.
---
## 3. Dest path list (remaining) with estimates
Estimates include a typical import block. Each dest must finish ≤400 wc / ≤300 counted.
### Session bag
| Path | Est. wc | Est. counted |
| --- | --- | --- |
| `connect-pane-pty-session-core.ts` | 160 | 140 |
| `connect-pane-pty-session-agent.ts` | 160 | 140 |
| `connect-pane-pty-session-transport.ts` | 160 | 140 |
| `connect-pane-pty-session-output.ts` | 180 | 155 |
| `connect-pane-pty-session-fn.ts` | 180 | 165 |
| `connect-pane-pty-session.ts` | 20 | 15 |
| `create-connect-pane-pty-session.ts` | 220 | 200 |
### Phase A installers (outer `connectPanePty`)
| Path | Source lines | Est. wc |
| --- | --- | --- |
| `install-sleeping-record-access.ts` | 543–664 | 180 |
| `install-launch-config-title.ts` | 665–716 | 90 |
| `install-shell-command-inference.ts` | 717–844 | 190 |
| `install-agent-completion-side-effects.ts` | 845–957 | 170 |
| `install-observe-shell-command-input.ts` | 958–1043 | 140 |
| `install-pane-agent-identity.ts` | 1044–1135 | 150 |
| `install-reattach-idle-cursor.ts` | 1136–1218 | 140 |
| `install-interrupt-input-intent.ts` | 1219–1358 | 200 |
| `install-command-finished-policy.ts` | 1359–1521 | 220 |
| `install-visible-foreground-sample.ts` | 1522–1593 | 130 |
| `install-terminal-keydown.ts` | 1594–1655 | 120 |
| `install-pane-pty-fit-binding.ts` | 1657–1743 | 150 |
| `install-agent-completion-coordinator.ts` | 1744–1824 | 150 |
| `install-hibernated-agent-wake.ts` | 1825–1946 | 180 |
| `install-pty-exit.ts` | 1947–2097 | 210 |
| `install-title-change.ts` | 2098–2177 | 140 |
| `install-command-code-status.ts` | 2178–2300 | 180 |
| `install-bind-spawn-rebind.ts` | 2301–2416 | 180 |
| `install-bell-notifications.ts` | 2418–2513 | 160 |
| `install-agent-task-complete-notification.ts` | 2514–2632 | 180 |
| `install-agent-idle-working-exited.ts` | 2633–2710 | 130 |
| `install-pane-identity-env.ts` | 2711–2812 | 160 |
| `install-direct-ssh-retry.ts` | 2813–2910 | 160 |
| `install-windows-conpty-mode.ts` | 2911–2972 | 120 |
| `install-renderer-owned-agent-status.ts` | 2973–3090 | 180 |
| `install-pty-transport.ts` | 3091–3256 | 230 |
| `install-viewport-recovery.ts` | 3257–3400 | 200 |
| `install-forward-pty-input.ts` | 3401–3552 | 210 |
| `install-pty-resize.ts` | 3553–3671 | 180 |
| `install-foreground-grid-drift.ts` | 3672–3728 | 110 |
| `install-observed-pane-geometry.ts` | 3729–3864 | 200 |
| `install-pty-size-reconcile.ts` | 3865–3912 | 100 |
| `install-startup-grid-settle.ts` | 3913–3969 | 110 |
### Phase B installers (`runDeferredConnect` inners)
| Path | Source lines | Est. wc |
| --- | --- | --- |
| `install-deferred-connect.ts` | 3970–4008 + sequencer | 120 |
| `install-pane-serializer.ts` | 4010–4158 | 200 |
| `install-startup-draft-paste.ts` | 4159–4295 | 200 |
| `install-cold-restore-resume.ts` | 4296–4450 | 210 |
| `install-pending-startup-command.ts` | 4451–4538 | 150 |
| `install-start-fresh-spawn.ts` | 4539–4640 | 180 |
| `settle-fresh-spawn-result.ts` | 4641–4764 | 190 |
| `install-replay-write.ts` | 4765–4924 | 220 |
| `install-replay-data-drain.ts` | 4925–5085 | 220 |
| `install-transport-output-callbacks.ts` | 5086–5148 | 120 |
| `install-hidden-restore-state.ts` | 5149–5250 | 170 |
| `install-hidden-output-snapshot.ts` | 5251–5350 | 160 |
| `install-hidden-restore-flood.ts` | 5351–5480 | 190 |
| `install-hidden-delivery-sync.ts` | 5481–5550 | 140 |
| `install-pty-output-refresh.ts` | 5551–5710 | 210 |
| `install-write-pty-output.ts` | 5711–5804 | 170 |
| `install-hidden-query-salvage.ts` | 5805–5960 | 210 |
| `install-restore-chunk-reconcile.ts` | 5961–6120 | 210 |
| `install-snapshot-kitty-seq.ts` | 6121–6240 | 180 |
| `install-hidden-restore-timers.ts` | 6241–6400 | 200 |
| `install-hidden-restore-abandon.ts` | 6401–6560 | 210 |
| `install-apply-main-buffer-snapshot.ts` | 6561–6660 | 180 |
| `install-snapshot-after-restore.ts` | 6661–6753 | 160 |
| `install-request-hidden-restore.ts` | 6754–6880 | 190 |
| `install-run-hidden-restore-loop.ts` | 6881–7023 | 210 |
| `install-live-data-callback.ts` | 7024–7193 | 230 |
| `install-reattach-live-deferral.ts` | 7194–7288 | 160 |
| `install-ssh-reattach-prepaint.ts` | 7289–7388 | 170 |
| `install-handle-reattach-result.ts` | 7389–7550 | 220 |
| `apply-reattach-daemon-snapshot.ts` | snapshot branch | 160 |
| `apply-reattach-replay-or-model.ts` | replay/model branch | 190 |
| `apply-reattach-cold-restore.ts` | cold-restore branch | 150 |
| `fit-after-reattach-restore.ts` | fit continuation | 100 |
| `install-attach-retained-legacy-pty.ts` | 7844–7867 | 70 |
| `install-ssh-deferred-connect-gate.ts` | 7868–7920 | 110 |
| `install-ssh-deferred-passphrase-wait.ts` | 7921–8040 | 190 |
| `install-ssh-deferred-reattach.ts` | 8041–8180 | 220 |
| `install-resolve-deferred-reattach-session.ts` | 8181–8280 | 170 |
| `install-deferred-reattach-connect.ts` | 8281–8420 | 210 |
| `install-deferred-attach-or-spawn.ts` | 8421–8518 | 180 |
| `install-deferred-connect-inners.ts` | Phase B sequencer only | 80 |
### Binding return + orchestrator
| Path | Source lines | Est. wc |
| --- | --- | --- |
| `install-session-reconcile.ts` | 8520–8589 | 130 |
| `create-pane-pty-binding.ts` | 8590–8670 | 150 |
| `dispose-pane-pty-binding.ts` | 8671–8792 | 190 |
| `connect-pane-pty.ts` | create session + Phase A `install*` calls + return binding | **≤200** |
`connect-pane-pty.ts` after the split is only the orchestrator (no handler bodies). That is what gets the remaining giant file under 400.
**New dest count: 73 files. Plus the 16 already-extracted module-level dests = 89 files in `pty-connection/`. Barrel stays at the parent path.**
If any dest is over after paste, split that dest again. Do not ship a dest over 400/300.
---
## 4. Import update list
| File | Change |
| --- | --- |
| `pty-connection.ts` | Already the barrel. Keep: re-export `connectPanePty` + `STARTUP_CWD_FALLBACK_NOTICE` only. Do not add more public exports. |
| `TerminalPane.tsx` | No change (`import { connectPanePty } from './pty-connection'`). |
| `use-terminal-pane-lifecycle.ts` | No change. |
| `pty-connection.test.ts` | No change (`await import('./pty-connection')`). |
| `pty-connection-types.ts` | No change. Dest files import `../pty-connection-types`. |
| `connect-pane-pty.ts` | Drop the 420-line import wall. Import session create + Phase A installers + binding factory only. |
| New dests | Sibling dests via `./name`. Parent pane modules via `../name`. Shared via `../../../../../shared/...`. `@/` aliases unchanged. |
| **Never** | A dest importing `../pty-connection` (the barrel). That is a cycle. |
Barrel re-export is an explicit exception to the no-re-export rule so consumers keep `./pty-connection`. Dest files must import each other directly.
---
## 5. Characterization tests
Do **not** add new tests unless a newly exported pure symbol has zero coverage from `pty-connection.test.ts`.
Existing coverage is the behavior lock: `pty-connection.test.ts` dynamically imports the barrel and drives `connectPanePty` (579 tests). Module-level dests stay unexported from the barrel, so they keep getting hit through those tests.
If a dest must export a pure function for another dest, keep it folder-internal. Do not add it to the barrel.
---
## 6. Sequencing
Do this in order so the file is always compilable and tests can run after each phase.
1. Add session type slices + `createConnectPanePtySession`. Leave `connectPanePty` bodies in place; only move the top-of-function `let`/`const` defaults onto the bag **in a follow-up commit-sized step**, not all at once if that blocks review — but the implementation may do it in one pass as long as identifiers rewrite 1:1.
2. Extract Phase A installers from the **bottom of the outer spine upward** (dispose/reconcile, then resize, then input, then transport, then title/exit). After each extract, `connect-pane-pty.ts` shrinks and still calls the installer.
3. Extract Phase B: first the sequencer + `runDeferredConnect` shell, then oversized inners (`handleReattachResult`, SSH tail, hidden restore, `startFreshSpawn`, `dataCallback`).
4. When `connect-pane-pty.ts` is only create + `install*` calls + `return createPanePtyBinding(session)`, stop.
5. `wc -l` every dest. Split any dest over 400 wc or 300 counted.
6. Run verification. Prune the stale max-lines baseline entry for the old `pty-connection.ts` disable (`pnpm check:max-lines-ratchet --prune`). `connect-pane-pty.ts` must not gain a disable.
Do not stop at a “partial” split that leaves `connect-pane-pty.ts` over 400.
---
## 7. Verification
```bash
pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts
pnpm run typecheck:web
pnpm run check:max-lines-ratchet
wc -l src/renderer/src/components/terminal-pane/pty-connection.ts \
src/renderer/src/components/terminal-pane/pty-connection/*.ts
```
Every path printed by `wc -l` must be ≤ 400. After the source/orchestrator lose any `oxlint-disable max-lines`:
```bash
pnpm check:max-lines-ratchet --prune
```
No push, no PR. Local commit only if green:
`refactor: split pty-connection.ts under 400 lines`
-1
View File
@@ -128,7 +128,6 @@ inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
inline src/renderer/src/components/terminal-pane/pty-connection.ts
inline src/renderer/src/components/terminal-pane/pty-transport.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
import type { AgentStatusEntry } from '../../../../../shared/agent-status-types'
/** Start time of the newest completed turn, counting turns already folded into history.
* Why: batched publications coalesce done→working→done into a single store notification,
* so the done EDGE survives only in stateHistory; comparing `state` alone misses it. */
export function resolveLatestAgentDoneStartedAt(
entry: AgentStatusEntry | undefined
): number | undefined {
if (!entry) {
return undefined
}
if (entry.state === 'done') {
return entry.stateStartedAt
}
const history = entry.stateHistory ?? []
for (let index = history.length - 1; index >= 0; index -= 1) {
if (history[index].state === 'done') {
return history[index].startedAt
}
}
return undefined
}
@@ -0,0 +1,244 @@
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'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): void {
session.onAgentBecameWorking = (): void => {
session.suppressNativeWindowsIdleCodexFocusReports = false
session.clearSuppressedTitleSideEffects()
if (session.syncAgentTaskCompleteTrackingEnabled()) {
session.requiresFreshWorkingForAgentTaskCompleteNotification = false
session.agentCompletionCoordinator.observeTitleWorking()
}
// Why: a new API call refreshes the prompt-cache TTL, so clear any running
// countdown. The timer will restart when the agent becomes idle again.
session.deps.setCacheTimerStartedAt(session.cacheKey, null)
session.clearPendingAgentTaskCompleteNotification()
if (session.pendingTerminalBellNotification) {
session.scheduleTerminalBellNotification()
}
}
session.onAgentExited = (): void => {
// Why: eligibility can disappear transiently during reconnect, but a
// confirmed shell-title transition is authoritative for native-chat exit.
session.deps.onAgentExitedRef.current(session.pane.leafId)
session.clearSuppressedTitleSideEffects()
session.clearCommandInferredPaneAgent()
session.requestKnownWindowsShiftEnterReconfirmation()
// Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"),
// the agent has exited. Clear any running cache timer so the sidebar doesn't
// show a stale countdown for a tab that no longer has an active Claude session.
session.deps.setCacheTimerStartedAt(session.cacheKey, null)
session.clearTitleOnlyInterruptTimer()
// Why: title reversion alone is not process death. The process/PTY tracker
// owns removing agent rows when the TUI actually exits.
}
// Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their
// callbacks to the correct Orca pane without resolving worktrees from cwd.
// The key matches the `${tabId}:${leafId}` composite used for cacheTimerByKey
// and agentStatusByPaneKey. Treat it as opaque outside Orca.
session.state = useAppStore.getState()
session.parsedWorkspaceKey = parseWorkspaceKey(session.deps.worktreeId)
session.folderWorkspace =
session.parsedWorkspaceKey?.type === 'folder'
? session.state.folderWorkspaces.find(
(workspace) => workspace.id === session.parsedWorkspaceKey.folderWorkspaceId
)
: null
session.workspaceEnv = { ORCA_WORKSPACE_ID: session.deps.worktreeId }
if (session.folderWorkspace) {
session.workspaceEnv.ORCA_PROJECT_GROUP_ID = session.folderWorkspace.projectGroupId
session.workspaceEnv.ORCA_WORKSPACE_ROOT = session.folderWorkspace.folderPath
}
session.paneIdentityEnv = {
...session.workspaceEnv,
ORCA_PANE_KEY: session.cacheKey,
ORCA_TAB_ID: session.deps.tabId,
ORCA_WORKTREE_ID: session.deps.worktreeId,
...(session.launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: session.launchToken } : {})
}
session.paneEnv = {
...session.paneStartup?.env,
...session.paneIdentityEnv
}
// Why: folder workspaces can inherit their SSH target from child repos, so
// use the shared resolver instead of only looking up repo-backed worktrees.
session.worktree = getWorktreeMapFromState(session.state).get(session.deps.worktreeId)
session.worktreeConnectionId = getConnectionId(session.deps.worktreeId)
session.tab = (session.state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
(t) => t.id === session.deps.tabId
)
session.restoredPtyIdForTransport =
session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId
? (session.deps.restoredPtyIdByLeafId[session.deps.restoredLeafId] ?? null)
: null
// Why: the floating terminal and inline setup/onboarding terminals are host-agnostic synthetic
// ids with no worktree/repo row, so the strict owner resolver reports them as unresolved. The
// shared terminal router scopes them to their floating owner (local for the floating terminal,
// the active runtime for setup terminals so remote skill installs land there) and returns null
// only for a genuinely unknown/stale worktree that must fail closed (#9994).
session.terminalWorktreeRoute = resolveTerminalWorktreeRoute(
session.state,
session.deps.worktreeId
)
session.explicitRuntimeEnvironmentId = session.terminalWorktreeRoute?.runtimeEnvironmentId ?? null
// Why: paired-web worktrees retain HUB execution identity; their runtime-scoped mirrored pane is the session-level transport owner.
session.mirroredRuntimeOwners = new Set(
isWebTerminalSurfaceTabId(session.deps.tabId)
? [session.restoredPtyIdForTransport, session.tab?.ptyId]
.map((ptyId) => (ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null))
.filter((environmentId): environmentId is string => Boolean(environmentId))
: []
)
session.mirroredRuntimeEnvironmentId = session.mirroredRuntimeOwners.values().next().value ?? null
session.terminalOwnerUnresolved =
session.mirroredRuntimeOwners.size > 1 ||
(session.terminalWorktreeRoute === null && !session.mirroredRuntimeEnvironmentId)
session.runtimeEnvironmentId = session.explicitRuntimeEnvironmentId
? session.explicitRuntimeEnvironmentId
: session.mirroredRuntimeEnvironmentId
? session.mirroredRuntimeEnvironmentId
: null
// Why: host-agnostic synthetic ids (floating terminal, inline setup panels) have no repo
// row by design, and a worktree row stamped 'local' proves its host on its own — both are
// resolved-local, not pending hydration (#10151). Only when nothing names the host does
// `undefined` mean the repo hasn't merged yet; coalescing that to null would fail-open a
// remote cwd onto the local daemon (ENOENT on Docker SSH paths).
session.hostAgnosticTerminalWorktree =
session.deps.worktreeId === FLOATING_TERMINAL_WORKTREE_ID ||
isEphemeralSetupTerminalWorktreeId(session.deps.worktreeId)
session.worktreeProvesLocalHost = parseExecutionHostId(session.worktree?.hostId)?.kind === 'local'
session.connectionOwnerHydrating =
!session.terminalOwnerUnresolved &&
!session.hostAgnosticTerminalWorktree &&
!session.worktreeProvesLocalHost &&
session.runtimeEnvironmentId === null &&
session.worktreeConnectionId === undefined
// Why: an SSH host nested under a HUB is execution identity, not permission for the paired client to dial that host.
session.connectionId =
!session.terminalOwnerUnresolved &&
!session.connectionOwnerHydrating &&
session.runtimeEnvironmentId === null
? (session.worktreeConnectionId ?? null)
: null
type DirectSshRetryLease = Pick<
DirectSshPaneRetryAttempt,
'attemptId' | 'authority' | 'tabGeneration'
>
session.directSshRetryAttempt = (() => {
const pendingAttempt = session.state.directSshPaneRetryByTabId?.[session.deps.tabId]
const liveBinding = session.state.directSshLivePtyBindingByTabId?.[session.deps.tabId]
const attempt =
pendingAttempt?.authority.targetId === session.connectionId &&
pendingAttempt.tabGeneration === (session.tab?.generation ?? 0)
? pendingAttempt
: liveBinding?.authority.targetId === session.connectionId &&
liveBinding.tabGeneration === (session.tab?.generation ?? 0)
? liveBinding
: undefined
return attempt
})()
session.pendingSpawnKey = session.directSshRetryAttempt
? JSON.stringify([session.cacheKey, session.directSshRetryAttempt.attemptId])
: session.cacheKey
session.capturedDirectSshRetryPtyAccepted = false
session.directSshPaneRetrySettlementCancelled = false
session.directSshPaneRetrySettlementTimers = new Set<ReturnType<typeof setTimeout>>()
session.directSshPaneRetryTimedPromises = new WeakSet<object>()
session.capturedDirectSshRetryLeaseMatches = (): boolean => {
if (!session.directSshRetryAttempt) {
return true
}
const currentState = useAppStore.getState()
const currentConnection = currentState.sshConnectionStates.get(
session.directSshRetryAttempt.authority.targetId
)
const currentTab = (currentState.tabsByWorktree[session.deps.worktreeId] ?? []).find(
(candidate) => candidate.id === session.deps.tabId
)
if (
currentConnection?.providerEpoch !== session.directSshRetryAttempt.authority.providerEpoch ||
currentConnection?.connectionGeneration !==
session.directSshRetryAttempt.authority.connectionGeneration ||
(currentTab?.generation ?? 0) !== session.directSshRetryAttempt.tabGeneration
) {
return false
}
const pendingAttempt = currentState.directSshPaneRetryByTabId?.[session.deps.tabId]
const pendingMatches =
pendingAttempt?.attemptId === session.directSshRetryAttempt.attemptId &&
directSshAuthoritiesEqual(
pendingAttempt.authority,
session.directSshRetryAttempt.authority
) &&
pendingAttempt.tabGeneration === session.directSshRetryAttempt.tabGeneration
const liveBinding = currentState.directSshLivePtyBindingByTabId?.[session.deps.tabId]
const liveBindingMatchesAttempt =
liveBinding?.attemptId === session.directSshRetryAttempt.attemptId &&
directSshAuthoritiesEqual(liveBinding.authority, session.directSshRetryAttempt.authority) &&
liveBinding.tabGeneration === session.directSshRetryAttempt.tabGeneration
return pendingMatches || liveBindingMatchesAttempt
}
session.capturedDirectSshRetryStateMatches = (ptyId: string): boolean => {
if (!session.directSshRetryAttempt) {
return true
}
const currentConnection = useAppStore
.getState()
.sshConnectionStates.get(session.directSshRetryAttempt.authority.targetId)
return (
parseAppSshPtyId(ptyId)?.connectionId === session.directSshRetryAttempt.authority.targetId &&
currentConnection?.status === 'connected' &&
session.capturedDirectSshRetryLeaseMatches()
)
}
session.claimCapturedDirectSshRetryPty = (ptyId: string): boolean => {
if (!session.capturedDirectSshRetryStateMatches(ptyId)) {
return false
}
session.capturedDirectSshRetryPtyAccepted = session.directSshRetryAttempt !== undefined
return true
}
session.canAdoptCapturedDirectSshRetryPty = (ptyId: string): boolean => {
const canAdopt = session.capturedDirectSshRetryStateMatches(ptyId)
if (canAdopt && session.directSshRetryAttempt) {
session.capturedDirectSshRetryPtyAccepted = true
}
return canAdopt
}
session.settleDirectSshPaneRetryAttempt = (
attempt: DirectSshRetryLease | undefined,
status: 'failed' | 'timed-out'
): void => {
if (!attempt) {
return
}
useAppStore.getState().settleDirectSshPaneRetry?.({
status,
tabId: session.deps.tabId,
attemptId: attempt.attemptId,
authority: attempt.authority,
tabGeneration: attempt.tabGeneration
})
}
}
@@ -0,0 +1,199 @@
import { detectAgentStatusFromTitle, isClaudeAgent } from '@/lib/agent-status'
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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { installAgentIdleWorkingHandlers } from './agent-idle-working-handlers'
export function installAgentTaskCompleteNotify(session: ConnectPanePtySession): void {
session.scheduleAgentTaskCompleteNotification = (
title: string,
options: {
allowDoneDetailAfterGrace?: boolean
agentStatusSnapshot?: AgentCompletionStatusSnapshot
agentCompletionSource?: AgentCompletionDispatchMeta['source']
} = {}
): void => {
if (
!session.syncAgentTaskCompleteTrackingEnabled() ||
session.requiresFreshWorkingForAgentTaskCompleteNotification
) {
return
}
session.clearPendingAgentTaskCompleteNotification()
let graceElapsed = false
const generationAtSchedule = session.agentTaskCompleteNotificationGeneration
const agentStatusAtSchedule = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
const hasNewerActiveHookStatus = (): boolean => {
const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
const scheduledAgentType = agentStatusAtSchedule?.agentType
const currentAgentForScheduledTurn = resolveCompatibleAgentTypeForOwner(
currentStatus?.agentType,
scheduledAgentType
)
const hasDifferentKnownAgent = Boolean(
currentStatus?.agentType &&
scheduledAgentType &&
currentStatus.agentType !== 'unknown' &&
scheduledAgentType !== 'unknown' &&
currentAgentForScheduledTurn !== scheduledAgentType
)
return (
options.agentCompletionSource === 'process-exit' &&
isFreshNonDoneAgentStatus(currentStatus) &&
(!agentStatusAtSchedule ||
currentStatus.state !== agentStatusAtSchedule.state ||
currentStatus.stateStartedAt !== agentStatusAtSchedule.stateStartedAt ||
hasDifferentKnownAgent)
)
}
const dispatch = (): void => {
session.clearPendingAgentTaskCompleteNotification()
if (
generationAtSchedule !== session.agentTaskCompleteNotificationGeneration ||
!session.syncAgentTaskCompleteTrackingEnabled() ||
hasNewerActiveHookStatus()
) {
return
}
if (session.disposed) {
return
}
// Why: terminal attention is a visual pane affordance, not an OS
// notification. Route through dispatch so stale pane completions are
// rejected before unread attention is marked.
const shouldDispatchOsNotification = isAgentTaskCompleteNotificationEnabled()
session.pendingTerminalBellNotification = false
session.clearTerminalBellNotificationTimer()
session.deps.dispatchNotification({
source: 'agent-task-complete',
terminalTitle: title,
paneKey: session.cacheKey,
...(options.agentCompletionSource
? { agentCompletionSource: options.agentCompletionSource }
: {}),
...(shouldDispatchOsNotification ? {} : { suppressOsNotification: true }),
...(options.agentStatusSnapshot ? { agentStatusSnapshot: options.agentStatusSnapshot } : {})
})
}
const dispatchIfDetailed = (): void => {
if (hasNewerActiveHookStatus()) {
// Why: the confirmed exit belongs to the row captured above; a replaced
// active row means a newer turn started during the notification delay.
session.clearPendingAgentTaskCompleteNotification()
return
}
if (!graceElapsed) {
return
}
const entry = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
if (canDispatchAgentNotificationAfterGrace(entry, options)) {
dispatch()
}
}
session.agentTaskCompleteStatusUnsubscribe = useAppStore.subscribe(dispatchIfDetailed)
session.agentTaskCompleteNotificationGraceTimer = setTimeout(() => {
session.agentTaskCompleteNotificationGraceTimer = null
graceElapsed = true
dispatchIfDetailed()
}, AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS)
// Why: some agents never surface assistant text through hooks. Keep a hard
// cap so task-complete notifications still fire instead of waiting forever.
session.agentTaskCompleteNotificationMaxTimer = setTimeout(
dispatch,
AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS
)
}
session.agentTaskCompleteSettingsUnsubscribe = subscribeAgentTaskCompleteTrackingEnabled(() => {
if (session.syncAgentTaskCompleteTrackingEnabled()) {
session.agentCompletionCoordinator.startProcessTracking()
}
})
// ─── Agent task-complete: notification-backed attention ───────────────
//
// The working→idle title transition drives two independent concerns:
// 1. The Claude prompt-cache countdown in the sidebar.
// 2. The "Agent Task Complete" OS notification users toggle in Settings.
//
// This path raises the same terminal attention marker as BEL through the
// shared notification dispatcher. Not every agent CLI reliably emits BEL on
// completion (Gemini, some Codex flows), and the highlight needs to remain
// findable after the OS banner is gone. Double-firing with a concurrent BEL
// is handled by delaying the BEL OS notification below; main still keeps a
// 5 s per-worktree dedupe as the final guard.
session.onAgentBecameIdle = (
title: string,
meta?: { staleWorkingTitleClear?: boolean }
): void => {
// Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not
// observed bytes — a merely-paused agent (>3s silent mid-task, window
// minimized) would otherwise mint a false task-complete OS notification
// that renderer timer throttling previously damped. Clear session-tied
// state only; never schedule completion attention from it.
if (meta?.staleWorkingTitleClear) {
session.deps.setCacheTimerStartedAt(session.cacheKey, null)
return
}
const currentState = useAppStore.getState()
const activeHookStatus = currentState.agentStatusByPaneKey[session.cacheKey]
if (session.shouldSuppressTitleCompletionForFreshHook(title, activeHookStatus)) {
// Why: agent CLIs can briefly publish an idle title while hook status
// still says the same agent turn is active (e.g. during tool output).
if (activeHookStatus) {
session.preserveSuppressedTitleSideEffects(title, activeHookStatus)
}
return
}
// Why: only start the prompt-cache countdown for Claude agents — other
// agents have different (or no) prompt-caching semantics and showing a
// timer for them would be misleading.
//
// Why we check `settings !== null` separately: during startup, settings
// hydrate asynchronously after terminals reconnect. If we treat null
// as disabled, the first working→idle transition on a restored Claude
// tab silently drops the timer. Writing a timestamp is cheap and the
// CacheTimer component gates rendering on the enabled flag, so a
// spurious write when the feature turns out to be disabled is harmless.
const settings = currentState.settings
if (isClaudeAgent(title) && (settings === null || settings.promptCacheTimerEnabled)) {
session.deps.setCacheTimerStartedAt(session.cacheKey, Date.now())
}
if (detectAgentStatusFromTitle(title) === 'idle') {
session.setFocusReportSuppressionForAgentCompletion(title, activeHookStatus?.agentType)
}
if (session.syncAgentTaskCompleteTrackingEnabled()) {
session.agentCompletionCoordinator.observeClassifiedTitleCompletion(title)
}
// Why: some agent TUIs leave xterm renderer modes active after a turn.
// Reset cursor everywhere, and Kitty keyboard state on native Windows.
session.queueAgentIdleTerminalModeReset()
}
installAgentIdleWorkingHandlers(session)
}
@@ -0,0 +1,54 @@
import { useAppStore } from '@/store'
import {
isAgentTaskCompleteOsNotificationEnabledFromState,
isAgentTaskCompleteTrackingEnabledFromState
} from '../agent-task-complete-policy'
export function isAgentTaskCompleteNotificationEnabled(): boolean {
return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState())
}
export function isAgentTaskCompleteTrackingEnabled(): boolean {
return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState())
}
const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>()
let agentTaskCompleteTrackingSettingsUnsubscribe: (() => void) | null = null
let agentTaskCompleteTrackingSettingsSnapshot: string | null = null
function getAgentTaskCompleteTrackingSettingsSnapshot(
state: ReturnType<typeof useAppStore.getState>
): string {
return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}`
}
export function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void {
if (agentTaskCompleteTrackingSettingsUnsubscribe === null) {
agentTaskCompleteTrackingSettingsSnapshot = getAgentTaskCompleteTrackingSettingsSnapshot(
useAppStore.getState()
)
agentTaskCompleteTrackingSettingsUnsubscribe = useAppStore.subscribe((state) => {
const snapshot = getAgentTaskCompleteTrackingSettingsSnapshot(state)
if (snapshot === agentTaskCompleteTrackingSettingsSnapshot) {
return
}
agentTaskCompleteTrackingSettingsSnapshot = snapshot
for (const subscriber of Array.from(agentTaskCompleteTrackingEnabledListeners)) {
subscriber()
}
})
}
agentTaskCompleteTrackingEnabledListeners.add(listener)
return () => {
agentTaskCompleteTrackingEnabledListeners.delete(listener)
if (
agentTaskCompleteTrackingEnabledListeners.size === 0 &&
agentTaskCompleteTrackingSettingsUnsubscribe !== null
) {
agentTaskCompleteTrackingSettingsUnsubscribe()
agentTaskCompleteTrackingSettingsUnsubscribe = null
agentTaskCompleteTrackingSettingsSnapshot = null
}
}
}
@@ -0,0 +1,315 @@
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'
// 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 {
buildMainModelSnapshotReplayWrites,
hasPositiveTerminalDimensions,
readProposedTerminalCols,
resolvePositiveTerminalDimensions,
shouldSkipAltFrameForWidthMismatch
} from '../terminal-snapshot-replay-paint'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import type { ReattachPayloadContext } from './reattach-payload-context'
export function bindApplyReattachPayload(
session: ConnectPanePtySession,
ctx: ReattachPayloadContext
): void {
session.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)
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
}
}
session.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')
}
}
}
@@ -0,0 +1,48 @@
import { useAppStore } from '@/store'
import { blocksCodexPaneInput } from '../../codex-restart-notice-state'
let codexRestartNoticePresenceSource: Record<
string,
{ previousAccountLabel: string; nextAccountLabel: string }
> | null = null
let codexRestartNoticePresence = false
export function hasCodexRestartNotices(
noticesByPtyId: Record<string, { previousAccountLabel: string; nextAccountLabel: string }>
): boolean {
if (codexRestartNoticePresenceSource !== noticesByPtyId) {
codexRestartNoticePresenceSource = noticesByPtyId
codexRestartNoticePresence = Object.keys(noticesByPtyId).length > 0
}
return codexRestartNoticePresence
}
export function isCodexPaneStale(args: {
tabId: string
worktreeId: string
panePtyId: string | null
}): boolean {
const state = useAppStore.getState()
const { codexRestartNoticeByPtyId } = state
if (!hasCodexRestartNotices(codexRestartNoticeByPtyId)) {
return false
}
// Why: a bound pane's own record is the last word — its ptyId is exactly the
// shell its keystrokes reach. `tab.ptyId` holds one sibling's id, not this
// pane's, so in a split tab consulting it would kill this pane's keyboard over
// that sibling's notice, with no prompt on screen once the sibling is answered.
if (args.panePtyId) {
return blocksCodexPaneInput(codexRestartNoticeByPtyId[args.panePtyId])
}
// Why: only an unbound pane needs the tab's persisted id. Both transports
// refuse writes while their pty binding is null, so a keystroke here arms
// recovery — a coarser signal than a bound pane's own record, but the tab's id
// is the only evidence available of which shell this pane is about to own.
const tab = (state.tabsByWorktree[args.worktreeId] ?? []).find((entry) => entry.id === args.tabId)
if (tab?.ptyId && blocksCodexPaneInput(codexRestartNoticeByPtyId[tab.ptyId])) {
return true
}
return false
}
@@ -0,0 +1,109 @@
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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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 session.pane's live shell, so its quoting must
// follow the tab's effective Windows shell, not the win32 PowerShell default.
const resumeTarget = resolveAgentResumeLaunchTarget({
projectRuntime: session.projectRuntime,
connectionId: session.connectionId,
executionHostId: session.executionHostId,
worktreePath: session.worktree?.path,
terminalWindowsShell: state.settings?.terminalWindowsShell,
tabShellOverride: session.shellOverride
})
const startupPlan = buildAgentResumeStartupPlan({
agent,
providerSession,
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
agentArgs:
launchConfig !== undefined
? launchConfig.agentArgs
: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs),
agentEnv:
launchConfig !== undefined
? launchConfig.agentEnv
: resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv),
...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}),
...(launchConfig?.ompResumeFilePath
? { ompResumeFilePath: launchConfig.ompResumeFilePath }
: {}),
platform: resumeTarget.platform,
shell: resumeTarget.shell
})
if (!startupPlan) {
return null
}
const coldRestoreLaunchToken = createBrowserUuid()
// Why: cold restore means the PTY process is gone but the agent provider
// session is still resumable, so the replacement spawn must launch it.
return {
agent,
command: startupPlan.launchCommand,
env: {
...startupPlan.env,
ORCA_AGENT_LAUNCH_TOKEN: coldRestoreLaunchToken
},
launchConfig: startupPlan.launchConfig,
resumeProviderSession: providerSession,
launchToken: coldRestoreLaunchToken,
useLiveEntry: Boolean(useLiveEntry),
hasSleepingRecord: Boolean(sleepingRecord),
sleepingRecordEntry
}
}
}
@@ -0,0 +1,277 @@
import { detectAgentStatusFromTitle, isClaudeAgent } from '@/lib/agent-status'
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 {
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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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 nextAgent =
state.paneForegroundAgentByPaneKey[session.cacheKey]?.agent ||
isTuiAgent(registeredLaunchAgent)
? 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,179 @@
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'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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'
export function connectPanePty(
pane: ManagedPane,
manager: PaneManager,
deps: PtyConnectionDeps
): PanePtyBinding {
const session = { pane, manager, deps } as ConnectPanePtySession
session.shouldRefreshForegroundSynchronously = (): boolean =>
!session.manager.hasWebglRenderer(session.pane.id)
// Why: recovery ownership belongs to this xterm instance. A request that
// settles after remount must not remount its already-replaced successor.
session.terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(session.deps.tabId)
session.terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(session.deps.tabId)
session.mountFollowsTerminalPark = session.deps.mountFollowsTerminalPark
session.authoritativeReattachGeneration = 0
exposeE2eTerminalPtyOutputDebug()
session.disposed = false
session.structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(
session.pane.terminal
)
session.connectFrame = null
session.connectFallbackTimer = null
session.startupGridSettleHandle = null
session.startupGridSettledForConnect = false
session.connectStarted = false
session.unregisterBacklogRecovery = null
session.unregisterDocumentVisibilityRecovery = null
session.cancelHiddenOutputSnapshotScrollRestore = (): void => {}
session.pendingHiddenSnapshotFit = null
session.pendingReattachFit = null
session.cancelFreshSpawnFollowReset = (): void => {}
session.cleanupHiddenOutputRestoreDeferredRetry = (): void => {}
session.cleanupHiddenOutputRestoreForegroundDeadline = (): void => {}
session.cleanupHiddenOutputRestoreFloodRepaint = (): void => {}
session.resetRendererOrderedSeqForPtyExit = () => {}
session.cleanupStartupDraftPasteTimers = (): void => {}
session.unregisterE2ePtyDataInjection = (): void => {}
session.startupInjectTimer = null
session.agentTaskCompleteNotificationGraceTimer = null
session.agentTaskCompleteNotificationMaxTimer = null
session.agentTaskCompleteStatusUnsubscribe = null
session.agentTaskCompleteSettingsUnsubscribe = null
session.agentTaskCompleteNotificationGeneration = 0
session.wasAgentTaskCompleteTrackingEnabled = isAgentTaskCompleteTrackingEnabled()
session.requiresFreshWorkingForAgentTaskCompleteNotification =
!session.wasAgentTaskCompleteTrackingEnabled
session.wasAgentTaskCompleteOsNotificationEnabled = isAgentTaskCompleteNotificationEnabled()
session.terminalBellNotificationTimer = null
session.pendingTerminalBellNotification = false
session.reattachIdleAgentCursorResetTimer = null
session.alternateScreenBackgroundRepaintTimer = null
session.shiftEnterReconfirmTimer = null
session.synchronizedForegroundOutputActive = false
// Why: carries up to one marker-length-1 of trailing bytes so a ConPTY-split DEC 2026 marker is still detected (#8754).
session.synchronizedForegroundMarkerTail = ''
// Why: tracks the keystroke proximity captured when the current synchronized
// foreground frame opened, so a split end marker that lands after the redraw
// window still drains on the fast path instead of the 1s coalesce fallback.
session.synchronizedForegroundFrameInteractive = false
session.suppressStructuralReplayPtyResize = false
// Why: hidden-delivery gate sync is wired up alongside the deferred PTY
// output plumbing inside the connect frame; lifecycle hooks (visibility
// flips, exit, dispose) run before/after it exists, so start with no-ops.
session.syncHiddenRendererPtyDelivery = () => {}
session.releaseHiddenRendererPtyDelivery = () => {}
session.handleRemoteOutputPauseChanged = () => {}
session.handleRendererOwnedAgentStatus = () => {}
session.remoteOutputGatedPtyId = null
session.remoteOutputFactConsumerPtyId = null
session.suppressViewportClaimTerminalResize = false
// Why: idle callbacks are registered before the deferred PTY output plumbing
// exists. Start with the shared scheduler, then switch to the PTY writer
// below so hidden-tab resets keep backlog-recovery callbacks and byte order.
session.idleAgentTerminalModeReset = RESET_TERMINAL_CURSOR_STYLE
session.suppressNativeWindowsIdleCodexFocusReports = false
session.setFocusReportSuppressionForAgentCompletion = (
title: string | undefined,
agentType: AgentType | undefined
): void => {
const titleAgentType = resolveCommittedTitleAgentType(title ?? '')
session.suppressNativeWindowsIdleCodexFocusReports =
agentType && agentType !== 'unknown' ? agentType === 'codex' : titleAgentType === 'codex'
}
session.queueAgentIdleTerminalModeReset = (): void => {
if (session.disposed) {
return
}
writeTerminalOutput(session.pane.terminal, session.idleAgentTerminalModeReset, {
foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
})
}
// Why: passphrase-gate waits register a teardown here so dispose() can
// actively unsubscribe + resolve them. Without this, a pane disposed
// mid-wait leaks its zustand subscriber and the surrounding async IIFE
// forever, since the subscriber's `disposed` check only fires when the
// store next emits — which may never happen after disconnect.
session.waitTeardowns = []
// Why: startup commands must only run once — in the pane they were
// targeted at. Capture `deps.startup` into a local and clear the field on
// the (already spread-copied) `deps` so nothing else inside this function
// can accidentally re-read it. The caller is responsible for clearing its
// own outer reference, since `deps` here is a shallow copy and our
// mutation does not propagate back.
session.paneStartup = session.deps.startup ?? null
session.deps.startup = undefined
// Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay.
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
session.cacheKey = makePaneKey(session.deps.tabId, session.pane.leafId)
// Why: mirrors the kitty keyboard flags the pane's application negotiates.
// Fed only from application output (live PTY bytes + daemon replay
// payloads), never from renderer-generated resets, so it reflects what the
// application expects even after defensive renderer-side kitty wipes.
session.kittyKeyboardModes = (() => {
const existing = session.deps.paneKittyKeyboardModesRef.current.get(session.pane.id)
if (existing) {
return existing
}
const created = new TerminalKittyKeyboardModeTracker()
session.deps.paneKittyKeyboardModesRef.current.set(session.pane.id, created)
return created
})()
installSleepingRecordAccess(session)
installShellCommandInference(session)
installInterruptInputIntent(session)
installTerminalKeydownFit(session)
installPtyExitHibernate(session)
installTitleSpawnBell(session)
installAgentTaskCompleteNotify(session)
installDirectSshRetryStatus(session)
installPtyInputRecovery(session)
installPtyInputForward(session)
installPtyResizeGeometry(session)
installRunDeferredConnect(session)
return installSessionReconcileDispose(session)
}
@@ -0,0 +1,89 @@
import type { IBuffer } from '@xterm/xterm'
import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor'
import { CSI_SEQUENCE_PATTERN } from '../../../../../shared/ansi-escape-sequences'
export type TerminalWithFocusMode = {
textarea?: HTMLTextAreaElement | null
modes?: {
sendFocusMode?: boolean
}
}
export type TerminalWithInspectableBuffer = {
cols: number
rows: number
buffer?: {
active?: IBuffer
}
}
// Why: replay bytes can carry a dead run's screen in scrollback — or still
// painted in the viewport with a shell prompt below it — so once xterm has
// parsed the replay the confirmation needs both the cursor-agent screen shape
// AND the parked cursor. A dead screen leaves the shell cursor after its
// prompt; a live agent that needs the focus-in is by definition parked, and a
// live agent that is not parked only loses focus reporting the way the
// pre-fix reattach always did. Returns null when the buffer is not
// inspectable (e.g. test doubles).
export function parsedViewportShowsParkedCursorAgentScreen(
terminal: TerminalWithInspectableBuffer
): boolean | null {
const buffer = terminal.buffer?.active
if (
!buffer ||
typeof buffer.getLine !== 'function' ||
typeof buffer.cursorX !== 'number' ||
typeof buffer.cursorY !== 'number'
) {
return null
}
return (
resolveCursorAgentImeAnchor({
buffer,
rows: terminal.rows,
cols: terminal.cols,
cursorX: buffer.cursorX,
cursorY: buffer.cursorY
}) !== null
)
}
export function terminalHasFocusReportingEnabled(terminal: TerminalWithFocusMode): boolean {
return terminal.modes?.sendFocusMode === true
}
export function terminalOwnsDomFocus(terminal: TerminalWithFocusMode): boolean {
if (typeof document === 'undefined' || !terminal.textarea) {
return false
}
return document.activeElement === terminal.textarea
}
export const CURSOR_AGENT_REATTACH_HEADER = 'Cursor Agent'
const CURSOR_AGENT_REATTACH_INPUT_MARKER = '→'
const CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS = 5000
// Why bounded: reattach payloads reach multiple MB, but every replay puts the current screen last
// and only the header nearest the end matters. 256KB clears even a fully SGR-styled frame by ~2x,
// so the cut only ever drops stale scrollback — which would have been rejected anyway.
const CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS = 256 * 1024
export function hasCursorAgentReattachPayloadScreenSignal(data: string): boolean {
const tail =
data.length > CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS
? data.slice(-CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS)
: data
// Why CSI only: OSC-carried titles must keep counting as a header occurrence, as they did when
// this stripped CSI by hand.
const normalized = tail.replace(CSI_SEQUENCE_PATTERN, '')
// Why: anchor on the LAST header occurrence — replay buffers keep scrollback,
// and an earlier finished run must not classify the current screen.
const headerIndex = normalized.lastIndexOf(CURSOR_AGENT_REATTACH_HEADER)
if (headerIndex === -1) {
return false
}
const screenTail = normalized.slice(
headerIndex + CURSOR_AGENT_REATTACH_HEADER.length,
headerIndex + CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS
)
return screenTail.includes(`${CURSOR_AGENT_REATTACH_INPUT_MARKER} `)
}
@@ -0,0 +1,217 @@
import { useAppStore } from '@/store'
import type { PtyReplayDataMeta } from '../pty-transport'
import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../../../../shared/terminal-color-scheme-protocol'
// 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 { 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 type { FreshSpawnOptions, ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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 }
): void {
if (typeof snapshot.seq !== 'number') {
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.foregroundImmediateBudgetChars = 0
session.foregroundImmediateBudgetWindowStart = 0
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,348 @@
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { useAppStore } from '@/store'
import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host'
// 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 { resolveSshPaneConnectGate } from '../ssh-pane-connect-gate'
import {
type UserInitiatedSshConnectOutcome,
isSshSessionExpiredError,
sshPromptConnectOutcomeForStatus,
waitForSshConnection
} from './ssh-session-connect'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { runDeferredSessionReattachChoice } from './deferred-session-reattach-choice'
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 gate = resolveSshPaneConnectGate({
connectionId: session.connectionId,
sshStatus: storeState.sshConnectionStates.get(session.connectionId)?.status,
isDeferredTarget: storeState.deferredSshReconnectTargets.includes(session.connectionId),
restoredLeafSessionId,
deferredTabSessionId: storeState.deferredSshSessionIdsByTabId[session.deps.tabId],
tabPtyId: storeState.tabsByWorktree[session.deps.worktreeId]?.find(
(t) => t.id === session.deps.tabId
)?.ptyId,
hasLeafSessionMap: Boolean(
session.deps.restoredPtyIdByLeafId &&
Object.keys(session.deps.restoredPtyIdByLeafId).length > 0
)
})
const pendingSessionId = gate.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 new Promise<UserInitiatedSshConnectOutcome>((resolve) => {
// Why: 'disconnected' counts as terminal only after a non-disconnected status was seen (a real connect attempt that returned to 'disconnected').
// Treating the entry-time 'disconnected' as terminal would skip the gate, defeating the passphrase-prompt deferral.
let sawNonDisconnected =
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status !==
'disconnected' &&
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status !==
undefined
let resolvedOutcome: UserInitiatedSshConnectOutcome = 'cancelled'
let settled = false
const finish = (nextOutcome: UserInitiatedSshConnectOutcome): void => {
if (settled) {
return
}
resolvedOutcome = nextOutcome
settled = true
unsub()
const idx = session.waitTeardowns.indexOf(teardown)
if (idx !== -1) {
session.waitTeardowns.splice(idx, 1)
}
resolve(resolvedOutcome)
}
const teardown = (): void => finish('cancelled')
// Why: register a teardown so dispose() can unsubscribe+resolve if the session.pane is torn down mid-wait.
// Else the zustand subscriber + async IIFE leak: the callback only checks `session.disposed` when it next fires, which may never happen.
session.waitTeardowns.push(teardown)
const unsub = useAppStore.subscribe((state) => {
if (session.disposed) {
finish('cancelled')
return
}
const status = state.sshConnectionStates.get(session.connectionId)?.status
if (status && status !== 'disconnected') {
sawNonDisconnected = true
}
const nextOutcome = sshPromptConnectOutcomeForStatus(status, sawNonDisconnected)
if (nextOutcome) {
finish(nextOutcome)
}
})
// Why: re-read state after subscribing to catch a status change that landed between the alreadyConnected check and the subscribe — else we'd wait forever.
if (session.disposed) {
finish('cancelled')
return
}
const currentStatus = useAppStore
.getState()
.sshConnectionStates.get(session.connectionId)?.status
const currentOutcome = sshPromptConnectOutcomeForStatus(
currentStatus,
sawNonDisconnected
)
if (currentOutcome) {
finish(currentOutcome)
}
})
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: the saved remote PTY id is single-use restore metadata; clear it before attach so remounts don't keep retrying an expired session.
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)
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)
})
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)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty
.clearPendingPaneSerializer(session.cacheKey, gen)
.catch(() => {})
}
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)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty
.clearPendingPaneSerializer(session.cacheKey, gen)
.catch(() => {})
}
if (session.disposed) {
return
}
if (session.rejectObsoleteDirectSshReattach(pendingSessionId)) {
return
}
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 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)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty
.clearPendingPaneSerializer(session.cacheKey, gen)
.catch(() => {})
}
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)) {
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, pendingSessionId)
session.deps.clearTabPtyId(session.deps.tabId, pendingSessionId)
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
})
session.armDirectSshPaneRetryTimeout(
trackedReattachPromise,
session.directSshRetryAttempt
)
} else {
session.startFreshColdRestoreAgentResume()
}
})()
return
}
}
runDeferredSessionReattachChoice(session)
}
@@ -0,0 +1,225 @@
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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function runDeferredSessionReattachChoice(session: ConnectPanePtySession): void {
// Why: re-read session IDs inside the rAF — cleanup during the 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 session.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 session.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 session.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)
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 session.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)
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 session.pane's DOM/container.
session.bindActivePanePty(attachedPtyId, {
updateTabPtyId: 'if-missing',
sampleVisibleForegroundAgent: true
})
})
.catch((err) => {
session.reportError(err instanceof Error ? err.message : String(err))
})
} else {
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> FRESH SPAWN`)
if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) {
session.startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined)
} else {
session.startFreshSpawn()
}
}
}
scheduleRuntimeGraphSync()
}
@@ -0,0 +1,153 @@
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 type { ConnectPanePtySession } from './connect-pane-pty-session'
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
const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup()
const outputCallbacks = session.captureTransportOutputCallbacks((message) => {
if (isSshSessionExpiredError(message)) {
expiredReattachError = true
return
}
if (!session.isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) {
return
}
session.reportError(message)
})
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 && 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
}
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
})
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId)
session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId)
if (session.connectionId && isSshSessionExpiredError(err)) {
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
session.reportError(message)
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
})
session.armDirectSshPaneRetryTimeout(trackedReattachPromise, session.directSshRetryAttempt)
}
@@ -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,291 @@
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'
// 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 {
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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import type { DirectSshRetryLease } from './direct-ssh-retry-lease'
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 session.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 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,
statusPayload,
statusTitle,
undefined,
routing,
{
launchToken: session.launchToken
}
)
} else {
currentState.setAgentStatus(session.cacheKey, statusPayload, 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.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()
// Why: input must probe a wedged xterm even when the PTY produces no renderer output.
requestTerminalWritePipelineProbe(session.pane.terminal)
}
session.recordTerminalInputForHibernation = (): void => {
useAppStore.getState().recordTerminalInput(session.cacheKey)
}
// Why: onData mixes real user input with xterm's parser auto-replies (focus
// reports, DA/DSR/CPR responses). Recording those replies as activity makes
// the hibernation planner treat a pane hidden after its agent finished as
// "input after done" forever. The core user-input signal fires only for real
// input, so hibernation activity records from it; onData recording remains
// solely as the fallback when the internal API is unavailable.
session.recordRealUserTerminalInput = (): void => {
session.recordTerminalInputForHibernation()
// Takeover must never fire from the onData fallback below: it mixes in auto-replies.
reportWorkerTerminalUserInput(session.cacheKey, session.runtimeEnvironmentId)
}
session.userInputActivityDisposable = subscribeToTerminalUserInput(
session.pane.terminal,
session.recordRealUserTerminalInput
)
session.recordTerminalInputForHibernationFallback = (): void => {
if (session.userInputActivityDisposable === null) {
session.recordTerminalInputForHibernation()
}
}
}
@@ -0,0 +1,148 @@
import { e2eConfig } from '@/lib/e2e-config'
import type { PtyBufferSnapshot } from '../pty-transport'
import type { PtyDataMeta } from '../pty-dispatcher'
type E2eTerminalPtyDataInjectionApi = {
inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean
keys: () => string[]
}
type E2eTerminalPtyDataInjectionWindow = Window & {
__terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi
__terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi
}
const e2eTerminalPtyDataInjectors = new Map<string, (data: string, meta?: PtyDataMeta) => void>()
type E2eTerminalHiddenSnapshotOverrideApi = {
setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void
resolve: (ptyId: string) => void
clear: (ptyId: string) => void
}
type E2eTerminalHiddenSnapshotOverride = {
promise: Promise<PtyBufferSnapshot | null>
resolve: () => void
}
const e2eTerminalHiddenSnapshotOverrides = new Map<string, E2eTerminalHiddenSnapshotOverride>()
// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes
// either never reach the renderer (delivery gate) or ride the background
// scheduler queue.
type E2eTerminalPtyOutputDebugSnapshot = {
hiddenRendererSkipCount: number
hiddenRendererSkippedChars: number
}
type E2eTerminalPtyOutputDebugApi = {
reset: () => void
snapshot: () => E2eTerminalPtyOutputDebugSnapshot
}
type E2eTerminalPtyOutputDebugWindow = Window & {
__terminalPtyOutputDebug?: E2eTerminalPtyOutputDebugApi
}
const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = {
hiddenRendererSkipCount: 0,
hiddenRendererSkippedChars: 0
}
export function resetE2eTerminalPtyOutputDebug(): void {
e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount = 0
e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars = 0
}
export function exposeE2eTerminalPtyOutputDebug(): void {
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
return
}
const target = window as E2eTerminalPtyOutputDebugWindow
target.__terminalPtyOutputDebug ??= {
reset: resetE2eTerminalPtyOutputDebug,
snapshot: () => ({ ...e2eTerminalPtyOutputDebugState })
}
}
export function recordHiddenRendererSkip(chars: number): void {
if (!e2eConfig.exposeStore) {
return
}
exposeE2eTerminalPtyOutputDebug()
e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount += 1
e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars += chars
}
export function exposeE2eTerminalPtyDataInjection(): void {
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
return
}
// Why: a real PTY can coalesce tiny TUI redraws before E2E sees them. This
// e2e-only seam lets tests replay the renderer-side data callback exactly.
const target = window as E2eTerminalPtyDataInjectionWindow
target.__terminalPtyDataInjection ??= {
inject: (paneKey, data, meta) => {
const inject = e2eTerminalPtyDataInjectors.get(paneKey)
if (!inject) {
return false
}
inject(data, meta)
return true
},
keys: () => [...e2eTerminalPtyDataInjectors.keys()]
}
target.__terminalHiddenSnapshotOverride ??= {
setPending: (ptyId, snapshot) => {
let resolve = (): void => {}
const wait = new Promise<void>((nextResolve) => {
resolve = nextResolve
})
e2eTerminalHiddenSnapshotOverrides.set(ptyId, {
promise: wait.then(() => snapshot),
resolve
})
},
resolve: (ptyId) => {
e2eTerminalHiddenSnapshotOverrides.get(ptyId)?.resolve()
},
clear: (ptyId) => {
e2eTerminalHiddenSnapshotOverrides.delete(ptyId)
}
}
}
export function registerE2eTerminalPtyDataInjection(
paneKey: string,
inject: (data: string, meta?: PtyDataMeta) => void
): () => void {
if (!e2eConfig.exposeStore) {
return () => {}
}
exposeE2eTerminalPtyDataInjection()
e2eTerminalPtyDataInjectors.set(paneKey, inject)
return () => {
if (e2eTerminalPtyDataInjectors.get(paneKey) === inject) {
e2eTerminalPtyDataInjectors.delete(paneKey)
}
}
}
export function readE2eHiddenSnapshotOverride(
ptyId: string
): Promise<PtyBufferSnapshot | null> | null {
if (!e2eConfig.exposeStore) {
return null
}
const override = e2eTerminalHiddenSnapshotOverrides.get(ptyId)
if (!override) {
return null
}
// Why: visual E2E needs to hold a hidden restore snapshot in flight so a
// newer live TUI frame can race it deterministically.
return override.promise.finally(() => {
if (e2eTerminalHiddenSnapshotOverrides.get(ptyId) === override) {
e2eTerminalHiddenSnapshotOverrides.delete(ptyId)
}
})
}
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,314 @@
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'
// 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 { 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,
FOREGROUND_BUDGET_WINDOW_MS
} from './foreground-output-budgets'
import {
shouldWritePtyOutputForeground,
consumeInactiveForegroundImmediateBudget
} from './foreground-output-scan'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function bindForegroundOutputRefresh(session: ConnectPanePtySession): void {
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 session.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 session.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 {
const now = performance.now()
if (now - session.foregroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) {
session.foregroundImmediateBudgetChars = 0
session.foregroundImmediateBudgetWindowStart = now
}
if (session.foregroundImmediateBudgetChars + dataLength > FOREGROUND_IMMEDIATE_BUDGET_CHARS) {
return false
}
session.foregroundImmediateBudgetChars += dataLength
return true
}
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 session.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.lastTerminalInputAt <= 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.lastTerminalInputAt <= 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
}
}
// 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.
}
@@ -0,0 +1,132 @@
import { isDocumentVisibilityProvenStale } from '../stale-document-visibility'
import {
FOREGROUND_BUDGET_WINDOW_MS,
INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS
} 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
let inactiveForegroundImmediateBudgetChars = 0
let inactiveForegroundImmediateBudgetWindowStart = 0
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 {
const now = performance.now()
if (now - inactiveForegroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) {
inactiveForegroundImmediateBudgetChars = 0
inactiveForegroundImmediateBudgetWindowStart = now
}
if (
inactiveForegroundImmediateBudgetChars + dataLength >
INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS
) {
return false
}
inactiveForegroundImmediateBudgetChars += dataLength
return true
}
@@ -0,0 +1,179 @@
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'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { bindStartFreshSpawn } from './fresh-spawn-start'
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)
]
}
}
bindStartFreshSpawn(session)
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 ''
}
}
return tail.slice(-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
// session.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 session.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 session.rows must leave the viewport before the first prompt redraw.
session.writeFreshShellViewportBlanking()
}
}
@@ -0,0 +1,258 @@
import { useAppStore } from '@/store'
import { hasPtySerializer } from '../pty-buffer-serializer'
// 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 { 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 type {
PendingStartupCommand,
FreshSpawnOptions,
ColdRestoreAgentResumeStartup
} from './fresh-spawn-types'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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 session.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 outputCallbacks = session.captureTransportOutputCallbacks(session.reportError)
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)
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 session.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 session.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 session.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
if (resolvedPtyId && (typeof gen === 'number' || isRemoteRuntimePtyId(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 session.pane
// as well as tab so a remount cannot attach to a sibling setup session.pane's PTY.
pendingSpawnByPaneKey.set(session.pendingSpawnKey, trackedPromise)
return trackedPromise
}
}
@@ -0,0 +1,25 @@
import type {
AgentProviderSessionMetadata,
ResumableTuiAgent,
SleepingAgentSessionRecord
} from '../../../../../shared/agent-session-resume'
import type { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
export type PendingStartupCommand = {
command: string
env?: Record<string, string>
}
export type FreshSpawnOptions = {
forceBlankRestoredViewport?: boolean
}
export type ColdRestoreAgentResumeStartup = PendingStartupCommand & {
agent: ResumableTuiAgent
resumeProviderSession: AgentProviderSessionMetadata
launchConfig: NonNullable<ReturnType<typeof buildAgentResumeStartupPlan>>['launchConfig']
launchToken: string
useLiveEntry: boolean
hasSleepingRecord: boolean
sleepingRecordEntry: { paneKey: string; record: SleepingAgentSessionRecord } | null
}
@@ -0,0 +1,99 @@
import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles'
// 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 { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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)
for (const chunk of pendingChunks) {
if (typeof chunk.seq === 'number' && session.restoredSnapshotExpectedStartSeq !== null) {
session.restoredSnapshotExpectedStartSeq = Math.max(
session.restoredSnapshotExpectedStartSeq,
chunk.seq
)
}
}
}
if (pendingData) {
session.writePtyOutputToXterm(pendingData, true)
}
}
}
@@ -0,0 +1,149 @@
// 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 type { PtyDataMeta } from '../pty-dispatcher'
import { HIDDEN_OUTPUT_RESTORE_PENDING_CHARS } from './hidden-output-restore-limits'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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 }
}
}
/**
* Apply an authoritative snapshot to the session.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.
*/
}
@@ -0,0 +1,164 @@
import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs'
import { redactPtyIdForDiagnostics } from '../../../../../shared/pty-delivery-diagnostics'
import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function bindHiddenOutputRestoreDrain(session: ConnectPanePtySession): void {
session.drainPendingLiveChunksAfterSnapshot = function (
snapshotSeq: number | undefined
): 'drained' | 'overflow' | 'refetch' {
if (session.hiddenOutputRestorePendingOverflow) {
session.hiddenOutputRestorePendingOverflow = false
discardPendingLiveChunksSalvagingQueries()
return 'overflow'
}
while (session.hiddenOutputRestorePendingChunks.length > 0) {
const chunks = session.hiddenOutputRestorePendingChunks
session.hiddenOutputRestorePendingChunks = []
session.hiddenOutputRestorePendingChars = 0
for (const [index, chunk] of chunks.entries()) {
const data = session.getChunkDataAfterSnapshot(chunk, snapshotSeq)
if (data === null) {
// Why: renderer-only OSC stripping makes raw seq offsets unmappable onto cleaned text; refetch instead of risking duplicate output.
for (const discarded of chunks.slice(index)) {
session.salvageRendererQueriesFromDiscardedRestoreData(discarded.data)
}
discardPendingLiveChunksSalvagingQueries()
return 'refetch'
}
// Why: advance the continuity point so reconciliation neither re-drops drained chunks as duplicates nor misreads the next live chunk as a gap.
if (typeof chunk.seq === 'number' && session.restoredSnapshotExpectedStartSeq !== null) {
session.restoredSnapshotExpectedStartSeq = Math.max(
session.restoredSnapshotExpectedStartSeq,
chunk.seq
)
}
if (data) {
session.writePtyOutputToXterm(data, true)
session.recordRendererOrderedSeq(chunk)
}
}
if (session.hiddenOutputRestorePendingOverflow) {
session.hiddenOutputRestorePendingOverflow = false
discardPendingLiveChunksSalvagingQueries()
return 'overflow'
}
}
return 'drained'
}
function discardPendingLiveChunksSalvagingQueries(): void {
const discarded = session.hiddenOutputRestorePendingChunks
session.hiddenOutputRestorePendingChunks = []
session.hiddenOutputRestorePendingChars = 0
for (const chunk of discarded) {
session.salvageRendererQueriesFromDiscardedRestoreData(chunk.data)
}
}
session.clearPendingLiveChunksDuringRestore = function (): void {
session.hiddenOutputRestorePendingChunks = []
session.hiddenOutputRestorePendingChars = 0
session.hiddenOutputRestorePendingOverflow = false
session.hiddenOutputRestoreFreshSnapshotNeeded = false
session.hiddenOutputRestoreRetryDeferred = false
session.hiddenOutputRestoreScheduled = false
cancelScheduledHiddenOutputRestore(session.pane.terminal)
session.clearHiddenOutputRestoreDeferredRetryTimer()
session.clearHiddenOutputRestoreForegroundDeadlineTimer()
session.hiddenOutputRestoreDeferredRetryAttempts = 0
}
session.clearHiddenOutputRestoreDeferredRetryTimer = function (): void {
if (session.hiddenOutputRestoreDeferredRetryTimer === null) {
return
}
clearTimeout(session.hiddenOutputRestoreDeferredRetryTimer)
session.hiddenOutputRestoreDeferredRetryTimer = null
}
session.cleanupHiddenOutputRestoreDeferredRetry =
session.clearHiddenOutputRestoreDeferredRetryTimer
session.clearHiddenOutputRestoreForegroundDeadlineTimer = function (): void {
if (session.hiddenOutputRestoreForegroundDeadlineTimer === null) {
return
}
clearTimeout(session.hiddenOutputRestoreForegroundDeadlineTimer)
session.hiddenOutputRestoreForegroundDeadlineTimer = null
}
session.cleanupHiddenOutputRestoreForegroundDeadline =
session.clearHiddenOutputRestoreForegroundDeadlineTimer
session.armHiddenOutputRestoreForegroundDeadline = function (): void {
if (
session.disposed ||
session.hiddenOutputRestoreForegroundDeadlineTimer !== null ||
!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) ||
(isRemoteRuntimePtyId(session.hiddenOutputRestorePtyId) &&
session.hiddenOutputRestoreLegacyPtyId !== session.hiddenOutputRestorePtyId &&
typeof session.transport.serializeBufferOutcome === 'function') ||
(session.hiddenOutputRestorePendingChunks.length === 0 &&
!session.hiddenOutputRestorePendingOverflow)
) {
return
}
const ptyId = session.hiddenOutputRestorePtyId
if (ptyId === null || session.transport.getPtyId() !== ptyId) {
return
}
const deadlineGeneration = session.hiddenOutputRestoreGeneration
// Why: only foreground output blocked behind recovery gets a deadline; hidden-time restore work has no user impact.
session.hiddenOutputRestoreForegroundDeadlineTimer = setTimeout(() => {
session.hiddenOutputRestoreForegroundDeadlineTimer = null
if (
session.disposed ||
session.hiddenOutputRestoreGeneration !== deadlineGeneration ||
session.hiddenOutputRestorePtyId !== ptyId ||
!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
) {
return
}
session.abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
}, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS)
}
// Trades the loss banner for one bounded post-suppression repaint from the
// host's authoritative buffer. Ordered before the state reset so the repaint
// timer arms against the live ptyId (mirrors the flood-abandon call sites).
session.rearmRemoteHiddenOutputRestoreInsteadOfWarning = function (
ptyId: string,
reason: string
): boolean {
if (
!isRemoteRuntimePtyId(ptyId) ||
session.hiddenOutputRestoreRemoteAbandonCycles >= HIDDEN_OUTPUT_RESTORE_REMOTE_REARM_MAX
) {
return false
}
session.hiddenOutputRestoreRemoteAbandonCycles += 1
recordTerminalFreezeBreadcrumb('restore-abandon-rearm', {
id: redactPtyIdForDiagnostics(ptyId),
reason,
cycle: session.hiddenOutputRestoreRemoteAbandonCycles
})
session.noteHiddenOutputRestoreFloodBackpressure()
session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
return true
}
}
@@ -0,0 +1,37 @@
export const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024
export const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50
export const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3
export const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750
// Why (rc.7.perf DSR-timeout feedback loop): under a foreground flood the
// restore pipeline is its own bottleneck — each synchronous snapshot replay
// starves ACK processing, main pins at the in-flight cap, drops at the
// pending cap, and every drop marker re-armed another restore until the
// flood ended. Backpressure evidence opens this suppression window: drop
// markers inside it must not re-arm restores; live bytes write through and
// ONE deferred repaint (when the window closes) heals the visual gap.
export const HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS = 2000
// Backstop for the same loop: a single in-flight restore task may re-iterate
// (fresh-snapshot marks, unmappable slices) only this many times before it
// abandons and lets live bytes flow.
export const HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS = 3
// Why: remote-runtime PTYs have no local main fallback — the host transport is
// the only recovery and legitimately answers null while it resyncs, trims a
// flooded snapshot, or waits out link RTT. Those nulls are not proof of loss,
// so an abandoned restore re-arms one quiet post-suppression repaint instead of
// claiming the bytes are gone. Five cycles (~2.15s each) outlast both the
// multiplexer resync window and the remote snapshot request timeout (10s);
// past that the host really is unreachable and the loss banner is honest.
export const HIDDEN_OUTPUT_RESTORE_REMOTE_REARM_MAX = 5
// Why: the host declined seven separate times; each one cost it a real serialize attempt, so stop asking.
export const HIDDEN_OUTPUT_RESTORE_REMOTE_OUTCOME_MAX_ATTEMPTS = 7
// Why separate and larger: these causes are decided before any frame leaves the
// client (resync gate, occupied request lane, detached stream), so the host
// declined nothing and charging them to its budget would banner a healthy pane —
// the exact elapsed-time guess this change removes. They cost zero host traffic,
// so the only job of this cap is termination. At the ~2s post-abandon re-arm
// cadence, 30 outlasts several full 10s resync watchdog cycles.
export const HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS = 30
// Why: this is only shown if hidden renderer output was skipped and main-owned
// terminal state is unavailable, so the user has an explicit loss signal.
export const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING =
'\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n'
@@ -0,0 +1,329 @@
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'
// 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 { 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 type { PtyBufferSnapshot } from '../pty-transport'
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 { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
type HiddenOutputSnapshotResult =
| { kind: 'snapshot'; snapshot: PtyBufferSnapshot }
| { kind: 'retry-worthy'; source: 'host' | 'local' }
| { kind: 'permanently-unavailable' }
| { kind: 'unknown-legacy-host' }
| { kind: 'unavailable' }
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 session.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 session.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 session.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)
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 session.pane lifecycle event; re-sync the hidden-delivery gate both ways.
session.syncHiddenRendererPtyDelivery()
if (shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) {
session.requestHiddenOutputRestoreIfNeeded()
}
}
document.addEventListener('visibilitychange', onDocumentVisibilityChange)
// Why: on stale macOS occlusion (visibilityState wedged 'hidden'), user input forces a resync — no visibilitychange fires, else the gate drops bytes forever.
const unregisterStaleVisibilityRecovery = registerStaleDocumentVisibilityRecovery(
onDocumentVisibilityChange
)
session.unregisterDocumentVisibilityRecovery = () => {
document.removeEventListener('visibilitychange', onDocumentVisibilityChange)
unregisterStaleVisibilityRecovery()
}
}
}
@@ -0,0 +1,239 @@
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'
// 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 {
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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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,
...(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 session.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 session.pane is handed back to whatever is still running.
session.writeReplayData(RESET_AFTER_BYTE_GAP)
} else {
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
skipAltFrame: skippedAltFrame,
paneOnAlternateScreen: session.isPaneOnAlternateScreen()
})) {
session.writeReplayData(replayChunk)
}
}
// Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup).
session.writeReplayData(
session.hasLiveAgentReattachStatusOrTitleSignal()
? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET
: POST_REPLAY_LIVE_SNAPSHOT_RESET
)
if (snapshot.pendingEscapeTailAnsi) {
// Why last: snapshot taken mid-escape; re-arm as the FINAL replay write (any later ESC aborts it) so the live tail completes it, not render literally (Bug E / #7329).
session.writeReplayData(snapshot.pendingEscapeTailAnsi)
}
session.hiddenRendererStateDirty = false
session.recordRendererOrderedSeq(snapshot)
recordTerminalOutput(session.pane.terminal)
await waitForTerminalReplayWritesParsed(session.pane.terminal)
},
{
shouldRestore: () =>
scrollRestore.valid &&
!session.disposed &&
session.transport.getPtyId() === scrollRestore.ptyId &&
session.hiddenOutputRestoreGeneration === scrollRestore.generation,
afterRestore: async () => {
const isCurrentRestore = (): boolean =>
scrollRestore.valid &&
!session.disposed &&
session.transport.getPtyId() === scrollRestore.ptyId &&
session.hiddenOutputRestoreGeneration === scrollRestore.generation
if (!isCurrentRestore()) {
return
}
const currentPtyId = session.transport.getPtyId()
if (!currentPtyId) {
return
}
if (getFitOverrideForPty(currentPtyId)) {
safeFit(session.pane)
if (skippedAltFrame && !isRemoteRuntimePtyId(currentPtyId)) {
window.api.pty.signal(currentPtyId, 'SIGWINCH')
}
return
}
const fit = safeFitAndThen(
session.pane,
'hidden-snapshot-pty-resize',
() => {
if (!isCurrentRestore() || session.transport.getPtyId() !== currentPtyId) {
return
}
const replayChangedDimensions = hasSnapshotDimensions
? session.pane.terminal.cols !== snapshot.cols ||
session.pane.terminal.rows !== snapshot.rows
: session.pane.terminal.cols !== colsBeforeReplay ||
session.pane.terminal.rows !== rowsBeforeReplay
if (skippedAltFrame) {
session.pulseVisibleLocalPtySizeForTuiRepaint(currentPtyId)
return
}
if (replayChangedDimensions && session.isRendererPtyResizeAuthoritative()) {
session.transport.resize(session.pane.terminal.cols, session.pane.terminal.rows)
if (!isRemoteRuntimePtyId(currentPtyId)) {
// Why: redundant SIGWINCH makes alt-screen TUIs rebuild their scroll viewport to the top on tab return.
window.api.pty.signal(currentPtyId, 'SIGWINCH')
}
}
},
{
shouldContinue: isCurrentRestore,
retryIfUnmeasurable: true,
// A skipped frame is blank until this fit pushes the final
// grid/SIGWINCH, so carry the continuation through reveal.
deferIfHidden: true
}
)
session.pendingHiddenSnapshotFit = fit
try {
await fit.completion
} finally {
if (session.pendingHiddenSnapshotFit === fit) {
session.pendingHiddenSnapshotFit = null
}
}
if (isCurrentRestore()) {
session.scheduleReattachIdleAgentCursorReset()
}
}
}
)
} finally {
if (session.hiddenOutputSnapshotScrollRestore === scrollRestore) {
session.hiddenOutputSnapshotScrollRestore = null
}
}
}
}
@@ -0,0 +1,234 @@
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'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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 {
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)
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 session.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
)
}
// 'drained' = painted all queued bytes; 'overflow' = queue blew its cap (stream outran fetch+replay); 'refetch' = offsets unmappable, need a fresher snapshot.
bindHiddenOutputRestoreDrain(session)
bindAbandonHiddenOutputRestore(session)
}
@@ -0,0 +1,151 @@
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { PtyBufferSnapshot } from '../pty-transport'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
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 session.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 session.pane is attributable to this
// session.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()
}
// Extends the suppression window; every backpressure signal resets the timer so the deferred repaint fires once, SUPPRESS_MS after the last signal.
}
@@ -0,0 +1,229 @@
import { useAppStore } from '@/store'
import type { PtyBufferSnapshot } from '../pty-transport'
import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../../../../shared/terminal-color-scheme-protocol'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { bindHiddenOutputRestoreSnapshot } from './hidden-output-restore-snapshot'
import { bindHiddenOutputRestoreRequest } from './hidden-output-restore-request'
import { bindLiveDataCallback } from './live-data-callback'
import { bindReattachLiveDataDeferral } from './reattach-live-data-deferral'
export function bindHiddenRestoreStateAndSshProbe(session: ConnectPanePtySession): void {
session.scheduleHiddenOutputRestoreDeferredRetry = function (): void {
if (
session.disposed ||
session.hiddenOutputRestoreDeferredRetryTimer !== null ||
!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
) {
return
}
if (
session.hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX
) {
const ptyId = session.hiddenOutputRestorePtyId
if (ptyId !== null) {
session.abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
} else {
session.clearHiddenOutputRestoreState()
session.writeRestoreUnavailableWarning()
}
return
}
session.hiddenOutputRestoreDeferredRetryAttempts += 1
// Why: a null snapshot usually means remote output was still mutating; retry after one quiet tick instead of spinning.
session.hiddenOutputRestoreDeferredRetryTimer = setTimeout(() => {
session.hiddenOutputRestoreDeferredRetryTimer = null
if (session.disposed || !session.hiddenOutputRestoreNeeded) {
return
}
session.hiddenOutputRestoreRetryDeferred = false
session.requestHiddenOutputRestoreIfNeeded()
}, HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS)
}
session.clearHiddenOutputRestoreState = function (): void {
session.cancelSnapshotScrollRestore()
session.clearPendingLiveChunksDuringRestore()
// Re-arm budget is per PTY stream, like the rest of this state.
session.hiddenOutputRestoreRemoteAbandonCycles = 0
session.hiddenOutputRestoreRemoteOutcomeAttempts = 0
session.hiddenOutputRestoreLocalGateAttempts = 0
session.hiddenStartupRendererQueryPending = ''
session.hiddenRendererStateDirty = false
session.hiddenOutputRestoreNeeded = false
session.hiddenOutputRestorePtyId = null
session.hiddenOutputRestoreReplayingSnapshot = null
session.hiddenOutputRestoreGeneration += 1
}
session.cancelSnapshotScrollRestore = function (): void {
session.pendingHiddenSnapshotFit?.cancel()
session.pendingHiddenSnapshotFit = null
const scrollRestore = session.hiddenOutputSnapshotScrollRestore
if (!scrollRestore) {
return
}
scrollRestore.valid = false
session.hiddenOutputSnapshotScrollRestore = null
if (scrollRestore.started) {
cancelTerminalScrollIntentBufferRebuildCompletions(session.pane.terminal)
}
// Why: invalidation suppresses restoration, but queued bytes still own the bracket until their FIFO sentinels prove parsing finished.
}
session.cancelHiddenOutputSnapshotScrollRestore = session.cancelSnapshotScrollRestore
session.clearPaneMode2031State = function (): void {
session.deps.paneMode2031Ref.current.delete(session.pane.id)
session.deps.paneLastThemeModeRef.current.delete(session.pane.id)
// A partial CSI prefix belongs to the stream that produced it; carrying it into a
// replacement PTY would splice two unrelated byte ranges into one sequence.
session.mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
}
session.pulseVisibleLocalPtySizeForTuiRepaint = function (ptyId: string): void {
if (
!session.isRendererPtyResizeAuthoritative() ||
session.shouldSuppressDesktopPtyResize() ||
isRemoteRuntimePtyId(ptyId)
) {
return
}
const cols = session.pane.terminal.cols
const rows = session.pane.terminal.rows
if (cols <= 2 || rows <= 0) {
return
}
// Why: a hidden alt-screen TUI can miss the same-size restore SIGWINCH; a one-column pulse makes the repaint observable to the child.
session.transport.resize(cols - 1, rows)
session.transport.resize(cols, rows)
}
session.skipBackgroundAlternateScreenOutput = function (data: string): void {
session.writeHiddenStartupRendererQueries(data)
session.hiddenRendererStateDirty = true
recordHiddenRendererSkip(data.length)
const ptyId = session.transport.getPtyId()
if (!ptyId || session.alternateScreenBackgroundRepaintTimer !== null) {
return
}
session.pulseVisibleLocalPtySizeForTuiRepaint(ptyId)
session.alternateScreenBackgroundRepaintTimer = setTimeout(() => {
session.alternateScreenBackgroundRepaintTimer = null
}, 100)
}
session.resetHiddenOutputRestoreIfPtyChanged = function (): void {
if (session.hiddenOutputRestorePtyId === null) {
return
}
if (session.transport.getPtyId() !== session.hiddenOutputRestorePtyId) {
// Why: renderer backlog is tied to the old PTY stream; after reattach it must not delay or replay before the new PTY.
session.clearHiddenOutputRestoreState()
session.clearRestoredSnapshotBaseline()
session.clearPaneMode2031State()
// Why: flood-backpressure evidence is per PTY stream too.
session.resetHiddenOutputRestoreFloodSuppression()
discardTerminalOutput(session.pane.terminal)
}
}
bindHiddenOutputRestoreSnapshot(session)
bindHiddenOutputRestoreRequest(session)
bindLiveDataCallback(session)
session.beginReattachLiveDataDeferral = (
ownerGeneration = session.transportStreamGeneration
): void => {
session.reattachLiveDataDeferralDepth += 1
if (session.reattachLiveDataDeferralDepth === 1) {
session.deferredReattachLiveData = new DeferredReattachLiveDataQueue()
session.deferredReattachLiveDataOwners = new Map()
}
if (!session.deferredReattachLiveDataOwners.has(ownerGeneration)) {
session.deferredReattachLiveDataOwners.set(ownerGeneration, { failed: false })
}
}
session.beginReattachLiveDataDeferralIfUnowned = (
ownerGeneration = session.transportStreamGeneration
): void => {
if (!session.deferredReattachLiveDataOwners.has(ownerGeneration)) {
session.beginReattachLiveDataDeferral(ownerGeneration)
}
}
bindReattachLiveDataDeferral(session)
session.isCapturedDirectSshReattachCurrent = (ptyId: string): boolean =>
!session.directSshRetryAttempt || session.capturedDirectSshRetryStateMatches(ptyId)
session.rejectObsoleteDirectSshReattach = (ptyId: string | null | undefined): boolean => {
if (
!session.directSshRetryAttempt ||
(ptyId && session.claimCapturedDirectSshRetryPty(ptyId))
) {
return false
}
session.transport.detach?.({ preserveExitObserver: false })
return true
}
let parkedSshSnapshotPrefetch: {
ptyId: string
fetch: () => Promise<PtyBufferSnapshot | null>
} | null = null
const createSshMainModelSnapshotProbe = (
ptyId: string
): (() => Promise<PtyBufferSnapshot | null>) =>
memoizeSshReattachModelSnapshotProbe(async (): Promise<PtyBufferSnapshot | null> => {
const sshParkingEnabled = useAppStore.getState().settings?.terminalSshViewParking !== false
if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) {
return null
}
const snapshot = await resolveSshReattachModelSnapshotWithTimeout(
window.api.pty.getMainBufferSnapshot(ptyId, {
scrollbackRows: resolveHiddenRestoreScrollbackRows(
session.pane.terminal.options.scrollback
)
})
)
return snapshot &&
decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) ===
'main-model-snapshot'
? snapshot
: null
})
session.getSshMainModelSnapshotProbe = (
ptyId: string
): (() => Promise<PtyBufferSnapshot | null>) => {
if (parkedSshSnapshotPrefetch?.ptyId !== ptyId) {
parkedSshSnapshotPrefetch = { ptyId, fetch: createSshMainModelSnapshotProbe(ptyId) }
}
return parkedSshSnapshotPrefetch.fetch
}
}
@@ -0,0 +1,166 @@
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'
// 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 type { PtyDataMeta } from '../pty-dispatcher'
import {
DEFAULT_DA1_RESPONSE,
sendTerminalOscColorQueryReplies
} from '../terminal-capability-replies'
import { recordHiddenRendererSkip } from './e2e-terminal-pty-harness'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function bindHiddenStartupRendererQueryWrite(session: ConnectPanePtySession): void {
session.takeHiddenStartupRendererQueryPendingForForeground = function (data: string): {
statelessQueryData: string
statefulQueryData: string
oscColorQueryData: string
remainingData: string
consumedCurrentChars: number
} {
const pending = session.hiddenStartupRendererQueryPending
session.hiddenStartupRendererQueryPending = ''
if (!pending) {
return {
statelessQueryData: '',
statefulQueryData: '',
oscColorQueryData: '',
remainingData: data,
consumedCurrentChars: 0
}
}
const input = pending + data
let statelessQueryData = ''
let statefulQueryData = ''
let oscColorQueryData = ''
let consumedInputChars = pending.length
let nextPending = ''
if (input.startsWith('\x1b[')) {
const finalByteIndex = findCsiFinalByteIndex(input, 2)
if (finalByteIndex === -1) {
nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS)
consumedInputChars = input.length
} else {
const sequence = input.slice(0, finalByteIndex + 1)
if (isStatelessRendererReplyCsiQuery(sequence)) {
statelessQueryData = sequence
} else if (isStatefulRendererReplyCsiQuery(sequence)) {
statefulQueryData = sequence
}
consumedInputChars = finalByteIndex + 1
}
} else if (input.startsWith('\x1b]')) {
const query = parseTerminalOscColorQuery(input, 0)
if (query.kind === 'partial') {
nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS)
consumedInputChars = input.length
} else if (query.kind === 'match') {
oscColorQueryData = input.slice(0, query.endIndex)
consumedInputChars = query.endIndex
} else {
consumedInputChars = pending.length
}
} else if (input.length === 1) {
nextPending = input
consumedInputChars = input.length
} else {
consumedInputChars = pending.length
}
session.hiddenStartupRendererQueryPending = nextPending
const consumedCurrentChars = Math.max(0, consumedInputChars - pending.length)
return {
statelessQueryData,
statefulQueryData,
oscColorQueryData,
remainingData: data.slice(consumedCurrentChars),
consumedCurrentChars
}
}
session.metaAfterConsumingCurrentChars = function (
meta: PtyDataMeta | undefined,
consumedCurrentChars: number
): PtyDataMeta | undefined {
if (consumedCurrentChars === 0 || typeof meta?.rawLength !== 'number') {
return meta
}
return {
...meta,
rawLength: Math.max(0, meta.rawLength - consumedCurrentChars)
}
}
session.skipHiddenRendererOutput = function (data: string): void {
session.writeHiddenStartupRendererQueries(data)
session.markHiddenOutputRestoreNeeded()
session.hiddenRendererStateDirty = true
if (session.hiddenOutputRestoreInFlight) {
session.hiddenOutputRestoreFreshSnapshotNeeded = true
}
recordHiddenRendererSkip(data.length)
}
// Why: discarding flood bytes must not swallow terminal queries (a lost DSR/CPR hangs the program); the snapshot repaint owns the content, so synthesize replies via the immediate input path, not xterm replay.
session.salvageRendererQueriesFromDiscardedRestoreData = function (data: string): void {
if (!data || !data.includes('\x1b')) {
return
}
const extracted = extractHiddenStartupRendererQueryData(data, '')
if (extracted.oscColorQueryData) {
sendTerminalOscColorQueryReplies(
extracted.oscColorQueryData,
session.pane.terminal,
session.sendDesktopQueryReplyImmediate
)
}
let unansweredQueryData = ''
for (const sequence of splitCsiSequences(
extracted.statefulQueryData + extracted.statelessQueryData
)) {
if (sequence === '\x1b[6n') {
// CPR from the live buffer; may be mid-repaint stale, but in a drop scenario liveness (unblock the reader) is the contract, not accuracy.
const buffer = session.pane.terminal.buffer.active
const row = Math.min(buffer.cursorY + 1, session.pane.terminal.rows)
const col = Math.min(buffer.cursorX + 1, session.pane.terminal.cols)
session.sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`)
} else if (sequence === '\x1b[c' || sequence === '\x1b[0c') {
session.sendDesktopQueryReplyImmediate(DEFAULT_DA1_RESPONSE)
} else {
unansweredQueryData += sequence
}
}
if (unansweredQueryData) {
// Best-effort for rarer queries (DECRQM, DA2, XTVERSION): replay into xterm so its handlers answer when no replay is active.
session.writePtyOutputToXterm(unansweredQueryData, true, { hiddenStartupRendererQuery: true })
}
}
function splitCsiSequences(queryData: string): string[] {
const sequences: string[] = []
let offset = queryData.indexOf('\x1b[')
while (offset !== -1) {
const finalByteIndex = findCsiFinalByteIndex(queryData, offset + 2)
if (finalByteIndex === -1) {
break
}
sequences.push(queryData.slice(offset, finalByteIndex + 1))
offset = queryData.indexOf('\x1b[', finalByteIndex + 1)
}
return sequences
}
}
@@ -0,0 +1,18 @@
import { containsCsiRendererQuery } from '../../../../../shared/terminal-reply-query-extraction'
import { isKnownTuiAgentTerminalStartupCommand } from '../terminal-startup-command-classifier'
import type { PtyConnectionDeps } from '../pty-connection-types'
export function shouldKeepHiddenStartupRendererQueriesLive(
startup: PtyConnectionDeps['startup']
): boolean {
return (
Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') ||
isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '')
)
}
export function containsHiddenStartupRendererQuery(data: string): boolean {
// Why: hidden Codex startup must not live-render ordinary redraw floods, but
// query chunks still need xterm's built-in terminal replies to unblock TUIs.
return containsCsiRendererQuery(data) || data.includes('\x1b]10;?') || data.includes('\x1b]11;?')
}
@@ -0,0 +1,184 @@
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
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 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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
session.sequencedInterruptStatusBaseline = 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,196 @@
import { useAppStore } from '@/store'
import { containsStatefulRendererQuery } from '../../../../../shared/terminal-reply-query-extraction'
import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-delivery-credit'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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 session.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 session.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 session.pane the mark starts the restore synchronously and must not flag itself.
const restoreWasInFlight = session.hiddenOutputRestoreInFlight !== null
session.markHiddenOutputRestoreNeeded()
if (restoreWasInFlight) {
session.hiddenOutputRestoreFreshSnapshotNeeded = true
}
return
}
} else {
data = reconciliation.data
meta = reconciliation.meta
}
// Why: a hidden Codex query can split just before visibility flips; hand xterm the completed query while other bytes still follow restore.
const pendingForegroundQuery = foreground
? session.takeHiddenStartupRendererQueryPendingForForeground(data)
: null
const rendererData = pendingForegroundQuery?.remainingData ?? data
const rendererMeta = session.metaAfterConsumingCurrentChars(
meta,
pendingForegroundQuery?.consumedCurrentChars ?? 0
)
session.observeRendererOrderedSeqRegression(meta)
const orderedRendererData = foreground
? rendererData
: session.getHiddenRendererDataAfterOrderedSeq(rendererData, rendererMeta)
if (orderedRendererData === null) {
// Why: renderer filtering can't map cleaned text back to raw seq offsets; rebuild from main instead of risking stale bytes.
session.markHiddenOutputRestoreNeeded()
session.schedulePendingStartupCommandDelivery()
return
}
if (!foreground && orderedRendererData.length === 0) {
session.recordRendererOrderedSeq(rendererMeta)
session.schedulePendingStartupCommandDelivery()
return
}
if (pendingForegroundQuery?.statelessQueryData) {
session.writePtyOutputToXterm(pendingForegroundQuery.statelessQueryData, true, {
hiddenStartupRendererQuery: true
})
}
if (pendingForegroundQuery?.oscColorQueryData) {
sendTerminalOscColorQueryReplies(
pendingForegroundQuery.oscColorQueryData,
session.pane.terminal,
// Why: OSC color reply sent immediately so the remote debounce can't delay it past the program's read window (#7329).
session.sendDesktopQueryReplyImmediate
)
}
const restoreAppliesToCurrentPty =
session.hiddenOutputRestorePtyId !== null &&
session.transport.getPtyId() === session.hiddenOutputRestorePtyId
const skipBackgroundAlternateScreenFrame =
meta?.background === true &&
shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) &&
session.pane.terminal.buffer.active.type === 'alternate' &&
!containsStatefulRendererQuery(orderedRendererData)
if (skipBackgroundAlternateScreenFrame) {
session.skipBackgroundAlternateScreenOutput(orderedRendererData)
} else if (session.shouldSkipHiddenRendererOutput(foreground, orderedRendererData)) {
session.skipHiddenRendererOutput(orderedRendererData)
} else if (
(session.hiddenOutputRestoreNeeded || session.hiddenOutputRestoreInFlight) &&
restoreAppliesToCurrentPty
) {
if (foreground) {
if (pendingForegroundQuery?.statefulQueryData) {
session.queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData)
}
session.queueLiveChunkDuringRestore(orderedRendererData, rendererMeta)
session.requestHiddenOutputRestoreIfNeeded()
} else if (session.hiddenOutputRestoreInFlight) {
session.hiddenOutputRestoreNeeded = true
session.hiddenOutputRestoreFreshSnapshotNeeded = true
}
// Why: hidden chunks with a restore already latched are dropped; the reveal snapshot covers their bytes.
} else {
// Why: hidden panes normally get no bytes (main drops post-ingestion); stragglers ride the bounded background queue, overflow latches restore.
if (pendingForegroundQuery?.statefulQueryData) {
session.writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, {
hiddenStartupRendererQuery: true
})
}
session.writePtyOutputToXterm(orderedRendererData, foreground)
if (foreground) {
session.recordRendererOrderedSeq(rendererMeta)
}
}
session.schedulePendingStartupCommandDelivery()
}
session.unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(
session.cacheKey,
(data, meta) => {
if (!session.disposed) {
session.dataCallback(data, meta)
}
}
)
}
@@ -0,0 +1,30 @@
import { useAppStore } from '@/store'
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { REMOTE_PTY_ID_PREFIX } from './pty-connect-limits'
export function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX)
}
export function canRestorePairedParkedTerminal(ptyId: string): boolean {
const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
return (
environmentId !== null &&
useAppStore
.getState()
.runtimeStatusByEnvironmentId.get(environmentId)
?.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) === true
)
}
// Why: daemon session IDs use the format `${worktreeId}@@${shortUuid}`.
// This validates that a session ID actually belongs to the given worktree,
// preventing cross-workspace contamination during restore.
export function isSessionOwnedByWorktree(sessionId: string, worktreeId: string): boolean {
const separatorIdx = sessionId.lastIndexOf('@@')
if (separatorIdx === -1) {
return true
}
return sessionId.slice(0, separatorIdx) === worktreeId
}
@@ -0,0 +1,268 @@
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'
// 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 { isLocalNativeWindowsConpty } 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
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))
}
session.paneExpectsLaunchAgent = (state: ReturnType<typeof useAppStore.getState>): boolean => {
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
(candidate) => candidate.id === session.deps.tabId
)
const registeredLaunchAgent =
state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType
return Boolean(
tab?.launchAgent ??
session.paneStartup?.launchAgent ??
session.paneStartup?.initialAgentStatus?.agent ??
(isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined)
)
}
// 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 state = useAppStore.getState()
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
(candidate) => candidate.id === session.deps.tabId
)
const candidate =
tab?.launchAgent ??
session.paneStartup?.launchAgent ??
session.paneStartup?.initialAgentStatus?.agent ??
state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType
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: tab?.shellOverride,
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'
}
})
// 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 ||
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)
}
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,23 @@
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
reconcileIfSessionDead: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
}
@@ -0,0 +1,242 @@
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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
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 => {
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)
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 })
}
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,101 @@
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'
// 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 {
discardTerminalOutput,
waitForTerminalOutputParsed
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
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 session.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,234 @@
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'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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 session.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 ||
session.transport.getPtyId() !== ptyId
) {
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 session.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 session.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 session.pane would keep claiming a session it never got back.
if (
sessionRestoredBannerShown === reason ||
sessionRestoredBannerShown === 'resume-unavailable'
) {
return
}
sessionRestoredBannerShown = reason
session.deps.onShowSessionRestoredBanner(session.pane.id, reason)
}
}
@@ -0,0 +1,5 @@
export type PendingHiddenOutputRestoreChunk = {
data: string
seq?: number
rawLength?: number
}
@@ -0,0 +1,23 @@
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
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,318 @@
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'
// 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 {
getProviderSessionClaimKey,
isPassiveCompletedHibernationEvidence
} from '@/lib/sleeping-agent-pane-ownership'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installPtyExitHibernate(session: ConnectPanePtySession): void {
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, 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, { 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
if (!preserveRendererBinding) {
session.deps.clearTabPtyId(session.deps.tabId, ptyId)
}
session.deps.consumeSuppressedPtyExit(ptyId)
scheduleRuntimeGraphSync()
return
}
session.handledExitPtyId = 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 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,379 @@
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'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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,348 @@
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'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
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
// 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.
session.TRANSPORT_CONNECT_SETTLE_GRACE_MS = 60_000
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 < session.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,258 @@
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'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installPtyResizeGeometry(session: ConnectPanePtySession): void {
session.handleObservedPaneGeometry = (): void => {
session.pendingGeometryReportRaf = null
if (session.disposed) {
return
}
if (
deferTerminalGeometryMutationDuringRebuild(
session.pane.terminal,
'observed-pane-geometry',
session.handleObservedPaneGeometry
)
) {
return
}
const paneGeometryChanged = session.pendingPaneGeometryChanged
session.pendingPaneGeometryChanged = false
const currentPtyId = session.transport.getPtyId()
if (!currentPtyId) {
// Why: ResizeObserver may deliver its initial measurement before the
// remote binding completes; retain that passive baseline for the first
// real focused resize instead of swallowing the user's first claim.
const proposed = session.readProposedTerminalGrid()
if (proposed) {
session.lastObservedDesktopGrid = proposed
}
return
}
const fitOverride = getFitOverrideForPty(currentPtyId)
if (!fitOverride) {
if (session.pane.terminal.cols > 0 && session.pane.terminal.rows > 0) {
// Why: record the local grid before a later remote hold parks xterm;
// the first real window/split resize can then claim immediately.
session.lastObservedDesktopGrid = {
cols: session.pane.terminal.cols,
rows: session.pane.terminal.rows
}
}
if (session.shouldSuppressDesktopPtyResize()) {
return
}
requestStablePaneFit(session.pane as ManagedPaneInternal, () =>
session.ptySizeReassertion.request({ fit: false })
)
return
}
let proposed: { cols: number; rows: number } | undefined
try {
proposed = session.pane.fitAddon.proposeDimensions()
} catch {
proposed = undefined
}
if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) {
return
}
const priorProposed = session.lastObservedDesktopGrid
session.lastObservedDesktopGrid = proposed
if (fitOverride.mode === 'remote-desktop-fit') {
if (
shouldClaimRemoteDesktopViewport({
holdMode: fitOverride.mode,
prior: priorProposed,
current: proposed,
paneGeometryChanged,
paneVisible: session.deps.isVisibleRef.current,
documentVisible: document.visibilityState !== 'hidden',
documentFocused: document.hasFocus()
})
) {
// Why: a focused, visible layout change is genuine activity; release
// the park and update xterm before claiming so the owner does not keep
// rendering the prior owner's stale grid.
session.suppressViewportClaimTerminalResize = true
try {
session.pane.terminal.resize(proposed.cols, proposed.rows)
} finally {
session.suppressViewportClaimTerminalResize = false
}
session.transport.resize(proposed.cols, proposed.rows, { claim: true })
}
return
}
if (isRemoteRuntimePtyId(currentPtyId)) {
session.transport.resize(proposed.cols, proposed.rows)
} else {
window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows)
}
}
session.geometryReportObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => {
const paneSize = session.readPaneSize()
if (
paneSize &&
session.lastObservedPaneSize &&
(paneSize.width !== session.lastObservedPaneSize.width ||
paneSize.height !== session.lastObservedPaneSize.height)
) {
session.pendingPaneGeometryChanged = true
}
session.lastObservedPaneSize = paneSize
if (session.pendingGeometryReportRaf !== null) {
return
}
session.pendingGeometryReportRaf = requestAnimationFrame(
session.handleObservedPaneGeometry
)
})
// Why: pane.xtermContainer is created later in pane-lifecycle's
// attachWebgl/initial-fit path; pane.container is always present at the
// moment connectPanePty runs (it's the .pane element). Both report the
// same layout signal — when the outer pane resizes, the inner xterm
// container resizes too — so this is the safe element to observe.
if (session.geometryReportObserver && session.pane.container instanceof Element) {
session.geometryReportObserver.observe(session.pane.container)
}
// Why: the deferred-rAF fit can spawn the PTY at a stale width when the pane's
// real (e.g. split/narrower) layout has not settled by the first frame — the
// PTY is born at the wide window width while xterm later reflows to the pane
// width. The corrective onResize is then dropped (isRendererPtyResizeAuthoritative()
// is false mid-mount), pinning process.stdout.columns forever and garbling
// TUIs. The reconcile re-fits across frames until the grid settles and forces
// the PTY to xterm's dimensions; the spawn-time sync is authoritative by
// definition so it bypasses the visibility gate (but not the mobile-fit
// override, which legitimately parks the PTY at phone dims). See
// pty-size-reconcile.ts for the convergence loop.
session.ptySizeReconcileHandle = null
session.liveScrollbackRestore = null
session.reconcilePtySizeAfterSpawn = (
ptyId: string,
spawnCols: number,
spawnRows: number
): void => {
session.ptySizeReconcileHandle?.cancel()
session.ptySizeReconcileHandle = reconcilePtySizeAcrossFrames({
spawnCols,
spawnRows,
isAlive: () => !session.disposed && session.transport.getPtyId() === ptyId,
// Mobile legitimately parks the PTY at phone dims; skip those frames
// (neither fit nor forward) instead of cancelling the reconcile window.
isParked: () => Boolean(getFitOverrideForPty(ptyId)) || isPtyLocked(ptyId),
// Once the renderer resize is authoritative (pane visible), the live
// onResize owns future corrections, so the reconcile can hand off after
// the grid stabilizes. While hidden it keeps watching for a late settle.
isAuthoritative: () => session.isRendererPtyResizeAuthoritative(),
measure: () => {
if (!safeFit(session.pane)) {
return null
}
const cols = session.pane.terminal.cols
const rows = session.pane.terminal.rows
return cols > 0 && rows > 0 ? { cols, rows } : null
},
resize: (cols, rows) => {
if (!session.shouldSuppressDesktopPtyResize()) {
session.transport.resize(cols, rows)
}
},
// Why: confirm the PTY actually applied the size we forwarded before the
// reconcile hands off. transport.resize is fire-and-forget for daemon/SSH
// PTYs, so the loop can otherwise settle on a size the PTY dropped, leaving
// it pinned wide while xterm shows narrow — the mount-time desync. Skip
// remote-runtime PTYs (separate viewport channel; pty:getSize never tracks
// them) so they fall back to the grid-stable handoff.
getAppliedSize: isRemoteRuntimePtyId(ptyId) ? undefined : () => window.api.pty.getSize(ptyId),
requestFrame: (callback) => requestAnimationFrame(callback),
cancelFrame: (handle) => {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(handle)
}
}
})
}
// Defer PTY spawn/attach to next frame so FitAddon has time to calculate
// the correct terminal dimensions from the laid-out container.
session.cancelScheduledConnectFrame = (): void => {
if (session.connectFrame !== null) {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(session.connectFrame)
}
session.connectFrame = null
}
}
session.measureStartupGrid = (): { cols: number; rows: number } | null => {
if (!safeFit(session.pane)) {
return null
}
const cols = session.pane.terminal.cols
const rows = session.pane.terminal.rows
return cols > 0 && rows > 0 ? { cols, rows } : null
}
session.shouldSettleStartupGridBeforeConnect = (): boolean =>
Boolean(session.paneStartup?.command) &&
session.deps.isVisibleRef.current &&
!session.connectionId &&
session.runtimeEnvironmentId === null
session.isStartupGridReadyForConnect = (): boolean => {
const setupSplitDirection = session.paneStartup?.waitForSetupSplitDirection
if (!setupSplitDirection) {
return true
}
// Why: the setup split reparents the main pane before its xterm grid
// necessarily reflects the new flex geometry; wait for both to agree.
return isSetupSplitGeometryReady(session.pane, session.manager, setupSplitDirection)
}
session.settleStartupGridBeforeConnect = (connect: () => void): void => {
session.startupGridSettleHandle?.cancel()
let settledSynchronously = false
// Why: local startup commands can launch a TUI before the split-pane grid
// has settled; spawn from a briefly stable grid so the TUI paints cleanly.
const handle = waitForStableStartupGrid({
isAlive: () => !session.disposed,
isReadyToSettle: session.paneStartup?.waitForSetupSplitDirection
? session.isStartupGridReadyForConnect
: undefined,
measure: session.measureStartupGrid,
onSettled: () => {
settledSynchronously = true
session.startupGridSettleHandle = null
connect()
},
requestFrame: (callback) => requestAnimationFrame(callback),
cancelFrame: (handle) => {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(handle)
}
}
})
if (!settledSynchronously) {
session.startupGridSettleHandle = handle
}
}
}
@@ -0,0 +1,81 @@
import { deliverTerminalDataWithDeferredCredit } from '@/lib/pane-manager/terminal-delivery-credit'
import { waitForTerminalReplayWritesParsed } from '../replay-guard'
// 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 { 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'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
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)
})
}
}
}
@@ -0,0 +1,16 @@
import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport'
import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
// Per-call locals for applyReattachPayload so overlapping reattaches stay isolated.
export type ReattachPayloadContext = {
isCurrentReattachPayload: () => boolean
connectResult: PtyConnectResult | null
ptyId: string
attemptGeneration: number
prefetchedParkModelSnapshot: PtyBufferSnapshot | null
revealFollowsTerminalPark: boolean
fetchSshMainModelReattachSnapshot: () => Promise<PtyBufferSnapshot | null>
hasStructuralReplay: boolean
coldRestoreStartup?: ColdRestoreAgentResumeStartup | null
reattachPayloadApplied: boolean
}
@@ -0,0 +1,227 @@
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport'
import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics'
// 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 { isPassiveCompletedHibernationEvidence } from '@/lib/sleeping-agent-pane-ownership'
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
import { resolveHiddenRestoreScrollbackRows } from '../terminal-hidden-restore-scrollback'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import type { ReattachPayloadContext } from './reattach-payload-context'
import { bindApplyReattachPayload } from './apply-reattach-payload'
export function bindHandleReattachResult(session: ConnectPanePtySession): void {
session.handleReattachResult = async (
result: PtyConnectResult | string | void,
staleSessionId?: string | null,
coldRestoreStartup?: ColdRestoreAgentResumeStartup | null,
attemptGeneration = session.transportStreamGeneration
): Promise<boolean> => {
session.authoritativeReattachGeneration += 1
if (session.disposed) {
return false
}
if (attemptGeneration !== session.transportStreamGeneration) {
return false
}
const connectResult =
result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null
if (connectResult?.exitedBeforeAttach) {
// Why: the session.transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach.
return true
}
const retryPtyId =
connectResult?.id ??
(typeof result === 'string' ? result : (staleSessionId ?? session.transport.getPtyId()))
if (session.rejectObsoleteDirectSshReattach(retryPtyId)) {
// Why: an obsolete reattach must stop consuming frames without killing the durable PTY a newer lease may adopt.
return false
}
const ptyId =
connectResult?.id ?? (typeof result === 'string' ? result : session.transport.getPtyId())
if (!ptyId) {
warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', {
tabId: session.deps.tabId,
worktreeId: session.deps.worktreeId,
leafId: session.deps.restoredLeafId ?? session.pane.leafId,
paneId: session.pane.id,
ptyId: staleSessionId ?? null
})
// Why: a stale restored session can fail reattach after mount; don't leave xterm alive without a backing PTY.
if (staleSessionId) {
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId)
} else {
session.deps.syncPanePtyLayoutBinding(session.pane.id, null)
}
if (staleSessionId) {
session.deps.clearTabPtyId(session.deps.tabId, staleSessionId)
}
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return false
}
session.registerEffectiveLaunchConfig(connectResult?.launchConfig, {
...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}),
...(connectResult?.launchAgent
? { launchAgent: connectResult.launchAgent }
: coldRestoreStartup
? { launchAgent: coldRestoreStartup.agent }
: {})
})
if (connectResult?.sessionExpired) {
if (staleSessionId) {
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId)
} else {
session.deps.syncPanePtyLayoutBinding(session.pane.id, null)
}
if (staleSessionId) {
session.deps.clearTabPtyId(session.deps.tabId, staleSessionId)
}
// Why: SSH sleep/reconnect can invalidate the relay PTY while the tab stays mounted; replace the dead lease in-place, not a stale overlay.
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return false
}
const isCurrentReattachPayload = (): boolean => {
const currentPtyId = session.transport.getPtyId()
return (
!session.disposed &&
attemptGeneration === session.transportStreamGeneration &&
currentPtyId === ptyId
)
}
if (!isCurrentReattachPayload()) {
return false
}
// Strict precedence snapshot > replay > coldRestore: paint exactly one, else overlapping tails duplicate TUI output on worktree switch.
const hasStructuralReplay = Boolean(
connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore
)
const resumeComesFromPassiveHibernation = Boolean(
coldRestoreStartup &&
!coldRestoreStartup.useLiveEntry &&
coldRestoreStartup.sleepingRecordEntry &&
isPassiveCompletedHibernationEvidence(coldRestoreStartup.sleepingRecordEntry.record)
)
// Why: reattach drops startup commands; only passive hibernation is authority to retire an empty adopted shell and resume its provider session.
if (!hasStructuralReplay && connectResult?.isReattach && resumeComesFromPassiveHibernation) {
session.transport.disconnect()
if (staleSessionId) {
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId)
session.deps.clearTabPtyId(session.deps.tabId, staleSessionId)
} else {
session.deps.syncPanePtyLayoutBinding(session.pane.id, null)
}
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return false
}
session.setPanePtyFitBinding(ptyId)
session.reportPanePtyVisibility(ptyId, session.deps.isVisibleRef.current)
session.registerSideEffectFactConsumerForPty(ptyId)
session.syncHiddenRendererPtyDelivery()
session.deps.syncPanePtyLayoutBinding(session.pane.id, ptyId)
notifyCodexPaneBoundForStaleSweep(ptyId)
if (session.capturedDirectSshRetryPtyAccepted && session.directSshRetryAttempt) {
session.deps.updateTabPtyId(
session.deps.tabId,
ptyId,
undefined,
session.directSshRetryAttempt.attemptId
)
} else {
session.deps.updateTabPtyId(session.deps.tabId, ptyId)
}
session.agentCompletionCoordinator.startProcessTracking()
session.sampleVisiblePaneForegroundAgent()
// Why: mobile streaming needs xterm's exact screen state; install the serializer + lastTitle source for main-process hydration parity.
session.registerPaneSerializerFor(ptyId)
// Why (C1 SSH parking): main's headless model holds ~5k rows for SSH ptys
// while the relay replay is a 100KiB raw-byte tail; prefer the model on
// reveal. Only a non-empty 'headless'-sourced snapshot qualifies — the
// renderer-serializer fallback has no mounted xterm after a park. The
// paint happens inline in the snapshot-branch style: session.applyMainBufferSnapshot
// would nest session.structuralReplayCoordinator.run inside the reattach task and
// deadlock on the coordinator's tail chain.
// Memoized: the prefetch and the payload task share one probe result, so a
// null prefetch can never buy a second timeout before the relay paint.
const fetchSshMainModelReattachSnapshot = session.getSshMainModelSnapshotProbe(ptyId)
// Why consume-once: only the first reattach of a reveal remount may pay
// the probe; a later in-place reconnect on this same mount must not buy a
// second timeout before the relay paint.
const revealFollowsTerminalPark =
session.mountFollowsTerminalPark &&
(connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId))
session.mountFollowsTerminalPark = false
// Why: ordinary parking destroys xterm. Rebuild from the authoritative
// host snapshot before releasing queued live bytes; null falls back to
// the subscribe screen without keeping the old xterm mounted.
let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null
if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) {
if (parseAppSshPtyId(ptyId)) {
prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot()
} else {
try {
const result = await session.serializeHiddenOutputSnapshot(ptyId, {
scrollbackRows: resolveHiddenRestoreScrollbackRows(
session.pane.terminal.options.scrollback
)
})
prefetchedParkModelSnapshot = result.kind === 'snapshot' ? result.snapshot : null
} catch {
prefetchedParkModelSnapshot = null
}
}
if (!isCurrentReattachPayload()) {
return false
}
}
const reattachPayload: ReattachPayloadContext = {
isCurrentReattachPayload,
connectResult,
ptyId,
attemptGeneration,
prefetchedParkModelSnapshot,
revealFollowsTerminalPark,
fetchSshMainModelReattachSnapshot,
hasStructuralReplay,
coldRestoreStartup,
reattachPayloadApplied: !hasStructuralReplay && prefetchedParkModelSnapshot === null
}
bindApplyReattachPayload(session, reattachPayload)
if (hasStructuralReplay || prefetchedParkModelSnapshot) {
await session.structuralReplayCoordinator.run(session.applyReattachPayload, {
shouldRestore: isCurrentReattachPayload,
afterRestore: session.fitAfterReattachRestore
})
} else {
await session.applyReattachPayload()
await session.fitAfterReattachRestore()
}
if (!isCurrentReattachPayload() || !reattachPayload.reattachPayloadApplied) {
return false
}
session.scheduleReattachIdleAgentCursorReset()
scheduleRuntimeGraphSync()
return true
}
}
@@ -0,0 +1,195 @@
// 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 { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import {
CURSOR_SHOW_SEQUENCE,
TERMINAL_FOCUS_IN_SEQUENCE,
FOCUS_REPORTING_DISABLE_SEQUENCE
} from './foreground-output-scan'
import {
parsedViewportShowsParkedCursorAgentScreen,
terminalHasFocusReportingEnabled
} from './cursor-agent-reattach-screen'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function bindReplayDataDrain(session: ConnectPanePtySession): void {
session.sendFocusedReattachFocusInAfterReplay = (
expectedPtyId: string | null = session.transport.getPtyId(),
expectedStreamGeneration = session.transportStreamGeneration
): void => {
const scheduledGeneration = session.reattachReplayPayloadSignalGeneration
void waitForTerminalOutputParsed(session.pane.terminal).then(() => {
const currentPtyId = session.transport.getPtyId()
if (
session.disposed ||
expectedStreamGeneration !== session.transportStreamGeneration ||
currentPtyId !== expectedPtyId
) {
return
}
// Why: a newer replay frame owns the judgment; its own post-parse
// callback will re-evaluate against its own viewport.
if (scheduledGeneration !== session.reattachReplayPayloadSignalGeneration) {
return
}
// Why: the replay-byte signal also matches a dead run's screen — in
// scrollback or still painted above a fresh shell prompt. The parsed
// viewport is the ground truth; unless it shows a parked-cursor
// cursor-agent screen and no status/title corroborates, downgrade to
// the plain-shell behavior (drop focus reporting, skip focus-in).
if (
!session.hasLiveAgentReattachStatusOrTitleSignal() &&
session.reattachReplayPayloadHasCursorAgentSignal
) {
if (parsedViewportShowsParkedCursorAgentScreen(session.pane.terminal) === false) {
session.reattachReplayPayloadHasCursorAgentSignal = false
// Why: the live-agent reset preserved the payload's ?25l; a plain
// shell never re-shows the cursor itself.
session.writeReplayData(`${CURSOR_SHOW_SEQUENCE}${FOCUS_REPORTING_DISABLE_SEQUENCE}`)
return
}
}
// Why: a live TUI such as cursor-agent parks the real terminal cursor off
// its own input caret and moves it back only on a focus-in. Reattach
// reuses the same live PTY and the xterm textarea already holds DOM
// focus, so xterm never emits the focus-in the agent needs and the parked
// cursor anchors the IME/caret to the wrong cell. Gated on ?1004h so a
// bare shell never receives a stray \x1b[I.
const sendFocusMode = terminalHasFocusReportingEnabled(session.pane.terminal)
if (!session.shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) {
return
}
session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE)
})
}
session.pendingReplayData = null
session.replayPayloadGeneration = 0
let replayDrainQueued = false
const drainReplayDataQueue = async (
expectedPtyId: string | null,
expectedStreamGeneration: number
): Promise<boolean> => {
let appliedCurrentPayload = false
while (session.pendingReplayData !== null) {
if (
session.pendingReplayData.ptyId !== expectedPtyId ||
session.pendingReplayData.streamGeneration !== expectedStreamGeneration
) {
return false
}
if (
session.transport.getPtyId() !== expectedPtyId ||
session.transportStreamGeneration !== expectedStreamGeneration
) {
session.pendingReplayData = null
return false
}
const payload = session.pendingReplayData
const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload
session.pendingReplayData = null
const isCurrentPayload = (): boolean =>
!session.disposed &&
payload.generation === session.replayPayloadGeneration &&
payload.streamGeneration === session.transportStreamGeneration &&
session.transport.getPtyId() === payload.ptyId
if (!isCurrentPayload()) {
continue
}
// Relay replay buffers may overlap with content already rendered in
// xterm. Local eager replay decides this earlier so metadata-only frames
// can keep restored scrollback while still using the replay guard.
if (clearBeforeReplay) {
await session.writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H')
if (!isCurrentPayload()) {
continue
}
}
if (clearBeforeReplay || data.length > 0) {
// Why: an empty clearing frame is still an authoritative repaint and
// must clear a stale agent signal from an earlier payload.
session.rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay })
}
// Why: replayed application bytes carry the live TUI's kitty keyboard
// negotiation; the mirror must re-arm from them after a reload. Replay
// semantics: relay reconnects redeliver the same window, so pushes
// apply as sets to keep the mirrored stack from accumulating frames.
session.applySnapshotKittyKeyboardModes(data, payload)
await session.writeReplayDataAsync(data)
if (!isCurrentPayload()) {
continue
}
if (clearBeforeReplay || data.length > 0) {
await session.writeReplayDataAsync(session.reattachReplayResetSequence(data))
if (!isCurrentPayload()) {
continue
}
session.sendFocusedReattachFocusInAfterReplay(payload.ptyId, payload.streamGeneration)
}
// Why: the daemon could not serialize a PTY read that ended mid-escape,
// so the emulator shipped the dangling partial separately. Write it LAST
// — after the reset, whose ESC would otherwise abort it — so the next
// live chunk completes the sequence instead of rendering literally
// (#7329). Guarded so a later ESC cannot leave the parser wedged.
if (pendingEscapeTailAnsi) {
await session.writeReplayDataAsync(pendingEscapeTailAnsi)
}
if (!isCurrentPayload()) {
continue
}
// Why: remote-runtime snapshots can arrive after WebGL attached to an
// empty buffer; rebuilding after replay parses seeds the glyph atlas
// from the now-populated xterm state.
session.manager.rebuildPaneWebgl(session.pane.id)
appliedCurrentPayload = true
}
return appliedCurrentPayload
}
session.scheduleReplayDataDrain = (): void => {
if (replayDrainQueued) {
return
}
const scheduledPtyId = session.pendingReplayData?.ptyId ?? null
replayDrainQueued = true
// Why: live bytes are newer than the authoritative replay frame. Hold
// them until clear + replay + reset have all parsed, or replay can erase them.
const scheduledStreamGeneration =
session.pendingReplayData?.streamGeneration ?? session.transportStreamGeneration
session.beginReattachLiveDataDeferral(scheduledStreamGeneration)
let replayCompleted = false
session.replayWriteQueue = session.replayWriteQueue
.catch(() => undefined)
.then(() =>
session.structuralReplayCoordinator.run(
async () => {
replayCompleted = await drainReplayDataQueue(scheduledPtyId, scheduledStreamGeneration)
},
{
shouldRestore: () =>
!session.disposed &&
session.transport.getPtyId() === scheduledPtyId &&
session.transportStreamGeneration === scheduledStreamGeneration
}
)
)
.then(() => {
replayCompleted &&= !session.disposed && session.transport.getPtyId() === scheduledPtyId
})
.finally(() => {
replayDrainQueued = false
if (session.pendingReplayData !== null) {
// Why: preserve the PTY identity captured when the callback fired;
// re-reading it here could retag stale bytes for a replacement PTY.
session.scheduleReplayDataDrain()
}
session.finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration)
})
}
}
@@ -0,0 +1,38 @@
// 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 { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function bindAttachRetainedLegacyPty(session: ConnectPanePtySession): void {
session.attachRetainedLegacyPty = (ptyId: string): boolean => {
try {
session.authoritativeReattachGeneration += 1
session.clearPaneMode2031State()
session.clearHiddenOutputRestoreState()
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError)
session.transport.attach({
existingPtyId: ptyId,
callbacks: outputCallbacks.callbacks
})
const attachedPtyId = session.transport.getPtyId() ?? ptyId
session.bindActivePanePty(attachedPtyId, {
updateTabPtyId: 'if-missing',
sampleVisibleForegroundAgent: true
})
if (isRemoteRuntimePtyId(attachedPtyId)) {
session.registerPaneSerializerFor(attachedPtyId)
}
return true
} catch (err) {
session.reportError(err instanceof Error ? err.message : String(err))
return false
}
}
}
@@ -0,0 +1,138 @@
import { createTerminalZeroDimensionsMessage } from '../../../../../shared/terminal-zero-dimensions-diagnostic'
import { isWorktreeRemovalFenceError } from '../../../../../shared/worktree/removal-fence-error'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
// 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 { createCodexBackfillErrorDetector } from '../codex-backfill-error-detector'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { bindBuildColdRestoreAgentResumeStartup } from './cold-restore-resume-startup'
import { bindPrepaintParkedSshSnapshot } from './ssh-snapshot-prepaint'
import { bindForegroundOutputRefresh } from './foreground-output-refresh'
import { bindRegisterPaneSerializer } from './pane-serializer-register'
import { bindHandleReattachResult } from './reattach-result-handler'
import { bindAttachRetainedLegacyPty } from './retained-legacy-pty-attach'
import { runDeferredSessionAttach } from './deferred-session-attach'
import { bindSerializeHiddenOutputSnapshot } from './hidden-output-snapshot-serialize'
import { bindSettlePaneSerializer } from './pane-serializer-settle'
import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot'
import { bindHiddenOutputSeqAndSkip } from './hidden-output-seq-and-skip'
import { bindHiddenRestoreStateAndSshProbe } from './hidden-restore-state-and-ssh-probe'
export function installRunDeferredConnect(session: ConnectPanePtySession): void {
session.runDeferredConnect = (): void => {
if (session.connectStarted) {
return
}
if (!session.startupGridSettledForConnect && session.shouldSettleStartupGridBeforeConnect()) {
session.cancelScheduledConnectFrame()
if (session.connectFallbackTimer !== null) {
clearTimeout(session.connectFallbackTimer)
session.connectFallbackTimer = null
}
session.settleStartupGridBeforeConnect(() => {
session.startupGridSettledForConnect = true
session.runDeferredConnect()
})
return
}
session.connectStarted = true
session.cancelScheduledConnectFrame()
if (session.connectFallbackTimer !== null) {
clearTimeout(session.connectFallbackTimer)
session.connectFallbackTimer = null
}
if (session.disposed) {
return
}
safeFit(session.pane)
session.cols = session.pane.terminal.cols
session.rows = session.pane.terminal.rows
// Why: if fitAddon resolved to 0×0, the container likely has no layout
// dimensions (display:none, unmounted, or zero-size parent). Surface a
// diagnostic so the user sees something instead of a blank pane.
// Gate on visibility: background/hidden tabs (orchestration workers, CLI
// `terminal create` without --focus) legitimately connect at 0×0 because
// safeFit skips fitting unmeasurable panes; they refit via the pane resize
// observer once shown, so the diagnostic must not fire while hidden.
if ((session.cols === 0 || session.rows === 0) && session.deps.isVisibleRef.current) {
session.deps.onPtyErrorRef?.current?.(
session.pane.id,
createTerminalZeroDimensionsMessage(session.cols, session.rows)
)
}
session.reportError = (message: string): void => {
// Why: the transport connect can reject asynchronously after the pane has been
// disposed (e.g. its workspace was deleted) — dropping a late error avoids a toast
// racing the unmount. Mirrors the connect scheduler's disposed guard above.
if (session.disposed) {
return
}
if (isWorktreeRemovalFenceError(message)) {
// Why: main fences a spawn/reattach whose worktree (or an overlapping
// parent/child root) is being deleted. That is expected teardown, not a
// user-facing failure — the pane unmounts once removal completes, so never
// surface the raw fence error. Covers the parent-removal-fences-child case
// that startFreshSpawn's own-worktree isDeleting skip cannot see.
return
}
session.deps.onPtyErrorRef?.current?.(session.pane.id, message)
}
session.codexBackfillErrorDetector =
session.paneStartup?.launchAgent === 'codex' || session.tab?.launchAgent === 'codex'
? createCodexBackfillErrorDetector()
: null
// Why: shared registration so both fresh-spawn and reattach paths install
// the same SerializeAddon-backed serializer plus the onTitleChange wrapper
// that drives lastTitle parity for mobile subscribers. Wires the resulting
// unregister into onDataDisposable.dispose so disposal stays a single
// teardown point. See docs/mobile-prefer-renderer-scrollback.md.
bindRegisterPaneSerializer(session)
bindSettlePaneSerializer(session)
bindBuildColdRestoreAgentResumeStartup(session)
bindDeferredColdRestoreAndSnapshot(session)
session.canUseMainBufferSnapshot = function (ptyId: string | null): ptyId is string {
return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId)
}
session.canUseHiddenOutputSnapshot = function (ptyId: string | null): ptyId is string {
if (!ptyId) {
return false
}
if (session.canUseMainBufferSnapshot(ptyId)) {
return true
}
return (
session.transport.getPtyId() === ptyId &&
typeof session.transport.serializeBuffer === 'function'
)
}
bindSerializeHiddenOutputSnapshot(session)
bindForegroundOutputRefresh(session)
bindHiddenOutputSeqAndSkip(session)
bindHiddenRestoreStateAndSshProbe(session)
bindPrepaintParkedSshSnapshot(session)
bindHandleReattachResult(session)
bindAttachRetainedLegacyPty(session)
runDeferredSessionAttach(session)
}
// Why: Wayland/CI compositors can starve rAF while timers/CDP stay responsive; the terminal must still start its PTY once.
session.connectFallbackTimer = setTimeout(session.runDeferredConnect, 250)
session.connectFrame = requestAnimationFrame(session.runDeferredConnect)
}
@@ -0,0 +1,302 @@
import { useAppStore } from '@/store'
import {
shouldReconcileDeadSession,
shouldReconcileMissingSession,
type HasPty
} from '../terminal-dead-session-reconcile'
import { cancelPendingSafeFitContinuations } from '@/lib/pane-manager/pane-tree-ops'
import { PANE_PTY_RESIZE_HOLD_FLUSH_EVENT } from '@/lib/pane-manager/pane-pty-resize-hold'
// 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 { discardTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import {
getProviderSessionClaimKey,
isPassiveCompletedHibernationEvidence
} from '@/lib/sleeping-agent-pane-ownership'
import { releaseRendererPtyVisibilityClaim } from '../pty-renderer-delivery-claims'
import { REMOTE_PTY_ID_PREFIX } from './pty-connect-limits'
import { SHIFT_ENTER_RECONFIRM_IDLE_MS } from './foreground-output-scan'
import type { PanePtyBinding } from './pane-pty-binding'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installSessionReconcileDispose(session: ConnectPanePtySession): PanePtyBinding {
session.reconcileIfSessionDead = (
liveSessionIds: Set<string>,
snapshotRequestedAt?: number
): void => {
if (session.disposed) {
return
}
const currentPtyId = session.transport.getPtyId()
if (
!currentPtyId ||
// Why: this exit was already handled — onExit guards it too, but skipping here avoids a redundant shouldReconcile evaluation.
session.handledExitPtyId === currentPtyId ||
!shouldReconcileDeadSession({
ptyId: currentPtyId,
connectionId: session.transport.getConnectionId?.(),
liveSessionIds,
ptyBoundAt: session.activePanePtyBindingBoundAt,
snapshotRequestedAt
})
) {
return
}
session.onExit(currentPtyId)
}
session.reconcileIfSessionMissing = (
hasPty: HasPty,
livenessRequestedAt = performance.now()
): void => {
const requestedPtyId = session.transport.getPtyId()
if (
!requestedPtyId ||
requestedPtyId === session.handledExitPtyId ||
requestedPtyId.startsWith(REMOTE_PTY_ID_PREFIX) ||
session.transport.getConnectionId?.() != null
) {
return
}
let livenessPromise: Promise<boolean | null>
try {
livenessPromise = Promise.resolve(hasPty(requestedPtyId))
} catch {
return
}
void livenessPromise
.then((isLive) => {
if (session.disposed) {
return
}
const currentPtyId = session.transport.getPtyId()
if (
!currentPtyId ||
currentPtyId !== requestedPtyId ||
session.handledExitPtyId === currentPtyId ||
!shouldReconcileMissingSession({
ptyId: currentPtyId,
connectionId: session.transport.getConnectionId?.(),
isLive,
ptyBoundAt: session.activePanePtyBindingBoundAt,
livenessRequestedAt
})
) {
return
}
session.onExit(currentPtyId)
})
.catch(() => {})
}
return {
syncProcessTracking() {
session.agentCompletionCoordinator.startProcessTracking()
// Why: the hidden-delivery gate must follow every pane visibility flip.
session.syncHiddenRendererPtyDelivery()
if (!session.deps.isVisibleRef.current) {
session.pendingVisibleRemoteViewportClaim = false
}
},
// Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback.
noteVisibilityResume() {
session.armVisibleRemoteViewportClaim()
session.claimPendingVisibleRemoteViewport()
session.ptySizeReassertion.request({ fit: false })
session.consumeHibernatedAgentWake()
session.requestKnownWindowsShiftEnterReconfirmation()
session.sampleVisiblePaneForegroundAgent()
},
reassertPtySizeAfterWindowWake() {
session.armVisibleRemoteViewportClaim()
session.claimPendingVisibleRemoteViewport()
session.ptySizeReassertion.request({ fit: false })
},
// Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads.
wakeHibernatedAgentIfArmed(claimedProviderSessions) {
if (session.hibernatedWakeInFlightClaimKey) {
if (claimedProviderSessions?.has(session.hibernatedWakeInFlightClaimKey)) {
return null
}
claimedProviderSessions?.add(session.hibernatedWakeInFlightClaimKey)
return session.hibernatedWakeInFlightClaimKey
}
const consumedClaimKey = session.consumeHibernatedAgentWake(claimedProviderSessions)
if (consumedClaimKey) {
return consumedClaimKey
}
// Why: wake arrived mid-hibernation-kill before onExit armed the wake target (transport still bound to the dying PTY).
// Only the exact PTY marked for suppressed shutdown may latch — never a stale/manual record beside an ordinary live PTY.
const state = useAppStore.getState()
const recordEntry = session.getSleepingRecordForPane(state)
const currentPtyId = session.transport.getPtyId()
if (
recordEntry &&
isPassiveCompletedHibernationEvidence(recordEntry.record) &&
currentPtyId !== null &&
state.suppressedPtyExitIds[currentPtyId] === true &&
!session.disposed &&
session.hibernatedWakeTarget === null &&
session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport &&
session.transport.getPtyId() === currentPtyId
) {
const claimKey = getProviderSessionClaimKey(recordEntry.record)
if (claimedProviderSessions?.has(claimKey)) {
return null
}
claimedProviderSessions?.add(claimKey)
session.pendingHibernatedWakeTarget = { ptyId: currentPtyId, record: recordEntry.record }
return claimKey
}
return null
},
sampleForegroundAgentOnFocus() {
session.requestKnownWindowsShiftEnterReconfirmation()
session.sampleVisiblePaneForegroundAgent()
},
requestWindowsShiftEnterReconfirmation() {
if (session.shiftEnterReconfirmTimer !== null) {
clearTimeout(session.shiftEnterReconfirmTimer)
}
// Why: confirm the composer only after the Shift+Enter burst goes idle, preserving rapid multiline input.
session.shiftEnterReconfirmTimer = setTimeout(() => {
session.shiftEnterReconfirmTimer = null
session.requestKnownWindowsShiftEnterReconfirmation()
session.sampleVisiblePaneForegroundAgent()
}, SHIFT_ENTER_RECONFIRM_IDLE_MS)
},
reconcileIfSessionDead: session.reconcileIfSessionDead,
reconcileIfSessionMissing: session.reconcileIfSessionMissing,
dispose() {
session.disposed = true
// Why: a detached client stops observing the pane's bytes, so it must cede
// agent-status authority back to the host on the next mirrored snapshot.
session.releaseRendererOwnedAgentStatusPane?.()
session.directSshPaneRetrySettlementCancelled = true
for (const timer of session.directSshPaneRetrySettlementTimers) {
clearTimeout(timer)
}
session.directSshPaneRetrySettlementTimers.clear()
// Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream.
const queue = session.deferredReattachLiveData
session.deferredReattachLiveData = null
queue?.discard()
session.reattachLiveDataDeferralDepth = 0
session.deferredReattachLiveDataOwners = new Map()
cancelPendingSafeFitContinuations(session.pane)
session.pendingHiddenSnapshotFit = null
session.pendingReattachFit = null
// Why: park/reconnect/remount doesn't advance the recovery epoch, so invalidate this xterm or its delayed retry could hit the next instance.
session.terminalRecoveryInstance.unregister()
session.unregisterUndeliverableWriteHandler()
session.unsubscribeRemoteDesktopActivationClaim()
session.cancelHiddenOutputSnapshotScrollRestore()
session.liveScrollbackRestore?.dispose()
session.liveScrollbackRestore = null
session.structuralReplayCoordinator.dispose()
session.cancelFreshSpawnFollowReset()
// Why: cancel the post-spawn reconcile's pending rAF so a torn-down pane can't keep fitting/resizing after disposal.
session.ptySizeReconcileHandle?.cancel()
session.ptySizeReconcileHandle = null
session.startupGridSettleHandle?.cancel()
session.startupGridSettleHandle = null
session.ptySizeReassertion.dispose()
if (session.pendingForegroundGridDriftCheckRaf !== null) {
cancelAnimationFrame(session.pendingForegroundGridDriftCheckRaf)
session.pendingForegroundGridDriftCheckRaf = null
}
// Why: a pane unmount must never leave its PTY delivery gated — the parked watcher or remounted pane re-decides.
session.releaseHiddenRendererPtyDelivery()
if (session.terminalKeyTargetSupportsEvents) {
session.terminalKeyTarget.removeEventListener('keydown', session.onTerminalKeyDown, {
capture: true
})
}
session.clearPendingTerminalInputIntent()
session.pendingTerminalInputWrite = null
session.interruptInference.dispose()
session.clearTitleOnlyInterruptTimer()
// Why release, not cancel: the pending settle belongs to the turn, not to
// this pane — a park mid-settle hands it to the parked watcher instead.
session.releaseCommandCodeDoneSettleExecutor()
if (session.shiftEnterReconfirmTimer !== null) {
clearTimeout(session.shiftEnterReconfirmTimer)
session.shiftEnterReconfirmTimer = null
}
// Why: resolve in-flight passphrase-gate waits so their zustand subscribers + async IIFEs don't hang when the pane is torn down before SSH state changes.
while (session.waitTeardowns.length > 0) {
const teardown = session.waitTeardowns.pop()
teardown?.()
}
if (session.startupInjectTimer !== null) {
clearTimeout(session.startupInjectTimer)
session.startupInjectTimer = null
}
session.cleanupStartupDraftPasteTimers()
session.releaseUnattemptedStartupDraftPasteDelivery()
session.unregisterAgentHookTerminalLifecycle()
session.clearSuppressedTitleSideEffects()
session.clearPendingAgentTaskCompleteNotification()
session.pendingTerminalBellNotification = false
session.clearTerminalBellNotificationTimer()
session.clearReattachIdleAgentCursorResetTimer()
if (session.alternateScreenBackgroundRepaintTimer !== null) {
clearTimeout(session.alternateScreenBackgroundRepaintTimer)
session.alternateScreenBackgroundRepaintTimer = null
}
session.cleanupHiddenOutputRestoreDeferredRetry()
session.cleanupHiddenOutputRestoreForegroundDeadline()
session.cleanupHiddenOutputRestoreFloodRepaint()
session.unregisterBacklogRecovery?.()
session.unregisterBacklogRecovery = null
session.unregisterDocumentVisibilityRecovery?.()
session.unregisterDocumentVisibilityRecovery = null
releaseRendererPtyVisibilityClaim(session.transport)
// Why: the pane's fact consumer must be gone before a parked-tab watcher takes over this PTY's facts in the same effect flush.
session.dropSideEffectFactConsumer()
session.clearPanePtyFitBinding()
discardTerminalOutput(session.pane.terminal)
session.unregisterE2ePtyDataInjection()
if (session.agentTaskCompleteSettingsUnsubscribe !== null) {
session.agentTaskCompleteSettingsUnsubscribe()
session.agentTaskCompleteSettingsUnsubscribe = null
}
if (session.unsubscribeWindowsDoneTerminalModeReset !== null) {
session.unsubscribeWindowsDoneTerminalModeReset()
session.unsubscribeWindowsDoneTerminalModeReset = null
}
if (session.connectFrame !== null) {
// Why: cancel the queued connect frame so a disposed pane (StrictMode/split-group remount) can't reattach the PTY and steal the live pane's handler wiring.
session.cancelScheduledConnectFrame()
}
if (session.connectFallbackTimer !== null) {
clearTimeout(session.connectFallbackTimer)
session.connectFallbackTimer = null
}
session.imeCompositionRouteDisposable.dispose()
session.onDataDisposable.dispose()
session.userInputActivityDisposable?.dispose()
session.terminalCapabilityRepliesDisposable.dispose()
session.onResizeDisposable.dispose()
session.pane.container.removeEventListener(
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
session.onHeldPtyResizeFlush
)
session.geometryReportObserver?.disconnect()
if (session.pendingGeometryReportRaf !== null) {
cancelAnimationFrame(session.pendingGeometryReportRaf)
session.pendingGeometryReportRaf = null
}
session.commandLifecycle.dispose()
session.deferredCommandFinishedStatusDrop = null
session.visibleForegroundSamplePending = false
session.visibleForegroundSampleSettled = false
session.paneForegroundAgentTracker.dispose()
session.agentCompletionCoordinator.dispose()
}
}
}
@@ -0,0 +1,75 @@
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
import type { SetupSplitDirection } from '../../../../../shared/worktree/launch-types'
const SPLIT_GEOMETRY_EPSILON_PX = 1
export function readElementRect(element: HTMLElement | null | undefined): DOMRect | null {
try {
return element?.getBoundingClientRect?.() ?? null
} catch {
return null
}
}
export function hasVisibleRect(rect: DOMRect | null): rect is DOMRect {
return Boolean(
rect && rect.width > SPLIT_GEOMETRY_EPSILON_PX && rect.height > SPLIT_GEOMETRY_EPSILON_PX
)
}
export function readProposedPaneGrid(pane: ManagedPane): { cols: number; rows: number } | null {
try {
const dimensions = pane.fitAddon.proposeDimensions()
if (!dimensions || dimensions.cols <= 0 || dimensions.rows <= 0) {
return null
}
return dimensions
} catch {
return null
}
}
export function isPaneGridAlignedWithFit(pane: ManagedPane): boolean {
const proposed = readProposedPaneGrid(pane)
return Boolean(
proposed && pane.terminal.cols === proposed.cols && pane.terminal.rows === proposed.rows
)
}
export function isSetupSplitGeometryReady(
pane: ManagedPane,
manager: PaneManager,
direction: SetupSplitDirection
): boolean {
const splitElement = pane.container.parentElement
const directionClass = direction === 'vertical' ? 'is-vertical' : 'is-horizontal'
if (
!splitElement?.classList?.contains('pane-split') ||
!splitElement.classList.contains(directionClass)
) {
return false
}
const sibling = manager
.getPanes()
.find(
(candidate) => candidate.id !== pane.id && candidate.container.parentElement === splitElement
)
const splitRect = readElementRect(splitElement)
const paneRect = readElementRect(pane.container)
const siblingRect = readElementRect(sibling?.container)
if (!hasVisibleRect(splitRect) || !hasVisibleRect(paneRect) || !hasVisibleRect(siblingRect)) {
return false
}
const splitAxis = direction === 'vertical' ? splitRect.width : splitRect.height
const paneAxis = direction === 'vertical' ? paneRect.width : paneRect.height
const siblingAxis = direction === 'vertical' ? siblingRect.width : siblingRect.height
return (
paneAxis > SPLIT_GEOMETRY_EPSILON_PX &&
siblingAxis > SPLIT_GEOMETRY_EPSILON_PX &&
splitAxis - paneAxis > SPLIT_GEOMETRY_EPSILON_PX &&
splitAxis - siblingAxis > SPLIT_GEOMETRY_EPSILON_PX &&
isPaneGridAlignedWithFit(pane)
)
}
@@ -0,0 +1,268 @@
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
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 type { AgentType } from '../../../../../shared/agent-status-types'
import { AGENT_INTERRUPT_SETTLE_MS } from '../../../../../shared/agent-interrupt-intent'
import { resolvePaneAgentOwner } from '../../../../../shared/pane-agent-owner'
import { MANUAL_AGENT_COMMAND_MAX_CHARS } from './pty-connect-limits'
import {
CURSOR_AGENT_REATTACH_HEADER,
terminalOwnsDomFocus,
hasCursorAgentReattachPayloadScreenSignal
} from './cursor-agent-reattach-screen'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installShellCommandInference(session: ConnectPanePtySession): void {
session.observeAcceptedShellCommandInput = (data: string): void => {
if (
data.includes('\r') ||
data.includes('\n') ||
data.includes('\x03') ||
data.includes('\x04')
) {
// Why: shells without OSC 133 give no command/exit boundary. An accepted
// submit or interrupt revokes stale trusted Shift+Enter routing and confirms once.
session.requestKnownWindowsShiftEnterReconfirmation()
}
if (session.commandInferredPaneAgent) {
return
}
// Why: bytes typed inside a live agent TUI are prompt text, not shell
// commands, even if they spell another agent binary name.
if (session.hasFreshPaneAgentSurface()) {
session.resetPendingShellCommandLine()
return
}
if (session.shellCommandInferenceSuspendedUntilCommandEnd) {
if (data.includes('\x03') || data.includes('\x15')) {
session.shellCommandInferenceSuspendedUntilCommandEnd = false
session.resetPendingShellCommandLine()
}
if (data.includes('\r') || data.includes('\n')) {
session.shellCommandInferenceSuspendedUntilCommandEnd = false
}
return
}
if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) {
session.resetPendingShellCommandLine()
session.shellCommandInferenceSuspendedUntilCommandEnd =
!data.includes('\r') && !data.includes('\n')
return
}
for (let index = 0; index < data.length; index += 1) {
const char = data[index]!
if (char === '\r' || char === '\n') {
session.shellCommandInferenceSuspendedUntilCommandEnd = false
session.rememberCommandInferredPaneAgent()
if (session.commandInferredPaneAgent) {
return
}
continue
}
if (char === '\x7f' || char === '\b') {
session.deletePendingShellCommandCharacter()
continue
}
if (char === '\x17') {
session.deletePendingShellCommandWord()
continue
}
if (char === '\x03' || char === '\x15') {
session.resetPendingShellCommandLine()
continue
}
if (char === '\x1b') {
const nextIndex = session.consumeShellCommandCsiSequence(data, index)
if (nextIndex !== null) {
index = nextIndex - 1
continue
}
session.resetPendingShellCommandLine()
continue
}
if (char < ' ') {
session.resetPendingShellCommandLine()
continue
}
if (char >= ' ') {
session.appendPendingShellCommandInput(char)
if (session.shellCommandInferenceSuspendedUntilCommandEnd) {
return
}
}
}
}
/**
* Resolves the authoritative owner agent type for this pane, checking tab launch,
* pane startup, typed command ownership, and store state configuration.
*
* Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an
* OMP-owned pane back to Pi; command ownership covers manually typed `omp`
* in generic terminals where launch metadata does not exist.
*/
session.getAuthoritativePaneAgent = (): AgentType | undefined => {
const state = useAppStore.getState()
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
(entry) => entry.id === session.deps.tabId
)
return (
resolvePaneAgentOwner({
launchAgent: tab?.launchAgent,
startupLaunchAgent: session.paneStartup?.launchAgent,
initialStatusAgent: session.paneStartup?.initialAgentStatus?.agent,
commandInferredAgent: session.commandInferredPaneAgent,
hookAgent: state.agentStatusByPaneKey[session.cacheKey]?.agentType
}) ?? undefined
)
}
// Why: the renderer veto (owner evidence beating a Gemini-looking title) must
// use only pane-scoped, CURRENT ownership. getAuthoritativePaneAgent leads
// with the tab-shared `tab.launchAgent` and a never-cleared
// `paneStartup.launchAgent`, which would let a sibling split pane or a reused
// pane keep WebGL for a genuine Gemini terminal (#7428 regression class).
// Launch identity is excluded, and the never-clearing startup seed
// (`paneStartup.initialAgentStatus`) too; a stale or `done` explicit row is
// ignored via the freshness predicate so a reused pane cannot inherit a prior
// agent's veto. Only live foreground command inference and a fresh, active
// hook row count. A genuine OMP/Pi pane stays protected owner-independently by
// the isPiAgentTitle guard inside isGeminiTerminalTitle.
session.getPaneScopedRendererOwner = (): AgentType | undefined => {
const entry = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
return (
session.commandInferredPaneAgent ??
(session.isFreshActivePaneAgentEntry(entry) ? entry.agentType : undefined)
)
}
session.clearInferredInterruptWorkingTitle = (): void => {
const state = useAppStore.getState()
const currentTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
const statusTitle = state.agentStatusByPaneKey[session.cacheKey]?.terminalTitle
const title = currentTitle ?? statusTitle
if (!title) {
return
}
const neutralTitle = session.neutralTerminalTitle()
// Why: inferred interrupts update the explicit hook row, but many CLIs leave
// their OSC title stuck on a working spinner. Replace only this fallback
// title signal with a neutral terminal label so the existing process tracker
// can still decide whether an agent TUI is truly alive.
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.titleOnlyInterruptTimer = null
session.clearTitleOnlyInterruptTimer = (): void => {
if (session.titleOnlyInterruptTimer !== null) {
clearTimeout(session.titleOnlyInterruptTimer)
session.titleOnlyInterruptTimer = null
}
}
session.observeTitleOnlyInterrupt = (): void => {
const state = useAppStore.getState()
if (state.agentStatusByPaneKey[session.cacheKey]) {
return
}
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
const baselineTitle = runtimeTitle ?? tabTitle
if (detectAgentStatusFromTitle(baselineTitle ?? '') !== 'working') {
return
}
session.clearTitleOnlyInterruptTimer()
session.titleOnlyInterruptTimer = setTimeout(() => {
session.titleOnlyInterruptTimer = null
if (useAppStore.getState().agentStatusByPaneKey[session.cacheKey]) {
return
}
const currentState = useAppStore.getState()
const currentRuntimeTitle =
currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
const currentTabTitle = (currentState.tabsByWorktree[session.deps.worktreeId] ?? []).find(
(entry) => entry.id === session.deps.tabId
)?.title
const currentTitle = currentRuntimeTitle ?? currentTabTitle
if (
currentTitle === baselineTitle &&
detectAgentStatusFromTitle(currentTitle ?? '') === 'working'
) {
// Why: title-only agents such as Pi can miss their own idle title after
// Ctrl+C. Clear only an unchanged, acknowledged working title.
session.clearInferredInterruptWorkingTitle()
}
}, AGENT_INTERRUPT_SETTLE_MS)
}
session.clearReattachIdleAgentCursorResetTimer = (): void => {
if (session.reattachIdleAgentCursorResetTimer !== null) {
clearTimeout(session.reattachIdleAgentCursorResetTimer)
session.reattachIdleAgentCursorResetTimer = null
}
}
session.getCurrentTerminalTitle = (): 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
}
session.reattachReplayPayloadHasCursorAgentSignal = false
// Why: post-parse veto callbacks must only judge the latest replay frame; a
// newer frame bumps the generation so a stale callback stands down.
session.reattachReplayPayloadSignalGeneration = 0
session.rememberReattachPayloadAgentSignal = (
data: string,
opts: { fullScreenReplay: boolean }
): void => {
session.reattachReplayPayloadSignalGeneration += 1
// Why: ordinary scrollback can mention agent names. Treat replay bytes as
// a live Cursor Agent signal only when they look like its restored screen.
const signal = hasCursorAgentReattachPayloadScreenSignal(data)
// Why: incremental (non-clearing) replay frames repaint only part of the
// screen, so their bytes can only add evidence — a full-screen replay is
// the authoritative repaint that may clear the flag.
session.reattachReplayPayloadHasCursorAgentSignal = opts.fullScreenReplay
? signal
: session.reattachReplayPayloadHasCursorAgentSignal || signal
}
session.isCursorAgentNativeTitle = (title: string): boolean => {
return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase()
}
session.hasLiveAgentReattachStatusOrTitleSignal = (): boolean => {
// Why: launch ownership (tab.launchAgent) never decays after the agent
// exits, so it must not count as liveness here — only live status, live
// titles, and the replayed screen shape do.
if (useAppStore.getState().agentStatusByPaneKey[session.cacheKey]) {
return true
}
const title = session.getCurrentTerminalTitle() ?? ''
// Why: broad token matching (getAgentLabel) fires on titles like
// "ssh devin@host"; that surface is too loose to gate mode preservation
// and PTY byte injection, so only exact/status titles count here.
return detectAgentStatusFromTitle(title) !== null || session.isCursorAgentNativeTitle(title)
}
session.hasLiveAgentReattachSignal = (): boolean => {
return (
session.hasLiveAgentReattachStatusOrTitleSignal() ||
session.reattachReplayPayloadHasCursorAgentSignal
)
}
session.shouldPreserveAgentReattachModes = (): boolean => {
// Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes.
// Preserve those modes only when reattach still looks agent-owned.
return session.hasLiveAgentReattachSignal()
}
session.shouldSendFocusedAgentReattachFocusIn = (): boolean => {
return terminalOwnsDomFocus(session.pane.terminal) && session.shouldPreserveAgentReattachModes()
}
}
@@ -0,0 +1,205 @@
import { useAppStore } from '@/store'
import type { PtyConnectResult } from '../pty-transport'
// 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 { createBrowserUuid } from '@/lib/browser-uuid'
import { parseLegacyNumericPaneKey } from '../../../../../shared/stable-pane-id'
import { getProviderSessionClaimKey } from '@/lib/sleeping-agent-pane-ownership'
import {
agentProviderSessionsEqual,
type SleepingAgentSessionRecord
} from '../../../../../shared/agent-session-resume'
import { recognizeAgentProcessFromCommandLine } from '../../../../../shared/agent-process-recognition'
import type { TuiAgent } from '../../../../../shared/tui-agent'
import { TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config'
import {
beginAgentStartupDeliveryAttempt,
releaseAgentStartupDeliveryAttempt
} from '@/lib/agent-startup-delayed-delivery'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { installCommandInferredPaneAgent } from './command-inferred-pane-agent'
export function installSleepingRecordAccess(session: ConnectPanePtySession): void {
session.getSleepingRecordForPane = (
state: ReturnType<typeof useAppStore.getState>
): { paneKey: string; record: SleepingAgentSessionRecord } | null => {
const stableRecord = state.sleepingAgentSessionsByPaneKey[session.cacheKey]
if (stableRecord) {
return { paneKey: session.cacheKey, record: stableRecord }
}
const legacyMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter(
([paneKey, record]) => {
const legacy = parseLegacyNumericPaneKey(paneKey)
return (
legacy?.tabId === session.deps.tabId &&
record.worktreeId === session.deps.worktreeId &&
(!record.tabId || record.tabId === session.deps.tabId)
)
}
)
const exactLegacyMatch = legacyMatches.find(([paneKey]) => {
const legacy = parseLegacyNumericPaneKey(paneKey)
return legacy?.numericPaneId === String(session.pane.id)
})
const providerSessionKeys = new Set(
legacyMatches.map(([, record]) => getProviderSessionClaimKey(record))
)
const oldestLegacyMatch = legacyMatches
.slice()
.sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0]
// Why: duplicate legacy aliases can point at one provider session; consume
// the oldest capture as canonical and clear its aliases after resume.
const selectedLegacyMatch =
exactLegacyMatch ??
(providerSessionKeys.size === 1
? legacyMatches.length === 1
? legacyMatches[0]
: oldestLegacyMatch
: null)
if (!selectedLegacyMatch) {
return null
}
const [paneKey, record] = selectedLegacyMatch
return { paneKey, record }
}
session.isLegacyWorkerAutomaticResumeBlocked = (): boolean =>
session.getSleepingRecordForPane(useAppStore.getState())?.record.automaticResumeBlockedBy ===
'legacy-orchestration-worker'
session.clearSleepingRecordProviderDuplicates = (
state: ReturnType<typeof useAppStore.getState>,
consumed: { paneKey: string; record: SleepingAgentSessionRecord }
): void => {
state.clearSleepingAgentSession(consumed.paneKey)
for (const [paneKey, record] of Object.entries(state.sleepingAgentSessionsByPaneKey)) {
if (
paneKey !== consumed.paneKey &&
record.worktreeId === consumed.record.worktreeId &&
record.agent === consumed.record.agent &&
agentProviderSessionsEqual(
record.agent,
record.providerSession,
consumed.record.providerSession
)
) {
// Why: legacy pane aliases can leave multiple sleeping rows for one
// provider session; once this pane resumes it, every alias is stale.
state.clearSleepingAgentSession(paneKey)
}
}
}
session.launchToken = session.paneStartup?.launchConfig
? (session.paneStartup.launchToken ?? createBrowserUuid())
: undefined
session.startupDraftAgent =
session.paneStartup?.launchAgent ?? session.paneStartup?.initialAgentStatus?.agent
session.startupDraftAgentConfig = session.startupDraftAgent
? TUI_AGENT_CONFIG[session.startupDraftAgent]
: null
session.startupDraftPrompt =
typeof session.paneStartup?.draftPrompt === 'string' && session.paneStartup.draftPrompt.trim()
? session.paneStartup.draftPrompt
: null
session.startupDraftPromptNeedsPaste =
session.startupDraftPrompt !== null &&
!session.startupDraftAgentConfig?.draftPromptFlag &&
!session.startupDraftAgentConfig?.draftPromptEnvVar
session.startupDraftDeliveryClaimed = false
session.startupDraftPasteAttempted = false
session.claimStartupDraftPasteDelivery = (): boolean => {
if (!session.startupDraftPromptNeedsPaste || session.launchToken === undefined) {
return false
}
if (session.startupDraftDeliveryClaimed) {
return true
}
// Why: launch-bound draft paste needs a launch token; all current
// draftPrompt startup callers pair it with launchConfig so this can safely
// fence off delayed sidecar delivery before Codex's first composer frame.
session.startupDraftDeliveryClaimed = beginAgentStartupDeliveryAttempt({
worktreeId: session.deps.worktreeId,
tabId: session.deps.tabId,
launchToken: session.launchToken
})
return session.startupDraftDeliveryClaimed
}
session.releaseUnattemptedStartupDraftPasteDelivery = (): void => {
if (
!session.startupDraftDeliveryClaimed ||
session.startupDraftPasteAttempted ||
session.launchToken === undefined
) {
return
}
releaseAgentStartupDeliveryAttempt({
worktreeId: session.deps.worktreeId,
tabId: session.deps.tabId,
launchToken: session.launchToken
})
session.startupDraftDeliveryClaimed = false
}
// Why: reserve before deferred connect so the creation sidecar cannot time out during setup and strand this pane's live scanner.
session.ownsStartupDraftPaste = session.claimStartupDraftPasteDelivery()
if (session.paneStartup?.launchConfig) {
useAppStore
.getState()
.registerAgentLaunchConfig(session.cacheKey, session.paneStartup.launchConfig, {
agentType: session.paneStartup.launchAgent ?? session.paneStartup.initialAgentStatus?.agent,
...(session.launchToken ? { launchToken: session.launchToken } : {}),
tabId: session.deps.tabId,
leafId: session.pane.leafId
})
} else if (session.paneStartup) {
useAppStore.getState().clearAgentLaunchConfig(session.cacheKey)
}
session.registerEffectiveLaunchConfig = (
effectiveLaunchConfig: PtyConnectResult['launchConfig'] | undefined,
metadata?: { launchToken?: string; launchAgent?: TuiAgent }
): void => {
if (!effectiveLaunchConfig) {
if (metadata?.launchAgent) {
// Why: daemon launch identity can outlive the process while Orca is
// closed. Use it to request confirmation, never as current byte authority.
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
agent: metadata.launchAgent,
shellForeground: false
})
}
return
}
// Why: daemon reattach preserves the pane's exact launch command but not
// renderer metadata; recover only allowlisted command identity from it.
const persistedLaunchAgent = recognizeAgentProcessFromCommandLine(
effectiveLaunchConfig.agentCommand
)?.agent
useAppStore.getState().registerAgentLaunchConfig(session.cacheKey, effectiveLaunchConfig, {
agentType:
metadata?.launchAgent ??
session.paneStartup?.launchAgent ??
session.paneStartup?.initialAgentStatus?.agent ??
persistedLaunchAgent,
...((metadata?.launchToken ?? session.launchToken)
? { launchToken: metadata?.launchToken ?? session.launchToken }
: {}),
tabId: session.deps.tabId,
leafId: session.pane.leafId
})
}
session.clearRegisteredStartupLaunchConfig = (): void => {
useAppStore.getState().clearAgentLaunchConfig(session.cacheKey)
}
session.neutralTerminalTitle = (): string => {
const state = useAppStore.getState()
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
(entry) => entry.id === session.deps.tabId
)
return tab?.defaultTitle?.trim() || 'Terminal'
}
installCommandInferredPaneAgent(session)
}
@@ -0,0 +1,63 @@
import { useAppStore } from '@/store'
import { SSH_SESSION_EXPIRED_ERROR } from './pty-connect-limits'
// Why: when multiple panes/tabs need the same deferred SSH connection,
// the first one calls ssh.connect() and subsequent ones must wait for it
// rather than returning early (which would leave them disconnected). This
// helper either connects or waits for an in-flight connect to finish.
export type SshConnectResult = { connected: true } | { connected: false; error: string }
export type UserInitiatedSshConnectOutcome = 'connected' | 'cancelled' | 'failed'
const sshConnectPromises = new Map<string, Promise<SshConnectResult>>()
export function isSshSessionExpiredError(err: unknown): boolean {
return (err instanceof Error ? err.message : String(err)).includes(SSH_SESSION_EXPIRED_ERROR)
}
export function sshPromptConnectOutcomeForStatus(
status: string | undefined,
sawNonDisconnected: boolean
): UserInitiatedSshConnectOutcome | null {
if (status === 'connected') {
return 'connected'
}
if (status === 'auth-failed' || status === 'error' || status === 'reconnection-failed') {
return 'failed'
}
// Why: this only counts after a real connect attempt; the entry-time
// disconnected state just means the user still needs to initiate auth.
if (sawNonDisconnected && status === 'disconnected') {
return 'cancelled'
}
return null
}
export async function waitForSshConnection(connectionId: string): Promise<SshConnectResult> {
const state = useAppStore.getState().sshConnectionStates.get(connectionId)
if (state?.status === 'connected') {
return { connected: true }
}
const existing = sshConnectPromises.get(connectionId)
if (existing) {
return existing
}
const promise: Promise<SshConnectResult> = (async (): Promise<SshConnectResult> => {
try {
await window.api.ssh.connect({ targetId: connectionId })
return { connected: true }
} catch (err) {
console.warn(`Deferred SSH reconnect failed for ${connectionId}:`, err)
return {
connected: false,
error: err instanceof Error ? err.message : String(err)
}
} finally {
sshConnectPromises.delete(connectionId)
}
})()
sshConnectPromises.set(connectionId, promise)
return promise
}
@@ -0,0 +1,85 @@
import { waitForTerminalReplayWritesParsed } from '../replay-guard'
// 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 { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
import {
buildMainModelSnapshotReplayWrites,
hasPositiveTerminalDimensions
} from '../terminal-snapshot-replay-paint'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function bindPrepaintParkedSshSnapshot(session: ConnectPanePtySession): void {
session.prepaintParkedSshSnapshot = (ptyId: string | null): void => {
const parsedPtyId = ptyId ? parseAppSshPtyId(ptyId) : null
if (
!ptyId ||
!session.mountFollowsTerminalPark ||
parsedPtyId?.connectionId !== session.connectionId ||
!session.capturedDirectSshRetryLeaseMatches()
) {
return
}
const capturedGeneration = session.authoritativeReattachGeneration
const isCurrent = (): boolean =>
!session.disposed &&
session.mountFollowsTerminalPark &&
session.authoritativeReattachGeneration === capturedGeneration &&
session.capturedDirectSshRetryLeaseMatches()
const fetchSnapshot = session.getSshMainModelSnapshotProbe(ptyId)
void fetchSnapshot()
.then(async (snapshot) => {
if (!snapshot || !isCurrent()) {
return
}
await session.structuralReplayCoordinator.run(
async () => {
if (!isCurrent()) {
return
}
const modelData = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
session.rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true })
if (
hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) &&
(session.pane.terminal.cols !== snapshot.cols ||
session.pane.terminal.rows !== snapshot.rows)
) {
session.suppressStructuralReplayPtyResize = true
try {
session.pane.terminal.resize(snapshot.cols, snapshot.rows)
} finally {
session.suppressStructuralReplayPtyResize = false
}
}
session.applySnapshotKittyKeyboardModes(modelData, {
kittyKeyboardFlags: snapshot.kittyKeyboardFlags,
snapshotSeq: snapshot.seq
})
// Why keep a too-wide frame: preconnect SSH has no live repaint owner or post-restore fit.
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
paneOnAlternateScreen: session.isPaneOnAlternateScreen()
})) {
session.writeReplayData(replayChunk)
}
session.writeReplayData(session.reattachReplayResetSequence(modelData))
if (snapshot.pendingEscapeTailAnsi) {
session.writeReplayData(snapshot.pendingEscapeTailAnsi)
}
recordTerminalOutput(session.pane.terminal)
await waitForTerminalReplayWritesParsed(session.pane.terminal)
if (isCurrent()) {
session.manager.rebuildPaneWebgl(session.pane.id)
}
},
{ shouldRestore: isCurrent }
)
})
.catch(() => {})
}
}
@@ -0,0 +1,4 @@
// Why: the notice deliberately omits the rejected path — saved cwds can
// contain private repo/user names; the terminal itself shows where it opened.
export const STARTUP_CWD_FALLBACK_NOTICE =
'\r\n[Orca opened this terminal at the workspace root because its saved start folder no longer exists.]\r\n'
@@ -0,0 +1,266 @@
import { useAppStore } from '@/store'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { bindPanePtyId, getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
// 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 { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
import { isFreshNonDoneAgentStatus } from '../../../../../shared/agent-status-types'
import { isCtrlCKeyEvent, isPlainEscapeKeyEvent } from '../agent-interrupt-inference'
import { createAgentCompletionCoordinator } from '../agent-completion-coordinator'
import { dispatchAgentHookTerminalLifecycle } from '../agent-hook-terminal-lifecycle'
import { createCodexAutoApprovalHookCompletionSuppressor } from '../codex-auto-approval-notification-suppression'
import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner'
import { registerTerminalSideEffectFactConsumer } from '../terminal-side-effect-facts-handler'
import { isAgentTaskCompleteTrackingEnabled } from './agent-task-complete-settings'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function installTerminalKeydownFit(session: ConnectPanePtySession): void {
session.onTerminalKeyDown = (event: KeyboardEvent): void => {
if (isPlainEscapeKeyEvent(event)) {
session.setPendingTerminalInputIntent('plain-escape')
// Why: plain Escape produces real terminal input (\x1b), so it is a
// genuine "user is here" signal and must still dismiss attention before
// the early return for interrupt-intent inference.
session.deps.clearTerminalTabUnread(session.deps.tabId)
session.deps.clearTerminalPaneUnread(session.cacheKey)
session.deps.clearWorktreeUnread(session.deps.worktreeId)
return
}
if (isCtrlCKeyEvent(event)) {
if (!navigator.userAgent.includes('Mac') && session.pane.terminal.hasSelection()) {
return
}
session.setPendingTerminalInputIntent('ctrl-c')
}
// Why: only treat keydowns that will produce real terminal input as the
// "user is here" signal. Modifier-only presses, autorepeat, and Cmd/Ctrl+C
// copy chords with an active selection must not dismiss attention on a
// sibling pane before the user has seen it.
if (
event.repeat ||
event.key === 'Alt' ||
event.key === 'AltGraph' ||
event.key === 'Control' ||
event.key === 'Meta' ||
event.key === 'Shift'
) {
return
}
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === 'c' &&
session.pane.terminal.hasSelection()
) {
return
}
// Why: user shell frameworks (bash-preexec/iTerm2) can replace Orca's
// OSC 133;C hook, so a manually launched agent produces no command-start
// signal at all. Enter at a shell-foreground prompt is the user-side
// equivalent; the sample is gated to panes with no live agent identity
// and publishes nothing for an idle shell.
if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey) {
session.sampleVisiblePaneForegroundAgent()
}
session.deps.clearTerminalTabUnread(session.deps.tabId)
session.deps.clearTerminalPaneUnread(session.cacheKey)
session.deps.clearWorktreeUnread(session.deps.worktreeId)
}
// Why: infer only from focused xterm key events. Raw PTY bytes cannot
// distinguish plain Escape from Alt/meta sequences, and programmatic writes
// should not clear agent status.
session.terminalKeyTarget = session.pane.terminal.element ?? session.pane.container
session.terminalKeyTargetSupportsEvents =
typeof session.terminalKeyTarget?.addEventListener === 'function' &&
typeof session.terminalKeyTarget?.removeEventListener === 'function'
if (session.terminalKeyTargetSupportsEvents) {
session.terminalKeyTarget.addEventListener('keydown', session.onTerminalKeyDown, {
capture: true
})
}
session.visibleRemoteViewportClaimPtyId = null
session.pendingVisibleRemoteViewportClaim = false
session.setPanePtyFitBinding = (ptyId: string): void => {
bindPanePtyId(session.pane.id, ptyId, session.deps.tabId)
session.pane.container.dataset.ptyId = ptyId
if (
session.deps.isVisibleRef.current &&
isRemoteRuntimePtyId(ptyId) &&
session.visibleRemoteViewportClaimPtyId !== ptyId
) {
// Why: the initial fit event consumes this activation arm before later peer ownership changes.
session.visibleRemoteViewportClaimPtyId = ptyId
session.pendingVisibleRemoteViewportClaim = true
}
// Why: override hydration can arrive before this pane knows its PTY. Once
// data-pty-id is bound, safeFit can park xterm at the authoritative grid.
if (getFitOverrideForPty(ptyId)) {
safeFit(session.pane)
}
session.claimPendingVisibleRemoteViewport()
}
session.activePanePtyBinding = null
// Why: bind time lets async liveness reconcile ignore a request started
// before this PTY bound (newborn race). Null disables the guard (fail-safe).
session.activePanePtyBindingBoundAt = null
// Why: with main side-effect authority on, the pane's title/bell/agent
// policy callbacks consume pty:sideEffect facts instead of transport byte
// parsers (which stay unregistered) — same policy code, single consumer.
// restoreTitleOnRegister replaces the eager-replay title restore: main's
// title-only snapshot carries the no-attention-replay rule.
session.unregisterSideEffectFactConsumer = null
session.registerSideEffectFactConsumerForPty = (
ptyId: string,
remoteOutputPaused = false
): void => {
if ((!session.mainSideEffectAuthority && !remoteOutputPaused) || session.disposed) {
return
}
session.unregisterSideEffectFactConsumer?.()
session.unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({
ptyId,
callbacks: {
onTitleChange: session.onTitleChange,
onBell: session.onBell,
onAgentBecameIdle: session.onAgentBecameIdle,
onAgentBecameWorking: session.onAgentBecameWorking,
onAgentExited: session.onAgentExited,
onCommandFinished: session.handleCommandFinished,
onPrLink: (link) =>
useAppStore
.getState()
.observeTerminalGitHubPullRequestLink(session.deps.worktreeId, link),
// Why: the Command Code settle policy stays here — the done settle
// timer must consult the live store row (which hook events and
// renderer seeds also write), so main only emits scrape facts.
onCommandCodeWorking: session.seedCommandCodeOutputWorkingStatus,
onCommandCodeDone: session.scheduleCommandCodeOutputDoneStatus,
...(session.shouldOwnAgentStatusInRenderer
? { onAgentStatus: (payload) => session.handleRendererOwnedAgentStatus(payload) }
: {}),
// Why: gated hidden panes never see the subscribe bytes; the fact
// replaces the byte scan (and the old post-latch subscribe drop).
...(session.hiddenDeliveryGateActive || remoteOutputPaused
? {
onMode2031Subscribe: session.handleHiddenMode2031SubscribeFact,
onMode2031Unsubscribe: session.handleHiddenMode2031UnsubscribeFact
}
: {})
},
restoreTitleOnRegister: true
})
}
session.dropSideEffectFactConsumer = (): void => {
session.unregisterSideEffectFactConsumer?.()
session.unregisterSideEffectFactConsumer = null
session.remoteOutputFactConsumerPtyId = null
}
session.clearPanePtyFitBinding = (): void => {
// Why: fit bindings live in a module-level map, so pane teardown must
// clear them explicitly instead of relying on DOM removal.
bindPanePtyId(session.pane.id, null, session.deps.tabId)
session.visibleRemoteViewportClaimPtyId = null
session.pendingVisibleRemoteViewportClaim = false
session.activePanePtyBinding = null
session.activePanePtyBindingBoundAt = null
delete session.pane.container.dataset.ptyId
delete session.pane.container.dataset.ptyRecoveryState
}
session.agentCompletionCoordinator = createAgentCompletionCoordinator({
paneKey: session.cacheKey,
statusLane: 'pty',
getPtyId: () => session.transport.getPtyId(),
getSettings: () => useAppStore.getState().settings,
inspectProcess: inspectRuntimeTerminalProcess,
dispatchHookLifecycle: (payload) =>
dispatchAgentHookTerminalLifecycle(session.cacheKey, payload),
shouldSuppressProcessReplacementCompletion: (_exited, replacement) => {
const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
const currentAgentForReplacement = resolveCompatibleAgentTypeForOwner(
currentStatus?.agentType,
replacement.agent
)
return (
isFreshNonDoneAgentStatus(currentStatus) && currentAgentForReplacement === replacement.agent
)
},
shouldSuppressConfirmedProcessExitCompletion: (exited) => {
const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
const currentAgentForExited = resolveCompatibleAgentTypeForOwner(
currentStatus?.agentType,
exited.agent
)
// Why: a replacement hook can lead process visibility by one cadence;
// only a different known active owner can veto confirmed old-process exit.
return Boolean(
isFreshNonDoneAgentStatus(currentStatus) &&
currentStatus.agentType &&
currentStatus.agentType !== 'unknown' &&
currentAgentForExited !== exited.agent
)
},
dispatchCompletion: (title, meta) => {
if (meta?.source === 'process-exit') {
session.clearSuppressedTitleSideEffects()
}
if (meta?.terminalIdleConfirmed === true) {
// Why: an agent can crash before its done hook; confirmed process death
// must still restore cursor and native Windows Kitty keyboard modes.
const currentAgentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
if (!isFreshNonDoneAgentStatus(currentAgentStatus)) {
session.setFocusReportSuppressionForAgentCompletion(title, meta.agentStatus?.agentType)
}
session.queueAgentIdleTerminalModeReset()
}
session.scheduleAgentTaskCompleteNotification(title, {
allowDoneDetailAfterGrace: meta?.quietedHookDone,
...(meta?.source === 'process-exit' ? { agentCompletionSource: meta.source } : {}),
...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {})
})
},
dispatchAttention: (title, meta) =>
session.scheduleAgentTaskCompleteNotification(title, {
agentStatusSnapshot: meta.agentStatus
}),
shouldPollProcessCadence: () =>
isAgentTaskCompleteTrackingEnabled() && session.deps.isVisibleRef.current,
isProcessInspectionCostly: () => {
// Why: local Windows inspection forks a powershell.exe whole-process-table
// CIM scan per poll (~10-40x heavier than POSIX `ps`); SSH/remote PTYs run
// their scans on the remote host, so only local Windows panes relax the
// no-evidence cadence.
if (!navigator.userAgent.includes('Windows')) {
return false
}
const ptyId = session.transport.getPtyId()
return ptyId !== null && !isRemoteRuntimePtyId(ptyId) && parseAppSshPtyId(ptyId) === null
},
isLive: () => {
if (session.disposed) {
return false
}
if (session.transport.getPtyId()) {
return true
}
return (useAppStore.getState().ptyIdsByTabId[session.deps.tabId] ?? []).length > 0
},
shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(
session.cacheKey,
() => ({
tabId: session.deps.tabId,
...(session.launchToken ? { launchToken: session.launchToken } : {})
})
)
})
}
@@ -0,0 +1,215 @@
import { resolvePaneTitleDecision } from '../terminal-title-evidence'
import { useAppStore } from '@/store'
import { shouldSeedCacheTimerOnInitialTitle } from '../cache-timer-seeding'
// 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 { shouldSuppressCodexAutoApprovalSyntheticTitle } from '../codex-auto-approval-notification-suppression'
import {
cancelCommandCodeDoneSettle,
openCommandCodeDoneSettle,
setCommandCodeDoneSettleExecutor
} from '../command-code-done-settle'
import { canCommandCodeOutputOwnPane } from '../command-code-output-ownership'
import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { installPanePtyVisibilityBind } from './pane-pty-visibility-bind'
export function installTitleSpawnBell(session: ConnectPanePtySession): void {
session.onTitleChange = (
title: string,
rawTitle: string,
meta?: { staleWorkingTitleClear?: boolean }
): void => {
// Why: one owner-aware decision drives the display label, the runtime/tab
// title, task-completion tracking, and the renderer gate, so raw title text
// can no longer disable GPU behind stronger owner evidence (#7428/#7447).
const decision = resolvePaneTitleDecision({
normalizedTitle: title,
rawTitle,
displayOwnerAgentType: session.getAuthoritativePaneAgent(),
rendererOwnerAgentType: session.getPaneScopedRendererOwner(),
userGpuMode: useAppStore.getState().settings?.terminalGpuAcceleration ?? 'auto'
})
const paneTitle = decision.displayTitle
if (
shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, {
paneKey: session.cacheKey,
tabId: session.deps.tabId,
...(session.launchToken ? { launchToken: session.launchToken } : {})
})
) {
return
}
session.manager.setPaneGpuRendering(session.pane.id, decision.rendererPolicy.gpuEnabled)
session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, paneTitle)
// Why: a stale-derived cleared title comes from main's unthrottled 3s
// timer, not agent output. It must update the visible title but never
// feed completion tracking — observeTitle would classify the cleared
// title as idle and mint a task-complete for a merely-paused agent.
if (!meta?.staleWorkingTitleClear && session.syncAgentTaskCompleteTrackingEnabled()) {
const activeHookStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
if (!session.shouldSuppressTitleCompletionForFreshHook(decision.rawTitle, activeHookStatus)) {
// Why: display titles still update while hooks are active, but a stale
// idle frame must not complete the coordinator turn before hook `done`.
session.agentCompletionCoordinator.observeTitle(decision.rawTitle)
}
}
// Why: only the focused pane should drive the tab title — otherwise two
// agents in split panes cause rapid title flickering as each emits OSC
// sequences. Only the active split's title propagates to the tab. When
// focus changes, onActivePaneChange syncs the newly active pane's stored
// title to the tab.
if (session.manager.getActivePane()?.id === session.pane.id) {
session.deps.updateTabTitle(session.deps.tabId, paneTitle)
}
if (!session.hasConsideredInitialCacheTimerSeed) {
session.hasConsideredInitialCacheTimerSeed = true
const state = useAppStore.getState()
if (
shouldSeedCacheTimerOnInitialTitle({
rawTitle,
allowInitialIdleSeed: session.allowInitialIdleCacheSeed,
existingTimerStartedAt: state.cacheTimerByKey[session.cacheKey],
promptCacheTimerEnabled: state.settings?.promptCacheTimerEnabled ?? null
})
) {
session.deps.setCacheTimerStartedAt(session.cacheKey, Date.now())
}
}
}
session.applyInitialAgentStatus = (terminalTitle?: string): void => {
const initialStatus = session.paneStartup?.initialAgentStatus
const routing = session.resolveCurrentAgentStatusRouting()
if (!initialStatus || !routing) {
return
}
const statusPayload = {
state: 'working' as const,
prompt: initialStatus.prompt,
agentType: resolveCompatibleAgentTypeForOwner(
initialStatus.agent,
session.getAuthoritativePaneAgent()
)
}
if (session.paneStartup.launchConfig) {
useAppStore
.getState()
.setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing, {
launchConfig: session.paneStartup.launchConfig,
...(session.launchToken ? { launchToken: session.launchToken } : {})
})
return
}
useAppStore
.getState()
.setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing)
}
session.canApplyCommandCodeOutputStatus = (): boolean => {
const state = useAppStore.getState()
const foreground = state.paneForegroundAgentByPaneKey[session.cacheKey]
return canCommandCodeOutputOwnPane({
foregroundAgent: foreground?.agent,
shellForeground: foreground?.shellForeground,
paneOwnerAgent: session.getAuthoritativePaneAgent(),
retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[session.cacheKey]?.agentType
})
}
session.seedCommandCodeOutputWorkingStatus = (prompt: string): void => {
if (!session.canApplyCommandCodeOutputStatus()) {
return
}
session.clearCommandCodeOutputDoneTimer()
const routing = session.resolveCurrentAgentStatusRouting()
if (!routing) {
return
}
const currentState = useAppStore.getState()
const currentEntry = currentState.agentStatusByPaneKey[session.cacheKey]
const currentTitle =
currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
const normalizedPrompt = prompt.trim()
if (
currentEntry?.agentType === 'command-code' &&
currentEntry.state === 'done' &&
(!normalizedPrompt || normalizedPrompt === currentEntry.prompt.trim())
) {
return
}
currentState.setAgentStatus(
session.cacheKey,
{
state: 'working',
prompt: normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''),
agentType: 'command-code'
},
currentTitle,
undefined,
routing
)
}
// Why the settle window lives outside this binding: park unmounts the pane
// mid-settle, so a pane-owned timer would be cancelled with nothing left to
// complete the turn — the row would stick at 'working'. Only the row write
// (routing + title slot) is pane-local; the deadline transfers to whichever
// owner (parked watcher or remounted pane) holds the pane next.
session.releaseCommandCodeDoneSettleExecutor = setCommandCodeDoneSettleExecutor(
session.cacheKey,
(normalizedPrompt) => {
const routing = session.resolveCurrentAgentStatusRouting()
if (!routing) {
return
}
const currentState = useAppStore.getState()
const currentEntry = currentState.agentStatusByPaneKey[session.cacheKey]
if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') {
return
}
const currentPrompt = currentEntry.prompt.trim()
if (currentPrompt && currentPrompt !== normalizedPrompt) {
return
}
const currentTitle =
currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
currentState.setAgentStatus(
session.cacheKey,
{
state: 'done',
prompt: currentPrompt || normalizedPrompt,
agentType: 'command-code'
},
currentTitle,
undefined,
routing
)
}
)
session.clearCommandCodeOutputDoneTimer = (): void =>
cancelCommandCodeDoneSettle(session.cacheKey)
session.scheduleCommandCodeOutputDoneStatus = (prompt: string): void => {
if (!session.canApplyCommandCodeOutputStatus()) {
return
}
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
cancelCommandCodeDoneSettle(session.cacheKey)
return
}
// Why: Command Code keeps rendering the composer while tools run. Only
// complete the row if no active status repaint arrives during this window.
openCommandCodeDoneSettle(session.cacheKey, normalizedPrompt)
}
installPanePtyVisibilityBind(session)
}
@@ -0,0 +1,123 @@
import type { PtyReplayDataMeta } from '../pty-transport'
import type { PtyTransportRecoveryState } from '../pty-transport-types'
// 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 type { PtyDataMeta } from '../pty-dispatcher'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySession): void {
session.captureTransportOutputCallbacks = (onError: (message: string) => void) => {
// Why: a new stream generation cannot inherit an old replay's pending
// destination-grid fit or keep its live-data waiter open.
session.pendingHiddenSnapshotFit?.cancel()
session.pendingHiddenSnapshotFit = null
session.pendingReattachFit?.cancel()
session.pendingReattachFit = null
const generation = (session.transportStreamGeneration += 1)
const isCurrent = (): boolean =>
!session.disposed && generation === session.transportStreamGeneration
return {
generation,
callbacks: {
onReattachDetermined: (): void => {
if (isCurrent()) {
session.beginReattachLiveDataDeferralIfUnowned(generation)
}
},
onConnect: (): void => {
if (isCurrent()) {
session.reportRemoteRendererSerializerReady()
}
},
onStreamRecovered: (): void => {
if (isCurrent()) {
session.markHiddenOutputRestoreNeeded()
}
},
onData: (data: string, meta?: PtyDataMeta): void => {
if (isCurrent()) {
session.dataCallback(data, meta, generation)
}
},
onReplayData: (data: string, meta?: PtyReplayDataMeta): void => {
if (isCurrent()) {
session.replayDataCallback(data, meta, generation)
}
},
onError: (message: string): void => {
if (isCurrent()) {
onError(message)
}
},
onWriteUnavailable: (): void => {
if (isCurrent()) {
session.requestRecoveryForUndeliverableInput(true)
}
},
onRecoveryStateChange: (state: PtyTransportRecoveryState): void => {
if (isCurrent()) {
// Why: cached pixels remain visible while detached; expose session.transport truth for diagnostics and recovery UI.
session.pane.container.dataset.ptyRecoveryState = state.phase
session.deps.onPtyRecoveryStateRef?.current?.(session.pane.id, state)
}
},
onOutputPauseChanged: (paused: boolean, supported: boolean): void => {
if (isCurrent()) {
session.handleRemoteOutputPauseChanged(paused, supported)
}
}
}
}
}
session.hiddenOutputRestoreNeeded = false
session.hiddenOutputRestoreInFlight = null
session.hiddenOutputRestorePendingChunks = []
session.hiddenOutputRestorePendingChars = 0
session.hiddenOutputRestorePendingOverflow = false
session.hiddenOutputRestoreFreshSnapshotNeeded = false
session.hiddenOutputRestoreRetryDeferred = false
session.hiddenOutputRestoreScheduled = false
session.hiddenOutputRestoreDeferredRetryTimer = null
session.hiddenOutputRestoreForegroundDeadlineTimer = null
session.hiddenOutputRestoreDeferredRetryAttempts = 0
session.hiddenOutputRestoreRemoteOutcomeAttempts = 0
session.hiddenOutputRestoreLocalGateAttempts = 0
session.hiddenOutputRestoreLegacyPtyId = null
// Bounded remote re-arms spent instead of the loss banner (per PTY stream).
session.hiddenOutputRestoreRemoteAbandonCycles = 0
session.hiddenOutputSnapshotScrollRestore = null
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
// can reuse the session.pane object for a different session before visibility.
session.hiddenOutputRestorePtyId = null
// One recovery re-kick per xterm instance. Generation-aware cooldown and
// window-cap retries keep a fresh-but-wedged replacement from fossilizing.
session.certifiedDeadRestoreRecoveryRequested = false
session.hiddenOutputRestoreGeneration = 0
// Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS).
session.hiddenOutputRestoreFloodSuppressedUntil = 0
// Why: queued replay writes still paint after deadline abandonment; the
// fallback drain must not write snapshot-covered live bytes a second time.
session.hiddenOutputRestoreReplayingSnapshot = null
session.hiddenOutputRestoreFloodRepaintTimer = null
session.cancelHiddenOutputRestoreFloodRepaintPark = null
// Why: after a snapshot restore, main can still drain ACK-backlog chunks
// whose bytes the snapshot already covers — writing them unguarded
// duplicates visible output. Track the restored baseline seq (per PTY)
// and the expected next chunk start so session.dataCallback can drop/slice
// overlaps and detect seq gaps from main-side pending-cap trims whose
// one-shot marker was already consumed.
session.restoredSnapshotBaselineSeq = null
session.restoredSnapshotBaselinePtyId = null
session.restoredSnapshotExpectedStartSeq = null
// Why: main samples its pending renderer-delivery queue with the snapshot.
// Chunks at or below this seq can never be backlog duplicates (delivery is
// once-and-in-order), so the dedupe window is (windowStart, baseline].
session.restoredSnapshotDeliveryWindowStartSeq = null
}
@@ -0,0 +1,119 @@
import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-delivery-credit'
import { nativeWindowsRewriteNeedsFollowupRenderRefresh } from '@/lib/pane-manager/terminal-complex-script'
// 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 { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS } from './foreground-output-budgets'
import {
shouldWritePtyOutputForeground,
scanSynchronizedForegroundOutput,
containsCursorRestore
} from './foreground-output-scan'
import { containsHiddenStartupRendererQuery } from './hidden-startup-renderer-query'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function bindWritePtyOutputToXterm(session: ConnectPanePtySession): void {
session.writePtyOutputToXterm = function (
data: string,
foreground: boolean,
opts?: { hiddenStartupRendererQuery?: boolean }
): void {
// Why: every application byte funnels through here, so it's the one place the kitty keyboard mirror observes the session.pane's protocol negotiation.
session.kittyKeyboardModes.scan(data)
if (foreground) {
session.resetHiddenOutputRestoreIfPtyChanged()
}
const parseHiddenStartupOutput =
!foreground &&
session.canUseHiddenOutputSnapshot(session.transport.getPtyId()) &&
session.shouldSnapshotHiddenCodexOutput &&
(opts?.hiddenStartupRendererQuery === true || containsHiddenStartupRendererQuery(data))
const synchronizedForegroundScan =
session.shouldProtectNativeWindowsSynchronizedOutput && foreground
? scanSynchronizedForegroundOutput(
data,
session.synchronizedForegroundMarkerTail,
session.synchronizedForegroundOutputActive
)
: null
const synchronizedOutputStarted = synchronizedForegroundScan?.started === true
const synchronizedOutputEnded = synchronizedForegroundScan?.ended === true
const synchronizedForegroundOutput =
synchronizedForegroundScan !== null &&
(session.synchronizedForegroundOutputActive ||
synchronizedOutputStarted ||
synchronizedOutputEnded)
const nextSynchronizedForegroundOutputActive = synchronizedForegroundScan?.active === true
// Why: xterm's DOM renderer draws the cursor as row content, so Windows cursor-only restores need row invalidation even outside DEC 2026.
const nativeWindowsCursorRestore =
session.shouldProtectNativeWindowsSynchronizedOutput &&
foreground &&
containsCursorRestore(data)
const foregroundOutput = foreground || parseHiddenStartupOutput
if (foreground) {
session.scheduleForegroundGridDriftCheck()
}
const renderRefreshDecision = foregroundOutput
? session.shouldForceForegroundRenderRefresh(data)
: { refresh: false, inPlaceRewrite: false }
const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh
// Why: Claude Code's in-place prompt redraws on Windows ConPTY can paint one frame late; a follow-up repaint fixes the column desync without a resize.
const nativeWindowsInPlaceRewriteFollowup = nativeWindowsRewriteNeedsFollowupRenderRefresh({
isNativeWindowsConpty: session.shouldApplyNativeWindowsRewriteRefresh,
isForeground: foreground,
isInPlaceRewrite: renderRefreshDecision.inPlaceRewrite
})
// Why: recompute the latch on every synchronized START so each frame's interactivity is judged by its own open time and can't leak across a same-chunk close+open; clear only on leaving synchronized output.
if (synchronizedForegroundOutput && synchronizedOutputStarted) {
session.synchronizedForegroundFrameInteractive =
performance.now() - session.lastTerminalInputAt <=
FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS
} else if (!nextSynchronizedForegroundOutputActive && !synchronizedOutputEnded) {
session.synchronizedForegroundFrameInteractive = false
}
// Why: ConPTY can split a submit repaint's closing chunk past the 150ms window, so treat a keystroke-opened frame as latency-sensitive to drain it fast (~16-32ms) not the 1s coalesce fallback.
const synchronizedFrameLatencySensitive =
synchronizedForegroundOutput && session.synchronizedForegroundFrameInteractive
session.synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive
session.synchronizedForegroundMarkerTail = synchronizedForegroundScan?.markerTail ?? ''
writeTerminalOutput(session.pane.terminal, data, {
foreground: foregroundOutput,
beforeWrite: session.beforeTerminalOutputWrite,
// Why: every scheduler write claims one child so a split delivery is credited only after all children parse or discard.
ackCredit: takeCurrentTerminalDeliveryCredit() ?? undefined,
onBackgroundBacklogDropped: session.markHiddenOutputRestoreNeeded,
latencySensitive:
!foreground || parseHiddenStartupOutput
? true
: synchronizedFrameLatencySensitive || session.isLatencySensitiveForegroundOutput(data),
forceForegroundRefresh:
foregroundOutput &&
(synchronizedForegroundOutput ||
nativeWindowsCursorRestore ||
foregroundRenderRefreshNeeded),
followupForegroundRefresh: nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup,
// Why: xterm already queued a WebGL frame parsing this chunk; merge the repair into it instead of rendering the grid twice.
shouldRefreshForegroundSynchronously: session.shouldRefreshForegroundSynchronously,
stripTransientCursorShows: session.shouldProtectNativeWindowsSynchronizedOutput && foreground,
coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded,
holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive
})
}
session.queueAgentIdleTerminalModeReset = (): void => {
if (session.disposed) {
return
}
session.writePtyOutputToXterm(
session.idleAgentTerminalModeReset,
shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
)
}
}