From 9b51be9507aa91fa2d9a833bfdb9433a15147093 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:51:08 -0700 Subject: [PATCH] refactor: split pty-connection.ts under 400 lines --- .orca-agent/split-pty-connection-plan.md | 383 + config/max-lines-baseline.txt | 1 - .../terminal-pane/pty-connection.ts | 9651 +---------------- .../pty-connection/agent-done-started-at.ts | 22 + .../agent-idle-working-handlers.ts | 244 + .../agent-task-complete-notify.ts | 199 + .../agent-task-complete-settings.ts | 54 + .../pty-connection/apply-reattach-payload.ts | 315 + .../pty-connection/codex-pane-stale.ts | 48 + .../cold-restore-resume-startup.ts | 109 + .../command-inferred-pane-agent.ts | 277 + .../connect-pane-pty-session.ts | 12 + .../pty-connection/connect-pane-pty.ts | 179 + .../cursor-agent-reattach-screen.ts | 89 + .../deferred-cold-restore-and-snapshot.ts | 217 + .../pty-connection/deferred-session-attach.ts | 348 + .../deferred-session-reattach-choice.ts | 225 + .../deferred-session-reattach-connect.ts | 153 + .../pty-connection/direct-ssh-retry-lease.ts | 6 + .../pty-connection/direct-ssh-retry-status.ts | 291 + .../e2e-terminal-pty-harness.ts | 148 + .../foreground-output-budgets.ts | 15 + .../foreground-output-refresh.ts | 314 + .../pty-connection/foreground-output-scan.ts | 132 + .../fresh-spawn-follow-reset.ts | 179 + .../pty-connection/fresh-spawn-start.ts | 258 + .../pty-connection/fresh-spawn-types.ts | 25 + .../hidden-output-restore-abandon.ts | 99 + .../hidden-output-restore-chunk.ts | 149 + .../hidden-output-restore-drain.ts | 164 + .../hidden-output-restore-limits.ts | 37 + .../hidden-output-restore-request.ts | 329 + .../hidden-output-restore-snapshot.ts | 239 + .../hidden-output-seq-and-skip.ts | 234 + .../hidden-output-snapshot-serialize.ts | 151 + .../hidden-restore-state-and-ssh-probe.ts | 229 + .../hidden-startup-renderer-query-write.ts | 166 + .../hidden-startup-renderer-query.ts | 18 + .../pty-connection/interrupt-input-intent.ts | 184 + .../pty-connection/live-data-callback.ts | 196 + .../paired-parked-terminal-restore.ts | 30 + .../pty-connection/pane-agent-identity.ts | 268 + .../pty-connection/pane-pty-binding.ts | 23 + .../pane-pty-visibility-bind.ts | 242 + .../pane-serializer-register.ts | 101 + .../pty-connection/pane-serializer-settle.ts | 234 + .../pending-hidden-output-restore-chunk.ts | 5 + .../pty-connection/pty-connect-limits.ts | 23 + .../pty-connection/pty-exit-hibernate.ts | 318 + .../pty-connection/pty-input-forward.ts | 379 + .../pty-connection/pty-input-recovery.ts | 348 + .../pty-connection/pty-resize-geometry.ts | 258 + .../reattach-live-data-deferral.ts | 81 + .../reattach-payload-context.ts | 16 + .../pty-connection/reattach-result-handler.ts | 227 + .../pty-connection/replay-data-drain.ts | 195 + .../retained-legacy-pty-attach.ts | 38 + .../pty-connection/run-deferred-connect.ts | 138 + .../session-reconcile-dispose.ts | 302 + .../pty-connection/setup-split-geometry.ts | 75 + .../pty-connection/shell-command-inference.ts | 268 + .../pty-connection/sleeping-record-access.ts | 205 + .../pty-connection/ssh-session-connect.ts | 63 + .../pty-connection/ssh-snapshot-prepaint.ts | 85 + .../startup-cwd-fallback-notice.ts | 4 + .../pty-connection/terminal-keydown-fit.ts | 266 + .../pty-connection/title-spawn-bell.ts | 215 + .../transport-output-callbacks.ts | 123 + .../write-pty-output-to-xterm.ts | 119 + 69 files changed, 11089 insertions(+), 9649 deletions(-) create mode 100644 .orca-agent/split-pty-connection-plan.md create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/agent-done-started-at.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-settings.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/codex-pane-stale.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/cursor-agent-reattach-screen.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-lease.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/e2e-terminal-pty-harness.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-seq-and-skip.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state-and-ssh-probe.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query-write.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/paired-parked-terminal-restore.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-live-data-deferral.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts diff --git a/.orca-agent/split-pty-connection-plan.md b/.orca-agent/split-pty-connection-plan.md new file mode 100644 index 00000000000..a09489422c6 --- /dev/null +++ b/.orca-agent/split-pty-connection-plan.md @@ -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` diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 85ea75132d2..940129be633 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 068cb9b6087..fbc51fc9c9d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,9649 +1,4 @@ -/* oxlint-disable max-lines */ -import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' -import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' -import type { IBuffer, IDisposable } from '@xterm/xterm' -import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' -import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations' -import { installTerminalImeCompositionRoute } from './terminal-ime-composition-route' -import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status' -import { reportWorkerTerminalUserInput } from '@/lib/worker-terminal-takeover-report' -import { resolvePaneTitleDecision } from './terminal-title-evidence' -import { blocksCodexPaneInput } from '../codex-restart-notice-state' -import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' -import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' -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 { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' -import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' -import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' -import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' -import { isWorktreeRemovalFenceError } from '../../../../shared/worktree/removal-fence-error' -import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' -import { - HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS, - containsCsiRendererQuery, - containsStatefulRendererQuery, - extractHiddenStartupRendererQueryData, - findCsiFinalByteIndex, - isStatefulRendererReplyCsiQuery, - isStatelessRendererReplyCsiQuery -} from '../../../../shared/terminal-reply-query-extraction' -import { - deliverTerminalDataWithDeferredCredit, - takeCurrentTerminalDeliveryCredit -} from '@/lib/pane-manager/terminal-delivery-credit' -import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' -import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' -import type { PtyBufferSnapshot, PtyConnectResult, PtyReplayDataMeta } from './pty-transport' -import type { IpcPtyTransportOptions, PtyTransportRecoveryState } from './pty-transport-types' -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 { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { getConnectionId } from '@/lib/connection-context' -import { recordTerminalTabParkedOnUnresolvedHost } from '@/lib/parked-terminal-host-hydration' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { - getCachedWindowsTerminalCapabilities, - hasCachedWindowsTerminalCapabilities -} from '@/lib/windows-terminal-capabilities' -import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' -import { - shouldReconcileDeadSession, - shouldReconcileMissingSession, - type HasPty -} from './terminal-dead-session-reconcile' -import type { PtyConnectionDeps } from './pty-connection-types' -import type { SessionRestoredBannerReason } from './session-restored-banner-pane-state' -import { - consumeCommittedPtyShutdownExit, - deferPtyShutdownExit, - isHostPtySleepPending -} from './pty-shutdown-exit-deferral' -import { - cancelPendingSafeFitContinuations, - safeFit, - safeFitAndThen, - type SafeFitContinuationHandle -} from '@/lib/pane-manager/pane-tree-ops' -import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' -import { - bindPanePtyId, - getFitOverrideForPty, - onOverrideChange -} from '@/lib/pane-manager/mobile-fit-overrides' -import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' -import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' -import { getDeleteStateForWorktreeHost } from '../sidebar/worktree-delete-state-host-match' -import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' -import { getAppliedSizeReadE2eDelayMs } from './pty-applied-size-read-e2e-delay' -import { createPtySizeReassertion } from './pty-size-reassertion' -import { - isPaneReplaying, - replayIntoTerminal, - replayIntoTerminalAsync, - waitForTerminalReplayWritesParsed -} from './replay-guard' -import { - isTerminalWritePipelineCertifiedDead, - registerUndeliverableWriteHandler, - requestTerminalWritePipelineProbe -} from '@/lib/pane-manager/terminal-write-pipeline-health' -import { - captureTerminalPaneRecoveryGeneration, - registerTerminalPaneRecoveryInstance, - requestTerminalPaneRecovery -} from './terminal-pane-recovery' -import { shouldDropQuarantinedTerminalInput } from './terminal-input-quarantine' -import { - isDocumentVisibilityProvenStale, - registerStaleDocumentVisibilityRecovery -} from './stale-document-visibility' -import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' -import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' -import { - nativeWindowsRewriteNeedsFollowupRenderRefresh, - terminalOutputPrefersRenderRefresh, - terminalRewriteOutputRenderRefreshDecision, - terminalRewriteOutputPrefersRenderRefresh, - windowsEastAsianOutputPrefersRenderRefresh -} from '@/lib/pane-manager/terminal-complex-script' -import { - PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, - queuePanePtyResizeIfHeld, - type PanePtyResizeHoldFlushDetail -} from '@/lib/pane-manager/pane-pty-resize-hold' -import { CSI_SEQUENCE_PATTERN } from '../../../../shared/ansi-escape-sequences' -import { - buildPostReplayLiveAgentReattachReset, - POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, - POST_REPLAY_LIVE_SNAPSHOT_RESET, - POST_REPLAY_MODE_RESET, - POST_REPLAY_REATTACH_RESET, - POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, - RESET_AFTER_BYTE_GAP, - RESET_GRAPHIC_RENDITION, - RESET_KITTY_KEYBOARD_PROTOCOL, - RESET_TERMINAL_CURSOR_STYLE -} from '../../../../shared/terminal-mode-reset-profiles' -import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' -import { getSystemPrefersDark } from '@/lib/terminal-theme' -import { - INITIAL_MODE_2031_REPLY_SCAN_STATE, - resolveTerminalColorSchemeMode, - scanMode2031ReplyDecision -} from '../../../../shared/terminal-color-scheme-protocol' -import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' -import { subscribeToTerminalUserInput } from './terminal-user-input-signal' -import { - hasPtySerializer, - registerPtySerializer, - registerPtyTitleSource -} 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 { notifyCodexPaneBoundForStaleSweep } from '@/lib/codex-stale-pane-sweep' -import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' -import { isHostAnsweredSnapshotRetryCause } from '@/runtime/remote-runtime-terminal-multiplexer' -import { - discardTerminalOutput, - flushTerminalOutput, - registerTerminalBacklogRecovery, - waitForTerminalOutputParsed, - writeTerminalOutput -} from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity' -import { - isLocalNativeWindowsConpty, - resolveWindowsShellOverride -} from '@/lib/pane-manager/windows-pty-compatibility' -import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' -import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' -import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' -import { - installTerminalLiveScrollbackRestore, - type TerminalLiveScrollbackRestore -} from '@/lib/pane-manager/terminal-live-scrollback-restore' -import { - enforceTerminalCurrentScrollIntent, - getTerminalScrollIntentKind, - markTerminalFollowOutput, - onTerminalScrollIntentFollowOutput -} from '@/lib/pane-manager/terminal-scroll-intent' -import { - cancelTerminalScrollIntentBufferRebuildCompletions, - deferTerminalGeometryMutationDuringRebuild -} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' -import { createTerminalStructuralReplayCoordinator } from '@/lib/pane-manager/terminal-structural-replay-coordinator' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' -import { - getProviderSessionClaimKey, - isPassiveCompletedHibernationEvidence -} from '@/lib/sleeping-agent-pane-ownership' -import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' -import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' -import { resolveSshPaneConnectGate } from './ssh-pane-connect-gate' -import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' -import { e2eConfig } from '@/lib/e2e-config' -import { - isFreshNonDoneAgentStatus, - type AgentStatusEntry, - type AgentType -} from '../../../../shared/agent-status-types' -import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' -import { - createAgentInterruptInference, - isCtrlCKeyEvent, - isPlainEscapeKeyEvent -} from './agent-interrupt-inference' -import { createAgentQuestionAnsweredInference } from './agent-question-answered-inference' -import { - AGENT_INTERRUPT_SETTLE_MS, - type AgentInterruptInputIntent -} from '../../../../shared/agent-interrupt-intent' -import { createAgentCompletionCoordinator } from './agent-completion-coordinator' -import { - dispatchAgentHookTerminalLifecycle, - registerAgentHookTerminalLifecycleHandler -} from './agent-hook-terminal-lifecycle' -import { - createCodexAutoApprovalHookCompletionSuppressor, - shouldSuppressCodexAutoApprovalSyntheticTitle, - shouldSuppressCodexAutoApprovalStatus -} from './codex-auto-approval-notification-suppression' -import type { - AgentCompletionDispatchMeta, - AgentCompletionStatusSnapshot -} from './agent-completion-coordinator-types' -import { - markTerminalBracketedPasteInterrupted, - observeTerminalBracketedPasteModeOutput -} from './terminal-bracketed-paste' -import { executeTerminalStartupCommandPaste } from './terminal-startup-command-paste' -import { - waitForStableStartupGrid, - type TerminalStartupGridSettleHandle -} from './terminal-startup-grid-settle' -import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' -import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' -import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' -import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' -import { createCodexBackfillErrorDetector } from './codex-backfill-error-detector' -import type { PtyDataMeta } from './pty-dispatcher' -import { getEagerPtyBufferHandle } from './pty-dispatcher' -import { - DeferredReattachLiveDataQueue, - MAX_DEFERRED_REATTACH_LIVE_CHARS -} from './deferred-reattach-live-data-queue' -import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' -import { - CONPTY_DA1_RESPONSE, - DEFAULT_DA1_RESPONSE, - createTerminalPixelSizeQueryResponder, - installTerminalCapabilityReplyHandlers, - sendTerminalOscColorQueryReplies -} from './terminal-capability-replies' -import { registerPtyModelRestoreNeededHandler } from './pty-model-restore-channel' -import { - acquireHiddenRendererPtyDeliveryClaim, - declareRendererPtyDeliveryVisible, - releaseRendererPtyVisibilityClaim, - setRendererPtyVisibilityClaim -} from './pty-renderer-delivery-claims' -import { - cancelScheduledHiddenOutputRestore, - scheduleHiddenOutputRestore -} from './hidden-output-restore-scheduler' -import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback' -import { - buildMainModelSnapshotReplayWrites, - hasPositiveTerminalDimensions, - readProposedTerminalCols, - resolvePositiveTerminalDimensions, - shouldSkipAltFrameForWidthMismatch -} from './terminal-snapshot-replay-paint' -import { - decideSshReattachPaintSource, - lastAlternateScreenTransition, - memoizeSshReattachModelSnapshotProbe, - resolveSshReattachModelSnapshotWithTimeout, - shouldFetchSshReattachModelSnapshot, - sshReconnectPaintsFromModel -} from './ssh-reattach-model-restore' -import { readInFlightCommandCodeTurn } from './parked-terminal-command-status' -import { - cancelCommandCodeDoneSettle, - openCommandCodeDoneSettle, - setCommandCodeDoneSettleExecutor -} from './command-code-done-settle' -import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' -import { - getExecutionHostIdForWorktree, - getSettingsForWorktreeRuntimeOwner -} from '@/lib/worktree-runtime-owner' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' -import { resolveAgentResumeLaunchTarget } from '@/lib/agent-resume-launch-target' -import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../../../shared/tui-agent-launch-defaults' -import { - agentProviderSessionsEqual, - isResumableTuiAgent, - normalizeAgentProviderSession, - type AgentProviderSessionMetadata, - type ResumableTuiAgent, - type SleepingAgentSessionRecord -} from '../../../../shared/agent-session-resume' -import { - normalizeCompatibleAgentTitleForOwner, - resolveCompatibleAgentTypeForOwner -} from '../../../../shared/agent-title-owner' -import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' -import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' -import { - isExpectedAgentProcess, - recognizeAgentProcessFromCommandLine -} from '../../../../shared/agent-process-recognition' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' -import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' -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 { - beginAgentStartupDeliveryAttempt, - releaseAgentStartupDeliveryAttempt -} from '@/lib/agent-startup-delayed-delivery' -import { - AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, - AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS, - canDispatchAgentNotificationAfterGrace, - isAgentTaskCompleteOsNotificationEnabledFromState, - isAgentTaskCompleteTrackingEnabledFromState -} from './agent-task-complete-policy' -import { - isMainTerminalSideEffectAuthorityForPty, - registerTerminalSideEffectFactConsumer -} from './terminal-side-effect-facts-handler' -import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' -import { - markRendererOwnedAgentStatusWrite, - registerRendererOwnedAgentStatusPane -} from './renderer-owned-agent-status-registry' -import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery' -import { directSshAuthoritiesEqual } from '@/store/slices/direct-ssh-terminal-authority-ledger' +import { connectPanePty } from './pty-connection/connect-pane-pty' +import { STARTUP_CWD_FALLBACK_NOTICE } from './pty-connection/startup-cwd-fallback-notice' -const pendingSpawnByPaneKey = new Map>() -const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' -// Why: relay requests expire at 30s; leave one second for their fallback before re-arming locally. -const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000 -const REMOTE_PTY_ID_PREFIX = 'remote:' -const PTY_CONNECT_DIAG_LIMIT = 200 -const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 -const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 -// 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' -const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 -const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 -const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 -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. -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. -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. -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. -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. -const HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS = 30 -const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 -const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' -const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' -const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 -const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' -const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' -const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' -const TERMINAL_FOCUS_OUT_SEQUENCE = '\x1b[O' -const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' -const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 -const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350 -const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 -const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024 -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. -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. -const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024 -const FOREGROUND_BUDGET_WINDOW_MS = 500 -const INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS = 32 * 1024 -const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250 -// 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. -const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = - '\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n' -type E2eTerminalPtyDataInjectionApi = { - inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean - keys: () => string[] -} - -type TerminalWithFocusMode = { - textarea?: HTMLTextAreaElement | null - modes?: { - sendFocusMode?: boolean - } -} - -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). -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 - ) -} - -function terminalHasFocusReportingEnabled(terminal: TerminalWithFocusMode): boolean { - return terminal.modes?.sendFocusMode === true -} - -function terminalOwnsDomFocus(terminal: TerminalWithFocusMode): boolean { - if (typeof document === 'undefined' || !terminal.textarea) { - return false - } - return document.activeElement === terminal.textarea -} - -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 - -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} `) -} - -type E2eTerminalPtyDataInjectionWindow = Window & { - __terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi - __terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi -} - -const e2eTerminalPtyDataInjectors = new Map void>() - -type E2eTerminalHiddenSnapshotOverrideApi = { - setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void - resolve: (ptyId: string) => void - clear: (ptyId: string) => void -} - -type E2eTerminalHiddenSnapshotOverride = { - promise: Promise - resolve: () => void -} - -const e2eTerminalHiddenSnapshotOverrides = new Map() - -// 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 -} - -type PendingStartupCommand = { - command: string - env?: Record -} - -type FreshSpawnOptions = { - forceBlankRestoredViewport?: boolean -} - -type ColdRestoreAgentResumeStartup = PendingStartupCommand & { - agent: ResumableTuiAgent - resumeProviderSession: AgentProviderSessionMetadata - launchConfig: NonNullable>['launchConfig'] - launchToken: string - useLiveEntry: boolean - hasSleepingRecord: boolean - sleepingRecordEntry: { paneKey: string; record: SleepingAgentSessionRecord } | null -} - -const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = { - hiddenRendererSkipCount: 0, - hiddenRendererSkippedChars: 0 -} - -function resetE2eTerminalPtyOutputDebug(): void { - e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount = 0 - e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars = 0 -} - -function exposeE2eTerminalPtyOutputDebug(): void { - if (!e2eConfig.exposeStore || typeof window === 'undefined') { - return - } - const target = window as E2eTerminalPtyOutputDebugWindow - target.__terminalPtyOutputDebug ??= { - reset: resetE2eTerminalPtyOutputDebug, - snapshot: () => ({ ...e2eTerminalPtyOutputDebugState }) - } -} - -function recordHiddenRendererSkip(chars: number): void { - if (!e2eConfig.exposeStore) { - return - } - exposeE2eTerminalPtyOutputDebug() - e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount += 1 - e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars += chars -} - -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((nextResolve) => { - resolve = nextResolve - }) - e2eTerminalHiddenSnapshotOverrides.set(ptyId, { - promise: wait.then(() => snapshot), - resolve - }) - }, - resolve: (ptyId) => { - e2eTerminalHiddenSnapshotOverrides.get(ptyId)?.resolve() - }, - clear: (ptyId) => { - e2eTerminalHiddenSnapshotOverrides.delete(ptyId) - } - } -} - -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) - } - } -} - -function readE2eHiddenSnapshotOverride(ptyId: string): Promise | 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) - } - }) -} - -function shouldKeepHiddenStartupRendererQueriesLive( - startup: PtyConnectionDeps['startup'] -): boolean { - return ( - Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') || - isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '') - ) -} - -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;?') -} - -let codexRestartNoticePresenceSource: Record< - string, - { previousAccountLabel: string; nextAccountLabel: string } -> | null = null -let codexRestartNoticePresence = false -let inactiveForegroundImmediateBudgetChars = 0 -let inactiveForegroundImmediateBudgetWindowStart = 0 - -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 | null - /** Re-sample process identity when the pane gains intra-tab focus: the tab - * icon follows the active leaf, and a shell-marked entry on a still-running - * agent pane has no OSC boundary left to correct it. */ - sampleForegroundAgentOnFocus: () => void - /** Reconfirm after direct shortcut input, which bypasses PTY onData. */ - requestWindowsShiftEnterReconfirmation: () => void - /** Refresh interactive redraw scheduling after captured shortcut input. */ - markShortcutTerminalInputSent: () => void - reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void - reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void -} - -function isAgentTaskCompleteNotificationEnabled(): boolean { - return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState()) -} - -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 -): string { - return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}` -} - -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 - } - } -} - -function recordPtyConnectDiagnostic(message: string): void { - if (!e2eConfig.exposeStore) { - return - } - console.log(`[pty-connect] ${message}`) - const target = globalThis as Record - 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) - } -} - -// 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. -type SshConnectResult = { connected: true } | { connected: false; error: string } -type UserInitiatedSshConnectOutcome = 'connected' | 'cancelled' | 'failed' - -const sshConnectPromises = new Map>() - -function isSshSessionExpiredError(err: unknown): boolean { - return (err instanceof Error ? err.message : String(err)).includes(SSH_SESSION_EXPIRED_ERROR) -} - -function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { - return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX) -} - -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 - ) -} - -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 -} - -function hasCodexRestartNotices( - noticesByPtyId: Record -): boolean { - if (codexRestartNoticePresenceSource !== noticesByPtyId) { - codexRestartNoticePresenceSource = noticesByPtyId - codexRestartNoticePresence = Object.keys(noticesByPtyId).length > 0 - } - return codexRestartNoticePresence -} - -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 -} - -async function waitForSshConnection(connectionId: string): Promise { - 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 = (async (): Promise => { - 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 -} - -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 -} - -// 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. -function isSessionOwnedByWorktree(sessionId: string, worktreeId: string): boolean { - const separatorIdx = sessionId.lastIndexOf('@@') - if (separatorIdx === -1) { - return true - } - return sessionId.slice(0, separatorIdx) === worktreeId -} - -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() -} - -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. -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) - } -} - -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 -} - -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) -} - -const SPLIT_GEOMETRY_EPSILON_PX = 1 - -function readElementRect(element: HTMLElement | null | undefined): DOMRect | null { - try { - return element?.getBoundingClientRect?.() ?? null - } catch { - return null - } -} - -function hasVisibleRect(rect: DOMRect | null): rect is DOMRect { - return Boolean( - rect && rect.width > SPLIT_GEOMETRY_EPSILON_PX && rect.height > SPLIT_GEOMETRY_EPSILON_PX - ) -} - -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 - } -} - -function isPaneGridAlignedWithFit(pane: ManagedPane): boolean { - const proposed = readProposedPaneGrid(pane) - return Boolean( - proposed && pane.terminal.cols === proposed.cols && pane.terminal.rows === proposed.rows - ) -} - -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) - ) -} - -/** 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. */ -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 -} - -/** - * Establishes a binding between a terminal pane and its corresponding PTY stream, - * managing input, output, title synchronization, and agent status tracking. - */ -export function connectPanePty( - pane: ManagedPane, - manager: PaneManager, - deps: PtyConnectionDeps -): PanePtyBinding { - const shouldRefreshForegroundSynchronously = (): boolean => !manager.hasWebglRenderer(pane.id) - // Why: recovery ownership belongs to this xterm instance. A request that - // settles after remount must not remount its already-replaced successor. - const terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(deps.tabId) - const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId) - let mountFollowsTerminalPark = deps.mountFollowsTerminalPark - let authoritativeReattachGeneration = 0 - exposeE2eTerminalPtyOutputDebug() - let disposed = false - const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal) - let connectFrame: number | null = null - let connectFallbackTimer: ReturnType | null = null - let startupGridSettleHandle: TerminalStartupGridSettleHandle | null = null - let startupGridSettledForConnect = false - let connectStarted = false - let unregisterBacklogRecovery: (() => void) | null = null - let unregisterDocumentVisibilityRecovery: (() => void) | null = null - let cancelHiddenOutputSnapshotScrollRestore = (): void => {} - let pendingHiddenSnapshotFit: SafeFitContinuationHandle | null = null - let pendingReattachFit: SafeFitContinuationHandle | null = null - let cancelFreshSpawnFollowReset = (): void => {} - let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} - let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} - let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} - let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} - let cleanupStartupDraftPasteTimers = (): void => {} - let unregisterE2ePtyDataInjection = (): void => {} - let startupInjectTimer: ReturnType | null = null - let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null - let agentTaskCompleteNotificationMaxTimer: ReturnType | null = null - let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null - let agentTaskCompleteSettingsUnsubscribe: (() => void) | null = null - let agentTaskCompleteNotificationGeneration = 0 - let wasAgentTaskCompleteTrackingEnabled = isAgentTaskCompleteTrackingEnabled() - let requiresFreshWorkingForAgentTaskCompleteNotification = !wasAgentTaskCompleteTrackingEnabled - let wasAgentTaskCompleteOsNotificationEnabled = isAgentTaskCompleteNotificationEnabled() - let terminalBellNotificationTimer: ReturnType | null = null - let pendingTerminalBellNotification = false - let reattachIdleAgentCursorResetTimer: ReturnType | null = null - let alternateScreenBackgroundRepaintTimer: ReturnType | null = null - let shiftEnterReconfirmTimer: ReturnType | null = null - let synchronizedForegroundOutputActive = false - // Why: carries up to one marker-length-1 of trailing bytes so a ConPTY-split DEC 2026 marker is still detected (#8754). - let 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. - let synchronizedForegroundFrameInteractive = false - let 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. - let syncHiddenRendererPtyDelivery: () => void = () => {} - let releaseHiddenRendererPtyDelivery: () => void = () => {} - let handleRemoteOutputPauseChanged: (paused: boolean, supported: boolean) => void = () => {} - let handleRendererOwnedAgentStatus: NonNullable< - IpcPtyTransportOptions['onAgentStatus'] - > = () => {} - let remoteOutputGatedPtyId: string | null = null - let remoteOutputFactConsumerPtyId: string | null = null - let 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. - let idleAgentTerminalModeReset = RESET_TERMINAL_CURSOR_STYLE - let suppressNativeWindowsIdleCodexFocusReports = false - const setFocusReportSuppressionForAgentCompletion = ( - title: string | undefined, - agentType: AgentType | undefined - ): void => { - const titleAgentType = resolveCommittedTitleAgentType(title ?? '') - suppressNativeWindowsIdleCodexFocusReports = - agentType && agentType !== 'unknown' ? agentType === 'codex' : titleAgentType === 'codex' - } - let queueAgentIdleTerminalModeReset = (): void => { - if (disposed) { - return - } - writeTerminalOutput(pane.terminal, idleAgentTerminalModeReset, { - foreground: shouldWritePtyOutputForeground(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. - const waitTeardowns: (() => void)[] = [] - // 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. - const paneStartup = deps.startup ?? null - 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. - const cacheKey = makePaneKey(deps.tabId, 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. - const kittyKeyboardModes = (() => { - const existing = deps.paneKittyKeyboardModesRef.current.get(pane.id) - if (existing) { - return existing - } - const created = new TerminalKittyKeyboardModeTracker() - deps.paneKittyKeyboardModesRef.current.set(pane.id, created) - return created - })() - const getSleepingRecordForPane = ( - state: ReturnType - ): { paneKey: string; record: SleepingAgentSessionRecord } | null => { - const stableRecord = state.sleepingAgentSessionsByPaneKey[cacheKey] - if (stableRecord) { - return { paneKey: cacheKey, record: stableRecord } - } - const legacyMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter( - ([paneKey, record]) => { - const legacy = parseLegacyNumericPaneKey(paneKey) - return ( - legacy?.tabId === deps.tabId && - record.worktreeId === deps.worktreeId && - (!record.tabId || record.tabId === deps.tabId) - ) - } - ) - const exactLegacyMatch = legacyMatches.find(([paneKey]) => { - const legacy = parseLegacyNumericPaneKey(paneKey) - return legacy?.numericPaneId === String(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 } - } - const isLegacyWorkerAutomaticResumeBlocked = (): boolean => - getSleepingRecordForPane(useAppStore.getState())?.record.automaticResumeBlockedBy === - 'legacy-orchestration-worker' - const clearSleepingRecordProviderDuplicates = ( - state: ReturnType, - 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) - } - } - } - const launchToken = paneStartup?.launchConfig - ? (paneStartup.launchToken ?? createBrowserUuid()) - : undefined - const startupDraftAgent = paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent - const startupDraftAgentConfig = startupDraftAgent ? TUI_AGENT_CONFIG[startupDraftAgent] : null - const startupDraftPrompt = - typeof paneStartup?.draftPrompt === 'string' && paneStartup.draftPrompt.trim() - ? paneStartup.draftPrompt - : null - const startupDraftPromptNeedsPaste = - startupDraftPrompt !== null && - !startupDraftAgentConfig?.draftPromptFlag && - !startupDraftAgentConfig?.draftPromptEnvVar - let startupDraftDeliveryClaimed = false - let startupDraftPasteAttempted = false - const claimStartupDraftPasteDelivery = (): boolean => { - if (!startupDraftPromptNeedsPaste || launchToken === undefined) { - return false - } - if (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. - startupDraftDeliveryClaimed = beginAgentStartupDeliveryAttempt({ - worktreeId: deps.worktreeId, - tabId: deps.tabId, - launchToken - }) - return startupDraftDeliveryClaimed - } - const releaseUnattemptedStartupDraftPasteDelivery = (): void => { - if (!startupDraftDeliveryClaimed || startupDraftPasteAttempted || launchToken === undefined) { - return - } - releaseAgentStartupDeliveryAttempt({ - worktreeId: deps.worktreeId, - tabId: deps.tabId, - launchToken - }) - startupDraftDeliveryClaimed = false - } - // Why: reserve before deferred connect so the creation sidecar cannot time out during setup and strand this pane's live scanner. - const ownsStartupDraftPaste = claimStartupDraftPasteDelivery() - if (paneStartup?.launchConfig) { - useAppStore.getState().registerAgentLaunchConfig(cacheKey, paneStartup.launchConfig, { - agentType: paneStartup.launchAgent ?? paneStartup.initialAgentStatus?.agent, - ...(launchToken ? { launchToken } : {}), - tabId: deps.tabId, - leafId: pane.leafId - }) - } else if (paneStartup) { - useAppStore.getState().clearAgentLaunchConfig(cacheKey) - } - const 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(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(cacheKey, effectiveLaunchConfig, { - agentType: - metadata?.launchAgent ?? - paneStartup?.launchAgent ?? - paneStartup?.initialAgentStatus?.agent ?? - persistedLaunchAgent, - ...((metadata?.launchToken ?? launchToken) - ? { launchToken: metadata?.launchToken ?? launchToken } - : {}), - tabId: deps.tabId, - leafId: pane.leafId - }) - } - const clearRegisteredStartupLaunchConfig = (): void => { - useAppStore.getState().clearAgentLaunchConfig(cacheKey) - } - const neutralTerminalTitle = (): string => { - const state = useAppStore.getState() - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - ) - return tab?.defaultTitle?.trim() || 'Terminal' - } - // 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. - let commandInferredPaneAgent: TuiAgent | null = null - let pendingShellCommandLine = '' - let pendingShellCommandCursor = 0 - let commandInferredPaneAgentGeneration = 0 - let shellCommandInferenceSuspendedUntilCommandEnd = false - let startAcceptedInferredCommand = (_agent: TuiAgent): void => {} - let requestKnownWindowsShiftEnterReconfirmation = (): void => {} - const resetPendingShellCommandLine = (): void => { - pendingShellCommandLine = '' - pendingShellCommandCursor = 0 - } - const rememberCommandInferredPaneAgent = (): void => { - const commandLine = pendingShellCommandLine.trim() - resetPendingShellCommandLine() - const candidateAgent = commandLine - ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) - : null - const state = useAppStore.getState() - const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[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[cacheKey]?.agent || isTuiAgent(registeredLaunchAgent) - ? null - : candidateAgent - commandInferredPaneAgent = nextAgent - commandInferredPaneAgentGeneration += 1 - if (nextAgent) { - startAcceptedInferredCommand(nextAgent) - } - } - const clearCommandInferredPaneAgent = (): void => { - commandInferredPaneAgent = null - resetPendingShellCommandLine() - commandInferredPaneAgentGeneration += 1 - } - const clearCommandInferredPaneAgentAfterPtySideEffects = (): void => { - const generation = commandInferredPaneAgentGeneration - resetPendingShellCommandLine() - queueMicrotask(() => { - setTimeout(() => { - if (commandInferredPaneAgentGeneration === generation) { - clearCommandInferredPaneAgent() - } - }, 0) - }) - } - const appendPendingShellCommandInput = (text: string): void => { - const available = MANUAL_AGENT_COMMAND_MAX_CHARS - pendingShellCommandLine.length - if (available <= 0) { - shellCommandInferenceSuspendedUntilCommandEnd = true - return - } - const inserted = text.slice(0, available) - pendingShellCommandLine = - pendingShellCommandLine.slice(0, pendingShellCommandCursor) + - inserted + - pendingShellCommandLine.slice(pendingShellCommandCursor) - pendingShellCommandCursor += inserted.length - if (inserted.length < text.length) { - shellCommandInferenceSuspendedUntilCommandEnd = true - } - } - const deletePendingShellCommandWord = (): void => { - const beforeCursor = pendingShellCommandLine.slice(0, pendingShellCommandCursor) - const afterCursor = pendingShellCommandLine.slice(pendingShellCommandCursor) - const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') - pendingShellCommandLine = nextBeforeCursor + afterCursor - pendingShellCommandCursor = nextBeforeCursor.length - } - const cancelSuspendedShellCommandInference = (): void => { - if (!shellCommandInferenceSuspendedUntilCommandEnd) { - return - } - shellCommandInferenceSuspendedUntilCommandEnd = false - resetPendingShellCommandLine() - } - const deletePendingShellCommandCharacter = (): void => { - if (pendingShellCommandCursor === 0) { - return - } - pendingShellCommandLine = - pendingShellCommandLine.slice(0, pendingShellCommandCursor - 1) + - pendingShellCommandLine.slice(pendingShellCommandCursor) - pendingShellCommandCursor -= 1 - } - const deletePendingShellCommandCharacterAtCursor = (): void => { - if (pendingShellCommandCursor >= pendingShellCommandLine.length) { - return - } - pendingShellCommandLine = - pendingShellCommandLine.slice(0, pendingShellCommandCursor) + - pendingShellCommandLine.slice(pendingShellCommandCursor + 1) - } - const movePendingShellCommandCursor = (delta: number): void => { - pendingShellCommandCursor = Math.min( - pendingShellCommandLine.length, - Math.max(0, pendingShellCommandCursor + delta) - ) - } - const 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 === '') { - movePendingShellCommandCursor(-1) - } else if (final === 'C' && params === '') { - movePendingShellCommandCursor(1) - } else if (final === 'H' || (final === '~' && params === '1')) { - pendingShellCommandCursor = 0 - } else if (final === 'F' || (final === '~' && params === '4')) { - pendingShellCommandCursor = pendingShellCommandLine.length - } else if (final === '~' && params === '3') { - deletePendingShellCommandCharacterAtCursor() - } else if (final === '~' && (params === '200' || params === '201')) { - // Bracketed paste wrappers are terminal framing, not shell command text. - } else { - resetPendingShellCommandLine() - } - return cursor + 1 - } - const getLivePaneAgentTitle = (): string | null => { - const state = useAppStore.getState() - const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === 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. - const isFreshActivePaneAgentEntry = ( - entry: AgentStatusEntry | undefined - ): entry is AgentStatusEntry => { - return isFreshNonDoneAgentStatus(entry) - } - const 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 - } - let pendingSuppressedTitleSideEffects: { - title: string - agentType: AgentType | undefined - } | null = null - const clearSuppressedTitleSideEffects = (): void => { - pendingSuppressedTitleSideEffects = null - } - const applyAgentCompletionSideEffects = ( - title: string, - agentType: AgentType | undefined - ): void => { - const settings = useAppStore.getState().settings - if ( - (agentType === 'claude' || isClaudeAgent(title)) && - (settings === null || settings.promptCacheTimerEnabled) - ) { - deps.setCacheTimerStartedAt(cacheKey, Date.now()) - } - setFocusReportSuppressionForAgentCompletion(title, agentType) - queueAgentIdleTerminalModeReset() - } - const preserveSuppressedTitleSideEffects = ( - title: string, - activeHookStatus: AgentStatusEntry - ): void => { - pendingSuppressedTitleSideEffects = { - title, - agentType: activeHookStatus.agentType - } - if (activeHookStatus.state === 'waiting' || activeHookStatus.state === 'blocked') { - suppressNativeWindowsIdleCodexFocusReports = false - queueAgentIdleTerminalModeReset() - } - } - const handleAgentHookTerminalLifecycle = (payload: AgentCompletionStatusSnapshot): void => { - const pending = 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') { - clearSuppressedTitleSideEffects() - return - } - if (payload.state === 'done') { - applyAgentCompletionSideEffects(pending.title, payload.agentType ?? pending.agentType) - clearSuppressedTitleSideEffects() - return - } - if (payload.state === 'waiting' || payload.state === 'blocked') { - suppressNativeWindowsIdleCodexFocusReports = false - queueAgentIdleTerminalModeReset() - } - } - const unregisterAgentHookTerminalLifecycle = registerAgentHookTerminalLifecycleHandler( - cacheKey, - handleAgentHookTerminalLifecycle - ) - const hasFreshPaneAgentSurface = (): boolean => { - const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] - if (isFreshActivePaneAgentEntry(entry)) { - return true - } - const liveTitle = getLivePaneAgentTitle() - return detectAgentStatusFromTitle(liveTitle ?? '') !== null - } - const 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. - requestKnownWindowsShiftEnterReconfirmation() - } - if (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 (hasFreshPaneAgentSurface()) { - resetPendingShellCommandLine() - return - } - if (shellCommandInferenceSuspendedUntilCommandEnd) { - if (data.includes('\x03') || data.includes('\x15')) { - shellCommandInferenceSuspendedUntilCommandEnd = false - resetPendingShellCommandLine() - } - if (data.includes('\r') || data.includes('\n')) { - shellCommandInferenceSuspendedUntilCommandEnd = false - } - return - } - if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { - resetPendingShellCommandLine() - 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') { - shellCommandInferenceSuspendedUntilCommandEnd = false - rememberCommandInferredPaneAgent() - if (commandInferredPaneAgent) { - return - } - continue - } - if (char === '\x7f' || char === '\b') { - deletePendingShellCommandCharacter() - continue - } - if (char === '\x17') { - deletePendingShellCommandWord() - continue - } - if (char === '\x03' || char === '\x15') { - resetPendingShellCommandLine() - continue - } - if (char === '\x1b') { - const nextIndex = consumeShellCommandCsiSequence(data, index) - if (nextIndex !== null) { - index = nextIndex - 1 - continue - } - resetPendingShellCommandLine() - continue - } - if (char < ' ') { - resetPendingShellCommandLine() - continue - } - if (char >= ' ') { - appendPendingShellCommandInput(char) - if (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. - */ - const getAuthoritativePaneAgent = (): AgentType | undefined => { - const state = useAppStore.getState() - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - ) - return ( - resolvePaneAgentOwner({ - launchAgent: tab?.launchAgent, - startupLaunchAgent: paneStartup?.launchAgent, - initialStatusAgent: paneStartup?.initialAgentStatus?.agent, - commandInferredAgent: commandInferredPaneAgent, - hookAgent: state.agentStatusByPaneKey[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. - const getPaneScopedRendererOwner = (): AgentType | undefined => { - const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] - return ( - commandInferredPaneAgent ?? (isFreshActivePaneAgentEntry(entry) ? entry.agentType : undefined) - ) - } - const clearInferredInterruptWorkingTitle = (): void => { - const state = useAppStore.getState() - const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle - const title = currentTitle ?? statusTitle - if (!title) { - return - } - const neutralTitle = 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. - deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle) - if (manager.getActivePane()?.id === pane.id) { - deps.updateTabTitle(deps.tabId, neutralTitle) - } - } - let titleOnlyInterruptTimer: ReturnType | null = null - const clearTitleOnlyInterruptTimer = (): void => { - if (titleOnlyInterruptTimer !== null) { - clearTimeout(titleOnlyInterruptTimer) - titleOnlyInterruptTimer = null - } - } - const observeTitleOnlyInterrupt = (): void => { - const state = useAppStore.getState() - if (state.agentStatusByPaneKey[cacheKey]) { - return - } - const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - )?.title - const baselineTitle = runtimeTitle ?? tabTitle - if (detectAgentStatusFromTitle(baselineTitle ?? '') !== 'working') { - return - } - clearTitleOnlyInterruptTimer() - titleOnlyInterruptTimer = setTimeout(() => { - titleOnlyInterruptTimer = null - if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) { - return - } - const currentState = useAppStore.getState() - const currentRuntimeTitle = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const currentTabTitle = (currentState.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === 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. - clearInferredInterruptWorkingTitle() - } - }, AGENT_INTERRUPT_SETTLE_MS) - } - const clearReattachIdleAgentCursorResetTimer = (): void => { - if (reattachIdleAgentCursorResetTimer !== null) { - clearTimeout(reattachIdleAgentCursorResetTimer) - reattachIdleAgentCursorResetTimer = null - } - } - const getCurrentTerminalTitle = (): string | null => { - const state = useAppStore.getState() - const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - )?.title - return runtimeTitle ?? tabTitle ?? null - } - let 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. - let reattachReplayPayloadSignalGeneration = 0 - const rememberReattachPayloadAgentSignal = ( - data: string, - opts: { fullScreenReplay: boolean } - ): void => { - 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. - reattachReplayPayloadHasCursorAgentSignal = opts.fullScreenReplay - ? signal - : reattachReplayPayloadHasCursorAgentSignal || signal - } - const isCursorAgentNativeTitle = (title: string): boolean => { - return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase() - } - const 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[cacheKey]) { - return true - } - const title = 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 || isCursorAgentNativeTitle(title) - } - const hasLiveAgentReattachSignal = (): boolean => { - return hasLiveAgentReattachStatusOrTitleSignal() || reattachReplayPayloadHasCursorAgentSignal - } - const shouldPreserveAgentReattachModes = (): boolean => { - // Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes. - // Preserve those modes only when reattach still looks agent-owned. - return hasLiveAgentReattachSignal() - } - const shouldSendFocusedAgentReattachFocusIn = (): boolean => { - return terminalOwnsDomFocus(pane.terminal) && shouldPreserveAgentReattachModes() - } - const scheduleReattachIdleAgentCursorReset = (): void => { - const status = detectAgentStatusFromTitle(getCurrentTerminalTitle() ?? '') - if (status !== 'idle' && status !== 'permission') { - return - } - clearReattachIdleAgentCursorResetTimer() - reattachIdleAgentCursorResetTimer = setTimeout(() => { - reattachIdleAgentCursorResetTimer = null - if (disposed) { - return - } - const latestStatus = detectAgentStatusFromTitle(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. - queueAgentIdleTerminalModeReset() - }, REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS) - } - const interruptInference = createAgentInterruptInference({ - paneKey: cacheKey, - getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[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) { - clearInferredInterruptWorkingTitle() - } - return applied - }) - .catch((err) => { - console.warn('[agent-interrupt] inferInterrupt failed:', err) - return false - }) - } - }) - const questionAnsweredInference = createAgentQuestionAnsweredInference({ - paneKey: cacheKey, - getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey], - inferQuestionAnswered: (request) => - window.api.agentStatus.inferQuestionAnswered(request).catch((err) => { - console.warn('[agent-question] inferQuestionAnswered failed:', err) - return false - }) - }) - const 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(cacheKey) - return - } - const current = state.agentStatusByPaneKey[cacheKey] - if (!current) { - state.clearAgentLaunchConfig(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(cacheKey) - } - let pendingTerminalInputIntent: AgentInterruptInputIntent | null = null - let clearPendingTerminalInputIntentTimer: ReturnType | null = null - const clearPendingTerminalInputIntent = (): void => { - pendingTerminalInputIntent = null - if (clearPendingTerminalInputIntentTimer !== null) { - clearTimeout(clearPendingTerminalInputIntentTimer) - clearPendingTerminalInputIntentTimer = null - } - } - const setPendingTerminalInputIntent = (intent: AgentInterruptInputIntent): void => { - clearPendingTerminalInputIntent() - pendingTerminalInputIntent = intent - clearPendingTerminalInputIntentTimer = setTimeout(() => { - clearPendingTerminalInputIntent() - }, 0) - } - const inputMatchesIntent = (intent: AgentInterruptInputIntent, data: string): boolean => { - return ( - (intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03') - ) - } - const inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => { - if (data === '\x03') { - return 'ctrl-c' - } - if (data === '\x1b') { - return 'plain-escape' - } - return null - } - const observeSentTerminalInputIntent = ( - data: string, - intent = pendingTerminalInputIntent - ): void => { - if (intent && inputMatchesIntent(intent, data)) { - interruptInference.observeInputIntent(intent) - observeTitleOnlyInterrupt() - } - } - const observeAcceptedTerminalInput = ( - data: string, - intent: AgentInterruptInputIntent | null = null - ): void => { - if (intent === 'ctrl-c' || data === '\x03') { - markTerminalBracketedPasteInterrupted(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. - questionAnsweredInference.observeSentTerminalInput(data) - } - let pendingTerminalInputWrite: Promise | null = null - let sequencedInterruptStatusBaseline: AgentStatusEntry | null | undefined - let interruptStatusBaselineSequence = 0 - const setPendingTerminalInputWrite = (promise: Promise): void => { - pendingTerminalInputWrite = promise - void promise.finally(() => { - if (pendingTerminalInputWrite === promise) { - pendingTerminalInputWrite = null - } - }) - } - const flushPendingInterruptInference = (): boolean | Promise => { - const pendingWrite = pendingTerminalInputWrite - if (!pendingWrite) { - return interruptInference.flushPending() - } - return pendingWrite.then((immediateResult) => { - return immediateResult ?? interruptInference.flushPending() - }) - } - // 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. - const paneHasLiveHookAgentIcon = (state: ReturnType): boolean => { - const entry = state.agentStatusByPaneKey[cacheKey] - return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType)) - } - const paneExpectsLaunchAgent = (state: ReturnType): boolean => { - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (candidate) => candidate.id === deps.tabId - ) - const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType - return Boolean( - tab?.launchAgent ?? - paneStartup?.launchAgent ?? - 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. - const resolveExpectedLaunchTuiAgent = (): TuiAgent | null => { - const state = useAppStore.getState() - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (candidate) => candidate.id === deps.tabId - ) - const candidate = - tab?.launchAgent ?? - paneStartup?.launchAgent ?? - paneStartup?.initialAgentStatus?.agent ?? - state.agentLaunchConfigByPaneKey[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. - const paneHasKnownAgentIdentity = (): boolean => { - const state = useAppStore.getState() - const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType - return ( - Boolean(state.paneForegroundAgentByPaneKey[cacheKey]?.agent) || - 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. - const clearStaleAgentTabTitleOnConfirmedShell = (): void => { - const state = useAppStore.getState() - const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (entry) => entry.id === deps.tabId - ) - const title = currentTitle ?? tab?.title - if (!title || resolveCommittedTitleAgentType(title) === null) { - return - } - const neutralTitle = neutralTerminalTitle() - deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle) - if (manager.getActivePane()?.id === pane.id) { - deps.updateTabTitle(deps.tabId, neutralTitle) - } - } - let deferredCommandFinishedStatusDrop: (() => void) | null = null - let visibleForegroundSamplePending = false - let visibleForegroundSampleSettled = false - const settleDeferredCommandFinishedStatusDrop = (): void => { - const dropStatus = deferredCommandFinishedStatusDrop - deferredCommandFinishedStatusDrop = null - dropStatus?.() - } - const 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[deps.worktreeId] ?? []).find( - (candidate) => candidate.id === 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(deps.worktreeId) ?? null, - cwd: deps.cwd, - shellOverride: resolveWindowsShellOverride( - tab?.shellOverride, - state.settings?.terminalWindowsShell - ), - executionHostId: getExecutionHostIdForWorktree(state, deps.worktreeId) - }) - } - const paneForegroundAgentTracker = createPaneForegroundAgentTracker({ - getPtyId: () => transport.getPtyId(), - isTrackablePtyId: isForegroundTrackingAllowed, - readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id), - confirmForegroundProcess: (id) => window.api.pty.confirmForegroundProcess(id), - publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry), - hasKnownAgentIdentity: paneHasKnownAgentIdentity, - onConfirmedShellForeground: (reason) => { - 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(pane, deps.replayingPanesRef, POST_REPLAY_REATTACH_RESET, { - breadcrumbIdentity: { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - ptyId: transport.getPtyId() - }, - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously - }) - if (reason === 'visible-pty') { - useAppStore.getState().clearAgentLaunchConfig(cacheKey) - return - } - settleDeferredCommandFinishedStatusDrop() - }, - onCommandFinishedUnavailable: settleDeferredCommandFinishedStatusDrop, - onVisibleForegroundSettled: (outcome) => { - visibleForegroundSamplePending = false - visibleForegroundSampleSettled = outcome !== 'inconclusive' - if (outcome !== 'inconclusive') { - return - } - const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[cacheKey] - if (foreground?.routingConfirmationPending !== true) { - return - } - useAppStore.getState().setPaneForegroundAgent(cacheKey, { - agent: foreground.agent, - routingRevoked: true, - shellForeground: foreground.shellForeground - }) - } - }) - // Why: one command-finished policy whether the signal arrives as bytes - // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — - // routing both through this handler keeps the drop/interrupt semantics - // identical across authority modes. - const handleCommandFinished = (bestEffortExitCode: number | null): void => { - clearCommandInferredPaneAgentAfterPtySideEffects() - visibleForegroundSamplePending = false - const shouldDeferStatusDrop = 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(deps.worktreeId, bestEffortExitCode) - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - const inferenceResult = flushPendingInterruptInference() - const dropStatus = (): void => { - if (inferenceResult === true) { - dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) - return - } - if (inferenceResult instanceof Promise) { - void inferenceResult.then((applied) => { - dropCommandFinishedStatusIfSameTurn(entry, { - allowInferredInterrupt: applied === true - }) - }) - return - } - 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. - deferredCommandFinishedStatusDrop = dropStatus - return - } - deferredCommandFinishedStatusDrop = null - dropStatus() - } - const sampleVisiblePaneForegroundAgent = (forceRoutingConfirmation = false): void => { - if ( - !deps.isVisibleRef.current || - visibleForegroundSamplePending || - visibleForegroundSampleSettled - ) { - return - } - const state = useAppStore.getState() - const foreground = state.paneForegroundAgentByPaneKey[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 && paneHasLiveHookAgentIcon(state)) { - return - } - const expectsAgent = 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. - visibleForegroundSamplePending = paneForegroundAgentTracker.onVisiblePtyBound(expectsAgent) - } - startAcceptedInferredCommand = (agent) => { - paneForegroundAgentTracker.onCommandStarted(agent) - } - requestKnownWindowsShiftEnterReconfirmation = () => { - const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[cacheKey] - // Why: daemon reattach/launch metadata is display-only until a live provider - // read confirms it, so only proven trust may be revoked-and-retained here. A - // pending entry must never qualify: re-arming itself would let one old read - // authorize CSI-u forever on a shell that emits no OSC boundary to publish over. - if ( - !foreground?.agent || - foreground.routingTrusted !== true || - TUI_AGENT_CONFIG[foreground.agent].windowsShiftEnterEncoding !== 'csi-u' - ) { - return - } - const revokedEntry = { - agent: foreground.agent, - routingRevoked: true, - shellForeground: false - } - useAppStore.getState().setPaneForegroundAgent(cacheKey, revokedEntry) - visibleForegroundSamplePending = false - visibleForegroundSampleSettled = false - // Why: hook rows can suppress display-only sampling, but cannot restore - // byte authority after this function explicitly revoked routing trust. - sampleVisiblePaneForegroundAgent(true) - // Why: cmd.exe and Git Bash have no OSC command boundaries, so retain the prior - // CSI-u capability across the async read rather than let the gap send Esc+CR. - // Only while a read is genuinely in flight — nothing else ever clears the flag. - if (paneForegroundAgentTracker.hasReadInFlight()) { - useAppStore.getState().setPaneForegroundAgent(cacheKey, { - ...revokedEntry, - routingConfirmationPending: true - }) - } - } - const 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. - deferredCommandFinishedStatusDrop = null - visibleForegroundSamplePending = false - visibleForegroundSampleSettled = false - // Why: typed commands can be aliases, so they only widen the bounded - // process-confirmation window; they never become routing evidence. - paneForegroundAgentTracker.onCommandStarted(commandInferredPaneAgent) - }, - onCommandFinished: handleCommandFinished - }) - // Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect — - // it stays attached in every authority mode. - commandLifecycle.attachXtermConsumer(pane.terminal) - const onTerminalKeyDown = (event: KeyboardEvent): void => { - if (isPlainEscapeKeyEvent(event)) { - 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. - deps.clearTerminalTabUnread(deps.tabId) - deps.clearTerminalPaneUnread(cacheKey) - deps.clearWorktreeUnread(deps.worktreeId) - return - } - if (isCtrlCKeyEvent(event)) { - if (!navigator.userAgent.includes('Mac') && pane.terminal.hasSelection()) { - return - } - 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' && - 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) { - sampleVisiblePaneForegroundAgent() - } - deps.clearTerminalTabUnread(deps.tabId) - deps.clearTerminalPaneUnread(cacheKey) - deps.clearWorktreeUnread(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. - const terminalKeyTarget = pane.terminal.element ?? pane.container - const terminalKeyTargetSupportsEvents = - typeof terminalKeyTarget?.addEventListener === 'function' && - typeof terminalKeyTarget?.removeEventListener === 'function' - if (terminalKeyTargetSupportsEvents) { - terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true }) - } - - let visibleRemoteViewportClaimPtyId: string | null = null - let pendingVisibleRemoteViewportClaim = false - const setPanePtyFitBinding = (ptyId: string): void => { - bindPanePtyId(pane.id, ptyId, deps.tabId) - pane.container.dataset.ptyId = ptyId - if ( - deps.isVisibleRef.current && - isRemoteRuntimePtyId(ptyId) && - visibleRemoteViewportClaimPtyId !== ptyId - ) { - // Why: the initial fit event consumes this activation arm before later peer ownership changes. - visibleRemoteViewportClaimPtyId = ptyId - 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(pane) - } - claimPendingVisibleRemoteViewport() - } - let activePanePtyBinding: string | null = null - // Why: bind time lets async liveness reconcile ignore a request started - // before this PTY bound (newborn race). Null disables the guard (fail-safe). - let activePanePtyBindingBoundAt: number | null = 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. - let unregisterSideEffectFactConsumer: (() => void) | null = null - const registerSideEffectFactConsumerForPty = ( - ptyId: string, - remoteOutputPaused = false - ): void => { - if ((!mainSideEffectAuthority && !remoteOutputPaused) || disposed) { - return - } - unregisterSideEffectFactConsumer?.() - unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({ - ptyId, - callbacks: { - onTitleChange, - onBell, - onAgentBecameIdle, - onAgentBecameWorking, - onAgentExited, - onCommandFinished: handleCommandFinished, - onPrLink: (link) => - useAppStore.getState().observeTerminalGitHubPullRequestLink(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: seedCommandCodeOutputWorkingStatus, - onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, - ...(shouldOwnAgentStatusInRenderer - ? { onAgentStatus: (payload) => handleRendererOwnedAgentStatus(payload) } - : {}), - // Why: gated hidden panes never see the subscribe bytes; the fact - // replaces the byte scan (and the old post-latch subscribe drop). - ...(hiddenDeliveryGateActive || remoteOutputPaused - ? { - onMode2031Subscribe: handleHiddenMode2031SubscribeFact, - onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact - } - : {}) - }, - restoreTitleOnRegister: true - }) - } - const dropSideEffectFactConsumer = (): void => { - unregisterSideEffectFactConsumer?.() - unregisterSideEffectFactConsumer = null - remoteOutputFactConsumerPtyId = null - } - const 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(pane.id, null, deps.tabId) - visibleRemoteViewportClaimPtyId = null - pendingVisibleRemoteViewportClaim = false - activePanePtyBinding = null - activePanePtyBindingBoundAt = null - delete pane.container.dataset.ptyId - delete pane.container.dataset.ptyRecoveryState - } - - const agentCompletionCoordinator = createAgentCompletionCoordinator({ - paneKey: cacheKey, - statusLane: 'pty', - getPtyId: () => transport.getPtyId(), - getSettings: () => useAppStore.getState().settings, - inspectProcess: inspectRuntimeTerminalProcess, - dispatchHookLifecycle: (payload) => dispatchAgentHookTerminalLifecycle(cacheKey, payload), - shouldSuppressProcessReplacementCompletion: (_exited, replacement) => { - const currentStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const currentAgentForReplacement = resolveCompatibleAgentTypeForOwner( - currentStatus?.agentType, - replacement.agent - ) - return ( - isFreshNonDoneAgentStatus(currentStatus) && currentAgentForReplacement === replacement.agent - ) - }, - shouldSuppressConfirmedProcessExitCompletion: (exited) => { - const currentStatus = useAppStore.getState().agentStatusByPaneKey[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') { - 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[cacheKey] - if (!isFreshNonDoneAgentStatus(currentAgentStatus)) { - setFocusReportSuppressionForAgentCompletion(title, meta.agentStatus?.agentType) - } - queueAgentIdleTerminalModeReset() - } - scheduleAgentTaskCompleteNotification(title, { - allowDoneDetailAfterGrace: meta?.quietedHookDone, - ...(meta?.source === 'process-exit' ? { agentCompletionSource: meta.source } : {}), - ...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {}) - }) - }, - dispatchAttention: (title, meta) => - scheduleAgentTaskCompleteNotification(title, { - agentStatusSnapshot: meta.agentStatus - }), - shouldPollProcessCadence: () => - isAgentTaskCompleteTrackingEnabled() && 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 = transport.getPtyId() - return ptyId !== null && !isRemoteRuntimePtyId(ptyId) && parseAppSshPtyId(ptyId) === null - }, - isLive: () => { - if (disposed) { - return false - } - if (transport.getPtyId()) { - return true - } - return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0 - }, - shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(cacheKey, () => ({ - tabId: deps.tabId, - ...(launchToken ? { launchToken } : {}) - })) - }) - - const focusSurvivingPtyPaneAfterKeptExit = (): void => { - if (manager.getActivePane()?.id !== pane.id) { - return - } - const hasPtyBinding = (paneId: number): boolean => - Boolean(deps.paneTransportsRef.current.get(paneId)?.getPtyId()) - const repairedActiveLeafId = - useAppStore.getState().terminalLayoutsByTabId[deps.tabId]?.activeLeafId ?? null - const repairedActivePaneId = repairedActiveLeafId - ? manager.getNumericIdForLeaf(repairedActiveLeafId) - : null - const targetPaneId = - repairedActivePaneId !== null && - repairedActivePaneId !== pane.id && - hasPtyBinding(repairedActivePaneId) - ? repairedActivePaneId - : (manager - .getPanes() - .find((candidate) => candidate.id !== 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. - manager.setActivePane(targetPaneId, { - focus: deps.isActiveRef.current && 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. - let handledExitPtyId: string | null = 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. - let spawnedFreshPtyId: string | null = 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. - type HibernatedWakeTarget = { ptyId: string; record: SleepingAgentSessionRecord } - let hibernatedWakeTarget: HibernatedWakeTarget | null = null - let wakeHibernatedAgentPane: (() => Promise) | null = 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. - let pendingHibernatedWakeTarget: HibernatedWakeTarget | null = 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. - let hibernatedWakeInFlightClaimKey: string | null = 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. - const consumeHibernatedAgentWake = (claimedProviderSessions?: Set): string | null => { - const target = hibernatedWakeTarget - if (!target || disposed) { - return null - } - if (deps.paneTransportsRef.current.get(pane.id) !== transport) { - return null - } - const currentRecord = getSleepingRecordForPane(useAppStore.getState())?.record - if (currentRecord !== target.record) { - hibernatedWakeTarget = null - pendingHibernatedWakeTarget = null - return null - } - const currentPtyId = 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) { - hibernatedWakeTarget = null - pendingHibernatedWakeTarget = null - return null - } - if (!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) - hibernatedWakeTarget = null - pendingHibernatedWakeTarget = null - 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 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. - hibernatedWakeTarget = target - } - }) - .finally(() => { - if (hibernatedWakeInFlightClaimKey === claimKey) { - hibernatedWakeInFlightClaimKey = null - } - }) - return claimKey - } - const onExit = (ptyId: string, opts: { preserveRendererBinding?: boolean } = {}): void => { - if (handledExitPtyId === ptyId) { - return - } - if (deps.isPtyShutdownPending(ptyId) || isHostPtySleepPending(ptyId, 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') { - onExit(ptyId, { preserveRendererBinding: true }) - } - }) - return - } - const preserveRendererBinding = - opts.preserveRendererBinding === true || - consumeCommittedPtyShutdownExit(ptyId, runtimeEnvironmentId) - resetRendererOrderedSeqForPtyExit(ptyId) - const currentPaneTransport = deps.paneTransportsRef.current.get(pane.id) - if (currentPaneTransport && currentPaneTransport !== 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. - handledExitPtyId = ptyId - if (!preserveRendererBinding) { - deps.clearTabPtyId(deps.tabId, ptyId) - } - deps.consumeSuppressedPtyExit(ptyId) - scheduleRuntimeGraphSync() - return - } - handledExitPtyId = ptyId - agentCompletionCoordinator.dispose() - 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. - releaseHiddenRendererPtyDelivery() - clearPanePtyFitBinding() - // Why: the negotiating application died with its PTY; any replacement - // session starts with kitty keyboard flags at zero. - kittyKeyboardModes.reset() - const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId) || preserveRendererBinding - if (!isSuppressedExit) { - deps.clearExitedPanePtyLayoutBinding(pane.id, ptyId) - } - deps.clearRuntimePaneTitle(deps.tabId, pane.id) - if (!preserveRendererBinding) { - deps.clearTabPtyId(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. - deps.setCacheTimerStartedAt(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(cacheKey) - useAppStore.getState().clearPaneForegroundAgent(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. - manager.setPaneGpuRendering(pane.id, true) - const sleepingRecordEntry = 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(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET, { - breadcrumbIdentity: { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - ptyId - }, - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously - }) - hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record } - const pendingWakeMatches = - pendingHibernatedWakeTarget?.ptyId === ptyId && - pendingHibernatedWakeTarget.record === sleepingRecordEntry.record - if (pendingHibernatedWakeTarget && !pendingWakeMatches) { - pendingHibernatedWakeTarget = null - } - if (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(() => { - consumeHibernatedAgentWake() - }) - } - } else if (pendingHibernatedWakeTarget?.ptyId === ptyId) { - pendingHibernatedWakeTarget = null - } - return - } - manager.setPaneGpuRendering(pane.id, true) - const panes = 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 (spawnedFreshPtyId === ptyId && !Number.isFinite(lastTerminalInputAt)) { - return - } - deps.onPtyExitRef.current(ptyId) - return - } - if ( - deps.isVisibleRef.current && - hadExistingPaneTransportAtConnect && - !restoredPtyIdForTransport && - !Number.isFinite(lastTerminalInputAt) && - !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. - focusSurvivingPtyPaneAfterKeptExit() - return - } - manager.closePane(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. - let hasConsideredInitialCacheTimerSeed = false - let allowInitialIdleCacheSeed = false - - const resolveCurrentAgentStatusRouting = () => { - const ptyId = activePanePtyBinding ?? transport.getPtyId() - const state = useAppStore.getState() - if (disposed || !ptyId) { - return undefined - } - return resolveLiveAgentStatusConnectionRouting({ - state, - paneKey: cacheKey, - ptyId, - expectedConnectionId: worktreeConnectionId, - runtimeEnvironmentId: transport.getRuntimeEnvironmentId?.() ?? runtimeEnvironmentId - }) - } - - const 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: getAuthoritativePaneAgent(), - rendererOwnerAgentType: getPaneScopedRendererOwner(), - userGpuMode: useAppStore.getState().settings?.terminalGpuAcceleration ?? 'auto' - }) - const paneTitle = decision.displayTitle - if ( - shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, { - paneKey: cacheKey, - tabId: deps.tabId, - ...(launchToken ? { launchToken } : {}) - }) - ) { - return - } - manager.setPaneGpuRendering(pane.id, decision.rendererPolicy.gpuEnabled) - deps.setRuntimePaneTitle(deps.tabId, 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 && syncAgentTaskCompleteTrackingEnabled()) { - const activeHookStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - if (!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`. - 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 (manager.getActivePane()?.id === pane.id) { - deps.updateTabTitle(deps.tabId, paneTitle) - } - - if (!hasConsideredInitialCacheTimerSeed) { - hasConsideredInitialCacheTimerSeed = true - const state = useAppStore.getState() - if ( - shouldSeedCacheTimerOnInitialTitle({ - rawTitle, - allowInitialIdleSeed: allowInitialIdleCacheSeed, - existingTimerStartedAt: state.cacheTimerByKey[cacheKey], - promptCacheTimerEnabled: state.settings?.promptCacheTimerEnabled ?? null - }) - ) { - deps.setCacheTimerStartedAt(cacheKey, Date.now()) - } - } - } - - const applyInitialAgentStatus = (terminalTitle?: string): void => { - const initialStatus = paneStartup?.initialAgentStatus - const routing = resolveCurrentAgentStatusRouting() - if (!initialStatus || !routing) { - return - } - const statusPayload = { - state: 'working' as const, - prompt: initialStatus.prompt, - agentType: resolveCompatibleAgentTypeForOwner( - initialStatus.agent, - getAuthoritativePaneAgent() - ), - // Why: Orca launched this agent, so this row predates any provider signal for the pane. - observation: rendererAgentStatusObservations.observe(cacheKey, { - origin: 'launch', - observedAt: Date.now(), - kind: 'transition' - }) - } - if (paneStartup.launchConfig) { - useAppStore - .getState() - .setAgentStatus(cacheKey, statusPayload, terminalTitle, undefined, routing, { - launchConfig: paneStartup.launchConfig, - ...(launchToken ? { launchToken } : {}) - }) - return - } - useAppStore - .getState() - .setAgentStatus(cacheKey, statusPayload, terminalTitle, undefined, routing) - } - - const canApplyCommandCodeOutputStatus = (): boolean => { - const state = useAppStore.getState() - const foreground = state.paneForegroundAgentByPaneKey[cacheKey] - return canCommandCodeOutputOwnPane({ - foregroundAgent: foreground?.agent, - shellForeground: foreground?.shellForeground, - paneOwnerAgent: getAuthoritativePaneAgent(), - retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[cacheKey]?.agentType - }) - } - - const seedCommandCodeOutputWorkingStatus = (prompt: string): void => { - if (!canApplyCommandCodeOutputStatus()) { - return - } - clearCommandCodeOutputDoneTimer() - const routing = resolveCurrentAgentStatusRouting() - if (!routing) { - return - } - const currentState = useAppStore.getState() - const currentEntry = currentState.agentStatusByPaneKey[cacheKey] - const currentTitle = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const normalizedPrompt = prompt.trim() - if ( - currentEntry?.agentType === 'command-code' && - currentEntry.state === 'done' && - (!normalizedPrompt || normalizedPrompt === currentEntry.prompt.trim()) - ) { - return - } - currentState.setAgentStatus( - cacheKey, - { - state: 'working', - prompt: normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''), - agentType: 'command-code', - // Why: Command Code has no prompt-start hook; this row is read off the pane's own output. - observation: rendererAgentStatusObservations.observe(cacheKey, { - origin: 'process', - observedAt: Date.now(), - kind: 'transition' - }) - }, - 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. - const releaseCommandCodeDoneSettleExecutor = setCommandCodeDoneSettleExecutor( - cacheKey, - (normalizedPrompt) => { - const routing = resolveCurrentAgentStatusRouting() - if (!routing) { - return - } - const currentState = useAppStore.getState() - const currentEntry = currentState.agentStatusByPaneKey[cacheKey] - if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') { - return - } - const currentPrompt = currentEntry.prompt.trim() - if (currentPrompt && currentPrompt !== normalizedPrompt) { - return - } - const currentTitle = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - currentState.setAgentStatus( - cacheKey, - { - state: 'done', - prompt: currentPrompt || normalizedPrompt, - agentType: 'command-code', - observation: rendererAgentStatusObservations.observe(cacheKey, { - origin: 'process', - observedAt: Date.now(), - kind: 'transition' - }) - }, - currentTitle, - undefined, - routing - ) - } - ) - const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(cacheKey) - const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => { - if (!canApplyCommandCodeOutputStatus()) { - return - } - const normalizedPrompt = prompt.trim() - if (!normalizedPrompt) { - cancelCommandCodeDoneSettle(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(cacheKey, normalizedPrompt) - } - - const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() - const 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(transport, ptyId, visible) - } - const bindActivePanePty = ( - ptyId: string, - options: { - seedInitialAgentStatus?: boolean - updateTabPtyId?: 'always' | 'if-missing' - replacePtyId?: string - sampleVisibleForegroundAgent?: boolean - } = {} - ): void => { - if (activePanePtyBinding && activePanePtyBinding !== ptyId) { - reportPanePtyVisibility(activePanePtyBinding, false) - } - setPanePtyFitBinding(ptyId) - activePanePtyBinding = ptyId - reportPanePtyVisibility(ptyId, 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. - activePanePtyBindingBoundAt = performance.now() - registerSideEffectFactConsumerForPty(ptyId) - syncHiddenRendererPtyDelivery() - deps.syncPanePtyLayoutBinding(pane.id, ptyId) - // Why: binding a live PTY here is the proof that this pane is current again, so - // lift any retirement fence left by a detach/reattach cycle before hooks arrive. - // Waiting for a new turn would strand a pane re-attached mid-turn or idle (STA-4114). - useAppStore.getState().restoreAgentPaneAuthority?.(cacheKey) - notifyCodexPaneBoundForStaleSweep(ptyId) - const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? [] - const directSshRetryAttemptId = - capturedDirectSshRetryPtyAccepted && directSshRetryAttempt - ? directSshRetryAttempt.attemptId - : undefined - if ( - directSshRetryAttemptId || - options.updateTabPtyId !== 'if-missing' || - !tabPtyIds.includes(ptyId) - ) { - if (directSshRetryAttemptId) { - deps.updateTabPtyId(deps.tabId, ptyId, options.replacePtyId, directSshRetryAttemptId) - } else if (options.replacePtyId) { - deps.updateTabPtyId(deps.tabId, ptyId, options.replacePtyId) - } else { - deps.updateTabPtyId(deps.tabId, ptyId) - } - } - if (options.seedInitialAgentStatus) { - 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() - 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) { - sampleVisiblePaneForegroundAgent() - } else if (options.seedInitialAgentStatus === true) { - const freshSpawnLaunchAgent = resolveExpectedLaunchTuiAgent() - if (freshSpawnLaunchAgent) { - paneForegroundAgentTracker.onCommandStarted(freshSpawnLaunchAgent) - } - } - } - - const onPtySpawn = (ptyId: string): void => { - if (!claimCapturedDirectSshRetryPty(ptyId)) { - // Why: this callback proves a fresh process was created, so rejecting its obsolete lease must also retire it. - queueMicrotask(() => { - if (transport.getPtyId() === ptyId) { - 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). - 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. - bindActivePanePty(ptyId, { seedInitialAgentStatus: true }) - // Spend the queued command only after this pane has a bound PTY. Retired panes never reach - // this point, and the pending entry keeps the worktree from being force-parked during spawn. - // This marks spawn, not delivery: Windows may run argv-embedded commands earlier, while POSIX - // delivery can still be waiting for shell-ready. - try { - deps.onQueuedStartupSpawned?.() - } catch { - // Why swallowed: this callback runs inside the connect promise, so a throw would reject the - // spawn and strand the pane with no pty at all — a far worse failure than a stale entry. - } - } - const onPtyRebind = (ptyId: string, replacedPtyId: string): void => { - if (!canAdoptCapturedDirectSshRetryPty(ptyId)) { - return - } - // Why: provider handle rotation keeps the existing pane/session generation; - // replace its stale store identity without fresh-spawn exit semantics. - 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. - const 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. - deps.markWorktreeUnread(deps.worktreeId) - deps.markTerminalTabUnread(deps.tabId) - if (useAppStore.getState().settings?.experimentalTerminalAttention === true) { - deps.markTerminalPaneUnread(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. - pendingTerminalBellNotification = true - if (!hasPendingAgentTaskCompleteNotification()) { - scheduleTerminalBellNotification() - } - } - - const clearTerminalBellNotificationTimer = (): void => { - if (terminalBellNotificationTimer !== null) { - clearTimeout(terminalBellNotificationTimer) - terminalBellNotificationTimer = null - } - } - - const scheduleTerminalBellNotification = (): void => { - if (terminalBellNotificationTimer !== null) { - return - } - terminalBellNotificationTimer = setTimeout(() => { - terminalBellNotificationTimer = null - if (disposed) { - pendingTerminalBellNotification = false - return - } - if (hasPendingAgentTaskCompleteNotification()) { - return - } - pendingTerminalBellNotification = false - deps.dispatchNotification({ source: 'terminal-bell', paneKey: cacheKey }) - }, AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) - } - - const hasPendingAgentTaskCompleteNotification = (): boolean => - isAgentTaskCompleteNotificationEnabled() && - (agentCompletionCoordinator.hasPendingHookDoneCompletion() || - agentTaskCompleteNotificationGraceTimer !== null || - agentTaskCompleteNotificationMaxTimer !== null || - agentTaskCompleteStatusUnsubscribe !== null) - - const clearPendingAgentTaskCompleteNotification = (): void => { - if (agentTaskCompleteNotificationGraceTimer !== null) { - clearTimeout(agentTaskCompleteNotificationGraceTimer) - agentTaskCompleteNotificationGraceTimer = null - } - if (agentTaskCompleteNotificationMaxTimer !== null) { - clearTimeout(agentTaskCompleteNotificationMaxTimer) - agentTaskCompleteNotificationMaxTimer = null - } - if (agentTaskCompleteStatusUnsubscribe !== null) { - agentTaskCompleteStatusUnsubscribe() - agentTaskCompleteStatusUnsubscribe = null - } - } - - const syncAgentTaskCompleteTrackingEnabled = (): boolean => { - const enabled = isAgentTaskCompleteTrackingEnabled() - const osNotificationsEnabled = isAgentTaskCompleteNotificationEnabled() - if ( - !osNotificationsEnabled && - wasAgentTaskCompleteOsNotificationEnabled && - pendingTerminalBellNotification - ) { - scheduleTerminalBellNotification() - } - if (!enabled && wasAgentTaskCompleteTrackingEnabled) { - // Why: disabling every completion consumer is an event-time boundary. - // Drop pending alerts while preserving accepted-hook lifecycle state. - agentTaskCompleteNotificationGeneration += 1 - requiresFreshWorkingForAgentTaskCompleteNotification = true - clearPendingAgentTaskCompleteNotification() - if (pendingTerminalBellNotification) { - scheduleTerminalBellNotification() - } - } else if (enabled && !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. - requiresFreshWorkingForAgentTaskCompleteNotification = true - } - wasAgentTaskCompleteTrackingEnabled = enabled - wasAgentTaskCompleteOsNotificationEnabled = osNotificationsEnabled - return enabled - } - - const scheduleAgentTaskCompleteNotification = ( - title: string, - options: { - allowDoneDetailAfterGrace?: boolean - agentStatusSnapshot?: AgentCompletionStatusSnapshot - agentCompletionSource?: AgentCompletionDispatchMeta['source'] - } = {} - ): void => { - if ( - !syncAgentTaskCompleteTrackingEnabled() || - requiresFreshWorkingForAgentTaskCompleteNotification - ) { - return - } - clearPendingAgentTaskCompleteNotification() - let graceElapsed = false - const generationAtSchedule = agentTaskCompleteNotificationGeneration - const agentStatusAtSchedule = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const hasNewerActiveHookStatus = (): boolean => { - const currentStatus = useAppStore.getState().agentStatusByPaneKey[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 => { - clearPendingAgentTaskCompleteNotification() - if ( - generationAtSchedule !== agentTaskCompleteNotificationGeneration || - !syncAgentTaskCompleteTrackingEnabled() || - hasNewerActiveHookStatus() - ) { - return - } - if (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() - pendingTerminalBellNotification = false - clearTerminalBellNotificationTimer() - deps.dispatchNotification({ - source: 'agent-task-complete', - terminalTitle: title, - paneKey: 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. - clearPendingAgentTaskCompleteNotification() - return - } - if (!graceElapsed) { - return - } - const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] - if (canDispatchAgentNotificationAfterGrace(entry, options)) { - dispatch() - } - } - - agentTaskCompleteStatusUnsubscribe = useAppStore.subscribe(dispatchIfDetailed) - agentTaskCompleteNotificationGraceTimer = setTimeout(() => { - 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. - agentTaskCompleteNotificationMaxTimer = setTimeout( - dispatch, - AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS - ) - } - agentTaskCompleteSettingsUnsubscribe = subscribeAgentTaskCompleteTrackingEnabled(() => { - if (syncAgentTaskCompleteTrackingEnabled()) { - 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. - const 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) { - deps.setCacheTimerStartedAt(cacheKey, null) - return - } - const currentState = useAppStore.getState() - const activeHookStatus = currentState.agentStatusByPaneKey[cacheKey] - if (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) { - 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)) { - deps.setCacheTimerStartedAt(cacheKey, Date.now()) - } - if (detectAgentStatusFromTitle(title) === 'idle') { - setFocusReportSuppressionForAgentCompletion(title, activeHookStatus?.agentType) - } - if (syncAgentTaskCompleteTrackingEnabled()) { - 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. - queueAgentIdleTerminalModeReset() - } - const onAgentBecameWorking = (): void => { - suppressNativeWindowsIdleCodexFocusReports = false - clearSuppressedTitleSideEffects() - if (syncAgentTaskCompleteTrackingEnabled()) { - requiresFreshWorkingForAgentTaskCompleteNotification = false - 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. - deps.setCacheTimerStartedAt(cacheKey, null) - clearPendingAgentTaskCompleteNotification() - if (pendingTerminalBellNotification) { - scheduleTerminalBellNotification() - } - } - const onAgentExited = (): void => { - // Why: eligibility can disappear transiently during reconnect, but a - // confirmed shell-title transition is authoritative for native-chat exit. - deps.onAgentExitedRef.current(pane.leafId) - clearSuppressedTitleSideEffects() - clearCommandInferredPaneAgent() - 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. - deps.setCacheTimerStartedAt(cacheKey, null) - 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. - const state = useAppStore.getState() - const parsedWorkspaceKey = parseWorkspaceKey(deps.worktreeId) - const folderWorkspace = - parsedWorkspaceKey?.type === 'folder' - ? state.folderWorkspaces.find( - (workspace) => workspace.id === parsedWorkspaceKey.folderWorkspaceId - ) - : null - const workspaceEnv: Record = { ORCA_WORKSPACE_ID: deps.worktreeId } - if (folderWorkspace) { - workspaceEnv.ORCA_PROJECT_GROUP_ID = folderWorkspace.projectGroupId - workspaceEnv.ORCA_WORKSPACE_ROOT = folderWorkspace.folderPath - } - const paneIdentityEnv = { - ...workspaceEnv, - ORCA_PANE_KEY: cacheKey, - ORCA_TAB_ID: deps.tabId, - ORCA_WORKTREE_ID: deps.worktreeId, - ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) - } - const paneEnv = { - ...paneStartup?.env, - ...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. - const worktree = getWorktreeMapFromState(state).get(deps.worktreeId) - const worktreeConnectionId = getConnectionId(deps.worktreeId) - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId) - const restoredPtyIdForTransport = - deps.restoredLeafId && deps.restoredPtyIdByLeafId - ? (deps.restoredPtyIdByLeafId[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). - const terminalWorktreeRoute = resolveTerminalWorktreeRoute(state, deps.worktreeId) - const explicitRuntimeEnvironmentId = terminalWorktreeRoute?.runtimeEnvironmentId ?? null - // Why: paired-web worktrees retain HUB execution identity; their runtime-scoped mirrored pane is the session-level transport owner. - const mirroredRuntimeOwners = new Set( - isWebTerminalSurfaceTabId(deps.tabId) - ? [restoredPtyIdForTransport, tab?.ptyId] - .map((ptyId) => (ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null)) - .filter((environmentId): environmentId is string => Boolean(environmentId)) - : [] - ) - const mirroredRuntimeEnvironmentId = mirroredRuntimeOwners.values().next().value ?? null - const terminalOwnerUnresolved = - mirroredRuntimeOwners.size > 1 || - (terminalWorktreeRoute === null && !mirroredRuntimeEnvironmentId) - const runtimeEnvironmentId = explicitRuntimeEnvironmentId - ? explicitRuntimeEnvironmentId - : mirroredRuntimeEnvironmentId - ? 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). - const hostAgnosticTerminalWorktree = - deps.worktreeId === FLOATING_TERMINAL_WORKTREE_ID || - isEphemeralSetupTerminalWorktreeId(deps.worktreeId) - const worktreeProvesLocalHost = parseExecutionHostId(worktree?.hostId)?.kind === 'local' - const connectionOwnerHydrating = - !terminalOwnerUnresolved && - !hostAgnosticTerminalWorktree && - !worktreeProvesLocalHost && - runtimeEnvironmentId === null && - worktreeConnectionId === undefined - // Why: an SSH host nested under a HUB is execution identity, not permission for the paired client to dial that host. - const connectionId = - !terminalOwnerUnresolved && !connectionOwnerHydrating && runtimeEnvironmentId === null - ? (worktreeConnectionId ?? null) - : null - type DirectSshRetryLease = Pick< - DirectSshPaneRetryAttempt, - 'attemptId' | 'authority' | 'tabGeneration' - > - const directSshRetryAttempt: DirectSshRetryLease | undefined = (() => { - const pendingAttempt = state.directSshPaneRetryByTabId?.[deps.tabId] - const liveBinding = state.directSshLivePtyBindingByTabId?.[deps.tabId] - const attempt = - pendingAttempt?.authority.targetId === connectionId && - pendingAttempt.tabGeneration === (tab?.generation ?? 0) - ? pendingAttempt - : liveBinding?.authority.targetId === connectionId && - liveBinding.tabGeneration === (tab?.generation ?? 0) - ? liveBinding - : undefined - return attempt - })() - // Only the PENDING retry marks a mount that a reconnect created. directSshRetryAttempt also - // accepts the live binding, which is written at the same tab generation once the reconnect - // succeeds and then outlives it — so it stays truthy for every later remount of that generation, - // not just this one. - const followsDirectSshReconnect = (() => { - const pending = state.directSshPaneRetryByTabId?.[deps.tabId] - return ( - pending?.authority.targetId === connectionId && - pending.tabGeneration === (tab?.generation ?? 0) - ) - })() - const pendingSpawnKey = directSshRetryAttempt - ? JSON.stringify([cacheKey, directSshRetryAttempt.attemptId]) - : cacheKey - let capturedDirectSshRetryPtyAccepted = false - let directSshPaneRetrySettlementCancelled = false - const directSshPaneRetrySettlementTimers = new Set>() - const directSshPaneRetryTimedPromises = new WeakSet() - const capturedDirectSshRetryLeaseMatches = (): boolean => { - if (!directSshRetryAttempt) { - return true - } - const currentState = useAppStore.getState() - const currentConnection = currentState.sshConnectionStates.get( - directSshRetryAttempt.authority.targetId - ) - const currentTab = (currentState.tabsByWorktree[deps.worktreeId] ?? []).find( - (candidate) => candidate.id === deps.tabId - ) - if ( - currentConnection?.providerEpoch !== directSshRetryAttempt.authority.providerEpoch || - currentConnection.connectionGeneration !== - directSshRetryAttempt.authority.connectionGeneration || - (currentTab?.generation ?? 0) !== directSshRetryAttempt.tabGeneration - ) { - return false - } - const pendingAttempt = currentState.directSshPaneRetryByTabId?.[deps.tabId] - const pendingMatches = - pendingAttempt?.attemptId === directSshRetryAttempt.attemptId && - directSshAuthoritiesEqual(pendingAttempt.authority, directSshRetryAttempt.authority) && - pendingAttempt.tabGeneration === directSshRetryAttempt.tabGeneration - const liveBinding = currentState.directSshLivePtyBindingByTabId?.[deps.tabId] - const liveBindingMatchesAttempt = - liveBinding?.attemptId === directSshRetryAttempt.attemptId && - directSshAuthoritiesEqual(liveBinding.authority, directSshRetryAttempt.authority) && - liveBinding.tabGeneration === directSshRetryAttempt.tabGeneration - return pendingMatches || liveBindingMatchesAttempt - } - const capturedDirectSshRetryStateMatches = (ptyId: string): boolean => { - if (!directSshRetryAttempt) { - return true - } - const currentConnection = useAppStore - .getState() - .sshConnectionStates.get(directSshRetryAttempt.authority.targetId) - return ( - parseAppSshPtyId(ptyId)?.connectionId === directSshRetryAttempt.authority.targetId && - currentConnection?.status === 'connected' && - capturedDirectSshRetryLeaseMatches() - ) - } - const claimCapturedDirectSshRetryPty = (ptyId: string): boolean => { - if (!capturedDirectSshRetryStateMatches(ptyId)) { - return false - } - capturedDirectSshRetryPtyAccepted = directSshRetryAttempt !== undefined - return true - } - const canAdoptCapturedDirectSshRetryPty = (ptyId: string): boolean => { - const canAdopt = capturedDirectSshRetryStateMatches(ptyId) - if (canAdopt && directSshRetryAttempt) { - capturedDirectSshRetryPtyAccepted = true - } - return canAdopt - } - const settleDirectSshPaneRetryAttempt = ( - attempt: DirectSshRetryLease | undefined, - status: 'failed' | 'timed-out' - ): void => { - if (!attempt) { - return - } - useAppStore.getState().settleDirectSshPaneRetry?.({ - status, - tabId: deps.tabId, - attemptId: attempt.attemptId, - authority: attempt.authority, - tabGeneration: attempt.tabGeneration - }) - } - const armDirectSshPaneRetryTimeout = ( - promise: Promise, - attempt: DirectSshRetryLease | undefined - ): void => { - if (!attempt || disposed || directSshPaneRetryTimedPromises.has(promise)) { - return - } - directSshPaneRetryTimedPromises.add(promise) - const timer = setTimeout(() => { - directSshPaneRetrySettlementTimers.delete(timer) - if (directSshPaneRetrySettlementCancelled) { - return - } - settleDirectSshPaneRetryAttempt(attempt, 'timed-out') - }, DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS) - directSshPaneRetrySettlementTimers.add(timer) - void promise - .finally(() => { - directSshPaneRetrySettlementTimers.delete(timer) - clearTimeout(timer) - }) - .catch(() => {}) - } - const shellOverride = 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. - const executionHostId = terminalOwnerUnresolved - ? ('runtime:unresolved-owner' as const) - : getExecutionHostIdForWorktree(state, deps.worktreeId) - const isNativeWindowsConpty = isLocalNativeWindowsConpty({ - userAgent: navigator.userAgent, - connectionId, - cwd: 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(shellOverride, state.settings?.terminalWindowsShell), - executionHostId - }) - if (isNativeWindowsConpty) { - // Why: Windows ConPTY agent turns can leave renderer keyboard modes armed - // after completion, corrupting plain input with encoded bytes. - idleAgentTerminalModeReset = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` - } - const shouldApplyNativeWindowsRewriteRefresh = isNativeWindowsConpty - const shouldApplyWindowsRendererUnicodeRefresh = CLIENT_PLATFORM === 'win32' - const shouldProtectNativeWindowsSynchronizedOutput = isNativeWindowsConpty - let unsubscribeWindowsDoneTerminalModeReset: (() => void) | null = null - if (isNativeWindowsConpty) { - const initialAgentStatus = state.agentStatusByPaneKey[cacheKey] - let lastAgentDoneStartedAt = resolveLatestAgentDoneStartedAt(initialAgentStatus) - if ( - !initialAgentStatus && - paneStartup?.telemetry?.launch_source === 'sidebar' && - paneStartup.telemetry.request_kind === 'resume' && - (paneStartup.launchAgent === 'codex' || 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. - suppressNativeWindowsIdleCodexFocusReports = true - } - if (initialAgentStatus?.state === 'done') { - setFocusReportSuppressionForAgentCompletion(undefined, initialAgentStatus.agentType) - } - unsubscribeWindowsDoneTerminalModeReset = useAppStore.subscribe((nextState) => { - const nextAgentStatus = nextState.agentStatusByPaneKey[cacheKey] - const nextAgentStatusState = nextAgentStatus?.state - if (nextAgentStatusState === 'done') { - setFocusReportSuppressionForAgentCompletion(undefined, nextAgentStatus.agentType) - } else if (nextAgentStatusState) { - 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 - ) { - queueAgentIdleTerminalModeReset() - } - lastAgentDoneStartedAt = nextAgentDoneStartedAt - }) - } - - const localWindowsTerminalCapabilities = hasCachedWindowsTerminalCapabilities() - ? getCachedWindowsTerminalCapabilities() - : null - const projectRuntime = - !tab?.forceHostRuntime && !connectionId && runtimeEnvironmentId === null - ? getLocalProjectExecutionRuntimeContext(state, deps.worktreeId, undefined, { - wslAvailable: localWindowsTerminalCapabilities?.wslAvailable, - availableWslDistros: localWindowsTerminalCapabilities?.wslDistros ?? null - }) - : undefined - const shouldOwnAgentStatusInRenderer = 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. - const releaseRendererOwnedAgentStatusPane = - runtimeEnvironmentId !== null - ? registerRendererOwnedAgentStatusPane(cacheKey, runtimeEnvironmentId) - : null - handleRendererOwnedAgentStatus = (payload): void => { - if ( - shouldSuppressCodexAutoApprovalStatus(payload, { - paneKey: cacheKey, - tabId: deps.tabId, - ...(launchToken ? { launchToken } : {}) - }) - ) { - return - } - const currentState = useAppStore.getState() - const routing = resolveCurrentAgentStatusRouting() - if (!routing) { - return - } - const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const authoritativePaneAgent = getAuthoritativePaneAgent() - const agentType = resolveCompatibleAgentTypeForOwner(payload.agentType, authoritativePaneAgent) - const statusPayload = agentType === payload.agentType ? payload : { ...payload, agentType } - // Why: this is the remote-runtime path where the renderer, not main, parses OSC 9999 out of - // PTY bytes — so the renderer is the sequencing authority for these rows and says so. Kept - // beside statusPayload, not merged into it, so the notification/title consumers below see - // byte-for-byte what they see today. - const observedStatusPayload = { - ...statusPayload, - observation: rendererAgentStatusObservations.observe(cacheKey, { - origin: 'osc', - observedAt: Date.now(), - kind: 'snapshot' - }) - } - const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title) - const statusTitle = resolvedStatusTitle - ? normalizeCompatibleAgentTitleForOwner( - resolvedStatusTitle, - agentType ?? authoritativePaneAgent - ) - : resolvedStatusTitle - // Why: proves the claim — only a pane that really produced byte-derived - // status may fence the host mirror out of its store key. - markRendererOwnedAgentStatusWrite(cacheKey) - if (launchToken) { - currentState.setAgentStatus( - cacheKey, - observedStatusPayload, - statusTitle, - undefined, - routing, - { - launchToken - } - ) - } else { - currentState.setAgentStatus(cacheKey, observedStatusPayload, statusTitle, undefined, routing) - } - if (payload.state === 'working' && syncAgentTaskCompleteTrackingEnabled()) { - requiresFreshWorkingForAgentTaskCompleteNotification = false - } - const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const notificationPayload = - typeof storedStatus?.stateStartedAt === 'number' - ? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt } - : statusPayload - // Why: hook lifecycle owns deferred side effects even when alerts are disabled. - agentCompletionCoordinator.observeHookStatus(notificationPayload) - if (payload.state === 'working' && pendingTerminalBellNotification) { - 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. - const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ - settings: state.settings, - 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. - const hiddenDeliveryGateActive = - mainSideEffectAuthority && isRendererHiddenPtyDeliveryGateEnabled(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). - const isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string => - 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. - const commandCodeOutputStatusDetector = mainSideEffectAuthority - ? null - : createCommandCodeOutputStatusDetector({ - startupCommand: 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(cacheKey), - onWorking: seedCommandCodeOutputWorkingStatus, - onDone: scheduleCommandCodeOutputDoneStatus - }) - const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' - const shouldUseProviderSshStartupDelivery = - Boolean(connectionId) && !shouldDeliverStartupViaTerminalPaste - const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 - let lastTerminalInputAt = Number.NEGATIVE_INFINITY - // Why: separate from lastTerminalInputAt because onExit reads that one as - // "the user never typed into this pane" to keep a dead newborn pane mounted. - // Captured shortcuts must open the redraw window without arming that teardown. - let lastInteractiveRedrawInputAt = Number.NEGATIVE_INFINITY - let hasReceivedPtyOutput = false - let deferredReattachLiveData: DeferredReattachLiveDataQueue | null = null - let reattachLiveDataDeferralDepth = 0 - let deferredReattachLiveDataOwners = new Map() - let transportStreamGeneration = 0 - const markInteractiveRedrawInput = (): void => { - lastInteractiveRedrawInputAt = performance.now() - // Why: input must probe a wedged xterm even when the PTY produces no renderer output. - requestTerminalWritePipelineProbe(pane.terminal) - } - const markTerminalInputSent = (): void => { - lastTerminalInputAt = performance.now() - markInteractiveRedrawInput() - } - const recordTerminalInputForHibernation = (): void => { - useAppStore.getState().recordTerminalInput(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. - const recordRealUserTerminalInput = (): void => { - recordTerminalInputForHibernation() - // Takeover must never fire from the onData fallback below: it mixes in auto-replies. - reportWorkerTerminalUserInput(cacheKey, runtimeEnvironmentId) - } - const userInputActivityDisposable = subscribeToTerminalUserInput( - pane.terminal, - recordRealUserTerminalInput - ) - const recordTerminalInputForHibernationFallback = (): void => { - if (userInputActivityDisposable === null) { - recordTerminalInputForHibernation() - } - } - const markAcceptedTerminalInputSent = (): void => { - markTerminalInputSent() - recordTerminalInputForHibernationFallback() - } - const terminalTheme = pane.terminal.options.theme - const terminalColorQueryReplies = terminalTheme - ? { foreground: terminalTheme.foreground, background: terminalTheme.background } - : undefined - const agentLaunchPreferences = toAgentLaunchPreferences(paneStartup?.sessionOptions) - const transportOptions = { - cwd: 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. - ...(runtimeEnvironmentId === null && !connectionId ? { cwdFallback: 'worktree' as const } : {}), - env: paneEnv, - ...(paneStartup?.envToDelete ? { envToDelete: paneStartup.envToDelete } : {}), - command: shouldDeliverStartupViaTerminalPaste ? undefined : paneStartup?.command, - ...(shouldUseProviderSshStartupDelivery ? { commandDelivery: 'provider' as const } : {}), - startupCommandDelivery: shouldDeliverStartupViaTerminalPaste - ? undefined - : connectionId && paneStartup?.command - ? 'shell-ready' - : paneStartup?.startupCommandDelivery, - connectionId, - executionHostId, - worktreeId: 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: deps.tabId, - leafId: pane.leafId, - activate: deps.isActiveRef.current && deps.isVisibleRef.current, - ...(shellOverride ? { shellOverride } : {}), - ...(projectRuntime ? { projectRuntime } : {}), - ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), - ...(paneStartup?.launchConfig ? { launchConfig: paneStartup.launchConfig } : {}), - ...(paneStartup?.resumeProviderSession - ? { resumeProviderSession: paneStartup.resumeProviderSession } - : {}), - ...((paneStartup?.initialAgentStatus?.prompt ?? paneStartup?.draftPrompt) - ? { agentPrompt: paneStartup?.initialAgentStatus?.prompt ?? paneStartup?.draftPrompt } - : {}), - ...(paneStartup?.initialAgentStatus?.prompt - ? { agentPromptDelivery: 'auto-submit' as const } - : paneStartup?.draftPrompt - ? { agentPromptDelivery: 'draft' as const } - : {}), - ...(paneStartup?.agentArgsOverride !== undefined - ? { agentArgsOverride: paneStartup.agentArgsOverride } - : {}), - ...(agentLaunchPreferences ? { agentLaunchPreferences } : {}), - ...(launchToken ? { launchToken } : {}), - ...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}), - ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), - onPtyExit: onExit, - onPtySpawn, - onPtyRebind, - ...(mainSideEffectAuthority - ? {} - : { - onTitleChange, - onBell, - onAgentBecameIdle, - onAgentBecameWorking, - 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. - ...(shouldOwnAgentStatusInRenderer ? { onAgentStatus: handleRendererOwnedAgentStatus } : {}) - } - if (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(deps.worktreeId, deps.tabId) - } - const transport = - terminalOwnerUnresolved || connectionOwnerHydrating - ? createUnresolvedOwnerPtyTransport( - terminalOwnerUnresolved - ? 'Workspace identity is ambiguous across hosts. Refresh projects and try again.' - : 'Workspace host is still loading. Retry when the project finishes hydrating.' - ) - : runtimeEnvironmentId - ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) - : createIpcPtyTransport(transportOptions) - const canSendDesktopQueryReply = (): boolean => { - const ptyId = 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. - const sendDesktopQueryReplyImmediate = (data: string): boolean => - canSendDesktopQueryReply() && 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 observeLiveMode2031Chunk, #9993). - const handleHiddenMode2031SubscribeFact = (): void => { - const ptyId = transport.getPtyId() - if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputGatedPtyId !== ptyId)) { - return - } - const mode = resolveTerminalColorSchemeMode( - useAppStore.getState().settings, - getSystemPrefersDark() - ) - deps.recordPaneMode2031Subscription?.(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). - const handleHiddenMode2031UnsubscribeFact = (): void => { - const ptyId = transport.getPtyId() - if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputGatedPtyId !== ptyId)) { - return - } - deps.paneMode2031Ref.current.delete(pane.id) - deps.paneLastThemeModeRef.current.delete(pane.id) - } - deps.paneTransportsRef.current.set(pane.id, transport) - const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({ - terminal: pane.terminal, - parser: 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: sendDesktopQueryReplyImmediate, - isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id), - ...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {}) - }) - const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder( - pane.terminal, - sendDesktopQueryReplyImmediate - ) - - const claimViewportForUserActivity = (): void => { - const currentPtyId = transport.getPtyId() - if (!currentPtyId || getFitOverrideForPty(currentPtyId)?.mode !== 'remote-desktop-fit') { - return - } - let proposed: { cols: number; rows: number } | undefined - try { - proposed = pane.fitAddon.proposeDimensions() - } catch { - proposed = undefined - } - const cols = proposed?.cols ?? pane.terminal.cols - const rows = proposed?.rows ?? 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. - transport.claimViewport?.(cols, rows) - } - } - const claimPendingVisibleRemoteViewport = (): void => { - if ( - !pendingVisibleRemoteViewportClaim || - !deps.isVisibleRef.current || - typeof document === 'undefined' || - document.visibilityState === 'hidden' || - typeof document.hasFocus !== 'function' || - !document.hasFocus() - ) { - return - } - claimViewportForUserActivity() - } - const armVisibleRemoteViewportClaim = (): void => { - const ptyId = transport.getPtyId() - if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { - visibleRemoteViewportClaimPtyId = null - pendingVisibleRemoteViewportClaim = false - return - } - if ( - visibleRemoteViewportClaimPtyId !== ptyId || - pendingVisibleRemoteViewportClaim || - getFitOverrideForPty(ptyId)?.mode === 'remote-desktop-fit' - ) { - visibleRemoteViewportClaimPtyId = ptyId - pendingVisibleRemoteViewportClaim = true - } - } - const unsubscribeRemoteDesktopActivationClaim = onOverrideChange((event) => { - if (event.ptyId !== transport.getPtyId() || !isRemoteRuntimePtyId(event.ptyId)) { - return - } - if (event.mode === 'desktop-fit') { - visibleRemoteViewportClaimPtyId = event.ptyId - pendingVisibleRemoteViewportClaim = false - return - } - if (event.mode === 'remote-desktop-fit') { - if (deps.isVisibleRef.current && visibleRemoteViewportClaimPtyId !== event.ptyId) { - visibleRemoteViewportClaimPtyId = event.ptyId - pendingVisibleRemoteViewportClaim = true - } - 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. - let transportConnectInFlightSince: number | null = 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. - const TRANSPORT_CONNECT_SETTLE_GRACE_MS = 60_000 - const requestRecoveryForUndeliverableInput = (providerRejected = false): void => { - if (!providerRejected && transport.isConnected?.() && 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 = - transportConnectInFlightSince !== null && - Date.now() - transportConnectInFlightSince < TRANSPORT_CONNECT_SETTLE_GRACE_MS - if (connectStillSettling || disposed) { - return - } - const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null - const undeliverablePtyId = 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: deps.tabId, - ptyId: undeliverablePtyId, - reason: hostRejectedRemoteInput ? 'input-rejected-by-host' : 'input-undeliverable', - terminalRecoveryGeneration, - terminalRecoveryInstanceId: 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(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. - const unregisterUndeliverableWriteHandler = registerUndeliverableWriteHandler( - pane.terminal, - (reason) => { - // Certification can arrive while this terminal still owns queued or - // detached scheduler work; release its delivery credits immediately. - discardTerminalOutput(pane.terminal) - const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null - void requestTerminalPaneRecovery({ - tabId: deps.tabId, - ptyId: transport.getPtyId() ?? storePtyId, - reason, - terminalRecoveryGeneration, - terminalRecoveryInstanceId: terminalRecoveryInstance.id - }) - } - ) - - const 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(deps.replayingPanesRef, pane.id)) { - return - } - const currentPtyId = 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: deps.tabId, - worktreeId: deps.worktreeId, - panePtyId: currentPtyId - }) - ) { - 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)) { - clearPendingTerminalInputIntent() - return - } - if ( - isNativeWindowsConpty && - 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)) { - 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(deps.tabId, data)) { - clearPendingTerminalInputIntent() - return - } - const intent = 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 ?? inferIntentFromExactTerminalInput(data) - if (acknowledgedIntent && transport.sendInputAccepted) { - const interruptStatusBaseline = useAppStore.getState().agentStatusByPaneKey[cacheKey] ?? null - // Why: equal snapshots retain double-Escape semantics while older snapshots lose ack races. - if (sequencedInterruptStatusBaseline !== interruptStatusBaseline) { - sequencedInterruptStatusBaseline = interruptStatusBaseline - interruptStatusBaselineSequence += 1 - } - const capturedBaselineSequence = interruptStatusBaselineSequence - 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. - cancelSuspendedShellCommandInference() - } - clearPendingTerminalInputIntent() - const writePromise = transport - .sendInputAccepted(data) - .then((accepted): boolean | Promise | null => { - if (accepted) { - // Why: rejected writes use transport recovery and must not arm a parser probe. - markAcceptedTerminalInputSent() - observeAcceptedShellCommandInput(data) - observeAcceptedTerminalInput(data, acknowledgedIntent) - const immediateResult = interruptInference.observeInputIntent( - acknowledgedIntent, - interruptStatusBaseline, - capturedBaselineSequence - ) - 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. - requestRecoveryForUndeliverableInput() - return null - }) - .catch((err) => { - console.warn('[agent-interrupt] acknowledged terminal input failed:', err) - return null - }) - setPendingTerminalInputWrite(writePromise) - return - } - if (intent) { - claimViewportForUserActivity() - if (transport.sendInput(data)) { - markAcceptedTerminalInputSent() - observeAcceptedShellCommandInput(data) - observeAcceptedTerminalInput(data, intent) - } else { - requestRecoveryForUndeliverableInput() - } - clearPendingTerminalInputIntent() - return - } - claimViewportForUserActivity() - if (transport.sendInput(data)) { - markAcceptedTerminalInputSent() - observeAcceptedShellCommandInput(data) - observeAcceptedTerminalInput(data) - observeSentTerminalInputIntent(data) - } else { - clearPendingTerminalInputIntent() - requestRecoveryForUndeliverableInput() - } - } - const onDataDisposable = pane.terminal.onData((data) => { - if (deps.deferPtyInput) { - deps.deferPtyInput(pane.id, data, forwardPtyInput) - return - } - forwardPtyInput(data) - }) - const imeCompositionRouteDisposable = installTerminalImeCompositionRoute({ - terminalElement: pane.terminal.element, - terminal: pane.terminal, - capturedTransport: transport, - getCurrentTransport: () => deps.paneTransportsRef.current.get(pane.id) - }) - - const shouldSuppressDesktopPtyResize = (): boolean => { - const currentPtyId = transport.getPtyId() - return Boolean( - currentPtyId && (getFitOverrideForPty(currentPtyId) || isPtyLocked(currentPtyId)) - ) - } - - const isRendererPtyResizeAuthoritative = (): boolean => { - if (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 - } - - const forwardPtyResize = (cols: number, rows: number): void => { - if (!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 (shouldSuppressDesktopPtyResize()) { - return - } - if (queuePanePtyResizeIfHeld(pane.container, cols, rows)) { - return - } - transport.resize(cols, rows, { claim: true }) - } - - const onHeldPtyResizeFlush = (event: Event): void => { - const detail = (event as CustomEvent).detail - if (!detail) { - return - } - forwardPtyResize(detail.cols, detail.rows) - } - pane.container.addEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) - - const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => { - if (suppressStructuralReplayPtyResize || suppressViewportClaimTerminalResize) { - return - } - 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. - const ptySizeReassertion = createPtySizeReassertion({ - isDisposed: () => disposed, - getPtyId: () => transport.getPtyId(), - isRemotePtyId: isRemoteRuntimePtyId, - shouldSuppressDesktopResize: () => shouldSuppressDesktopPtyResize(), - fitAndRun: (continuation) => safeFitAndThen(pane, 'pty-size-reassertion', continuation), - getTerminalDimensions: () => ({ cols: pane.terminal.cols, rows: 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: forwardPtyResize - }) - // Why built here and not inside 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. - const createReattachGridPush = ( - attemptGeneration: number, - reattachPtyId: string - ): { shouldContinue: () => boolean; continuation: () => void } => { - const isCurrent = (): boolean => - !disposed && - attemptGeneration === transportStreamGeneration && - 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 (shouldSuppressDesktopPtyResize()) { - return - } - const reattachCols = pane.terminal.cols - const reattachRows = pane.terminal.rows - if (reattachCols > 0 && reattachRows > 0) { - 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 (deps.isVisibleRef.current) { - ptySizeReassertion.request({ fit: false }) - } - } - } - } - let pendingForegroundGridDriftCheckRaf: number | null = null - let lastForegroundGridDriftCheckAt = Number.NEGATIVE_INFINITY - const readProposedTerminalGrid = (): { cols: number; rows: number } | null => { - try { - const proposed = pane.fitAddon.proposeDimensions() - if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) { - return null - } - return proposed - } catch { - return null - } - } - const terminalGridDriftedFromFit = (): boolean => { - const proposed = readProposedTerminalGrid() - return Boolean( - proposed && (pane.terminal.cols !== proposed.cols || pane.terminal.rows !== proposed.rows) - ) - } - const 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 ( - disposed || - !deps.isVisibleRef.current || - shouldSuppressDesktopPtyResize() || - pendingForegroundGridDriftCheckRaf !== null - ) { - return - } - const now = performance.now() - if (now - lastForegroundGridDriftCheckAt < FOREGROUND_GRID_DRIFT_CHECK_MIN_MS) { - return - } - lastForegroundGridDriftCheckAt = now - pendingForegroundGridDriftCheckRaf = requestAnimationFrame(() => { - pendingForegroundGridDriftCheckRaf = null - if ( - disposed || - !deps.isVisibleRef.current || - shouldSuppressDesktopPtyResize() || - !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(pane as ManagedPaneInternal, () => - 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. - let pendingGeometryReportRaf: number | null = null - let lastObservedDesktopGrid: { cols: number; rows: number } | null = null - const readPaneSize = (): { width: number; height: number } | null => { - if (typeof pane.container.getBoundingClientRect !== 'function') { - return null - } - const rect = pane.container.getBoundingClientRect() - return { width: rect.width, height: rect.height } - } - let lastObservedPaneSize = readPaneSize() - let pendingPaneGeometryChanged = false - const handleObservedPaneGeometry = (): void => { - pendingGeometryReportRaf = null - if (disposed) { - return - } - if ( - deferTerminalGeometryMutationDuringRebuild( - pane.terminal, - 'observed-pane-geometry', - handleObservedPaneGeometry - ) - ) { - return - } - const paneGeometryChanged = pendingPaneGeometryChanged - pendingPaneGeometryChanged = false - const currentPtyId = 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 = readProposedTerminalGrid() - if (proposed) { - lastObservedDesktopGrid = proposed - } - return - } - const fitOverride = getFitOverrideForPty(currentPtyId) - if (!fitOverride) { - if (pane.terminal.cols > 0 && 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. - lastObservedDesktopGrid = { - cols: pane.terminal.cols, - rows: pane.terminal.rows - } - } - if (shouldSuppressDesktopPtyResize()) { - return - } - requestStablePaneFit(pane as ManagedPaneInternal, () => - ptySizeReassertion.request({ fit: false }) - ) - return - } - let proposed: { cols: number; rows: number } | undefined - try { - proposed = pane.fitAddon.proposeDimensions() - } catch { - proposed = undefined - } - if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) { - return - } - const priorProposed = lastObservedDesktopGrid - lastObservedDesktopGrid = proposed - if (fitOverride.mode === 'remote-desktop-fit') { - if ( - shouldClaimRemoteDesktopViewport({ - holdMode: fitOverride.mode, - prior: priorProposed, - current: proposed, - paneGeometryChanged, - paneVisible: 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. - suppressViewportClaimTerminalResize = true - try { - pane.terminal.resize(proposed.cols, proposed.rows) - } finally { - suppressViewportClaimTerminalResize = false - } - transport.resize(proposed.cols, proposed.rows, { claim: true }) - } - return - } - if (isRemoteRuntimePtyId(currentPtyId)) { - transport.resize(proposed.cols, proposed.rows) - } else { - window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows) - } - } - const geometryReportObserver = - typeof ResizeObserver === 'undefined' - ? null - : new ResizeObserver(() => { - const paneSize = readPaneSize() - if ( - paneSize && - lastObservedPaneSize && - (paneSize.width !== lastObservedPaneSize.width || - paneSize.height !== lastObservedPaneSize.height) - ) { - pendingPaneGeometryChanged = true - } - lastObservedPaneSize = paneSize - if (pendingGeometryReportRaf !== null) { - return - } - pendingGeometryReportRaf = requestAnimationFrame(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 (geometryReportObserver && pane.container instanceof Element) { - geometryReportObserver.observe(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. - let ptySizeReconcileHandle: PtySizeReconcileHandle | null = null - let liveScrollbackRestore: TerminalLiveScrollbackRestore | null = null - const reconcilePtySizeAfterSpawn = ( - ptyId: string, - spawnCols: number, - spawnRows: number - ): void => { - ptySizeReconcileHandle?.cancel() - ptySizeReconcileHandle = reconcilePtySizeAcrossFrames({ - spawnCols, - spawnRows, - isAlive: () => !disposed && 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: () => isRendererPtyResizeAuthoritative(), - measure: () => { - if (!safeFit(pane)) { - return null - } - const cols = pane.terminal.cols - const rows = pane.terminal.rows - return cols > 0 && rows > 0 ? { cols, rows } : null - }, - resize: (cols, rows) => { - if (!shouldSuppressDesktopPtyResize()) { - 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. - const cancelScheduledConnectFrame = (): void => { - if (connectFrame !== null) { - if (typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(connectFrame) - } - connectFrame = null - } - } - const measureStartupGrid = (): { cols: number; rows: number } | null => { - if (!safeFit(pane)) { - return null - } - const cols = pane.terminal.cols - const rows = pane.terminal.rows - return cols > 0 && rows > 0 ? { cols, rows } : null - } - const shouldSettleStartupGridBeforeConnect = (): boolean => - Boolean(paneStartup?.command) && - deps.isVisibleRef.current && - !connectionId && - runtimeEnvironmentId === null - const isStartupGridReadyForConnect = (): boolean => { - const setupSplitDirection = 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(pane, manager, setupSplitDirection) - } - const settleStartupGridBeforeConnect = (connect: () => void): void => { - 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: () => !disposed, - isReadyToSettle: paneStartup?.waitForSetupSplitDirection - ? isStartupGridReadyForConnect - : undefined, - measure: measureStartupGrid, - onSettled: () => { - settledSynchronously = true - startupGridSettleHandle = null - connect() - }, - requestFrame: (callback) => requestAnimationFrame(callback), - cancelFrame: (handle) => { - if (typeof cancelAnimationFrame === 'function') { - cancelAnimationFrame(handle) - } - } - }) - if (!settledSynchronously) { - startupGridSettleHandle = handle - } - } - const runDeferredConnect = (): void => { - if (connectStarted) { - return - } - if (!startupGridSettledForConnect && shouldSettleStartupGridBeforeConnect()) { - cancelScheduledConnectFrame() - if (connectFallbackTimer !== null) { - clearTimeout(connectFallbackTimer) - connectFallbackTimer = null - } - settleStartupGridBeforeConnect(() => { - startupGridSettledForConnect = true - runDeferredConnect() - }) - return - } - connectStarted = true - cancelScheduledConnectFrame() - if (connectFallbackTimer !== null) { - clearTimeout(connectFallbackTimer) - connectFallbackTimer = null - } - if (disposed) { - return - } - safeFit(pane) - const cols = pane.terminal.cols - const rows = 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 ((cols === 0 || rows === 0) && deps.isVisibleRef.current) { - deps.onPtyErrorRef?.current?.(pane.id, createTerminalZeroDimensionsMessage(cols, rows)) - } - - const 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 (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 - } - deps.onPtyErrorRef?.current?.(pane.id, message) - } - const codexBackfillErrorDetector = - paneStartup?.launchAgent === 'codex' || 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. - const registerPaneSerializerFor = (ptyId: string): void => { - // Why: StrictMode mounts panes twice; the first mount is disposed - // before the second runs, but its pty:spawn IPC may have resolved by - // the time `disposed` flips. Without this guard, the disposed first - // mount would register against a torn-down xterm and replace the live - // second-mount registration via owner-token shadowing. - if (disposed) { - return - } - const unregisterSerializer = registerPtySerializer( - ptyId, - async (opts) => { - try { - if (isTerminalWritePipelineCertifiedDead(pane.terminal)) { - return null - } - await waitForTerminalOutputParsed(pane.terminal) - // Certification can land while the serializer waits for an older - // write; never publish a fossil frame from a dead renderer. - if (isTerminalWritePipelineCertifiedDead(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 = 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(pane.serializeAddon, pane.terminal, { scrollback: 0 }) - : serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { - scrollback: opts?.scrollbackRows - }) - const orderedSeq = rendererOrderedPtyId === ptyId ? rendererOrderedSeq : null - // Why snapshotFlags and not `flags`: this pane may itself have - // consumed an old-host snapshot that proved nothing, and its - // conservative `0` fallback must not be republished downstream as - // a host-proven inactive protocol. - const provenKittyFlags = kittyKeyboardModes.hasProvenBaseline - ? kittyKeyboardModes.snapshotFlags - : undefined - return { - data, - cols: pane.terminal.cols, - rows: pane.terminal.rows, - ...(orderedSeq !== null ? { seq: orderedSeq } : {}), - ...(orderedSeq !== null && provenKittyFlags !== undefined - ? { kittyKeyboardFlags: provenKittyFlags } - : {}) - } - } catch { - return null - } - }, - () => { - clearHiddenOutputRestoreState() - discardTerminalOutput(pane.terminal) - clearTerminalScrollbackAndFollowOutput(pane.terminal) - } - ) - const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => - pane.terminal.onTitleChange(handler) - ) - const origOnDataDisposableDispose = onDataDisposable.dispose.bind(onDataDisposable) - onDataDisposable.dispose = () => { - unregisterTitleSource() - unregisterSerializer() - origOnDataDisposableDispose() - } - } - - let replayWriteQueue = Promise.resolve() - const settlePaneSerializerAfterReplay = async ( - ptyId: string, - generation: number - ): Promise => { - try { - await replayWriteQueue - if (disposed || transport.getPtyId() !== ptyId) { - await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) - return - } - await waitForTerminalOutputParsed(pane.terminal) - if (!disposed && transport.getPtyId() === ptyId) { - await window.api.pty.settlePaneSerializer(cacheKey, generation) - return - } - } catch { - // Clear below so a failed parser/replay cannot leave the pane generation pending. - } - await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) - } - const reportRemoteRendererSerializerReady = (): void => { - const ptyId = transport.getPtyId() - if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { - return - } - if (!hasPtySerializer(ptyId)) { - registerPaneSerializerFor(ptyId) - } - // Why: onSubscribed follows the snapshot callback, but replay drains - // asynchronously; join it and xterm's parser before reporting readiness. - void replayWriteQueue - .then(() => waitForTerminalOutputParsed(pane.terminal)) - .then(() => { - if (!disposed && 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. - let pendingStartupCommand: PendingStartupCommand | null = shouldDeliverStartupViaTerminalPaste - ? paneStartup?.command - ? { command: paneStartup.command } - : null - : null - const startupDraftReadyScanner = ownsStartupDraftPaste - ? createDraftPasteReadyScanner( - startupDraftAgentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' - ) - : null - let startupDraftReadinessArmed = false - let startupDraftPasteSettled = !ownsStartupDraftPaste - let startupDraftPasteInFlight = false - let startupDraftInputRecorded = false - let startupDraftQuietTimer: ReturnType | null = null - let startupDraftHardTimer: ReturnType | null = null - const clearStartupDraftPasteTimers = (): void => { - if (startupDraftQuietTimer !== null) { - clearTimeout(startupDraftQuietTimer) - startupDraftQuietTimer = null - } - if (startupDraftHardTimer !== null) { - clearTimeout(startupDraftHardTimer) - startupDraftHardTimer = null - } - } - cleanupStartupDraftPasteTimers = clearStartupDraftPasteTimers - const getStartupDraftPtyId = (): string | null => { - const ptyId = transport.getPtyId() - if ( - !ptyId || - disposed || - deps.paneTransportsRef.current.get(pane.id) !== transport || - transport.getPtyId() !== ptyId - ) { - return null - } - return ptyId - } - const sendStartupDraftPaste = (): void => { - if ( - !startupDraftPrompt || - startupDraftPasteSettled || - startupDraftPasteInFlight || - !startupDraftReadinessArmed - ) { - return - } - const ptyId = getStartupDraftPtyId() - if (!ptyId) { - return - } - startupDraftPasteInFlight = true - startupDraftPasteSettled = true - startupDraftPasteAttempted = true - cleanupStartupDraftPasteTimers() - const settings = getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), deps.worktreeId) - // Why: xterm focus reports share this transport queue. Bypassing it can - // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. - void sendAgentDraftPasteContent(settings, ptyId, startupDraftPrompt, async (data) => { - const accepted = await writeTerminalPastePtyInput(transport, data) - if (accepted && !startupDraftInputRecorded) { - // Why: this transport write bypasses xterm's user-input signal; keep - // the composed draft from being discarded by later hibernation. - startupDraftInputRecorded = true - recordTerminalInputForHibernation() - } - return accepted - }) - .catch(() => false) - .finally(() => { - startupDraftPasteInFlight = false - }) - } - const deliverStartupDraftIfAgentOwnsPty = async (): Promise => { - if (!startupDraftAgentConfig || startupDraftPasteSettled) { - return - } - const ptyId = getStartupDraftPtyId() - if (!ptyId) { - return - } - const settings = getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), deps.worktreeId) - try { - const process = await inspectRuntimeTerminalProcess(settings, ptyId) - const foreground = process.foregroundProcess?.toLowerCase() ?? '' - if ( - getStartupDraftPtyId() === ptyId && - isExpectedAgentProcess(foreground, 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(startupDraftAgent)) - } - const armStartupDraftQuietTimer = (): void => { - if (!startupDraftReadyScanner || startupDraftPasteSettled) { - return - } - if (startupDraftQuietTimer !== null) { - clearTimeout(startupDraftQuietTimer) - } - startupDraftQuietTimer = setTimeout(() => { - startupDraftQuietTimer = null - sendStartupDraftPaste() - }, STARTUP_DRAFT_PASTE_QUIET_MS) - } - const armStartupDraftReadinessObservation = (): void => { - if (!startupDraftReadyScanner || startupDraftReadinessArmed) { - return - } - startupDraftReadinessArmed = true - armStartupDraftHardTimer() - } - const observeStartupDraftPasteReadiness = (data: string): void => { - if (!startupDraftReadyScanner || !startupDraftReadinessArmed || startupDraftPasteSettled) { - return - } - const scanned = startupDraftReadyScanner.observe(data) - if (scanned.ready) { - sendStartupDraftPaste() - return - } - if (scanned.armQuietTimer) { - armStartupDraftQuietTimer() - } - } - if (ownsStartupDraftPaste && !connectionId && !shouldDeliverStartupViaTerminalPaste) { - armStartupDraftReadinessObservation() - } - let sessionRestoredBannerShown: SessionRestoredBannerReason | null = null - const showSessionRestoredBanner = (reason: SessionRestoredBannerReason = 'restored'): void => { - // Why: a plain 'restored' banner must not latch out the later 'resume-unavailable' - // upgrade — the pane would keep claiming a session it never got back. - if ( - sessionRestoredBannerShown === reason || - sessionRestoredBannerShown === 'resume-unavailable' - ) { - return - } - sessionRestoredBannerShown = reason - deps.onShowSessionRestoredBanner(pane.id, reason) - } - const buildColdRestoreAgentResumeStartup = (): ColdRestoreAgentResumeStartup | null => { - if (pendingStartupCommand) { - return null - } - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - const sleepingRecordEntry = getSleepingRecordForPane(state) - const sleepingRecord = sleepingRecordEntry?.record - if (isLegacyWorkerAutomaticResumeBlocked()) { - return null - } - const useLiveEntry = entry && entry.state !== 'done' - const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent - if (!agent || !isResumableTuiAgent(agent)) { - return null - } - const providerSession = normalizeAgentProviderSession( - useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession - ) - if (!providerSession) { - return null - } - const matchingSleepingLaunchConfig = - sleepingRecord?.launchConfig && - (!useLiveEntry || - (sleepingRecord.agent === agent && - agentProviderSessionsEqual(agent, sleepingRecord.providerSession, providerSession))) - ? sleepingRecord.launchConfig - : undefined - const launchConfig = - (useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? - matchingSleepingLaunchConfig - // Why: the resume line is typed into this pane's live shell, so its quoting must - // follow the tab's effective Windows shell, not the win32 PowerShell default. - const resumeTarget = resolveAgentResumeLaunchTarget({ - projectRuntime, - connectionId, - executionHostId, - worktreePath: worktree?.path, - terminalWindowsShell: state.settings?.terminalWindowsShell, - tabShellOverride: 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 - } - } - const applyColdRestoreAgentResumeStartup = ( - startup: ColdRestoreAgentResumeStartup | null - ): boolean => { - if (!startup) { - return false - } - const state = useAppStore.getState() - state.registerAgentLaunchConfig(cacheKey, startup.launchConfig, { - agentType: startup.agent, - launchToken: startup.launchToken, - tabId: deps.tabId, - leafId: pane.leafId - }) - return true - } - const clearSleepingRecordAfterColdRestoreSpawn = ( - startup: ColdRestoreAgentResumeStartup | null - ): void => { - if (startup && !startup.useLiveEntry && startup.sleepingRecordEntry) { - clearSleepingRecordProviderDuplicates(useAppStore.getState(), startup.sleepingRecordEntry) - } - } - const mergeStartupEnvWithPaneIdentity = ( - env: Record | undefined - ): Record | undefined => - env - ? { - ...env, - ...paneIdentityEnv, - ...(env.ORCA_AGENT_LAUNCH_TOKEN - ? { ORCA_AGENT_LAUNCH_TOKEN: env.ORCA_AGENT_LAUNCH_TOKEN } - : {}) - } - : undefined - const startFreshColdRestoreAgentResume = ( - startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(), - options: FreshSpawnOptions = {} - ): Promise => { - applyColdRestoreAgentResumeStartup(startup) - return startFreshSpawn(startup, options) - } - // Why: the hibernation wake fires from noteVisibilityResume in the outer - // connection scope, long after this deferred-connect closure has run. - wakeHibernatedAgentPane = () => startFreshColdRestoreAgentResume() - const isStartupPasteTargetCurrent = (ptyId: string | null): boolean => - !disposed && - deps.paneTransportsRef.current.get(pane.id) === transport && - transport.getPtyId() === ptyId - const runTerminalPasteStartupCommand = async (command: string): Promise => { - const ptyId = transport.getPtyId() - const result = await executeTerminalStartupCommandPaste({ - command, - pane, - ptyId, - runtime: resolveTerminalPasteRuntime({ - platform: CLIENT_PLATFORM, - ptyId, - connectionId, - remotePlatform: getTerminalPasteSshRemotePlatform(connectionId), - transport, - isWindowsConpty: isNativeWindowsConpty - }), - transport, - isTargetCurrent: isStartupPasteTargetCurrent - }) - if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { - return false - } - return transport.sendInput('\r') - } - const schedulePendingStartupCommandDelivery = (): void => { - const startup = pendingStartupCommand - if (!startup) { - return - } - if (startupInjectTimer !== null) { - clearTimeout(startupInjectTimer) - } - startupInjectTimer = setTimeout(() => { - startupInjectTimer = null - void (async () => { - if (pendingStartupCommand !== startup || disposed) { - return - } - if (shouldDeliverStartupViaTerminalPaste) { - await waitForTerminalOutputParsed(pane.terminal) - } - if (pendingStartupCommand !== startup || disposed) { - return - } - const command = startup.command - const submitted = shouldDeliverStartupViaTerminalPaste - ? await runTerminalPasteStartupCommand(command) - : transport.sendInput(`${command}\r`) - if (submitted) { - armStartupDraftReadinessObservation() - } else { - releaseUnattemptedStartupDraftPasteDelivery() - } - pendingStartupCommand = null - })() - }, 50) - } - - let freshSpawnFollowResetDisposables: IDisposable[] = [] - cancelFreshSpawnFollowReset = (): void => { - for (const disposable of freshSpawnFollowResetDisposables) { - disposable.dispose() - } - freshSpawnFollowResetDisposables = [] - } - const resetFreshSpawnFollowOutput = (): void => { - cancelFreshSpawnFollowReset() - markTerminalFollowOutput(pane.terminal) - let nativeFollowResetComplete = false - const tryResetNativeFollow = (): void => { - if ( - disposed || - getTerminalScrollIntentKind(pane.terminal) !== 'followOutput' || - deferTerminalGeometryMutationDuringRebuild( - pane.terminal, - 'fresh-spawn-follow-reset', - tryResetNativeFollow - ) - ) { - return - } - try { - pane.terminal.scrollToBottom() - nativeFollowResetComplete = true - cancelFreshSpawnFollowReset() - } catch (err) { - if (!(err instanceof TypeError && /dimensions/.test(err.message))) { - 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. - freshSpawnFollowResetDisposables = [ - pane.terminal.onRender(tryResetNativeFollow), - pane.terminal.onResize(tryResetNativeFollow) - ] - } - } - - const startFreshSpawn = ( - startupOverride?: PendingStartupCommand | null, - options: FreshSpawnOptions = {} - ): Promise => { - if (isLegacyWorkerAutomaticResumeBlocked()) { - return Promise.resolve(null) - } - const currentState = useAppStore.getState() - if ( - getDeleteStateForWorktreeHost( - { id: deps.worktreeId, hostId: executionHostId }, - currentState.deleteStateByWorktreeId - )?.isDeleting - ) { - // Why: the worktree is being deleted; its PTYs were just killed for the - // filesystem teardown. A fresh shell must not spawn into a directory the - // removal is about to delete (main fences it anyway), and the pane is - // about to unmount — so skip the doomed respawn instead of racing it. - return Promise.resolve(null) - } - authoritativeReattachGeneration += 1 - clearPaneMode2031State() - clearHiddenOutputRestoreState() - // Why: a canceled old replay clear can preserve xterm's native - // isUserScrolling flag. A replacement shell must start in follow mode. - 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. - kittyKeyboardModes.reset() - 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 = runtimeEnvironmentId - ? Promise.resolve(null) - : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) - - transportConnectInFlightSince = Date.now() - const outputCallbacks = captureTransportOutputCallbacks(reportError) - const spawnedRaw = transport.connect({ - url: '', - cols, - rows, - ...(startupOverride?.command ? { command: startupOverride.command } : {}), - ...(connectionId && startupOverride?.command && !shouldDeliverStartupViaTerminalPaste - ? { commandDelivery: 'provider' as const } - : {}), - ...(connectionId && startupOverride?.command - ? { startupCommandDelivery: 'shell-ready' as const } - : {}), - ...(startupOverride?.env - ? { env: mergeStartupEnvWithPaneIdentity(startupOverride.env) } - : {}), - ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), - ...(coldRestoreOverride - ? { resumeProviderSession: coldRestoreOverride.resumeProviderSession } - : {}), - ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), - ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), - ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: outputCallbacks.callbacks - }) - - void Promise.resolve(spawnedRaw) - .catch(() => null) - .finally(() => { - transportConnectInFlightSince = null - }) - const trackedPromise: Promise = Promise.resolve(spawnedRaw) - .then(async (spawnedPtyId) => { - if (outputCallbacks.generation !== transportStreamGeneration) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return null - } - const resolvedPtyId = - spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId - ? spawnedPtyId.id - : typeof spawnedPtyId === 'string' - ? spawnedPtyId - : transport.getPtyId() - if (resolvedPtyId && !claimCapturedDirectSshRetryPty(resolvedPtyId)) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - return null - } - const connectResult = - spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId - ? spawnedPtyId - : null - if (connectResult?.isReattach) { - pendingStartupCommand = null - const accepted = await handleReattachResult( - connectResult, - null, - coldRestoreOverride, - outputCallbacks.generation - ) - finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) - const gen = await preSignalPromise - if (accepted && resolvedPtyId && typeof gen === 'number') { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) - } else if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return accepted ? resolvedPtyId : null - } - if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { - registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { - ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), - ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}) - }) - } - if (resolvedPtyId) { - if ( - spawnedPtyId && - typeof spawnedPtyId === 'object' && - spawnedPtyId.startupCwdFallback?.kind === 'worktree' - ) { - writeTerminalOutput(pane.terminal, STARTUP_CWD_FALLBACK_NOTICE, { - foreground: shouldWritePtyOutputForeground(deps.isVisibleRef.current) - }) - } - if ( - spawnedPtyId && - typeof spawnedPtyId === 'object' && - spawnedPtyId.agentResumeUnavailable - ) { - // Why: main dropped the resume argv, so this pane is a NEW session — - // the plain restored banner would claim the old one came back. - showSessionRestoredBanner('resume-unavailable') - } else if (coldRestoreOverride?.hasSleepingRecord) { - showSessionRestoredBanner() - } - clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) - } else if ( - paneStartup?.launchConfig || - (startupOverride && 'launchConfig' in startupOverride) - ) { - // Why: delayed draft/follow-up delivery keys off this launch - // registry. If spawn produced no PTY, the launch is no longer a - // viable delivery target and must not wait for a future pane. - clearRegisteredStartupLaunchConfig() - } - if ( - resolvedPtyId && - spawnedPtyId && - typeof spawnedPtyId === 'object' && - 'id' in spawnedPtyId && - activePanePtyBinding !== resolvedPtyId && - transport.getPtyId() === resolvedPtyId - ) { - // Why: daemon createOrAttach can turn an apparent fresh spawn into - // a reattach; the transport skips onPtySpawn there to preserve recency. - bindActivePanePty(resolvedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - } - if (resolvedPtyId) { - reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) - } - const gen = await preSignalPromise - if (resolvedPtyId && (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId))) { - if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) { - registerPaneSerializerFor(resolvedPtyId) - } - if (typeof gen === 'number') { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) - } - } else if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - if (resolvedPtyId && connectionId) { - if ( - shouldUseProviderSshStartupDelivery && - (startupOverride?.command || paneStartup?.command) - ) { - armStartupDraftReadinessObservation() - } - schedulePendingStartupCommandDelivery() - } - finishReattachLiveDataDeferral(Boolean(resolvedPtyId), outputCallbacks.generation) - return resolvedPtyId - }) - .catch(async () => { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - if (paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride)) { - clearRegisteredStartupLaunchConfig() - } - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return null - }) - .finally(() => { - if (pendingSpawnByPaneKey.get(pendingSpawnKey) === trackedPromise) { - pendingSpawnByPaneKey.delete(pendingSpawnKey) - } - }) - armDirectSshPaneRetryTimeout(trackedPromise, directSshRetryAttempt) - void trackedPromise.then((spawnedPtyId) => { - if (spawnedPtyId) { - return - } - queueMicrotask(() => { - if (disposed || transport.getPtyId() || pendingSpawnByPaneKey.has(pendingSpawnKey)) { - return - } - settleDirectSshPaneRetryAttempt(directSshRetryAttempt, 'failed') - }) - }) - // Why: split panes in the same tab can spawn concurrently. Key by pane - // as well as tab so a remount cannot attach to a sibling setup pane's PTY. - pendingSpawnByPaneKey.set(pendingSpawnKey, trackedPromise) - return trackedPromise - } - - let foregroundRefreshRiskScanTail = '' - - 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) - } - - function foregroundRendererRiskOutputPrefersRenderRefresh(data: string): boolean { - if (!data) { - return false - } - const scanData = foregroundRefreshRiskScanTail - ? `${foregroundRefreshRiskScanTail}${data}` - : data - const prefersRefresh = - (scanData.includes('\x1b[') || containsNonAsciiOutput(scanData)) && - terminalOutputPrefersRenderRefresh(scanData) - 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. - const 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(pane.terminal) - replayIntoTerminal(pane, deps.replayingPanesRef, data, { - breadcrumbIdentity: { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - ptyId: transport.getPtyId() - }, - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously, - shouldReleaseRenderPause: () => deps.isVisibleRef.current - }) - } - - const writeReplayDataAsync = (data: string): Promise => { - // Why: WebGL must be rebuilt after xterm has parsed replay bytes, not - // merely after the write was queued. - flushTerminalOutput(pane.terminal) - return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data, { - breadcrumbIdentity: { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - ptyId: transport.getPtyId() - }, - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously, - shouldReleaseRenderPause: () => deps.isVisibleRef.current - }) - } - - const reattachReplayResetSequence = ( - payload: string, - ownerProcessEnded = false, - isAlternateScreen?: boolean - ): string => { - // Why a cold restore overrides the agent signal: liveness is read from the - // pane's status and title, both of which are persisted, so after a cold - // restore they describe the process that died. Preserving "its" modes arms - // mouse, focus and paste reporting against the fresh shell that replaces it, - // which then prints the reports as junk at the prompt (#12101). - if (ownerProcessEnded) { - return POST_REPLAY_MODE_RESET - } - if (shouldPreserveAgentReattachModes()) { - return buildPostReplayLiveAgentReattachReset(payload) - } - // Why: an alt-screen pane is a live TUI Orca just does not recognise as an agent, and the - // replay already re-armed its mouse modes — keep them instead of wiping them (#8291). - return (isAlternateScreen ?? kittyKeyboardModes.isAlternateScreen) - ? POST_REPLAY_REATTACH_RESET_KEEP_MOUSE - : POST_REPLAY_REATTACH_RESET - } - - const consumeRestoredViewportBlankingMarker = (): boolean => { - return deps.restoredViewportBlankingPanesRef?.current.delete(pane.id) ?? false - } - - const writeFreshShellViewportBlanking = (rows = pane.terminal.rows): void => { - writeReplayData(buildFreshShellViewportBlankingSequence(rows)) - } - - const prepareFreshShellViewportForSpawn = (options: FreshSpawnOptions): void => { - const hadRestoredViewport = consumeRestoredViewportBlankingMarker() - if (!options.forceBlankRestoredViewport && !hadRestoredViewport) { - return - } - // Why: fresh Windows ConPTY output paints at screen coordinates, so - // restored rows must leave the viewport before the first prompt redraw. - writeFreshShellViewportBlanking() - } - - const sendFocusedReattachFocusInAfterReplay = ( - expectedPtyId: string | null = transport.getPtyId(), - expectedStreamGeneration = transportStreamGeneration - ): void => { - const scheduledGeneration = reattachReplayPayloadSignalGeneration - void waitForTerminalOutputParsed(pane.terminal).then(() => { - const currentPtyId = transport.getPtyId() - if ( - disposed || - expectedStreamGeneration !== 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 !== 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 ( - !hasLiveAgentReattachStatusOrTitleSignal() && - reattachReplayPayloadHasCursorAgentSignal - ) { - if (parsedViewportShowsParkedCursorAgentScreen(pane.terminal) === false) { - reattachReplayPayloadHasCursorAgentSignal = false - // Why: the live-agent reset preserved the payload's ?25l; a plain - // shell never re-shows the cursor itself. - 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(pane.terminal) - if (!shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { - return - } - transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) - }) - } - - type PendingReplayData = { - data: string - clearBeforeReplay: boolean - ptyId: string | null - generation: number - streamGeneration: number - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - snapshotSeq?: number - } - - let pendingReplayData: PendingReplayData | null = null - let replayPayloadGeneration = 0 - let replayDrainQueued = false - const drainReplayDataQueue = async ( - expectedPtyId: string | null, - expectedStreamGeneration: number - ): Promise => { - let appliedCurrentPayload = false - while (pendingReplayData !== null) { - if ( - pendingReplayData.ptyId !== expectedPtyId || - pendingReplayData.streamGeneration !== expectedStreamGeneration - ) { - return false - } - if ( - transport.getPtyId() !== expectedPtyId || - transportStreamGeneration !== expectedStreamGeneration - ) { - pendingReplayData = null - return false - } - const payload = pendingReplayData - const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload - pendingReplayData = null - const isCurrentPayload = (): boolean => - !disposed && - payload.generation === replayPayloadGeneration && - payload.streamGeneration === transportStreamGeneration && - 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 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. - 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. - applySnapshotKittyKeyboardModes(data, payload) - await writeReplayDataAsync(data) - if (!isCurrentPayload()) { - continue - } - if (clearBeforeReplay || data.length > 0) { - await writeReplayDataAsync(reattachReplayResetSequence(data)) - if (!isCurrentPayload()) { - continue - } - 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 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. - manager.rebuildPaneWebgl(pane.id) - appliedCurrentPayload = true - } - return appliedCurrentPayload - } - const scheduleReplayDataDrain = (): void => { - if (replayDrainQueued) { - return - } - const scheduledPtyId = 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 = - pendingReplayData?.streamGeneration ?? transportStreamGeneration - beginReattachLiveDataDeferral(scheduledStreamGeneration) - let replayCompleted = false - replayWriteQueue = replayWriteQueue - .catch(() => undefined) - .then(() => - structuralReplayCoordinator.run( - async () => { - replayCompleted = await drainReplayDataQueue( - scheduledPtyId, - scheduledStreamGeneration - ) - }, - { - shouldRestore: () => - !disposed && - transport.getPtyId() === scheduledPtyId && - transportStreamGeneration === scheduledStreamGeneration - } - ) - ) - .then(() => { - replayCompleted &&= !disposed && transport.getPtyId() === scheduledPtyId - }) - .finally(() => { - replayDrainQueued = false - if (pendingReplayData !== null) { - // Why: preserve the PTY identity captured when the callback fired; - // re-reading it here could retag stale bytes for a replacement PTY. - scheduleReplayDataDrain() - } - finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration) - }) - } - const replayDataCallback = ( - data: string, - meta: PtyReplayDataMeta = {}, - streamGeneration = transportStreamGeneration - ): void => { - pendingReplayData = { - data, - clearBeforeReplay: meta.clearBeforeReplay !== false, - ptyId: transport.getPtyId(), - generation: (replayPayloadGeneration += 1), - streamGeneration, - ...(meta.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } - : {}), - ...(meta.kittyKeyboardFlags !== undefined && meta.snapshotSeq !== undefined - ? { - kittyKeyboardFlags: meta.kittyKeyboardFlags, - snapshotSeq: meta.snapshotSeq - } - : {}) - } - scheduleReplayDataDrain() - } - - const 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. - pendingHiddenSnapshotFit?.cancel() - pendingHiddenSnapshotFit = null - pendingReattachFit?.cancel() - pendingReattachFit = null - const generation = (transportStreamGeneration += 1) - const isCurrent = (): boolean => !disposed && generation === transportStreamGeneration - return { - generation, - callbacks: { - onReattachDetermined: (): void => { - if (isCurrent()) { - beginReattachLiveDataDeferralIfUnowned(generation) - } - }, - onConnect: (): void => { - if (isCurrent()) { - reportRemoteRendererSerializerReady() - // Why: a visibility flip during a rebind can't reach the host (no bound - // pty id), and the transport replays the stale pause bit onto the new - // stream — re-derive it from live visibility once the stream is bound. - syncHiddenRendererPtyDelivery() - } - }, - onStreamRecovered: (): void => { - if (isCurrent()) { - syncHiddenRendererPtyDelivery() - markHiddenOutputRestoreNeeded() - } - }, - onData: (data: string, meta?: PtyDataMeta): void => { - if (isCurrent()) { - dataCallback(data, meta, generation) - } - }, - onReplayData: (data: string, meta?: PtyReplayDataMeta): void => { - if (isCurrent()) { - replayDataCallback(data, meta, generation) - } - }, - onError: (message: string): void => { - if (isCurrent()) { - onError(message) - } - }, - onWriteUnavailable: (): void => { - if (isCurrent()) { - requestRecoveryForUndeliverableInput(true) - } - }, - onRecoveryStateChange: (state: PtyTransportRecoveryState): void => { - if (isCurrent()) { - // Why: cached pixels remain visible while detached; expose transport truth for diagnostics and recovery UI. - pane.container.dataset.ptyRecoveryState = state.phase - deps.onPtyRecoveryStateRef?.current?.(pane.id, state) - } - }, - onOutputPauseChanged: (paused: boolean, supported: boolean): void => { - if (isCurrent()) { - handleRemoteOutputPauseChanged(paused, supported) - } - } - } - } - } - - type PendingHiddenOutputRestoreChunk = { - data: string - seq?: number - rawLength?: number - } - - let hiddenOutputRestoreNeeded = false - let hiddenOutputRestoreInFlight: Promise | null = null - let hiddenOutputRestorePendingChunks: PendingHiddenOutputRestoreChunk[] = [] - let hiddenOutputRestorePendingChars = 0 - let hiddenOutputRestorePendingOverflow = false - let hiddenOutputRestoreFreshSnapshotNeeded = false - let hiddenOutputRestoreRetryDeferred = false - let hiddenOutputRestoreScheduled = false - let hiddenOutputRestoreDeferredRetryTimer: ReturnType | null = null - let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType | null = null - let hiddenOutputRestoreDeferredRetryAttempts = 0 - let hiddenOutputRestoreRemoteOutcomeAttempts = 0 - let hiddenOutputRestoreLocalGateAttempts = 0 - let hiddenOutputRestoreLegacyPtyId: string | null = null - // Bounded remote re-arms spent instead of the loss banner (per PTY stream). - let hiddenOutputRestoreRemoteAbandonCycles = 0 - let hiddenOutputSnapshotScrollRestore: { - ptyId: string | null - generation: number - valid: boolean - started: boolean - } | null = null - // Why: hidden recovery state belongs to one PTY stream. Reattach/restart - // can reuse the pane object for a different session before visibility. - let hiddenOutputRestorePtyId: string | null = null - // One recovery re-kick per xterm instance. Generation-aware cooldown and - // window-cap retries keep a fresh-but-wedged replacement from fossilizing. - let certifiedDeadRestoreRecoveryRequested = false - let hiddenOutputRestoreGeneration = 0 - // Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS). - let hiddenOutputRestoreFloodSuppressedUntil = 0 - // Why: queued replay writes still paint after deadline abandonment; the - // fallback drain must not write snapshot-covered live bytes a second time. - let hiddenOutputRestoreReplayingSnapshot: { - seq?: number - pendingDeliveryStartSeq?: number - } | null = null - let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null - let cancelHiddenOutputRestoreFloodRepaintPark: (() => void) | null = 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 dataCallback can drop/slice - // overlaps and detect seq gaps from main-side pending-cap trims whose - // one-shot marker was already consumed. - let restoredSnapshotBaselineSeq: number | null = null - let restoredSnapshotBaselinePtyId: string | null = null - let restoredSnapshotExpectedStartSeq: number | null = 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]. - let restoredSnapshotDeliveryWindowStartSeq: number | null = null - - function setRestoredSnapshotBaseline( - ptyId: string, - snapshot: { seq?: number; pendingDeliveryStartSeq?: number } - ): void { - if (typeof snapshot.seq !== 'number') { - 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. - clearRestoredSnapshotBaseline() - return - } - restoredSnapshotBaselineSeq = snapshot.seq - restoredSnapshotBaselinePtyId = ptyId - restoredSnapshotExpectedStartSeq = snapshot.seq - restoredSnapshotDeliveryWindowStartSeq = windowStartSeq - } - - function clearRestoredSnapshotBaseline(): void { - restoredSnapshotBaselineSeq = null - restoredSnapshotBaselinePtyId = null - restoredSnapshotExpectedStartSeq = null - restoredSnapshotDeliveryWindowStartSeq = null - } - let foregroundImmediateBudgetChars = 0 - let foregroundImmediateBudgetWindowStart = 0 - let foregroundRewriteChunkEndedWithCarriageReturn = false - let foregroundRewriteCsiScanTail = '' - let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE - const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) - let hiddenStartupRendererQueryPending = '' - let hiddenRendererStateDirty = false - let rendererOrderedPtyId: string | null = null - let rendererOrderedSeq: number | null = null - let rendererChannelSeqPtyId: string | null = null - let rendererChannelSeq: number | null = null - - function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { - return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) - } - - function canUseHiddenOutputSnapshot(ptyId: string | null): ptyId is string { - if (!ptyId) { - return false - } - if (canUseMainBufferSnapshot(ptyId)) { - return true - } - return transport.getPtyId() === ptyId && typeof transport.serializeBuffer === 'function' - } - - type HiddenOutputSnapshotResult = - | { kind: 'snapshot'; snapshot: PtyBufferSnapshot } - // `source` picks the budget: only 'host' answers cost the host a serialize attempt. - | { kind: 'retry-worthy'; source: 'host' | 'local' } - | { kind: 'permanently-unavailable' } - | { kind: 'unknown-legacy-host' } - | { kind: 'unavailable' } - - async function serializeHiddenOutputSnapshot( - ptyId: string, - opts: { scrollbackRows?: number } - ): Promise { - const e2eSnapshot = readE2eHiddenSnapshotOverride(ptyId) - if (e2eSnapshot) { - const snapshot = await e2eSnapshot - return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unavailable' } - } - if (canUseMainBufferSnapshot(ptyId)) { - const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, opts) - return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unavailable' } - } - if (transport.getPtyId() !== ptyId || typeof transport.serializeBuffer !== 'function') { - return { kind: 'unavailable' } - } - if ( - hiddenOutputRestoreLegacyPtyId === ptyId || - typeof transport.serializeBufferOutcome !== 'function' - ) { - const snapshot = await transport.serializeBuffer(opts) - return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unknown-legacy-host' } - } - try { - const outcome = await 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 - // 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). - function shouldDeclareHiddenAtSpawn(): boolean { - return ( - hiddenDeliveryGateActive && - !runtimeEnvironmentId && - !disposed && - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) - } - - // ── Hidden-delivery gate sync (Phase 4) ───────────────────────────── - // Why: marks this pane's PTY hidden in main while no visible view needs - // its bytes; main then drops delivery after model ingestion and reveal - // restores from the snapshot. The marked id is tracked locally so PTY - // changes (reattach/restart) can never leave a stale id gated. - let hiddenDeliverySyncedPtyId: string | null = null - let releaseHiddenDeliveryClaim: (() => void) | null = null - let modelRestoreSubscribedPtyId: string | null = null - let unregisterModelRestoreNeeded: (() => void) | null = null - - function isHiddenOutputRestoreFloodSuppressed(): boolean { - return Date.now() < hiddenOutputRestoreFloodSuppressedUntil - } - - // True when a drop/gap signal on a visible pane is attributable to this - // pane's OWN restore backpressure (a restore is replaying right now, or - // one was just cut off for outrunning the stream). Such signals must not - // re-arm restores — that is the rc.7.perf feedback loop. - function isForegroundRestoreBackpressureContext(): boolean { - return ( - shouldWritePtyOutputForeground(deps.isVisibleRef.current) && - (hiddenOutputRestoreInFlight !== null || isHiddenOutputRestoreFloodSuppressed()) - ) - } - - function clearHiddenOutputRestoreFloodRepaintTimer(): void { - cancelHiddenOutputRestoreFloodRepaintPark?.() - cancelHiddenOutputRestoreFloodRepaintPark = null - if (hiddenOutputRestoreFloodRepaintTimer === null) { - return - } - clearTimeout(hiddenOutputRestoreFloodRepaintTimer) - hiddenOutputRestoreFloodRepaintTimer = null - } - cleanupHiddenOutputRestoreFloodRepaint = 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. - function repaintAfterFloodWhenFollowingOutput(ptyId: string): void { - cancelHiddenOutputRestoreFloodRepaintPark?.() - cancelHiddenOutputRestoreFloodRepaintPark = null - let repainted = false - const cancelPark = onTerminalScrollIntentFollowOutput(pane.terminal, () => { - repainted = true - cancelHiddenOutputRestoreFloodRepaintPark = null - if (disposed || transport.getPtyId() !== ptyId) { - return - } - markHiddenOutputRestoreNeeded() - }) - if (!repainted) { - cancelHiddenOutputRestoreFloodRepaintPark = cancelPark - } - } - - function resetHiddenOutputRestoreFloodSuppression(): void { - hiddenOutputRestoreFloodSuppressedUntil = 0 - clearHiddenOutputRestoreFloodRepaintTimer() - } - - // Extends the suppression window; every backpressure signal resets the timer so the deferred repaint fires once, SUPPRESS_MS after the last signal. - function noteHiddenOutputRestoreFloodBackpressure(): void { - hiddenOutputRestoreFloodSuppressedUntil = Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS - const ptyId = transport.getPtyId() - if (ptyId === null) { - return - } - clearHiddenOutputRestoreFloodRepaintTimer() - hiddenOutputRestoreFloodRepaintTimer = setTimeout(() => { - hiddenOutputRestoreFloodRepaintTimer = null - if (disposed || 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. - 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 (disposed) { - return - } - recordTerminalFreezeBreadcrumb('restore-marker', { - id: redactPtyIdForDiagnostics(transport.getPtyId() ?? '') - }) - // Why: dropped bytes invalidate cross-chunk carry — a partial OSC-9999 prefix spanning the gap would corrupt the next live chunk. - transport.resetCrossChunkParserState?.() - // Why gated (rc.7.perf loop): on a visible pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint. - if (isForegroundRestoreBackpressureContext()) { - 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 - // buildMainModelSnapshotReplayWrites, which grounds the pen itself, so - // nothing is lost by skipping it here. - writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) - // Why: a marker during an in-flight restore means that snapshot may predate the drop, so a fresh one must follow; capture BEFORE the mark, which starts a restore synchronously on a visible pane. - const restoreWasInFlight = hiddenOutputRestoreInFlight !== null - markHiddenOutputRestoreNeeded() - if (restoreWasInFlight) { - hiddenOutputRestoreFreshSnapshotNeeded = true - } - } - - function syncModelRestoreNeededSubscription(ptyId: string | null): void { - if (modelRestoreSubscribedPtyId === ptyId) { - return - } - unregisterModelRestoreNeeded?.() - unregisterModelRestoreNeeded = null - modelRestoreSubscribedPtyId = ptyId - // Why: markers exist only for PTYs whose bytes transit local main; - // remote-runtime transports are structurally unaffected. - if (!ptyId || isRemoteRuntimePtyId(ptyId)) { - return - } - unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler( - ptyId, - handleModelRestoreNeededMarker - ) - } - - handleRemoteOutputPauseChanged = (paused, supported): void => { - const ptyId = transport.getPtyId() - if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { - return - } - if (!paused) { - const wasGated = remoteOutputGatedPtyId === ptyId - if (wasGated) { - remoteOutputGatedPtyId = null - if (!mainSideEffectAuthority) { - dropSideEffectFactConsumer() - } - } - if (wasGated && hiddenOutputRestorePtyId === ptyId) { - requestHiddenOutputRestoreIfNeeded() - } - return - } - if (remoteOutputGatedPtyId !== ptyId) { - remoteOutputGatedPtyId = ptyId - } - if (supported && remoteOutputFactConsumerPtyId !== ptyId) { - registerSideEffectFactConsumerForPty(ptyId, true) - remoteOutputFactConsumerPtyId = ptyId - } - markHiddenOutputRestoreNeeded() - } - - syncHiddenRendererPtyDelivery = (): void => { - const ptyId = transport.getPtyId() - syncModelRestoreNeededSubscription(ptyId) - if (remoteOutputGatedPtyId !== null && remoteOutputGatedPtyId !== ptyId) { - remoteOutputGatedPtyId = null - if (!mainSideEffectAuthority) { - dropSideEffectFactConsumer() - } - } - if (isRemoteRuntimePtyId(ptyId) && canUseHiddenOutputSnapshot(ptyId)) { - transport.setOutputPaused?.( - !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) - return - } - if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) { - releaseHiddenDeliveryClaim?.() - releaseHiddenDeliveryClaim = null - hiddenDeliverySyncedPtyId = null - } - if (!isHiddenDeliveryGateManagedPty(ptyId) || !canUseHiddenOutputSnapshot(ptyId)) { - return - } - const shouldHide = !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - const isFirstSyncForPty = hiddenDeliverySyncedPtyId !== ptyId - hiddenDeliverySyncedPtyId = ptyId - if (shouldHide) { - if (!releaseHiddenDeliveryClaim) { - releaseHiddenDeliveryClaim = acquireHiddenRendererPtyDeliveryClaim(ptyId) - } - } else if (releaseHiddenDeliveryClaim) { - releaseHiddenDeliveryClaim() - 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) - } - } - releaseHiddenRendererPtyDelivery = (): void => { - transport.setOutputPaused?.(false) - if (remoteOutputGatedPtyId !== null) { - remoteOutputGatedPtyId = null - if (!mainSideEffectAuthority) { - dropSideEffectFactConsumer() - } - } - releaseHiddenDeliveryClaim?.() - releaseHiddenDeliveryClaim = null - hiddenDeliverySyncedPtyId = null - unregisterModelRestoreNeeded?.() - unregisterModelRestoreNeeded = null - modelRestoreSubscribedPtyId = null - } - - function beforeTerminalOutputWrite(data: string): void { - // Why: shaping must register before xterm parses the RTL bytes that need it. - ensureArabicShapingJoinerForText(pane.terminal, data) - recordTerminalOutput(pane.terminal) - } - - function consumeForegroundImmediateBudget(dataLength: number): boolean { - const now = performance.now() - if (now - foregroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) { - foregroundImmediateBudgetChars = 0 - foregroundImmediateBudgetWindowStart = now - } - if (foregroundImmediateBudgetChars + dataLength > FOREGROUND_IMMEDIATE_BUDGET_CHARS) { - return false - } - foregroundImmediateBudgetChars += dataLength - return true - } - - function isActiveSplitPane(): boolean { - if (!deps.isActiveRef.current) { - return false - } - const activePane = manager.getActivePane?.() ?? null - return activePane ? activePane.id === pane.id : true - } - - function isLatencySensitiveForegroundOutput(data: string): boolean { - if (!isActiveSplitPane()) { - // Why: many visible split panes each emit tiny TUI frames; a shared budget keeps them live without letting aggregate xterm work starve typing in the active pane. - if (data.includes('\x1b[')) { - return false - } - return consumeInactiveForegroundImmediateBudget(data.length) - } - if (data.length <= FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS) { - return consumeForegroundImmediateBudget(data.length) - } - const recentInput = - performance.now() - lastInteractiveRedrawInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS - if ( - recentInput && - data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS && - data.includes('\x1b[') - ) { - return consumeForegroundImmediateBudget(data.length) - } - return false - } - - function containsNonAsciiOutput(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) - } - - function foregroundRewriteOutputPrefersRenderRefresh(data: string): boolean { - const decision = terminalRewriteOutputRenderRefreshDecision(data, { - previousChunkEndsWithCarriageReturn: foregroundRewriteChunkEndedWithCarriageReturn, - previousRewriteCsiScanTail: foregroundRewriteCsiScanTail - }) - foregroundRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn - foregroundRewriteCsiScanTail = decision.nextRewriteCsiScanTail - return decision.prefersRenderRefresh - } - - function shouldForceForegroundRenderRefresh(data: string): { - refresh: boolean - inPlaceRewrite: boolean - } { - const rewriteOutputPrefersRenderRefresh = foregroundRewriteOutputPrefersRenderRefresh(data) - const recentInput = - performance.now() - lastInteractiveRedrawInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS - if (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: shouldApplyWindowsRendererUnicodeRefresh, - isNativeWindowsConpty: 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: - shouldApplyNativeWindowsRewriteRefresh && - 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. - function observeLiveMode2031Chunk(data: string): void { - // Gate-managed PTYs never see these bytes; main's '2031-subscribe' fact records them. - if (isHiddenDeliveryGateManagedPty(transport.getPtyId())) { - return - } - const result = scanMode2031ReplyDecision(mode2031ReplyScanState, data) - mode2031ReplyScanState = result.state - if (result.decision === 'unsubscribed') { - deps.paneMode2031Ref.current.delete(pane.id) - deps.paneLastThemeModeRef.current.delete(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. - deps.paneMode2031Ref.current.set(pane.id, true) - deps.paneLastThemeModeRef.current.set( - 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. - liveScrollbackRestore?.dispose() - liveScrollbackRestore = installTerminalLiveScrollbackRestore(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. - function isPaneOnAlternateScreen(): boolean { - return pane.terminal.buffer.active.type === 'alternate' - } - - function writePtyOutputToXterm( - 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 pane's protocol negotiation. - kittyKeyboardModes.scan(data) - if (foreground) { - resetHiddenOutputRestoreIfPtyChanged() - } - const parseHiddenStartupOutput = - !foreground && - canUseHiddenOutputSnapshot(transport.getPtyId()) && - shouldSnapshotHiddenCodexOutput && - (opts?.hiddenStartupRendererQuery === true || containsHiddenStartupRendererQuery(data)) - const synchronizedForegroundScan = - shouldProtectNativeWindowsSynchronizedOutput && foreground - ? scanSynchronizedForegroundOutput( - data, - synchronizedForegroundMarkerTail, - synchronizedForegroundOutputActive - ) - : null - const synchronizedOutputStarted = synchronizedForegroundScan?.started === true - const synchronizedOutputEnded = synchronizedForegroundScan?.ended === true - const synchronizedForegroundOutput = - synchronizedForegroundScan !== null && - (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 = - shouldProtectNativeWindowsSynchronizedOutput && foreground && containsCursorRestore(data) - const foregroundOutput = foreground || parseHiddenStartupOutput - if (foreground) { - scheduleForegroundGridDriftCheck() - } - const renderRefreshDecision = foregroundOutput - ? 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: 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) { - synchronizedForegroundFrameInteractive = - performance.now() - lastInteractiveRedrawInputAt <= - FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS - } else if (!nextSynchronizedForegroundOutputActive && !synchronizedOutputEnded) { - 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 && synchronizedForegroundFrameInteractive - synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive - synchronizedForegroundMarkerTail = synchronizedForegroundScan?.markerTail ?? '' - writeTerminalOutput(pane.terminal, data, { - foreground: foregroundOutput, - beforeWrite: 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: markHiddenOutputRestoreNeeded, - latencySensitive: - !foreground || parseHiddenStartupOutput - ? true - : synchronizedFrameLatencySensitive || 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, - stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, - coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, - holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive - }) - } - - queueAgentIdleTerminalModeReset = (): void => { - if (disposed) { - return - } - writePtyOutputToXterm( - idleAgentTerminalModeReset, - shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) - } - - function markHiddenOutputRestoreNeeded(): void { - const ptyId = transport.getPtyId() - if (!canUseHiddenOutputSnapshot(ptyId)) { - return - } - if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) { - clearHiddenOutputRestoreState() - } - hiddenOutputRestorePtyId = ptyId - hiddenOutputRestoreNeeded = true - if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { - requestHiddenOutputRestoreIfNeeded() - } - } - - function shouldSkipHiddenRendererOutput(foreground: boolean, data: string): boolean { - const ptyId = transport.getPtyId() - if ( - foreground || - (!shouldSnapshotHiddenCodexOutput && remoteOutputGatedPtyId !== ptyId) || - !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 hiddenRendererStateDirty || !containsStatefulRendererQuery(data) - } - - function writeHiddenStartupRendererQueries(data: string): void { - const extracted = extractHiddenStartupRendererQueryData( - data, - hiddenStartupRendererQueryPending - ) - 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, - pane.terminal, - sendDesktopQueryReplyImmediate - ) - } - if (extracted.statelessQueryData) { - 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. - } - - function takeHiddenStartupRendererQueryPendingForForeground(data: string): { - statelessQueryData: string - statefulQueryData: string - oscColorQueryData: string - remainingData: string - consumedCurrentChars: number - } { - const pending = hiddenStartupRendererQueryPending - 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 - } - - hiddenStartupRendererQueryPending = nextPending - const consumedCurrentChars = Math.max(0, consumedInputChars - pending.length) - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - remainingData: data.slice(consumedCurrentChars), - consumedCurrentChars - } - } - - function metaAfterConsumingCurrentChars( - 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) - } - } - - function skipHiddenRendererOutput(data: string): void { - writeHiddenStartupRendererQueries(data) - markHiddenOutputRestoreNeeded() - hiddenRendererStateDirty = true - if (hiddenOutputRestoreInFlight) { - 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. - function salvageRendererQueriesFromDiscardedRestoreData(data: string): void { - if (!data || !data.includes('\x1b')) { - return - } - const extracted = extractHiddenStartupRendererQueryData(data, '') - if (extracted.oscColorQueryData) { - sendTerminalOscColorQueryReplies( - extracted.oscColorQueryData, - pane.terminal, - 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 = pane.terminal.buffer.active - const row = Math.min(buffer.cursorY + 1, pane.terminal.rows) - const col = Math.min(buffer.cursorX + 1, pane.terminal.cols) - sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`) - } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { - 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. - 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 - } - - function queueLiveChunkDuringRestore(data: string, meta?: PtyDataMeta): void { - if (!data) { - return - } - const ptyId = transport.getPtyId() - if (!canUseHiddenOutputSnapshot(ptyId)) { - return - } - if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) { - clearHiddenOutputRestoreState() - } - hiddenOutputRestorePtyId = ptyId - hiddenOutputRestoreNeeded = true - if (hiddenOutputRestorePendingOverflow) { - // Why: the overflow latch discards everything queued at the next drain, so queueing more only grows the discard; salvage queries, drop content. - salvageRendererQueriesFromDiscardedRestoreData(data) - armHiddenOutputRestoreForegroundDeadline() - return - } - if (hiddenOutputRestorePendingChars + data.length > HIDDEN_OUTPUT_RESTORE_PENDING_CHARS) { - const discardedChunks = hiddenOutputRestorePendingChunks - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - hiddenOutputRestorePendingOverflow = true - for (const chunk of discardedChunks) { - salvageRendererQueriesFromDiscardedRestoreData(chunk.data) - } - salvageRendererQueriesFromDiscardedRestoreData(data) - armHiddenOutputRestoreForegroundDeadline() - return - } - const pending: PendingHiddenOutputRestoreChunk = { data } - if (typeof meta?.seq === 'number') { - pending.seq = meta.seq - } - if (typeof meta?.rawLength === 'number') { - pending.rawLength = meta.rawLength - } - hiddenOutputRestorePendingChunks.push(pending) - hiddenOutputRestorePendingChars += data.length - armHiddenOutputRestoreForegroundDeadline() - } - - function getChunkDataAfterSnapshot( - 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 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). - function reconcileChunkAgainstRestoredSnapshot( - data: string, - meta: PtyDataMeta | undefined - ): RestoredSnapshotReconciliation { - if (restoredSnapshotBaselineSeq === null) { - return { action: 'write', data, meta } - } - if (transport.getPtyId() !== restoredSnapshotBaselinePtyId) { - 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 getChunkDataAfterSnapshot. - return { action: 'write', data, meta } - } - if ( - restoredSnapshotDeliveryWindowStartSeq !== null && - meta.seq <= 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. - clearRestoredSnapshotBaseline() - return { action: 'write', data, meta } - } - const rawLength = meta.rawLength ?? data.length - const startSeq = meta.seq - rawLength - const expectedStartSeq = restoredSnapshotExpectedStartSeq - 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 <= restoredSnapshotBaselineSeq) { - return { action: 'drop-duplicate' } - } - if (startSeq >= 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(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 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. - */ - function applySnapshotKittyKeyboardModes( - 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 (!kittyKeyboardModes.hasProvenBaseline) { - kittyKeyboardModes.resetForSnapshot() - } - kittyKeyboardModes.scanReplay(snapshotData) - return - } - kittyKeyboardModes.resetForSnapshot() - kittyKeyboardModes.scanReplay(snapshotData) - kittyKeyboardModes.restoreSnapshotFlags(proven) - // Why in the same critical section: without the baseline a quiet pane - // could not publish a coherent snapshot until unrelated output arrived. - recordRendererOrderedSeq({ seq: snapshot.snapshotSeq }) - } - - function recordRendererOrderedSeq(meta?: Pick): void { - if (typeof meta?.seq !== 'number') { - return - } - const ptyId = transport.getPtyId() - if (!ptyId) { - return - } - if (rendererOrderedPtyId !== ptyId) { - rendererOrderedPtyId = ptyId - rendererOrderedSeq = meta.seq - return - } - rendererOrderedSeq = Math.max(rendererOrderedSeq ?? 0, meta.seq) - } - - 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 (restoredSnapshotBaselinePtyId === exitedPtyId) { - clearRestoredSnapshotBaseline() - } - if (rendererOrderedPtyId === exitedPtyId) { - rendererOrderedPtyId = null - rendererOrderedSeq = null - } - if (rendererChannelSeqPtyId === exitedPtyId) { - rendererChannelSeqPtyId = null - rendererChannelSeq = null - } - } - - function observeRendererOrderedSeqRegression(meta: PtyDataMeta | undefined): void { - if (typeof meta?.seq !== 'number') { - return - } - const ptyId = transport.getPtyId() - if (!ptyId) { - return - } - if (rendererChannelSeqPtyId !== ptyId) { - rendererChannelSeqPtyId = ptyId - rendererChannelSeq = meta.seq - return - } - if (rendererChannelSeq !== null && meta.seq < 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 (rendererOrderedPtyId === ptyId) { - rendererOrderedPtyId = null - rendererOrderedSeq = null - } - } - rendererChannelSeq = meta.seq - } - - function getHiddenRendererDataAfterOrderedSeq( - data: string, - meta: PtyDataMeta | undefined - ): string | null { - if ( - rendererOrderedPtyId === null || - rendererOrderedSeq === null || - transport.getPtyId() !== rendererOrderedPtyId - ) { - return data - } - return getChunkDataAfterSnapshot( - { data, seq: meta?.seq, rawLength: meta?.rawLength }, - rendererOrderedSeq - ) - } - - // 'drained' = painted all queued bytes; 'overflow' = queue blew its cap (stream outran fetch+replay); 'refetch' = offsets unmappable, need a fresher snapshot. - function drainPendingLiveChunksAfterSnapshot( - snapshotSeq: number | undefined - ): 'drained' | 'overflow' | 'refetch' { - if (hiddenOutputRestorePendingOverflow) { - hiddenOutputRestorePendingOverflow = false - discardPendingLiveChunksSalvagingQueries() - return 'overflow' - } - while (hiddenOutputRestorePendingChunks.length > 0) { - const chunks = hiddenOutputRestorePendingChunks - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - for (const [index, chunk] of chunks.entries()) { - const data = 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)) { - 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' && restoredSnapshotExpectedStartSeq !== null) { - restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) - } - if (data) { - writePtyOutputToXterm(data, true) - recordRendererOrderedSeq(chunk) - } - } - if (hiddenOutputRestorePendingOverflow) { - hiddenOutputRestorePendingOverflow = false - discardPendingLiveChunksSalvagingQueries() - return 'overflow' - } - } - return 'drained' - } - - function discardPendingLiveChunksSalvagingQueries(): void { - const discarded = hiddenOutputRestorePendingChunks - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - for (const chunk of discarded) { - salvageRendererQueriesFromDiscardedRestoreData(chunk.data) - } - } - - function clearPendingLiveChunksDuringRestore(): void { - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - hiddenOutputRestorePendingOverflow = false - hiddenOutputRestoreFreshSnapshotNeeded = false - hiddenOutputRestoreRetryDeferred = false - hiddenOutputRestoreScheduled = false - cancelScheduledHiddenOutputRestore(pane.terminal) - clearHiddenOutputRestoreDeferredRetryTimer() - clearHiddenOutputRestoreForegroundDeadlineTimer() - hiddenOutputRestoreDeferredRetryAttempts = 0 - } - - function clearHiddenOutputRestoreDeferredRetryTimer(): void { - if (hiddenOutputRestoreDeferredRetryTimer === null) { - return - } - clearTimeout(hiddenOutputRestoreDeferredRetryTimer) - hiddenOutputRestoreDeferredRetryTimer = null - } - cleanupHiddenOutputRestoreDeferredRetry = clearHiddenOutputRestoreDeferredRetryTimer - - function clearHiddenOutputRestoreForegroundDeadlineTimer(): void { - if (hiddenOutputRestoreForegroundDeadlineTimer === null) { - return - } - clearTimeout(hiddenOutputRestoreForegroundDeadlineTimer) - hiddenOutputRestoreForegroundDeadlineTimer = null - } - cleanupHiddenOutputRestoreForegroundDeadline = clearHiddenOutputRestoreForegroundDeadlineTimer - - function armHiddenOutputRestoreForegroundDeadline(): void { - if ( - disposed || - hiddenOutputRestoreForegroundDeadlineTimer !== null || - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) || - (isRemoteRuntimePtyId(hiddenOutputRestorePtyId) && - hiddenOutputRestoreLegacyPtyId !== hiddenOutputRestorePtyId && - typeof transport.serializeBufferOutcome === 'function') || - (hiddenOutputRestorePendingChunks.length === 0 && !hiddenOutputRestorePendingOverflow) - ) { - return - } - const ptyId = hiddenOutputRestorePtyId - if (ptyId === null || transport.getPtyId() !== ptyId) { - return - } - const deadlineGeneration = hiddenOutputRestoreGeneration - // Why: only foreground output blocked behind recovery gets a deadline; hidden-time restore work has no user impact. - hiddenOutputRestoreForegroundDeadlineTimer = setTimeout(() => { - hiddenOutputRestoreForegroundDeadlineTimer = null - if ( - disposed || - hiddenOutputRestoreGeneration !== deadlineGeneration || - hiddenOutputRestorePtyId !== ptyId || - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) { - return - } - 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). - function rearmRemoteHiddenOutputRestoreInsteadOfWarning( - ptyId: string, - reason: string - ): boolean { - if ( - !isRemoteRuntimePtyId(ptyId) || - hiddenOutputRestoreRemoteAbandonCycles >= HIDDEN_OUTPUT_RESTORE_REMOTE_REARM_MAX - ) { - return false - } - hiddenOutputRestoreRemoteAbandonCycles += 1 - recordTerminalFreezeBreadcrumb('restore-abandon-rearm', { - id: redactPtyIdForDiagnostics(ptyId), - reason, - cycle: hiddenOutputRestoreRemoteAbandonCycles - }) - noteHiddenOutputRestoreFloodBackpressure() - writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) - return true - } - - function abandonHiddenOutputRestoreAndDrainPendingForeground( - expectedPtyId: string, - opts: { quiet?: boolean; rearmRemote?: boolean } = {} - ): void { - if (transport.getPtyId() !== expectedPtyId || hiddenOutputRestorePtyId !== expectedPtyId) { - resetHiddenOutputRestoreIfPtyChanged() - return - } - const rearmedRemoteRestore = - opts.rearmRemote !== false && - !opts.quiet && - canUseHiddenOutputSnapshot(expectedPtyId) && - rearmRemoteHiddenOutputRestoreInsteadOfWarning(expectedPtyId, 'abandon-deadline') - const pendingChunks = hiddenOutputRestorePendingOverflow - ? [] - : hiddenOutputRestorePendingChunks.slice() - const hadPendingOverflow = hiddenOutputRestorePendingOverflow - const replayingSnapshot = hiddenOutputRestoreReplayingSnapshot - hiddenOutputRestoreReplayingSnapshot = null - hiddenOutputRestoreGeneration += 1 - if ( - hiddenOutputSnapshotScrollRestore?.valid && - hiddenOutputSnapshotScrollRestore.ptyId === expectedPtyId - ) { - // Why: flood abandonment stops recovery bookkeeping, but its already-queued replay must keep the rebuild bracket and final pin. - hiddenOutputSnapshotScrollRestore.generation = hiddenOutputRestoreGeneration - } - hiddenOutputRestoreInFlight = null - hiddenOutputRestoreNeeded = false - hiddenOutputRestorePtyId = null - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - hiddenOutputRestorePendingOverflow = false - hiddenOutputRestoreFreshSnapshotNeeded = false - hiddenOutputRestoreRetryDeferred = false - hiddenOutputRestoreScheduled = false - hiddenStartupRendererQueryPending = '' - hiddenRendererStateDirty = false - cancelScheduledHiddenOutputRestore(pane.terminal) - clearHiddenOutputRestoreDeferredRetryTimer() - clearHiddenOutputRestoreForegroundDeadlineTimer() - 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. - clearHiddenOutputRestoreFloodRepaintTimer() - writeRestoreUnavailableWarning() - } - // Why an else: the branch above already grounds the gap inside - // writeRestoreUnavailableWarning, and the remote re-arm grounds it in - // rearmRemoteHiddenOutputRestoreInsteadOfWarning. Only the quiet - // flood-abandon reaches neither, and it still drains chunks below. - else if (!rearmedRemoteRestore) { - 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 : getChunkDataAfterSnapshot(chunk, replayedSeq) - pendingData += sliced ?? chunk.data - } - if (replayingSnapshot && replayedSeq !== null) { - setRestoredSnapshotBaseline(expectedPtyId, replayingSnapshot) - for (const chunk of pendingChunks) { - if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) { - restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) - } - } - } - if (pendingData) { - writePtyOutputToXterm(pendingData, true) - } - } - - function scheduleHiddenOutputRestoreDeferredRetry(): void { - if ( - disposed || - hiddenOutputRestoreDeferredRetryTimer !== null || - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) { - return - } - if (hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX) { - const ptyId = hiddenOutputRestorePtyId - if (ptyId !== null) { - abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId) - } else { - clearHiddenOutputRestoreState() - writeRestoreUnavailableWarning() - } - return - } - hiddenOutputRestoreDeferredRetryAttempts += 1 - // Why: a null snapshot usually means remote output was still mutating; retry after one quiet tick instead of spinning. - hiddenOutputRestoreDeferredRetryTimer = setTimeout(() => { - hiddenOutputRestoreDeferredRetryTimer = null - if (disposed || !hiddenOutputRestoreNeeded) { - return - } - hiddenOutputRestoreRetryDeferred = false - requestHiddenOutputRestoreIfNeeded() - }, HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS) - } - - function clearHiddenOutputRestoreState(): void { - cancelSnapshotScrollRestore() - clearPendingLiveChunksDuringRestore() - // Re-arm budget is per PTY stream, like the rest of this state. - hiddenOutputRestoreRemoteAbandonCycles = 0 - hiddenOutputRestoreRemoteOutcomeAttempts = 0 - hiddenOutputRestoreLocalGateAttempts = 0 - hiddenStartupRendererQueryPending = '' - hiddenRendererStateDirty = false - hiddenOutputRestoreNeeded = false - hiddenOutputRestorePtyId = null - hiddenOutputRestoreReplayingSnapshot = null - hiddenOutputRestoreGeneration += 1 - } - - function cancelSnapshotScrollRestore(): void { - pendingHiddenSnapshotFit?.cancel() - pendingHiddenSnapshotFit = null - const scrollRestore = hiddenOutputSnapshotScrollRestore - if (!scrollRestore) { - return - } - scrollRestore.valid = false - hiddenOutputSnapshotScrollRestore = null - if (scrollRestore.started) { - cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) - } - // Why: invalidation suppresses restoration, but queued bytes still own the bracket until their FIFO sentinels prove parsing finished. - } - cancelHiddenOutputSnapshotScrollRestore = cancelSnapshotScrollRestore - - function clearPaneMode2031State(): void { - deps.paneMode2031Ref.current.delete(pane.id) - deps.paneLastThemeModeRef.current.delete(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. - mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE - } - - function pulseVisibleLocalPtySizeForTuiRepaint(ptyId: string): void { - if ( - !isRendererPtyResizeAuthoritative() || - shouldSuppressDesktopPtyResize() || - isRemoteRuntimePtyId(ptyId) - ) { - return - } - const cols = pane.terminal.cols - const rows = 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. - transport.resize(cols - 1, rows) - transport.resize(cols, rows) - } - - function skipBackgroundAlternateScreenOutput(data: string): void { - writeHiddenStartupRendererQueries(data) - hiddenRendererStateDirty = true - recordHiddenRendererSkip(data.length) - const ptyId = transport.getPtyId() - if (!ptyId || alternateScreenBackgroundRepaintTimer !== null) { - return - } - pulseVisibleLocalPtySizeForTuiRepaint(ptyId) - alternateScreenBackgroundRepaintTimer = setTimeout(() => { - alternateScreenBackgroundRepaintTimer = null - }, 100) - } - - function resetHiddenOutputRestoreIfPtyChanged(): void { - if (hiddenOutputRestorePtyId === null) { - return - } - if (transport.getPtyId() !== hiddenOutputRestorePtyId) { - // Why: renderer backlog is tied to the old PTY stream; after reattach it must not delay or replay before the new PTY. - clearHiddenOutputRestoreState() - clearRestoredSnapshotBaseline() - clearPaneMode2031State() - // Why: flood-backpressure evidence is per PTY stream too. - resetHiddenOutputRestoreFloodSuppression() - discardTerminalOutput(pane.terminal) - } - } - - function writeRestoreUnavailableWarning(): void { - // The reset must parse before both the warning and any foreground drain. - writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) - if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { - return - } - writeTerminalOutput(pane.terminal, HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING, { - foreground: true, - beforeWrite: beforeTerminalOutputWrite - }) - } - - async function applyMainBufferSnapshot(snapshot: { - data: string - frameRestoreAnsi?: string - cols: number - rows: number - seq?: number - pendingDeliveryStartSeq?: number - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - }): Promise { - const restorePtyId = transport.getPtyId() - const restoreGeneration = hiddenOutputRestoreGeneration - if (hiddenOutputSnapshotScrollRestore) { - cancelSnapshotScrollRestore() - } - const scrollRestore = { - ptyId: restorePtyId, - generation: restoreGeneration, - valid: true, - started: false - } - hiddenOutputSnapshotScrollRestore = scrollRestore - const colsBeforeReplay = pane.terminal.cols - const rowsBeforeReplay = pane.terminal.rows - const hasSnapshotDimensions = hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) - let skippedAltFrame = false - try { - await structuralReplayCoordinator.run( - async () => { - if ( - !scrollRestore.valid || - disposed || - transport.getPtyId() !== scrollRestore.ptyId || - hiddenOutputRestoreGeneration !== scrollRestore.generation - ) { - return - } - scrollRestore.started = true - if (typeof snapshot.seq === 'number') { - hiddenOutputRestoreReplayingSnapshot = { - seq: snapshot.seq, - ...(typeof snapshot.pendingDeliveryStartSeq === 'number' - ? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq } - : {}) - } - } - discardTerminalOutput(pane.terminal) - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) - ) { - // Why: xterm parses writes later; hold snapshot dimensions until the FIFO sentinel completes so serialized wraps stay exact. - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(snapshot.cols, snapshot.rows) - } finally { - 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. - 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(pane)) - // Why: an imageless success is not proof the pane is empty; normal replay clears screen and scrollback. - const snapshotCarriesNoImage = - snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi - if (snapshotCarriesNoImage) { - // Why still ground: a restore only runs because bytes were dropped, so - // the gap's pen outlives a snapshot that cannot repaint over it. The - // live-path constant, not the replay baseline — nothing repaints here, - // so the pane is handed back to whatever is still running. - writeReplayData(RESET_AFTER_BYTE_GAP) - } else { - for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { - skipAltFrame: skippedAltFrame, - paneOnAlternateScreen: isPaneOnAlternateScreen() - })) { - 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). - writeReplayData( - 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). - writeReplayData(snapshot.pendingEscapeTailAnsi) - } - hiddenRendererStateDirty = false - recordRendererOrderedSeq(snapshot) - recordTerminalOutput(pane.terminal) - await waitForTerminalReplayWritesParsed(pane.terminal) - }, - { - shouldRestore: () => - scrollRestore.valid && - !disposed && - transport.getPtyId() === scrollRestore.ptyId && - hiddenOutputRestoreGeneration === scrollRestore.generation, - afterRestore: async () => { - const isCurrentRestore = (): boolean => - scrollRestore.valid && - !disposed && - transport.getPtyId() === scrollRestore.ptyId && - hiddenOutputRestoreGeneration === scrollRestore.generation - if (!isCurrentRestore()) { - return - } - const currentPtyId = transport.getPtyId() - if (!currentPtyId) { - return - } - if (getFitOverrideForPty(currentPtyId)) { - safeFit(pane) - if (skippedAltFrame && !isRemoteRuntimePtyId(currentPtyId)) { - window.api.pty.signal(currentPtyId, 'SIGWINCH') - } - return - } - const fit = safeFitAndThen( - pane, - 'hidden-snapshot-pty-resize', - () => { - if (!isCurrentRestore() || transport.getPtyId() !== currentPtyId) { - return - } - const replayChangedDimensions = hasSnapshotDimensions - ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows - : pane.terminal.cols !== colsBeforeReplay || - pane.terminal.rows !== rowsBeforeReplay - if (skippedAltFrame) { - pulseVisibleLocalPtySizeForTuiRepaint(currentPtyId) - return - } - if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { - transport.resize(pane.terminal.cols, 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 - } - ) - pendingHiddenSnapshotFit = fit - try { - await fit.completion - } finally { - if (pendingHiddenSnapshotFit === fit) { - pendingHiddenSnapshotFit = null - } - } - if (isCurrentRestore()) { - scheduleReattachIdleAgentCursorReset() - } - } - } - ) - } finally { - if (hiddenOutputSnapshotScrollRestore === scrollRestore) { - hiddenOutputSnapshotScrollRestore = null - } - } - } - - function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean { - // Why: once the write pipeline is probe-certified dead a restore can never parse; recovery owns the pane and the remount gets a fresh xterm + restore. - if (isTerminalWritePipelineCertifiedDead(pane.terminal)) { - // Why the re-kick: certification's recovery request can be budget-declined or cancelled by a sibling remount; without this, a revealed dead pane keeps the stale frame forever. - if (!certifiedDeadRestoreRecoveryRequested && !disposed) { - certifiedDeadRestoreRecoveryRequested = true - const storePtyId = useAppStore.getState().ptyIdsByTabId?.[deps.tabId]?.[0] ?? null - void requestTerminalPaneRecovery({ - tabId: deps.tabId, - ptyId: transport.getPtyId() ?? storePtyId, - reason: 'restore-blocked', - terminalRecoveryGeneration, - terminalRecoveryInstanceId: terminalRecoveryInstance.id - }) - } - return false - } - resetHiddenOutputRestoreIfPtyChanged() - const ptyId = hiddenOutputRestorePtyId ?? transport.getPtyId() - if (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) { - return false - } - if (!canUseHiddenOutputSnapshot(ptyId)) { - return false - } - hiddenOutputRestorePtyId = ptyId - if (hiddenOutputRestoreInFlight) { - armHiddenOutputRestoreForegroundDeadline() - return true - } - if (!opts?.bypassScheduler) { - const priority = isActiveSplitPane() ? 'active' : 'inactive' - if (priority === 'inactive') { - if (!hiddenOutputRestoreScheduled) { - hiddenOutputRestoreScheduled = true - const scheduledPtyId = ptyId - const scheduledGeneration = hiddenOutputRestoreGeneration - // Why: resume can reveal many split panes at once; spread inactive replays across frames so xterm scrollback replay doesn't block return. - scheduleHiddenOutputRestore( - pane.terminal, - () => { - hiddenOutputRestoreScheduled = false - if ( - disposed || - hiddenOutputRestoreGeneration !== scheduledGeneration || - hiddenOutputRestorePtyId !== scheduledPtyId || - transport.getPtyId() !== scheduledPtyId || - !canUseHiddenOutputSnapshot(scheduledPtyId) || - (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) || - !shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) { - return - } - requestHiddenOutputRestoreIfNeeded({ bypassScheduler: true }) - }, - priority - ) - } - return true - } - cancelScheduledHiddenOutputRestore(pane.terminal) - hiddenOutputRestoreScheduled = false - } - clearHiddenOutputRestoreDeferredRetryTimer() - hiddenOutputRestoreRetryDeferred = false - - 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 (!disposed) { - const currentPtyId = hiddenOutputRestorePtyId - if (currentPtyId === null) { - clearHiddenOutputRestoreState() - return - } - if (!canUseHiddenOutputSnapshot(currentPtyId)) { - if (hiddenOutputRestorePtyId === currentPtyId) { - clearHiddenOutputRestoreState() - } - // Remote-only path: the transport swapped PTYs mid-restore, which is a - // stream change, not proof the hidden bytes are unrecoverable. - if ( - !rearmRemoteHiddenOutputRestoreInsteadOfWarning(currentPtyId, 'restore-pty-swapped') - ) { - writeRestoreUnavailableWarning() - } - return - } - if (transport.getPtyId() !== currentPtyId) { - if (hiddenOutputRestorePtyId === currentPtyId) { - clearHiddenOutputRestoreState() - } - return - } - const restoreGeneration = hiddenOutputRestoreGeneration - hiddenOutputRestoreNeeded = false - let snapshotResult: HiddenOutputSnapshotResult - try { - snapshotResult = await serializeHiddenOutputSnapshot(currentPtyId, { - scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) - }) - } catch { - snapshotResult = - !isRemoteRuntimePtyId(currentPtyId) || - hiddenOutputRestoreLegacyPtyId === currentPtyId || - typeof 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 (disposed) { - return - } - const restoreGenerationChanged = hiddenOutputRestoreGeneration !== restoreGeneration - const restorePtyChanged = - transport.getPtyId() !== currentPtyId || 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 && hiddenOutputRestorePtyId === currentPtyId) { - clearHiddenOutputRestoreState() - } - return - } - if (snapshotResult.kind === 'retry-worthy') { - let budgetExhausted: boolean - if (snapshotResult.source === 'host') { - hiddenOutputRestoreRemoteOutcomeAttempts += 1 - budgetExhausted = - hiddenOutputRestoreRemoteOutcomeAttempts >= - HIDDEN_OUTPUT_RESTORE_REMOTE_OUTCOME_MAX_ATTEMPTS - } else { - hiddenOutputRestoreLocalGateAttempts += 1 - budgetExhausted = - hiddenOutputRestoreLocalGateAttempts >= - HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS - } - if (budgetExhausted) { - abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { - rearmRemote: false - }) - return - } - hiddenOutputRestoreNeeded = true - hiddenOutputRestoreFreshSnapshotNeeded = false - noteHiddenOutputRestoreFloodBackpressure() - abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) - return - } - if (snapshotResult.kind === 'permanently-unavailable') { - abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { - rearmRemote: false - }) - return - } - if (snapshotResult.kind === 'unknown-legacy-host') { - hiddenOutputRestoreLegacyPtyId = currentPtyId - armHiddenOutputRestoreForegroundDeadline() - } - if (snapshotResult.kind !== 'snapshot') { - hiddenOutputRestoreNeeded = true - hiddenOutputRestoreFreshSnapshotNeeded = false - hiddenOutputRestoreRetryDeferred = true - scheduleHiddenOutputRestoreDeferredRetry() - return - } - const snapshot = snapshotResult.snapshot - hiddenOutputRestoreDeferredRetryAttempts = 0 - hiddenOutputRestoreRemoteAbandonCycles = 0 - hiddenOutputRestoreRemoteOutcomeAttempts = 0 - hiddenOutputRestoreLocalGateAttempts = 0 - restoreIterations += 1 - await applyMainBufferSnapshot(snapshot) - if ( - disposed || - hiddenOutputRestoreGeneration !== restoreGeneration || - hiddenOutputRestorePtyId !== currentPtyId || - 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. - setRestoredSnapshotBaseline(currentPtyId, snapshot) - hiddenOutputRestoreReplayingSnapshot = null - const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded - hiddenOutputRestoreFreshSnapshotNeeded = false - const drainOutcome = drainPendingLiveChunksAfterSnapshot(snapshot.seq) - if (drainOutcome === 'drained' && !needsFreshSnapshot) { - hiddenOutputRestoreNeeded = false - hiddenOutputRestorePtyId = null - clearHiddenOutputRestoreForegroundDeadlineTimer() - return - } - if (!shouldWritePtyOutputForeground(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. - 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. - noteHiddenOutputRestoreFloodBackpressure() - 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: deps.tabId, - worktreeId: deps.worktreeId, - leafId: pane.leafId, - paneId: pane.id, - ptyId: currentPtyId, - reason: drainOutcome - }) - noteHiddenOutputRestoreFloodBackpressure() - abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) - return - } - hiddenOutputRestoreNeeded = true - } - })() - const hiddenOutputRestoreTask = hiddenOutputRestoreInFlight - let trackedHiddenOutputRestore: Promise - trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => { - if (hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) { - 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 (disposed) { - return - } - if (hiddenOutputRestorePendingChunks.length > 0 || hiddenOutputRestorePendingOverflow) { - hiddenOutputRestoreNeeded = true - armHiddenOutputRestoreForegroundDeadline() - } - if ( - !hiddenOutputRestoreRetryDeferred && - hiddenOutputRestoreNeeded && - shouldWritePtyOutputForeground(deps.isVisibleRef.current) - ) { - requestHiddenOutputRestoreIfNeeded() - } - }) - hiddenOutputRestoreInFlight = trackedHiddenOutputRestore - return true - } - - unregisterBacklogRecovery = registerTerminalBacklogRecovery(pane.terminal, () => { - // Why: clear the hidden-delivery bit BEFORE the restore snapshot request; bytes arriving in between are reconciled by the seq guard. - syncHiddenRendererPtyDelivery() - return requestHiddenOutputRestoreIfNeeded() - }) - if ( - typeof document !== 'undefined' && - typeof document.addEventListener === 'function' && - typeof document.removeEventListener === 'function' - ) { - const onDocumentVisibilityChange = (): void => { - // Why: document hide/show flips the foreground predicate with no pane lifecycle event; re-sync the hidden-delivery gate both ways. - syncHiddenRendererPtyDelivery() - if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { - 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 - ) - unregisterDocumentVisibilityRecovery = () => { - document.removeEventListener('visibilitychange', onDocumentVisibilityChange) - unregisterStaleVisibilityRecovery() - } - } - - const dataCallback = ( - data: string, - meta?: PtyDataMeta, - streamGeneration = transportStreamGeneration - ): void => { - if (streamGeneration !== transportStreamGeneration) { - return - } - if (deferredReattachLiveData !== null) { - const ackCredit = takeCurrentTerminalDeliveryCredit() - deferredReattachLiveData.enqueue({ - data, - ptyId: transport.getPtyId(), - streamGeneration, - ...(meta ? { meta } : {}), - ...(ackCredit ? { ackCredit } : {}) - }) - return - } - if (data.length > 0) { - hasReceivedPtyOutput = true - recordAgentHibernationPaneOutput(cacheKey) - // Why: output is the agent-start signal that ends the relaxed no-evidence process-scan cadence (a starting agent always prints). - agentCompletionCoordinator.observeOutputActivity() - } - observeStartupDraftPasteReadiness(data) - resetHiddenOutputRestoreIfPtyChanged() - observeLiveMode2031Chunk(data) - if (meta?.droppedOutput === true) { - // Why gated (rc.7.perf loop): a visible pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel. - if (meta?.background !== true && isForegroundRestoreBackpressureContext()) { - 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. - markHiddenOutputRestoreNeeded() - if (data) { - // The sentinel can carry query bytes carved from the bulk drop (extractDroppedPtyQueryBytes in main); replies must still flow. - salvageRendererQueriesFromDiscardedRestoreData(data) - } - return - } - } - respondToTerminalPixelSizeQueries(data) - observeTerminalBracketedPasteModeOutput(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 (!mainSideEffectAuthority) { - for (const link of observeTerminalGitHubPRLink(data)) { - useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) - } - commandLifecycle.handlePtyData(data) - } - commandCodeOutputStatusDetector?.observe(data) - const codexBackfillNotice = codexBackfillErrorDetector?.observe(data) - if (codexBackfillNotice) { - reportError(codexBackfillNotice) - } - // Why: split panes have visible-but-inactive panes the user watches; throttle only when the pane or whole document is hidden. - const foreground = - shouldWritePtyOutputForeground(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) { - 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 = 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 && isForegroundRestoreBackpressureContext()) { - noteHiddenOutputRestoreFloodBackpressure() - clearRestoredSnapshotBaseline() - // fall through with the ORIGINAL data/meta — post-gap bytes are new - } else { - // Why: capture in-flight BEFORE the mark — on a visible pane the mark starts the restore synchronously and must not flag itself. - const restoreWasInFlight = hiddenOutputRestoreInFlight !== null - markHiddenOutputRestoreNeeded() - if (restoreWasInFlight) { - 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 - ? takeHiddenStartupRendererQueryPendingForForeground(data) - : null - const rendererData = pendingForegroundQuery?.remainingData ?? data - const rendererMeta = metaAfterConsumingCurrentChars( - meta, - pendingForegroundQuery?.consumedCurrentChars ?? 0 - ) - observeRendererOrderedSeqRegression(meta) - const orderedRendererData = foreground - ? rendererData - : 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. - markHiddenOutputRestoreNeeded() - schedulePendingStartupCommandDelivery() - return - } - if (!foreground && orderedRendererData.length === 0) { - recordRendererOrderedSeq(rendererMeta) - schedulePendingStartupCommandDelivery() - return - } - if (pendingForegroundQuery?.statelessQueryData) { - writePtyOutputToXterm(pendingForegroundQuery.statelessQueryData, true, { - hiddenStartupRendererQuery: true - }) - } - if (pendingForegroundQuery?.oscColorQueryData) { - sendTerminalOscColorQueryReplies( - pendingForegroundQuery.oscColorQueryData, - pane.terminal, - // Why: OSC color reply sent immediately so the remote debounce can't delay it past the program's read window (#7329). - sendDesktopQueryReplyImmediate - ) - } - const restoreAppliesToCurrentPty = - hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId - const skipBackgroundAlternateScreenFrame = - meta?.background === true && - shouldWritePtyOutputForeground(deps.isVisibleRef.current) && - pane.terminal.buffer.active.type === 'alternate' && - !containsStatefulRendererQuery(orderedRendererData) - if (skipBackgroundAlternateScreenFrame) { - skipBackgroundAlternateScreenOutput(orderedRendererData) - } else if (shouldSkipHiddenRendererOutput(foreground, orderedRendererData)) { - skipHiddenRendererOutput(orderedRendererData) - } else if ( - (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && - restoreAppliesToCurrentPty - ) { - if (foreground) { - if (pendingForegroundQuery?.statefulQueryData) { - queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData) - } - queueLiveChunkDuringRestore(orderedRendererData, rendererMeta) - requestHiddenOutputRestoreIfNeeded() - } else if (hiddenOutputRestoreInFlight) { - hiddenOutputRestoreNeeded = true - 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) { - writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, { - hiddenStartupRendererQuery: true - }) - } - writePtyOutputToXterm(orderedRendererData, foreground) - if (foreground) { - recordRendererOrderedSeq(rendererMeta) - } - } - - schedulePendingStartupCommandDelivery() - } - unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(cacheKey, (data, meta) => { - if (!disposed) { - dataCallback(data, meta) - } - }) - - const beginReattachLiveDataDeferral = (ownerGeneration = transportStreamGeneration): void => { - reattachLiveDataDeferralDepth += 1 - if (reattachLiveDataDeferralDepth === 1) { - deferredReattachLiveData = new DeferredReattachLiveDataQueue() - deferredReattachLiveDataOwners = new Map() - } - if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { - deferredReattachLiveDataOwners.set(ownerGeneration, { failed: false }) - } - } - - const beginReattachLiveDataDeferralIfUnowned = ( - ownerGeneration = transportStreamGeneration - ): void => { - if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { - beginReattachLiveDataDeferral(ownerGeneration) - } - } - - const finishReattachLiveDataDeferral = ( - deliver: boolean, - acceptedGeneration = transportStreamGeneration - ): void => { - if (reattachLiveDataDeferralDepth <= 0) { - return - } - if (!deliver) { - const owner = deferredReattachLiveDataOwners.get(acceptedGeneration) - if (owner) { - owner.failed = true - } - } - reattachLiveDataDeferralDepth -= 1 - if (reattachLiveDataDeferralDepth > 0) { - return - } - const queue = deferredReattachLiveData - deferredReattachLiveData = null - const currentPtyId = transport.getPtyId() - const currentGeneration = transportStreamGeneration - const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) - deferredReattachLiveDataOwners = new Map() - if (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, () => { - dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) - }) - } else { - 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(pane.terminal, { maxChars: MAX_DEFERRED_REATTACH_LIVE_CHARS }) - void waitForTerminalReplayWritesParsed(pane.terminal).then(() => { - if ( - disposed || - !deps.isVisibleRef.current || - transport.getPtyId() !== currentPtyId || - transportStreamGeneration !== currentGeneration - ) { - return - } - enforceTerminalCurrentScrollIntent(pane.terminal) - }) - } - } - - const isCapturedDirectSshReattachCurrent = (ptyId: string): boolean => - !directSshRetryAttempt || capturedDirectSshRetryStateMatches(ptyId) - const rejectObsoleteDirectSshReattach = (ptyId: string | null | undefined): boolean => { - if (!directSshRetryAttempt || (ptyId && claimCapturedDirectSshRetryPty(ptyId))) { - return false - } - transport.detach?.({ preserveExitObserver: false }) - return true - } - - let parkedSshSnapshotPrefetch: { - ptyId: string - fetch: () => Promise - } | null = null - - const createSshMainModelSnapshotProbe = ( - ptyId: string - ): (() => Promise) => - memoizeSshReattachModelSnapshotProbe(async (): Promise => { - const sshParkingEnabled = useAppStore.getState().settings?.terminalSshViewParking !== false - if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) { - return null - } - const snapshot = await resolveSshReattachModelSnapshotWithTimeout( - window.api.pty.getMainBufferSnapshot(ptyId, { - scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) - }) - ) - return snapshot && - decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) === - 'main-model-snapshot' - ? snapshot - : null - }) - - const getSshMainModelSnapshotProbe = ( - ptyId: string - ): (() => Promise) => { - if (parkedSshSnapshotPrefetch?.ptyId !== ptyId) { - parkedSshSnapshotPrefetch = { ptyId, fetch: createSshMainModelSnapshotProbe(ptyId) } - } - return parkedSshSnapshotPrefetch.fetch - } - - const prepaintParkedSshSnapshot = (ptyId: string | null): void => { - const parsedPtyId = ptyId ? parseAppSshPtyId(ptyId) : null - if ( - !ptyId || - !mountFollowsTerminalPark || - parsedPtyId?.connectionId !== connectionId || - !capturedDirectSshRetryLeaseMatches() - ) { - return - } - const capturedGeneration = authoritativeReattachGeneration - const isCurrent = (): boolean => - !disposed && - mountFollowsTerminalPark && - authoritativeReattachGeneration === capturedGeneration && - capturedDirectSshRetryLeaseMatches() - const fetchSnapshot = getSshMainModelSnapshotProbe(ptyId) - void fetchSnapshot() - .then(async (snapshot) => { - if (!snapshot || !isCurrent()) { - return - } - await structuralReplayCoordinator.run( - async () => { - if (!isCurrent()) { - return - } - const modelData = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` - rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true }) - if ( - hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) && - (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) - ) { - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(snapshot.cols, snapshot.rows) - } finally { - suppressStructuralReplayPtyResize = false - } - } - 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: isPaneOnAlternateScreen() - })) { - writeReplayData(replayChunk) - } - writeReplayData(reattachReplayResetSequence(modelData)) - if (snapshot.pendingEscapeTailAnsi) { - writeReplayData(snapshot.pendingEscapeTailAnsi) - } - recordTerminalOutput(pane.terminal) - await waitForTerminalReplayWritesParsed(pane.terminal) - if (isCurrent()) { - manager.rebuildPaneWebgl(pane.id) - } - }, - { shouldRestore: isCurrent } - ) - }) - .catch(() => {}) - } - - const handleReattachResult = async ( - result: PtyConnectResult | string | void, - staleSessionId?: string | null, - coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, - attemptGeneration = transportStreamGeneration - ): Promise => { - authoritativeReattachGeneration += 1 - if (disposed) { - return false - } - if (attemptGeneration !== transportStreamGeneration) { - return false - } - const connectResult = - result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null - - if (connectResult?.exitedBeforeAttach) { - // Why: the 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 ?? transport.getPtyId())) - if (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 : transport.getPtyId()) - if (!ptyId) { - warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - leafId: deps.restoredLeafId ?? pane.leafId, - paneId: 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) { - deps.clearExitedPanePtyLayoutBinding(pane.id, staleSessionId) - } else { - deps.syncPanePtyLayoutBinding(pane.id, null) - } - if (staleSessionId) { - deps.clearTabPtyId(deps.tabId, staleSessionId) - } - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return false - } - registerEffectiveLaunchConfig(connectResult?.launchConfig, { - ...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}), - ...(connectResult?.launchAgent - ? { launchAgent: connectResult.launchAgent } - : coldRestoreStartup - ? { launchAgent: coldRestoreStartup.agent } - : {}) - }) - if (connectResult?.sessionExpired) { - if (staleSessionId) { - deps.clearExitedPanePtyLayoutBinding(pane.id, staleSessionId) - } else { - deps.syncPanePtyLayoutBinding(pane.id, null) - } - if (staleSessionId) { - deps.clearTabPtyId(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. - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return false - } - const isCurrentReattachPayload = (): boolean => { - const currentPtyId = transport.getPtyId() - return ( - !disposed && attemptGeneration === 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) { - transport.disconnect() - if (staleSessionId) { - deps.clearExitedPanePtyLayoutBinding(pane.id, staleSessionId) - deps.clearTabPtyId(deps.tabId, staleSessionId) - } else { - deps.syncPanePtyLayoutBinding(pane.id, null) - } - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return false - } - setPanePtyFitBinding(ptyId) - reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) - registerSideEffectFactConsumerForPty(ptyId) - syncHiddenRendererPtyDelivery() - deps.syncPanePtyLayoutBinding(pane.id, ptyId) - // Why: this is the daemon-backed reattach path — the live PTY outlived the - // renderer, so the pane is current the moment it binds (STA-4114). - useAppStore.getState().restoreAgentPaneAuthority?.(cacheKey) - notifyCodexPaneBoundForStaleSweep(ptyId) - if (capturedDirectSshRetryPtyAccepted && directSshRetryAttempt) { - deps.updateTabPtyId(deps.tabId, ptyId, undefined, directSshRetryAttempt.attemptId) - } else { - deps.updateTabPtyId(deps.tabId, ptyId) - } - agentCompletionCoordinator.startProcessTracking() - sampleVisiblePaneForegroundAgent() - - // Why: mobile streaming needs xterm's exact screen state; install the serializer + lastTitle source for main-process hydration parity. - 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: applyMainBufferSnapshot - // would nest 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 = 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 = - mountFollowsTerminalPark && - (connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId)) - // An SSH reconnect remounts the pane (tab.generation is its React key), so it also paints into - // a fresh xterm — but unlike a park it may only use the model for a FULL-SCREEN app. See - // sshReconnectPaintsFromModel for why. - // - // NOT consume-once, unlike mountFollowsTerminalPark: followsDirectSshReconnect is captured per - // connectPanePty and never cleared, so this re-arms if one connect reaches handleReattachResult - // twice. Bounded by connectStarted and by the emptiness/alt-screen gates rather than by the - // read itself. It still reads the PENDING retry rather than directSshRetryAttempt, which also - // accepts the live binding and so stays truthy for every later remount of the generation. - const reconnectMayUseModel = followsDirectSshReconnect && !revealFollowsTerminalPark - 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 serializeHiddenOutputSnapshot(ptyId, { - scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) - }) - prefetchedParkModelSnapshot = result.kind === 'snapshot' ? result.snapshot : null - } catch { - prefetchedParkModelSnapshot = null - } - } - if (!isCurrentReattachPayload()) { - return false - } - } - let reattachPayloadApplied = !hasStructuralReplay && prefetchedParkModelSnapshot === null - const applyReattachPayload = async (): Promise => { - if (!isCurrentReattachPayload()) { - return - } - if (connectResult?.snapshot) { - const snapshotPrefixAnsi = connectResult.snapshotPrefixAnsi - const snapshotFrameAnsi = connectResult.snapshotFrameAnsi - const snapshotFrameRestoreAnsi = connectResult.snapshotFrameRestoreAnsi - const hasSplitDaemonAltFrame = - typeof snapshotPrefixAnsi === 'string' && - snapshotPrefixAnsi.length > 0 && - typeof snapshotFrameAnsi === 'string' && - snapshotFrameAnsi.length > 0 - const daemonSnapshotReplay = hasSplitDaemonAltFrame - ? snapshotPrefixAnsi + snapshotFrameAnsi - : connectResult.snapshot - 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( - connectResult.snapshotCols, - connectResult.snapshotRows - ) - if ( - snapshotDimensions && - (pane.terminal.cols !== snapshotDimensions.cols || - pane.terminal.rows !== snapshotDimensions.rows) - ) { - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(snapshotDimensions.cols, snapshotDimensions.rows) - } finally { - suppressStructuralReplayPtyResize = false - } - } - 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. - applySnapshotKittyKeyboardModes(daemonSnapshotReplay, { - kittyKeyboardFlags: connectResult.snapshotKittyKeyboardFlags, - snapshotSeq: 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( - connectResult.snapshotCols, - readProposedTerminalCols(pane) - ) - const groundDaemonSnapshot = - Boolean(connectResult.coldRestore) || - (!shouldPreserveAgentReattachModes() && - !(connectResult.isAlternateScreen ?? kittyKeyboardModes.isAlternateScreen)) - writeReplayData( - `${groundDaemonSnapshot ? RESET_GRAPHIC_RENDITION : ''}${ - daemonAltFrameSkippable - ? snapshotPrefixAnsi + snapshotFrameRestoreAnsi - : daemonSnapshotReplay - }` - ) - writeReplayData( - reattachReplayResetSequence( - daemonSnapshotReplay, - Boolean(connectResult.coldRestore), - connectResult.isAlternateScreen - ) - ) - if (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). - writeReplayData(connectResult.pendingEscapeTailAnsi) - } - sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) - if (connectResult.coldRestore) { - // Snapshot superseded the cold-restore payload; ack so the daemon doesn't redeliver it. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.replay || 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. - // An SSH reconnect may use the model only under sshReconnectPaintsFromModel: the reconnect - // replay reaches the renderer without passing through main's model (forwardReattachReplay - // and the inline attach replay both bypass onPtyData), so the model is stale by exactly - // the outage and the replay is the only witness to it. A park has no such hole, so it - // keeps using the model either way. - const revealSnapshot = revealFollowsTerminalPark - ? (prefetchedParkModelSnapshot ?? - (isRemoteRuntimePtyId(ptyId) ? null : await fetchSshMainModelReattachSnapshot())) - : null - // Asked before the probe, not after: if the replay shows the app left the alternate - // screen, no snapshot can be used no matter what it says, so fetching one only spends the - // 750ms timeout to throw the answer away — and spends it inside the coordinator, with live - // PTY bytes deferred and the payload still able to be superseded. - const replayTransition = lastAlternateScreenTransition(connectResult?.replay) - const replayVetoesModel = replayTransition === 'exited' - const reconnectSnapshot = - !revealSnapshot && reconnectMayUseModel && !replayVetoesModel - ? await fetchSshMainModelReattachSnapshot() - : null - const paintsReconnectFromModel = sshReconnectPaintsFromModel({ - snapshot: reconnectSnapshot, - hasReplay: Boolean(connectResult?.replay), - replayTransition, - altFrameWouldBeSkipped: shouldSkipAltFrameForWidthMismatch( - reconnectSnapshot?.cols, - readProposedTerminalCols(pane) - ) - }) - const modelSnapshot = - revealSnapshot ?? (paintsReconnectFromModel ? reconnectSnapshot : null) - if (!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}` - rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true }) - const modelCols = modelSnapshot.cols - const modelRows = modelSnapshot.rows - if ( - hasPositiveTerminalDimensions(modelCols, modelRows) && - (pane.terminal.cols !== modelCols || pane.terminal.rows !== modelRows) - ) { - // Why: replay at the snapshot's own dimensions (see the daemon-snapshot branch, #7279). - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(modelCols, modelRows) - } finally { - suppressStructuralReplayPtyResize = false - } - } - applySnapshotKittyKeyboardModes(modelData, { - kittyKeyboardFlags: modelSnapshot.kittyKeyboardFlags, - snapshotSeq: modelSnapshot.seq - }) - if (paintsReconnectFromModel && connectResult?.replay) { - // Why after the snapshot: painting the model discards the replay, but the app's kitty - // pushes during the outage exist ONLY there. Snapshot first for the pre-outage - // baseline, then the replay layers the outage on top — otherwise Option/Alt chords - // encode against a stale flag stack. - kittyKeyboardModes.scanReplay(connectResult.replay) - } - // 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(pane) - ), - paneOnAlternateScreen: isPaneOnAlternateScreen() - })) { - writeReplayData(replayChunk) - } - writeReplayData( - reattachReplayResetSequence( - modelData, - Boolean(connectResult?.coldRestore), - modelSnapshot.alternateScreen ?? connectResult?.isAlternateScreen - ) - ) - if (modelSnapshot.pendingEscapeTailAnsi) { - // Why last: re-arm the dangling mid-escape after the reset so the live continuation completes it (#7329). - writeReplayData(modelSnapshot.pendingEscapeTailAnsi) - } - // Why: main sampled its delivery backlog with the snapshot; the baseline drops/slices deferred and live chunks the snapshot already covers. - setRestoredSnapshotBaseline(ptyId, modelSnapshot) - recordRendererOrderedSeq(modelSnapshot) - sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) - if (connectResult?.coldRestore && !isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } else if (connectResult?.replay) { - rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) - // Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication. - 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 (!kittyKeyboardModes.hasProvenBaseline) { - kittyKeyboardModes.resetForSnapshot() - } - kittyKeyboardModes.scanReplay(connectResult.replay) - writeReplayData( - `${connectResult.coldRestore ? RESET_GRAPHIC_RENDITION : ''}${connectResult.replay}` - ) - writeReplayData( - reattachReplayResetSequence( - connectResult.replay, - Boolean(connectResult.coldRestore), - connectResult.isAlternateScreen - ) - ) - sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) - if (connectResult.coldRestore) { - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } - } else if (connectResult?.coldRestore) { - let destinationRows = pane.terminal.rows - try { - const proposedDestination = 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. - writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H`) - await waitForTerminalReplayWritesParsed(pane.terminal) - if (!isCurrentReattachPayload()) { - return - } - const coldRestoreDimensions = resolvePositiveTerminalDimensions( - connectResult.coldRestore.cols, - connectResult.coldRestore.rows - ) - if ( - coldRestoreDimensions && - (pane.terminal.cols !== coldRestoreDimensions.cols || - 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. - suppressStructuralReplayPtyResize = true - try { - pane.terminal.resize(coldRestoreDimensions.cols, coldRestoreDimensions.rows) - } finally { - 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. - writeReplayData(`${RESET_GRAPHIC_RENDITION}${connectResult.coldRestore.scrollback}`) - const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() - const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) - if (didPrepareResume) { - if (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. - showSessionRestoredBanner('resume-unavailable') - } else if (preparedStartup?.hasSleepingRecord) { - showSessionRestoredBanner() - } - 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. - 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. - kittyKeyboardModes.reset() - consumeRestoredViewportBlankingMarker() - // Why: a taller destination fit must not pull recovered rows back into the fresh shell's viewport after source-grid replay. - writeFreshShellViewportBlanking(Math.max(destinationRows, pane.terminal.rows)) - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - if (didPrepareResume && !coldRestoreStartup) { - schedulePendingStartupCommandDelivery() - } - } - if (hasStructuralReplay || prefetchedParkModelSnapshot) { - await waitForTerminalReplayWritesParsed(pane.terminal) - if (!isCurrentReattachPayload()) { - return - } - reattachPayloadApplied = true - } - } - - const fitAfterReattachRestore = async (): Promise => { - if (!isCurrentReattachPayload()) { - return - } - const reattachPtyId = transport.getPtyId() - if (!reattachPtyId) { - return - } - if (!getFitOverrideForPty(reattachPtyId)) { - const gridPush = createReattachGridPush(attemptGeneration, reattachPtyId) - const fit = safeFitAndThen(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 - }) - 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 (pendingReattachFit === fit) { - pendingReattachFit = null - } - } - } else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { - window.api.pty.signal(reattachPtyId, 'SIGWINCH') - } - } - if (hasStructuralReplay || prefetchedParkModelSnapshot) { - await structuralReplayCoordinator.run(applyReattachPayload, { - shouldRestore: isCurrentReattachPayload, - afterRestore: fitAfterReattachRestore - }) - } else { - await applyReattachPayload() - await fitAfterReattachRestore() - } - if (!isCurrentReattachPayload() || !reattachPayloadApplied) { - return false - } - scheduleReattachIdleAgentCursorReset() - - scheduleRuntimeGraphSync() - return true - } - - const attachRetainedLegacyPty = (ptyId: string): boolean => { - try { - authoritativeReattachGeneration += 1 - clearPaneMode2031State() - clearHiddenOutputRestoreState() - const outputCallbacks = captureTransportOutputCallbacks(reportError) - transport.attach({ - existingPtyId: ptyId, - callbacks: outputCallbacks.callbacks - }) - const attachedPtyId = transport.getPtyId() ?? ptyId - bindActivePanePty(attachedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - if (isRemoteRuntimePtyId(attachedPtyId)) { - registerPaneSerializerFor(attachedPtyId) - } - return true - } catch (err) { - reportError(err instanceof Error ? err.message : String(err)) - return false - } - } - - // 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 (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(connectionId) && - storeState.sshTargetLabels instanceof Map && - !storeState.sshTargetLabels.has(connectionId) - ) { - return - } - const restoredLeafSessionId = - deps.restoredLeafId && deps.restoredPtyIdByLeafId - ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) - : null - const gate = resolveSshPaneConnectGate({ - connectionId, - sshStatus: storeState.sshConnectionStates.get(connectionId)?.status, - isDeferredTarget: storeState.deferredSshReconnectTargets.includes(connectionId), - restoredLeafSessionId, - deferredTabSessionId: storeState.deferredSshSessionIdsByTabId[deps.tabId], - tabPtyId: storeState.tabsByWorktree[deps.worktreeId]?.find((t) => t.id === deps.tabId) - ?.ptyId, - hasLeafSessionMap: Boolean( - deps.restoredPtyIdByLeafId && Object.keys(deps.restoredPtyIdByLeafId).length > 0 - ) - }) - const pendingSessionId = gate.pendingSessionId - console.warn( - `[pty-connection] SSH tab=${deps.tabId} connectionId=${connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}` - ) - const legacyWorkerOwnsPane = isLegacyWorkerAutomaticResumeBlocked() - if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) { - // Paint main's parked model while SSH recovery continues off the render path. - 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: 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 (disposed || !capturedDirectSshRetryLeaseMatches()) { - return - } - if (needsPrompt) { - const alreadyConnected = - useAppStore.getState().sshConnectionStates.get(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((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(connectionId)?.status !== - 'disconnected' && - useAppStore.getState().sshConnectionStates.get(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 = waitTeardowns.indexOf(teardown) - if (idx !== -1) { - waitTeardowns.splice(idx, 1) - } - resolve(resolvedOutcome) - } - const teardown = (): void => finish('cancelled') - // Why: register a teardown so dispose() can unsubscribe+resolve if the pane is torn down mid-wait. - // Else the zustand subscriber + async IIFE leak: the callback only checks `disposed` when it next fires, which may never happen. - waitTeardowns.push(teardown) - const unsub = useAppStore.subscribe((state) => { - if (disposed) { - finish('cancelled') - return - } - const status = state.sshConnectionStates.get(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 (disposed) { - finish('cancelled') - return - } - const currentStatus = useAppStore - .getState() - .sshConnectionStates.get(connectionId)?.status - const currentOutcome = sshPromptConnectOutcomeForStatus( - currentStatus, - sawNonDisconnected - ) - if (currentOutcome) { - finish(currentOutcome) - } - }) - if (disposed || !capturedDirectSshRetryLeaseMatches()) { - return - } - if (outcome === 'cancelled') { - return - } - if (outcome === 'failed') { - 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(connectionId) - if (disposed || !capturedDirectSshRetryLeaseMatches()) { - return - } - if (!connectResult.connected) { - reportError(`SSH connection failed: ${connectResult.error}`) - return - } - useAppStore.getState().removeDeferredSshReconnectTarget(connectionId) - if (disposed) { - return - } - if (pendingSessionId) { - if (isLegacyWorkerAutomaticResumeBlocked()) { - if (attachRetainedLegacyPty(pendingSessionId)) { - useAppStore.getState().removeDeferredSshSessionId(deps.tabId) - scheduleRuntimeGraphSync() - } - return - } - console.warn( - `[pty-connection] Attempting reattach for tab=${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(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 = - runtimeEnvironmentId || isRemoteRuntimePtyId(pendingSessionId) - ? Promise.resolve(null) - : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) - let expiredReattachError = false - const coldRestoreStartup = buildColdRestoreAgentResumeStartup() - clearPaneMode2031State() - clearHiddenOutputRestoreState() - const outputCallbacks = captureTransportOutputCallbacks((message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - if (!isCapturedDirectSshReattachCurrent(pendingSessionId)) { - return - } - reportError(message) - }) - beginReattachLiveDataDeferral(outputCallbacks.generation) - transportConnectInFlightSince = Date.now() - const reattachPromise = transport.connect({ - url: '', - cols, - rows, - sessionId: pendingSessionId, - ...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}), - ...(coldRestoreStartup?.env - ? { env: mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) } - : {}), - ...(coldRestoreStartup?.launchConfig - ? { launchConfig: coldRestoreStartup.launchConfig } - : {}), - ...(coldRestoreStartup?.resumeProviderSession - ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } - : {}), - ...(coldRestoreStartup?.launchToken - ? { launchToken: coldRestoreStartup.launchToken } - : {}), - ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), - ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - ...(directSshRetryAttempt ? { admitPtyId: claimCapturedDirectSshRetryPty } : {}), - callbacks: outputCallbacks.callbacks - }) - void Promise.resolve(reattachPromise) - .catch(() => null) - .finally(() => { - transportConnectInFlightSince = null - }) - const trackedReattachPromise = Promise.resolve(reattachPromise) - .then(async (result) => { - if (outputCallbacks.generation !== transportStreamGeneration) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return - } - console.warn( - `[pty-connection] Reattach result for tab=${deps.tabId}:`, - result - ? { - sessionExpired: (result as Record).sessionExpired, - replay: !!(result as Record).replay - } - : 'undefined' - ) - if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - if (disposed) { - return - } - if (rejectObsoleteDirectSshReattach(pendingSessionId)) { - return - } - deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) - deps.clearTabPtyId(deps.tabId, pendingSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return - } - const accepted = await handleReattachResult( - result, - pendingSessionId, - coldRestoreStartup, - outputCallbacks.generation - ) - finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - if (!accepted) { - await window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } else if (!isRemoteRuntimePtyId(pendingSessionId)) { - const settledPtyId = - result && typeof result === 'object' && 'id' in result - ? result.id - : (transport.getPtyId() ?? pendingSessionId) - const hasRestorePayload = - result && - typeof result === 'object' && - ('snapshot' in result || 'replay' in result || 'coldRestore' in result) - await (hasRestorePayload - ? settlePaneSerializerAfterReplay(settledPtyId, gen) - : window.api.pty.settlePaneSerializer(cacheKey, gen)) - } - } - }) - .catch(async (err) => { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - console.warn(`[pty-connection] Reattach FAILED for tab=${deps.tabId}:`, err) - if (disposed || outputCallbacks.generation !== transportStreamGeneration) { - return - } - if (rejectObsoleteDirectSshReattach(pendingSessionId)) { - return - } - if (isSshSessionExpiredError(err)) { - deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) - deps.clearTabPtyId(deps.tabId, pendingSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return - } - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - }) - armDirectSshPaneRetryTimeout(trackedReattachPromise, directSshRetryAttempt) - } else { - startFreshColdRestoreAgentResume() - } - })() - return - } - } - - // Why: re-read session IDs inside the rAF — cleanup during the one-frame gap could otherwise reattach a dead session. - const restoredPtyId = - deps.restoredLeafId && deps.restoredPtyIdByLeafId - ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) - : null - const storeSnapshot = useAppStore.getState() - const existingPtyId = storeSnapshot.tabsByWorktree[deps.worktreeId]?.find( - (t) => t.id === deps.tabId - )?.ptyId - const hasSleepingAgentSession = Boolean(getSleepingRecordForPane(storeSnapshot)) - - // Why: the tab-level fallback must not steal a PTY a setup sibling already published while the main pane waited for split geometry. - const tabFallbackPtyId = - existingPtyId && - !Array.from(deps.paneTransportsRef.current.entries()).some( - ([candidatePaneId, candidateTransport]) => - candidatePaneId !== pane.id && candidateTransport.getPtyId() === existingPtyId - ) - ? existingPtyId - : null - - const restoredSessionId = restoredPtyId ?? null - const sleptRemoteRuntimeSessionId = - restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && hasSleepingAgentSession - ? restoredSessionId - : null - const detachedLivePtyId = - tabFallbackPtyId && !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 = - runtimeEnvironmentId && - candidateReattachSessionId && - !isRemoteRuntimePtyId(candidateReattachSessionId) - ? candidateReattachSessionId - : null - const sleptRemoteColdRestoreStartup = sleptRemoteRuntimeSessionId - ? buildColdRestoreAgentResumeStartup() - : null - if (sleptRemoteRuntimeSessionId) { - deps.syncPanePtyLayoutBinding(pane.id, null) - deps.clearTabPtyId(deps.tabId, sleptRemoteRuntimeSessionId) - } - const currentTabLivePtyIds = storeSnapshot.ptyIdsByTabId[deps.tabId] ?? [] - const candidateHasEagerBuffer = Boolean( - candidateReattachSessionId && - !isRemoteRuntimePtyId(candidateReattachSessionId) && - getEagerPtyBufferHandle(candidateReattachSessionId) - ) - // Why: a still-live locally-spawned PTY (e.g. a background automation agent) keeps an eager buffer until a pane adopts it. - // It must be adopted via attach()+replay — connect({ sessionId }) on its non-session ptyId would spawn a fresh shell and orphan the agent. - const eagerLivePtyId = - candidateReattachSessionId && - candidateHasEagerBuffer && - currentTabLivePtyIds.includes(candidateReattachSessionId) - ? candidateReattachSessionId - : null - // Why: after a daemon crash + cold restore, a stale session-to-tab mapping can make a tab hold a ptyId from another worktree. - // Restoring it would paint the wrong terminal content, so drop the reattach and spawn fresh. - const legacyAttachOnlyPtyId = isLegacyWorkerAutomaticResumeBlocked() - ? candidateReattachSessionId - : null - const pairedParkedReattachSessionId = - mountFollowsTerminalPark && - candidateReattachSessionId && - isRemoteRuntimePtyId(candidateReattachSessionId) && - canRestorePairedParkedTerminal(candidateReattachSessionId) - ? candidateReattachSessionId - : null - const deferredReattachSessionId = legacyAttachOnlyPtyId - ? null - : (runtimeHostPtyWakeHint ?? - pairedParkedReattachSessionId ?? - (candidateReattachSessionId && - !isRemoteRuntimePtyId(candidateReattachSessionId) && - !candidateHasEagerBuffer && - isSessionOwnedByWorktree(candidateReattachSessionId, deps.worktreeId) - ? candidateReattachSessionId - : null)) - recordPtyConnectDiagnostic( - `pane=${pane.id} tab=${deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedRemoteLeafPtyId ?? detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${hadExistingPaneTransportAtConnect} pendingKey=${pendingSpawnKey}` - ) - - if (deferredReattachSessionId) { - allowInitialIdleCacheSeed = true - recordPtyConnectDiagnostic(`pane=${pane.id} -> REATTACH ${deferredReattachSessionId}`) - 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 = - runtimeEnvironmentId || isRemoteRuntimePtyId(deferredReattachSessionId) - ? Promise.resolve(null) - : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) - - let expiredReattachError = false - const coldRestoreStartup = buildColdRestoreAgentResumeStartup() - const outputCallbacks = captureTransportOutputCallbacks((message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - if (!isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) { - return - } - reportError(message) - }) - beginReattachLiveDataDeferral(outputCallbacks.generation) - transportConnectInFlightSince = Date.now() - const reattachPromise = transport.connect({ - url: '', - cols, - rows, - sessionId: deferredReattachSessionId, - ...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}), - ...(coldRestoreStartup?.env - ? { env: mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) } - : {}), - ...(coldRestoreStartup?.launchConfig - ? { launchConfig: coldRestoreStartup.launchConfig } - : {}), - ...(coldRestoreStartup?.resumeProviderSession - ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } - : {}), - ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), - ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), - ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - ...(directSshRetryAttempt ? { admitPtyId: claimCapturedDirectSshRetryPty } : {}), - callbacks: outputCallbacks.callbacks - }) - - void Promise.resolve(reattachPromise) - .catch(() => null) - .finally(() => { - transportConnectInFlightSince = null - }) - const trackedReattachPromise = Promise.resolve(reattachPromise) - .then(async (result) => { - if (outputCallbacks.generation !== transportStreamGeneration) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - return - } - if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - if (disposed) { - return - } - if (rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { - return - } - deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) - deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return - } - const accepted = await handleReattachResult( - result, - deferredReattachSessionId, - coldRestoreStartup, - outputCallbacks.generation - ) - finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - if (!accepted) { - await window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } else if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { - const settledPtyId = - result && typeof result === 'object' && 'id' in result - ? result.id - : (transport.getPtyId() ?? deferredReattachSessionId) - const hasRestorePayload = - result && - typeof result === 'object' && - ('snapshot' in result || 'replay' in result || 'coldRestore' in result) - await (hasRestorePayload - ? settlePaneSerializerAfterReplay(settledPtyId, gen) - : window.api.pty.settlePaneSerializer(cacheKey, gen)) - } - } - }) - .catch(async (err) => { - finishReattachLiveDataDeferral(false, outputCallbacks.generation) - const gen = await preSignalPromise - if (typeof gen === 'number') { - void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) - } - const message = err instanceof Error ? err.message : String(err) - if (outputCallbacks.generation !== transportStreamGeneration) { - return - } - if (rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { - return - } - warnTerminalLifecycleAnomaly('restored PTY reattach threw', { - tabId: deps.tabId, - worktreeId: deps.worktreeId, - leafId: deps.restoredLeafId ?? pane.leafId, - paneId: pane.id, - ptyId: deferredReattachSessionId, - reason: message - }) - deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) - deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) - if (connectionId && isSshSessionExpiredError(err)) { - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - return - } - reportError(message) - startFreshColdRestoreAgentResume(coldRestoreStartup, { - forceBlankRestoredViewport: true - }) - }) - armDirectSshPaneRetryTimeout(trackedReattachPromise, directSshRetryAttempt) - } 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=${pane.id} -> ATTACH detached=${attachPtyId}`) - allowInitialIdleCacheSeed = false - if (legacyAttachOnlyPtyId) { - if (attachRetainedLegacyPty(legacyAttachOnlyPtyId) && connectionId) { - useAppStore.getState().removeDeferredSshSessionId(deps.tabId) - } - } else { - // Why: surface synchronous attach failures via reportError so the pane shows a diagnostic instead of a blank surface. - // On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here. - try { - clearPaneMode2031State() - clearHiddenOutputRestoreState() - const outputCallbacks = captureTransportOutputCallbacks(reportError) - transport.attach({ - existingPtyId: attachPtyId, - cols, - rows, - callbacks: outputCallbacks.callbacks - }) - const attachedPtyId = transport.getPtyId() ?? attachPtyId - bindActivePanePty(attachedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { - registerPaneSerializerFor(attachedPtyId) - } - } catch (err) { - reportError(err instanceof Error ? err.message : String(err)) - deps.clearTabPtyId(deps.tabId, attachPtyId) - startFreshSpawn() - } - } - } else { - allowInitialIdleCacheSeed = false - const pendingSpawn = pendingSpawnByPaneKey.get(pendingSpawnKey) - if (pendingSpawn) { - recordPtyConnectDiagnostic(`pane=${pane.id} -> PENDING SPAWN`) - armDirectSshPaneRetryTimeout(pendingSpawn, directSshRetryAttempt) - void pendingSpawn - .then((spawnedPtyId) => { - if (disposed) { - return - } - if (transport.getPtyId()) { - return - } - if (!spawnedPtyId) { - // Why: React StrictMode can mount+spawn then immediately remount; if the first mount produced no PTY id, - // the remounted pane must issue its own spawn instead of attaching to a completed-but-empty promise (a dead surface). - if (!isWebTerminalSurfaceTabId(deps.tabId)) { - console.warn( - `Pending PTY spawn for tab ${deps.tabId} resolved without a PTY id, retrying fresh spawn` - ) - } - if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) { - startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined) - } else { - startFreshSpawn() - } - return - } - if (!canAdoptCapturedDirectSshRetryPty(spawnedPtyId)) { - return - } - clearPaneMode2031State() - clearHiddenOutputRestoreState() - const outputCallbacks = captureTransportOutputCallbacks(reportError) - transport.attach({ - existingPtyId: spawnedPtyId, - cols, - rows, - callbacks: outputCallbacks.callbacks - }) - const attachedPtyId = transport.getPtyId() ?? spawnedPtyId - // Why: this reuses a PTY spawned by an earlier mount, so no later spawn event will bind this remounted pane's DOM/container. - bindActivePanePty(attachedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - }) - .catch((err) => { - reportError(err instanceof Error ? err.message : String(err)) - }) - } else { - recordPtyConnectDiagnostic(`pane=${pane.id} -> FRESH SPAWN`) - if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) { - startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined) - } else { - startFreshSpawn() - } - } - } - scheduleRuntimeGraphSync() - } - - // Why: Wayland/CI compositors can starve rAF while timers/CDP stay responsive; the terminal must still start its PTY once. - connectFallbackTimer = setTimeout(runDeferredConnect, 250) - connectFrame = requestAnimationFrame(runDeferredConnect) - - // Why: on visibility resume a pane may still be bound to a session reaped while hidden (missed-exit defect); route through onExit's teardown, - // re-validating identity at apply time so a reattach racing the listSessions snapshot isn't clobbered. - const reconcileIfSessionDead = ( - liveSessionIds: Set, - snapshotRequestedAt?: number - ): void => { - if (disposed) { - return - } - const currentPtyId = transport.getPtyId() - if ( - !currentPtyId || - // Why: this exit was already handled — onExit guards it too, but skipping here avoids a redundant shouldReconcile evaluation. - handledExitPtyId === currentPtyId || - !shouldReconcileDeadSession({ - ptyId: currentPtyId, - connectionId: transport.getConnectionId?.(), - liveSessionIds, - ptyBoundAt: activePanePtyBindingBoundAt, - snapshotRequestedAt - }) - ) { - return - } - onExit(currentPtyId) - } - - const reconcileIfSessionMissing = ( - hasPty: HasPty, - livenessRequestedAt = performance.now() - ): void => { - const requestedPtyId = transport.getPtyId() - if ( - !requestedPtyId || - requestedPtyId === handledExitPtyId || - requestedPtyId.startsWith(REMOTE_PTY_ID_PREFIX) || - transport.getConnectionId?.() != null - ) { - return - } - - let livenessPromise: Promise - try { - livenessPromise = Promise.resolve(hasPty(requestedPtyId)) - } catch { - return - } - - void livenessPromise - .then((isLive) => { - if (disposed) { - return - } - const currentPtyId = transport.getPtyId() - if ( - !currentPtyId || - currentPtyId !== requestedPtyId || - handledExitPtyId === currentPtyId || - !shouldReconcileMissingSession({ - ptyId: currentPtyId, - connectionId: transport.getConnectionId?.(), - isLive, - ptyBoundAt: activePanePtyBindingBoundAt, - livenessRequestedAt - }) - ) { - return - } - onExit(currentPtyId) - }) - .catch(() => {}) - } - - return { - syncProcessTracking() { - agentCompletionCoordinator.startProcessTracking() - // Why: the hidden-delivery gate must follow every pane visibility flip. - syncHiddenRendererPtyDelivery() - if (!deps.isVisibleRef.current) { - pendingVisibleRemoteViewportClaim = false - } - }, - // Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback. - noteVisibilityResume() { - armVisibleRemoteViewportClaim() - claimPendingVisibleRemoteViewport() - ptySizeReassertion.request({ fit: false }) - consumeHibernatedAgentWake() - requestKnownWindowsShiftEnterReconfirmation() - sampleVisiblePaneForegroundAgent() - }, - reassertPtySizeAfterWindowWake() { - armVisibleRemoteViewportClaim() - claimPendingVisibleRemoteViewport() - 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 (hibernatedWakeInFlightClaimKey) { - if (claimedProviderSessions?.has(hibernatedWakeInFlightClaimKey)) { - return null - } - claimedProviderSessions?.add(hibernatedWakeInFlightClaimKey) - return hibernatedWakeInFlightClaimKey - } - const consumedClaimKey = 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 = getSleepingRecordForPane(state) - const currentPtyId = transport.getPtyId() - if ( - recordEntry && - isPassiveCompletedHibernationEvidence(recordEntry.record) && - currentPtyId !== null && - state.suppressedPtyExitIds[currentPtyId] === true && - !disposed && - hibernatedWakeTarget === null && - deps.paneTransportsRef.current.get(pane.id) === transport && - transport.getPtyId() === currentPtyId - ) { - const claimKey = getProviderSessionClaimKey(recordEntry.record) - if (claimedProviderSessions?.has(claimKey)) { - return null - } - claimedProviderSessions?.add(claimKey) - pendingHibernatedWakeTarget = { ptyId: currentPtyId, record: recordEntry.record } - return claimKey - } - return null - }, - sampleForegroundAgentOnFocus() { - requestKnownWindowsShiftEnterReconfirmation() - sampleVisiblePaneForegroundAgent() - }, - requestWindowsShiftEnterReconfirmation() { - if (shiftEnterReconfirmTimer !== null) { - clearTimeout(shiftEnterReconfirmTimer) - } - // Why: confirm the composer only after the Shift+Enter burst goes idle, preserving rapid multiline input. - shiftEnterReconfirmTimer = setTimeout(() => { - shiftEnterReconfirmTimer = null - requestKnownWindowsShiftEnterReconfirmation() - sampleVisiblePaneForegroundAgent() - }, SHIFT_ENTER_RECONFIRM_IDLE_MS) - }, - markShortcutTerminalInputSent() { - markInteractiveRedrawInput() - }, - reconcileIfSessionDead, - reconcileIfSessionMissing, - dispose() { - 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. - releaseRendererOwnedAgentStatusPane?.() - directSshPaneRetrySettlementCancelled = true - for (const timer of directSshPaneRetrySettlementTimers) { - clearTimeout(timer) - } - 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 = deferredReattachLiveData - deferredReattachLiveData = null - queue?.discard() - reattachLiveDataDeferralDepth = 0 - deferredReattachLiveDataOwners = new Map() - cancelPendingSafeFitContinuations(pane) - pendingHiddenSnapshotFit = null - 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. - terminalRecoveryInstance.unregister() - unregisterUndeliverableWriteHandler() - unsubscribeRemoteDesktopActivationClaim() - cancelHiddenOutputSnapshotScrollRestore() - liveScrollbackRestore?.dispose() - liveScrollbackRestore = null - structuralReplayCoordinator.dispose() - cancelFreshSpawnFollowReset() - // Why: cancel the post-spawn reconcile's pending rAF so a torn-down pane can't keep fitting/resizing after disposal. - ptySizeReconcileHandle?.cancel() - ptySizeReconcileHandle = null - startupGridSettleHandle?.cancel() - startupGridSettleHandle = null - ptySizeReassertion.dispose() - if (pendingForegroundGridDriftCheckRaf !== null) { - cancelAnimationFrame(pendingForegroundGridDriftCheckRaf) - pendingForegroundGridDriftCheckRaf = null - } - // Why: a pane unmount must never leave its PTY delivery gated — the parked watcher or remounted pane re-decides. - releaseHiddenRendererPtyDelivery() - if (terminalKeyTargetSupportsEvents) { - terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true }) - } - clearPendingTerminalInputIntent() - pendingTerminalInputWrite = null - interruptInference.dispose() - 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. - releaseCommandCodeDoneSettleExecutor() - if (shiftEnterReconfirmTimer !== null) { - clearTimeout(shiftEnterReconfirmTimer) - 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 (waitTeardowns.length > 0) { - const teardown = waitTeardowns.pop() - teardown?.() - } - if (startupInjectTimer !== null) { - clearTimeout(startupInjectTimer) - startupInjectTimer = null - } - cleanupStartupDraftPasteTimers() - releaseUnattemptedStartupDraftPasteDelivery() - unregisterAgentHookTerminalLifecycle() - clearSuppressedTitleSideEffects() - clearPendingAgentTaskCompleteNotification() - pendingTerminalBellNotification = false - clearTerminalBellNotificationTimer() - clearReattachIdleAgentCursorResetTimer() - if (alternateScreenBackgroundRepaintTimer !== null) { - clearTimeout(alternateScreenBackgroundRepaintTimer) - alternateScreenBackgroundRepaintTimer = null - } - cleanupHiddenOutputRestoreDeferredRetry() - cleanupHiddenOutputRestoreForegroundDeadline() - cleanupHiddenOutputRestoreFloodRepaint() - unregisterBacklogRecovery?.() - unregisterBacklogRecovery = null - unregisterDocumentVisibilityRecovery?.() - unregisterDocumentVisibilityRecovery = null - releaseRendererPtyVisibilityClaim(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. - dropSideEffectFactConsumer() - clearPanePtyFitBinding() - discardTerminalOutput(pane.terminal) - unregisterE2ePtyDataInjection() - if (agentTaskCompleteSettingsUnsubscribe !== null) { - agentTaskCompleteSettingsUnsubscribe() - agentTaskCompleteSettingsUnsubscribe = null - } - if (unsubscribeWindowsDoneTerminalModeReset !== null) { - unsubscribeWindowsDoneTerminalModeReset() - unsubscribeWindowsDoneTerminalModeReset = null - } - if (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. - cancelScheduledConnectFrame() - } - if (connectFallbackTimer !== null) { - clearTimeout(connectFallbackTimer) - connectFallbackTimer = null - } - imeCompositionRouteDisposable.dispose() - onDataDisposable.dispose() - userInputActivityDisposable?.dispose() - terminalCapabilityRepliesDisposable.dispose() - onResizeDisposable.dispose() - pane.container.removeEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) - geometryReportObserver?.disconnect() - if (pendingGeometryReportRaf !== null) { - cancelAnimationFrame(pendingGeometryReportRaf) - pendingGeometryReportRaf = null - } - commandLifecycle.dispose() - deferredCommandFinishedStatusDrop = null - visibleForegroundSamplePending = false - visibleForegroundSampleSettled = false - paneForegroundAgentTracker.dispose() - agentCompletionCoordinator.dispose() - } - } -} +export { connectPanePty, STARTUP_CWD_FALLBACK_NOTICE } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-done-started-at.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-done-started-at.ts new file mode 100644 index 00000000000..db5e0df0e93 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-done-started-at.ts @@ -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 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts new file mode 100644 index 00000000000..547816079ab --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts @@ -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>() + session.directSshPaneRetryTimedPromises = new WeakSet() + 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 + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts new file mode 100644 index 00000000000..3f8e8088509 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts @@ -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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-settings.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-settings.ts new file mode 100644 index 00000000000..95d493a23a6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-settings.ts @@ -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 +): 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 + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts new file mode 100644 index 00000000000..a62855f6fa7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -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 => { + 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 => { + 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') + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/codex-pane-stale.ts b/src/renderer/src/components/terminal-pane/pty-connection/codex-pane-stale.ts new file mode 100644 index 00000000000..a19bd0f00b2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/codex-pane-stale.ts @@ -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 +): 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 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts new file mode 100644 index 00000000000..e328bed1bc9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts @@ -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 + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts b/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts new file mode 100644 index 00000000000..19dc5b94420 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts @@ -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 + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts new file mode 100644 index 00000000000..c6a25e0d461 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts @@ -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 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts new file mode 100644 index 00000000000..40412667689 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cursor-agent-reattach-screen.ts b/src/renderer/src/components/terminal-pane/pty-connection/cursor-agent-reattach-screen.ts new file mode 100644 index 00000000000..525d20ea602 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/cursor-agent-reattach-screen.ts @@ -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} `) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts new file mode 100644 index 00000000000..bf9f3f49061 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -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 | undefined + ): Record | 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 => { + 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 => { + 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 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts new file mode 100644 index 00000000000..76bd8714cb9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts @@ -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((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).sessionExpired, + replay: !!(result as Record).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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts new file mode 100644 index 00000000000..3e03d02f440 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts @@ -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() +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts new file mode 100644 index 00000000000..91eb9b7dfb0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-lease.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-lease.ts new file mode 100644 index 00000000000..cf183e24796 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-lease.ts @@ -0,0 +1,6 @@ +import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery' + +export type DirectSshRetryLease = Pick< + DirectSshPaneRetryAttempt, + 'attemptId' | 'authority' | 'tabGeneration' +> diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts new file mode 100644 index 00000000000..cd397227bcc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts @@ -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, + 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() + 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() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/e2e-terminal-pty-harness.ts b/src/renderer/src/components/terminal-pane/pty-connection/e2e-terminal-pty-harness.ts new file mode 100644 index 00000000000..8939a63b6c1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/e2e-terminal-pty-harness.ts @@ -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 void>() + +type E2eTerminalHiddenSnapshotOverrideApi = { + setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void + resolve: (ptyId: string) => void + clear: (ptyId: string) => void +} + +type E2eTerminalHiddenSnapshotOverride = { + promise: Promise + resolve: () => void +} + +const e2eTerminalHiddenSnapshotOverrides = new Map() + +// 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((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 | 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) + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.ts new file mode 100644 index 00000000000..a7704bd5f94 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts new file mode 100644 index 00000000000..51e8c564c96 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts @@ -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. +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts new file mode 100644 index 00000000000..e1c0b334ff2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts @@ -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 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts new file mode 100644 index 00000000000..4c8ce6902c7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts @@ -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 => { + // 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() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts new file mode 100644 index 00000000000..2672290c1d0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -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 => { + 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 = 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 + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts new file mode 100644 index 00000000000..4bb06f8dbde --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts @@ -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 +} + +export type FreshSpawnOptions = { + forceBlankRestoredViewport?: boolean +} + +export type ColdRestoreAgentResumeStartup = PendingStartupCommand & { + agent: ResumableTuiAgent + resumeProviderSession: AgentProviderSessionMetadata + launchConfig: NonNullable>['launchConfig'] + launchToken: string + useLiveEntry: boolean + hasSleepingRecord: boolean + sleepingRecordEntry: { paneKey: string; record: SleepingAgentSessionRecord } | null +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts new file mode 100644 index 00000000000..2306e56cf08 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts @@ -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) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts new file mode 100644 index 00000000000..dd1c127dbee --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-chunk.ts @@ -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. + */ +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts new file mode 100644 index 00000000000..41a00e4960a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts @@ -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 + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts new file mode 100644 index 00000000000..80ae6078f20 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-limits.ts @@ -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' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts new file mode 100644 index 00000000000..934e27562b7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts @@ -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 + 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() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts new file mode 100644 index 00000000000..d74b38319e4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -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 { + 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 + } + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-seq-and-skip.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-seq-and-skip.ts new file mode 100644 index 00000000000..8d0bed4d294 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-seq-and-skip.ts @@ -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): 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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts new file mode 100644 index 00000000000..2cf8604c24d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-snapshot-serialize.ts @@ -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 { + 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. +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state-and-ssh-probe.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state-and-ssh-probe.ts new file mode 100644 index 00000000000..55bc3948850 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-restore-state-and-ssh-probe.ts @@ -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 + } | null = null + + const createSshMainModelSnapshotProbe = ( + ptyId: string + ): (() => Promise) => + memoizeSshReattachModelSnapshotProbe(async (): Promise => { + 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) => { + if (parkedSshSnapshotPrefetch?.ptyId !== ptyId) { + parkedSshSnapshotPrefetch = { ptyId, fetch: createSshMainModelSnapshotProbe(ptyId) } + } + return parkedSshSnapshotPrefetch.fetch + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query-write.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query-write.ts new file mode 100644 index 00000000000..45abb5c34cb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query-write.ts @@ -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 + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts new file mode 100644 index 00000000000..c48730296c8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts @@ -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;?') +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts b/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts new file mode 100644 index 00000000000..b11b289f1cc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts @@ -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): void => { + session.pendingTerminalInputWrite = promise + void promise.finally(() => { + if (session.pendingTerminalInputWrite === promise) { + session.pendingTerminalInputWrite = null + } + }) + } + session.flushPendingInterruptInference = (): boolean | Promise => { + const pendingWrite = session.pendingTerminalInputWrite + if (!pendingWrite) { + return session.interruptInference.flushPending() + } + return pendingWrite.then((immediateResult) => { + return immediateResult ?? session.interruptInference.flushPending() + }) + } + installPaneAgentIdentity(session) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts b/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts new file mode 100644 index 00000000000..a5c26d98d2d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/live-data-callback.ts @@ -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) + } + } + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/paired-parked-terminal-restore.ts b/src/renderer/src/components/terminal-pane/pty-connection/paired-parked-terminal-restore.ts new file mode 100644 index 00000000000..b010078b104 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/paired-parked-terminal-restore.ts @@ -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 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts new file mode 100644 index 00000000000..5b7ee57d6fd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -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): boolean => { + const entry = state.agentStatusByPaneKey[session.cacheKey] + return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType)) + } + session.paneExpectsLaunchAgent = (state: ReturnType): 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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts new file mode 100644 index 00000000000..96d93de3533 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts @@ -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 | 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, snapshotRequestedAt?: number) => void + reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts new file mode 100644 index 00000000000..4448d94ab56 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts @@ -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 + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts new file mode 100644 index 00000000000..8c9cf157174 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts @@ -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() +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts new file mode 100644 index 00000000000..13510d5cf0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -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 => { + 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 | null = null + let startupDraftHardTimer: ReturnType | 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 => { + 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) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts b/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts new file mode 100644 index 00000000000..086b894ebff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pending-hidden-output-restore-chunk.ts @@ -0,0 +1,5 @@ +export type PendingHiddenOutputRestoreChunk = { + data: string + seq?: number + rawLength?: number +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts new file mode 100644 index 00000000000..f6c8a1ce54d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-connect-limits.ts @@ -0,0 +1,23 @@ +import { e2eConfig } from '@/lib/e2e-config' + +export const pendingSpawnByPaneKey = new Map>() +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 + 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) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts new file mode 100644 index 00000000000..0a6c6812fea --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts @@ -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 | 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 + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts new file mode 100644 index 00000000000..a589d89c6fd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -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 | 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).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 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts new file mode 100644 index 00000000000..a861644c060 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -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 + }) + } + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts new file mode 100644 index 00000000000..5bd13a99ec5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts @@ -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 + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-live-data-deferral.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-live-data-deferral.ts new file mode 100644 index 00000000000..2974e80f5c3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-live-data-deferral.ts @@ -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) + }) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts new file mode 100644 index 00000000000..e7b1579b909 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts @@ -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 + hasStructuralReplay: boolean + coldRestoreStartup?: ColdRestoreAgentResumeStartup | null + reattachPayloadApplied: boolean +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts new file mode 100644 index 00000000000..edd9ecb61fc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -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 => { + 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 + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts new file mode 100644 index 00000000000..673d1d43edc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -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 => { + 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) + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts b/src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts new file mode 100644 index 00000000000..f21162bf809 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts @@ -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 + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts new file mode 100644 index 00000000000..d0d98ad529e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts new file mode 100644 index 00000000000..6bf8f170d96 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts @@ -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, + 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 + 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() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts b/src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts new file mode 100644 index 00000000000..fc65e447b22 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts @@ -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) + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts new file mode 100644 index 00000000000..a45f2769d5c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts @@ -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() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts new file mode 100644 index 00000000000..5e85b6a0566 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -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 + ): { 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, + 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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts new file mode 100644 index 00000000000..4e197725fd5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/ssh-session-connect.ts @@ -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>() + +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 { + 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 = (async (): Promise => { + 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 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts b/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts new file mode 100644 index 00000000000..25a40a66c9b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts @@ -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(() => {}) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts b/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts new file mode 100644 index 00000000000..649aabb8b39 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/startup-cwd-fallback-notice.ts @@ -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' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts new file mode 100644 index 00000000000..de039c3e6be --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -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 } : {}) + }) + ) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts new file mode 100644 index 00000000000..d76c8cc2e70 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts @@ -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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts new file mode 100644 index 00000000000..999bd005bfa --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts @@ -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 +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts new file mode 100644 index 00000000000..1fe06737be2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts @@ -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) + ) + } +}