mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
refactor: split pty-connection.ts under 400 lines
This commit is contained in:
@@ -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`
|
||||
@@ -128,7 +128,6 @@ inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
|
||||
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
import type { AgentStatusEntry } from '../../../../../shared/agent-status-types'
|
||||
|
||||
/** Start time of the newest completed turn, counting turns already folded into history.
|
||||
* Why: batched publications coalesce done→working→done into a single store notification,
|
||||
* so the done EDGE survives only in stateHistory; comparing `state` alone misses it. */
|
||||
export function resolveLatestAgentDoneStartedAt(
|
||||
entry: AgentStatusEntry | undefined
|
||||
): number | undefined {
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
if (entry.state === 'done') {
|
||||
return entry.stateStartedAt
|
||||
}
|
||||
const history = entry.stateHistory ?? []
|
||||
for (let index = history.length - 1; index >= 0; index -= 1) {
|
||||
if (history[index].state === 'done') {
|
||||
return history[index].startedAt
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { parseWorkspaceKey } from '../../../../../shared/workspace-scope'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants'
|
||||
import { isEphemeralSetupTerminalWorktreeId } from '../../../../../shared/ephemeral-setup-terminal-worktree-id'
|
||||
import { parseExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
|
||||
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
|
||||
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
|
||||
import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery'
|
||||
import { directSshAuthoritiesEqual } from '@/store/slices/direct-ssh-terminal-authority-ledger'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): void {
|
||||
session.onAgentBecameWorking = (): void => {
|
||||
session.suppressNativeWindowsIdleCodexFocusReports = false
|
||||
session.clearSuppressedTitleSideEffects()
|
||||
if (session.syncAgentTaskCompleteTrackingEnabled()) {
|
||||
session.requiresFreshWorkingForAgentTaskCompleteNotification = false
|
||||
session.agentCompletionCoordinator.observeTitleWorking()
|
||||
}
|
||||
// Why: a new API call refreshes the prompt-cache TTL, so clear any running
|
||||
// countdown. The timer will restart when the agent becomes idle again.
|
||||
session.deps.setCacheTimerStartedAt(session.cacheKey, null)
|
||||
session.clearPendingAgentTaskCompleteNotification()
|
||||
if (session.pendingTerminalBellNotification) {
|
||||
session.scheduleTerminalBellNotification()
|
||||
}
|
||||
}
|
||||
session.onAgentExited = (): void => {
|
||||
// Why: eligibility can disappear transiently during reconnect, but a
|
||||
// confirmed shell-title transition is authoritative for native-chat exit.
|
||||
session.deps.onAgentExitedRef.current(session.pane.leafId)
|
||||
session.clearSuppressedTitleSideEffects()
|
||||
session.clearCommandInferredPaneAgent()
|
||||
session.requestKnownWindowsShiftEnterReconfirmation()
|
||||
// Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"),
|
||||
// the agent has exited. Clear any running cache timer so the sidebar doesn't
|
||||
// show a stale countdown for a tab that no longer has an active Claude session.
|
||||
session.deps.setCacheTimerStartedAt(session.cacheKey, null)
|
||||
session.clearTitleOnlyInterruptTimer()
|
||||
// Why: title reversion alone is not process death. The process/PTY tracker
|
||||
// owns removing agent rows when the TUI actually exits.
|
||||
}
|
||||
// Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their
|
||||
// callbacks to the correct Orca pane without resolving worktrees from cwd.
|
||||
// The key matches the `${tabId}:${leafId}` composite used for cacheTimerByKey
|
||||
// and agentStatusByPaneKey. Treat it as opaque outside Orca.
|
||||
session.state = useAppStore.getState()
|
||||
session.parsedWorkspaceKey = parseWorkspaceKey(session.deps.worktreeId)
|
||||
session.folderWorkspace =
|
||||
session.parsedWorkspaceKey?.type === 'folder'
|
||||
? session.state.folderWorkspaces.find(
|
||||
(workspace) => workspace.id === session.parsedWorkspaceKey.folderWorkspaceId
|
||||
)
|
||||
: null
|
||||
session.workspaceEnv = { ORCA_WORKSPACE_ID: session.deps.worktreeId }
|
||||
if (session.folderWorkspace) {
|
||||
session.workspaceEnv.ORCA_PROJECT_GROUP_ID = session.folderWorkspace.projectGroupId
|
||||
session.workspaceEnv.ORCA_WORKSPACE_ROOT = session.folderWorkspace.folderPath
|
||||
}
|
||||
session.paneIdentityEnv = {
|
||||
...session.workspaceEnv,
|
||||
ORCA_PANE_KEY: session.cacheKey,
|
||||
ORCA_TAB_ID: session.deps.tabId,
|
||||
ORCA_WORKTREE_ID: session.deps.worktreeId,
|
||||
...(session.launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: session.launchToken } : {})
|
||||
}
|
||||
session.paneEnv = {
|
||||
...session.paneStartup?.env,
|
||||
...session.paneIdentityEnv
|
||||
}
|
||||
|
||||
// Why: folder workspaces can inherit their SSH target from child repos, so
|
||||
// use the shared resolver instead of only looking up repo-backed worktrees.
|
||||
session.worktree = getWorktreeMapFromState(session.state).get(session.deps.worktreeId)
|
||||
session.worktreeConnectionId = getConnectionId(session.deps.worktreeId)
|
||||
session.tab = (session.state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(t) => t.id === session.deps.tabId
|
||||
)
|
||||
session.restoredPtyIdForTransport =
|
||||
session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId
|
||||
? (session.deps.restoredPtyIdByLeafId[session.deps.restoredLeafId] ?? null)
|
||||
: null
|
||||
// Why: the floating terminal and inline setup/onboarding terminals are host-agnostic synthetic
|
||||
// ids with no worktree/repo row, so the strict owner resolver reports them as unresolved. The
|
||||
// shared terminal router scopes them to their floating owner (local for the floating terminal,
|
||||
// the active runtime for setup terminals so remote skill installs land there) and returns null
|
||||
// only for a genuinely unknown/stale worktree that must fail closed (#9994).
|
||||
session.terminalWorktreeRoute = resolveTerminalWorktreeRoute(
|
||||
session.state,
|
||||
session.deps.worktreeId
|
||||
)
|
||||
session.explicitRuntimeEnvironmentId = session.terminalWorktreeRoute?.runtimeEnvironmentId ?? null
|
||||
// Why: paired-web worktrees retain HUB execution identity; their runtime-scoped mirrored pane is the session-level transport owner.
|
||||
session.mirroredRuntimeOwners = new Set(
|
||||
isWebTerminalSurfaceTabId(session.deps.tabId)
|
||||
? [session.restoredPtyIdForTransport, session.tab?.ptyId]
|
||||
.map((ptyId) => (ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null))
|
||||
.filter((environmentId): environmentId is string => Boolean(environmentId))
|
||||
: []
|
||||
)
|
||||
session.mirroredRuntimeEnvironmentId = session.mirroredRuntimeOwners.values().next().value ?? null
|
||||
session.terminalOwnerUnresolved =
|
||||
session.mirroredRuntimeOwners.size > 1 ||
|
||||
(session.terminalWorktreeRoute === null && !session.mirroredRuntimeEnvironmentId)
|
||||
session.runtimeEnvironmentId = session.explicitRuntimeEnvironmentId
|
||||
? session.explicitRuntimeEnvironmentId
|
||||
: session.mirroredRuntimeEnvironmentId
|
||||
? session.mirroredRuntimeEnvironmentId
|
||||
: null
|
||||
// Why: host-agnostic synthetic ids (floating terminal, inline setup panels) have no repo
|
||||
// row by design, and a worktree row stamped 'local' proves its host on its own — both are
|
||||
// resolved-local, not pending hydration (#10151). Only when nothing names the host does
|
||||
// `undefined` mean the repo hasn't merged yet; coalescing that to null would fail-open a
|
||||
// remote cwd onto the local daemon (ENOENT on Docker SSH paths).
|
||||
session.hostAgnosticTerminalWorktree =
|
||||
session.deps.worktreeId === FLOATING_TERMINAL_WORKTREE_ID ||
|
||||
isEphemeralSetupTerminalWorktreeId(session.deps.worktreeId)
|
||||
session.worktreeProvesLocalHost = parseExecutionHostId(session.worktree?.hostId)?.kind === 'local'
|
||||
session.connectionOwnerHydrating =
|
||||
!session.terminalOwnerUnresolved &&
|
||||
!session.hostAgnosticTerminalWorktree &&
|
||||
!session.worktreeProvesLocalHost &&
|
||||
session.runtimeEnvironmentId === null &&
|
||||
session.worktreeConnectionId === undefined
|
||||
// Why: an SSH host nested under a HUB is execution identity, not permission for the paired client to dial that host.
|
||||
session.connectionId =
|
||||
!session.terminalOwnerUnresolved &&
|
||||
!session.connectionOwnerHydrating &&
|
||||
session.runtimeEnvironmentId === null
|
||||
? (session.worktreeConnectionId ?? null)
|
||||
: null
|
||||
type DirectSshRetryLease = Pick<
|
||||
DirectSshPaneRetryAttempt,
|
||||
'attemptId' | 'authority' | 'tabGeneration'
|
||||
>
|
||||
session.directSshRetryAttempt = (() => {
|
||||
const pendingAttempt = session.state.directSshPaneRetryByTabId?.[session.deps.tabId]
|
||||
const liveBinding = session.state.directSshLivePtyBindingByTabId?.[session.deps.tabId]
|
||||
const attempt =
|
||||
pendingAttempt?.authority.targetId === session.connectionId &&
|
||||
pendingAttempt.tabGeneration === (session.tab?.generation ?? 0)
|
||||
? pendingAttempt
|
||||
: liveBinding?.authority.targetId === session.connectionId &&
|
||||
liveBinding.tabGeneration === (session.tab?.generation ?? 0)
|
||||
? liveBinding
|
||||
: undefined
|
||||
return attempt
|
||||
})()
|
||||
session.pendingSpawnKey = session.directSshRetryAttempt
|
||||
? JSON.stringify([session.cacheKey, session.directSshRetryAttempt.attemptId])
|
||||
: session.cacheKey
|
||||
session.capturedDirectSshRetryPtyAccepted = false
|
||||
session.directSshPaneRetrySettlementCancelled = false
|
||||
session.directSshPaneRetrySettlementTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||
session.directSshPaneRetryTimedPromises = new WeakSet<object>()
|
||||
session.capturedDirectSshRetryLeaseMatches = (): boolean => {
|
||||
if (!session.directSshRetryAttempt) {
|
||||
return true
|
||||
}
|
||||
const currentState = useAppStore.getState()
|
||||
const currentConnection = currentState.sshConnectionStates.get(
|
||||
session.directSshRetryAttempt.authority.targetId
|
||||
)
|
||||
const currentTab = (currentState.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === session.deps.tabId
|
||||
)
|
||||
if (
|
||||
currentConnection?.providerEpoch !== session.directSshRetryAttempt.authority.providerEpoch ||
|
||||
currentConnection?.connectionGeneration !==
|
||||
session.directSshRetryAttempt.authority.connectionGeneration ||
|
||||
(currentTab?.generation ?? 0) !== session.directSshRetryAttempt.tabGeneration
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const pendingAttempt = currentState.directSshPaneRetryByTabId?.[session.deps.tabId]
|
||||
const pendingMatches =
|
||||
pendingAttempt?.attemptId === session.directSshRetryAttempt.attemptId &&
|
||||
directSshAuthoritiesEqual(
|
||||
pendingAttempt.authority,
|
||||
session.directSshRetryAttempt.authority
|
||||
) &&
|
||||
pendingAttempt.tabGeneration === session.directSshRetryAttempt.tabGeneration
|
||||
const liveBinding = currentState.directSshLivePtyBindingByTabId?.[session.deps.tabId]
|
||||
const liveBindingMatchesAttempt =
|
||||
liveBinding?.attemptId === session.directSshRetryAttempt.attemptId &&
|
||||
directSshAuthoritiesEqual(liveBinding.authority, session.directSshRetryAttempt.authority) &&
|
||||
liveBinding.tabGeneration === session.directSshRetryAttempt.tabGeneration
|
||||
return pendingMatches || liveBindingMatchesAttempt
|
||||
}
|
||||
session.capturedDirectSshRetryStateMatches = (ptyId: string): boolean => {
|
||||
if (!session.directSshRetryAttempt) {
|
||||
return true
|
||||
}
|
||||
const currentConnection = useAppStore
|
||||
.getState()
|
||||
.sshConnectionStates.get(session.directSshRetryAttempt.authority.targetId)
|
||||
return (
|
||||
parseAppSshPtyId(ptyId)?.connectionId === session.directSshRetryAttempt.authority.targetId &&
|
||||
currentConnection?.status === 'connected' &&
|
||||
session.capturedDirectSshRetryLeaseMatches()
|
||||
)
|
||||
}
|
||||
session.claimCapturedDirectSshRetryPty = (ptyId: string): boolean => {
|
||||
if (!session.capturedDirectSshRetryStateMatches(ptyId)) {
|
||||
return false
|
||||
}
|
||||
session.capturedDirectSshRetryPtyAccepted = session.directSshRetryAttempt !== undefined
|
||||
return true
|
||||
}
|
||||
session.canAdoptCapturedDirectSshRetryPty = (ptyId: string): boolean => {
|
||||
const canAdopt = session.capturedDirectSshRetryStateMatches(ptyId)
|
||||
if (canAdopt && session.directSshRetryAttempt) {
|
||||
session.capturedDirectSshRetryPtyAccepted = true
|
||||
}
|
||||
return canAdopt
|
||||
}
|
||||
session.settleDirectSshPaneRetryAttempt = (
|
||||
attempt: DirectSshRetryLease | undefined,
|
||||
status: 'failed' | 'timed-out'
|
||||
): void => {
|
||||
if (!attempt) {
|
||||
return
|
||||
}
|
||||
useAppStore.getState().settleDirectSshPaneRetry?.({
|
||||
status,
|
||||
tabId: session.deps.tabId,
|
||||
attemptId: attempt.attemptId,
|
||||
authority: attempt.authority,
|
||||
tabGeneration: attempt.tabGeneration
|
||||
})
|
||||
}
|
||||
}
|
||||
+199
@@ -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)
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
isAgentTaskCompleteOsNotificationEnabledFromState,
|
||||
isAgentTaskCompleteTrackingEnabledFromState
|
||||
} from '../agent-task-complete-policy'
|
||||
|
||||
export function isAgentTaskCompleteNotificationEnabled(): boolean {
|
||||
return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState())
|
||||
}
|
||||
|
||||
export function isAgentTaskCompleteTrackingEnabled(): boolean {
|
||||
return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState())
|
||||
}
|
||||
|
||||
const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>()
|
||||
let agentTaskCompleteTrackingSettingsUnsubscribe: (() => void) | null = null
|
||||
let agentTaskCompleteTrackingSettingsSnapshot: string | null = null
|
||||
|
||||
function getAgentTaskCompleteTrackingSettingsSnapshot(
|
||||
state: ReturnType<typeof useAppStore.getState>
|
||||
): string {
|
||||
return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}`
|
||||
}
|
||||
|
||||
export function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void {
|
||||
if (agentTaskCompleteTrackingSettingsUnsubscribe === null) {
|
||||
agentTaskCompleteTrackingSettingsSnapshot = getAgentTaskCompleteTrackingSettingsSnapshot(
|
||||
useAppStore.getState()
|
||||
)
|
||||
agentTaskCompleteTrackingSettingsUnsubscribe = useAppStore.subscribe((state) => {
|
||||
const snapshot = getAgentTaskCompleteTrackingSettingsSnapshot(state)
|
||||
if (snapshot === agentTaskCompleteTrackingSettingsSnapshot) {
|
||||
return
|
||||
}
|
||||
agentTaskCompleteTrackingSettingsSnapshot = snapshot
|
||||
for (const subscriber of Array.from(agentTaskCompleteTrackingEnabledListeners)) {
|
||||
subscriber()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
agentTaskCompleteTrackingEnabledListeners.add(listener)
|
||||
return () => {
|
||||
agentTaskCompleteTrackingEnabledListeners.delete(listener)
|
||||
if (
|
||||
agentTaskCompleteTrackingEnabledListeners.size === 0 &&
|
||||
agentTaskCompleteTrackingSettingsUnsubscribe !== null
|
||||
) {
|
||||
agentTaskCompleteTrackingSettingsUnsubscribe()
|
||||
agentTaskCompleteTrackingSettingsUnsubscribe = null
|
||||
agentTaskCompleteTrackingSettingsSnapshot = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { waitForTerminalReplayWritesParsed } from '../replay-guard'
|
||||
import {
|
||||
POST_REPLAY_MODE_RESET,
|
||||
RESET_GRAPHIC_RENDITION
|
||||
} from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import {
|
||||
buildMainModelSnapshotReplayWrites,
|
||||
hasPositiveTerminalDimensions,
|
||||
readProposedTerminalCols,
|
||||
resolvePositiveTerminalDimensions,
|
||||
shouldSkipAltFrameForWidthMismatch
|
||||
} from '../terminal-snapshot-replay-paint'
|
||||
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import type { ReattachPayloadContext } from './reattach-payload-context'
|
||||
|
||||
export function bindApplyReattachPayload(
|
||||
session: ConnectPanePtySession,
|
||||
ctx: ReattachPayloadContext
|
||||
): void {
|
||||
session.applyReattachPayload = async (): Promise<void> => {
|
||||
if (!ctx.isCurrentReattachPayload()) {
|
||||
return
|
||||
}
|
||||
if (ctx.connectResult?.snapshot) {
|
||||
const snapshotPrefixAnsi = ctx.connectResult.snapshotPrefixAnsi
|
||||
const snapshotFrameAnsi = ctx.connectResult.snapshotFrameAnsi
|
||||
const snapshotFrameRestoreAnsi = ctx.connectResult.snapshotFrameRestoreAnsi
|
||||
const hasSplitDaemonAltFrame =
|
||||
typeof snapshotPrefixAnsi === 'string' &&
|
||||
snapshotPrefixAnsi.length > 0 &&
|
||||
typeof snapshotFrameAnsi === 'string' &&
|
||||
snapshotFrameAnsi.length > 0
|
||||
const daemonSnapshotReplay = hasSplitDaemonAltFrame
|
||||
? snapshotPrefixAnsi + snapshotFrameAnsi
|
||||
: ctx.connectResult.snapshot
|
||||
session.rememberReattachPayloadAgentSignal(daemonSnapshotReplay, { fullScreenReplay: true })
|
||||
// Why: replay at the snapshot's own dimensions to avoid rewrapping soft-wrapped rows at a different column count (#7279); suppress the PTY forward so this layout-only resize doesn't SIGWINCH the remote TUI.
|
||||
const snapshotDimensions = resolvePositiveTerminalDimensions(
|
||||
ctx.connectResult.snapshotCols,
|
||||
ctx.connectResult.snapshotRows
|
||||
)
|
||||
if (
|
||||
snapshotDimensions &&
|
||||
(session.pane.terminal.cols !== snapshotDimensions.cols ||
|
||||
session.pane.terminal.rows !== snapshotDimensions.rows)
|
||||
) {
|
||||
session.suppressStructuralReplayPtyResize = true
|
||||
try {
|
||||
session.pane.terminal.resize(snapshotDimensions.cols, snapshotDimensions.rows)
|
||||
} finally {
|
||||
session.suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
session.writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`)
|
||||
// Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload.
|
||||
session.applySnapshotKittyKeyboardModes(daemonSnapshotReplay, {
|
||||
kittyKeyboardFlags: ctx.connectResult.snapshotKittyKeyboardFlags,
|
||||
snapshotSeq: ctx.connectResult.snapshotSeq
|
||||
})
|
||||
// A narrower fit clips the fixed-grid alt frame; drop it and let SIGWINCH repaint.
|
||||
// A dead-owner restore keeps history plus its restored-session treatment;
|
||||
// a frozen foreign-width frame would look live when no owner remains.
|
||||
const daemonAltFrameSkippable =
|
||||
hasSplitDaemonAltFrame &&
|
||||
typeof snapshotFrameRestoreAnsi === 'string' &&
|
||||
shouldSkipAltFrameForWidthMismatch(
|
||||
ctx.connectResult.snapshotCols,
|
||||
readProposedTerminalCols(session.pane)
|
||||
)
|
||||
const groundDaemonSnapshot =
|
||||
Boolean(ctx.connectResult.coldRestore) ||
|
||||
(!session.shouldPreserveAgentReattachModes() &&
|
||||
!(ctx.connectResult.isAlternateScreen ?? session.kittyKeyboardModes.isAlternateScreen))
|
||||
session.writeReplayData(
|
||||
`${groundDaemonSnapshot ? RESET_GRAPHIC_RENDITION : ''}${
|
||||
daemonAltFrameSkippable
|
||||
? snapshotPrefixAnsi + snapshotFrameRestoreAnsi
|
||||
: daemonSnapshotReplay
|
||||
}`
|
||||
)
|
||||
session.writeReplayData(
|
||||
session.reattachReplayResetSequence(
|
||||
daemonSnapshotReplay,
|
||||
Boolean(ctx.connectResult.coldRestore),
|
||||
ctx.connectResult.isAlternateScreen
|
||||
)
|
||||
)
|
||||
if (ctx.connectResult.pendingEscapeTailAnsi) {
|
||||
// Why last: re-arm the dangling mid-escape after the reset (whose ESC would abort it) so the live continuation completes it (#7329).
|
||||
session.writeReplayData(ctx.connectResult.pendingEscapeTailAnsi)
|
||||
}
|
||||
session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration)
|
||||
if (ctx.connectResult.coldRestore) {
|
||||
// Snapshot superseded the cold-restore payload; ack so the daemon doesn't redeliver it.
|
||||
if (!isRemoteRuntimePtyId(ctx.ptyId)) {
|
||||
window.api.pty.ackColdRestore(ctx.ptyId)
|
||||
}
|
||||
}
|
||||
} else if (ctx.connectResult?.replay || ctx.prefetchedParkModelSnapshot) {
|
||||
// Why scoped to a park-reveal: the 100KiB relay tail loses scrollback the
|
||||
// model still holds, but an in-place reattach (network reconnect, wake,
|
||||
// reload) already has that replay in hand, so probing would only delay its
|
||||
// paint by the timeout. Memoized, so this is never a second probe.
|
||||
const modelSnapshot = ctx.revealFollowsTerminalPark
|
||||
? (ctx.prefetchedParkModelSnapshot ??
|
||||
(isRemoteRuntimePtyId(ctx.ptyId) ? null : await ctx.fetchSshMainModelReattachSnapshot()))
|
||||
: null
|
||||
if (!ctx.isCurrentReattachPayload()) {
|
||||
return
|
||||
}
|
||||
if (modelSnapshot) {
|
||||
// Why composed for scan/reset only: kitty + reset heuristics need
|
||||
// the full byte stream; the actual writes go through the shared
|
||||
// alt-screen choreography below (scrollbackAnsi is '' for
|
||||
// normal-buffer snapshots, so composition matches data there).
|
||||
const modelData = `${modelSnapshot.scrollbackAnsi ?? ''}${modelSnapshot.data}`
|
||||
session.rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true })
|
||||
const modelCols = modelSnapshot.cols
|
||||
const modelRows = modelSnapshot.rows
|
||||
if (
|
||||
hasPositiveTerminalDimensions(modelCols, modelRows) &&
|
||||
(session.pane.terminal.cols !== modelCols || session.pane.terminal.rows !== modelRows)
|
||||
) {
|
||||
// Why: replay at the snapshot's own dimensions (see the daemon-snapshot branch, #7279).
|
||||
session.suppressStructuralReplayPtyResize = true
|
||||
try {
|
||||
session.pane.terminal.resize(modelCols, modelRows)
|
||||
} finally {
|
||||
session.suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
session.applySnapshotKittyKeyboardModes(modelData, {
|
||||
kittyKeyboardFlags: modelSnapshot.kittyKeyboardFlags,
|
||||
snapshotSeq: modelSnapshot.seq
|
||||
})
|
||||
// Why shared: park+reveal of an alt-screen TUI needs the same
|
||||
// ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips
|
||||
// the ?1049h marker when splitting scrollbackAnsi) — inlined here
|
||||
// because nesting structuralReplayCoordinator would deadlock.
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, {
|
||||
skipAltFrame: shouldSkipAltFrameForWidthMismatch(
|
||||
modelCols,
|
||||
readProposedTerminalCols(session.pane)
|
||||
),
|
||||
paneOnAlternateScreen: session.isPaneOnAlternateScreen()
|
||||
})) {
|
||||
session.writeReplayData(replayChunk)
|
||||
}
|
||||
session.writeReplayData(
|
||||
session.reattachReplayResetSequence(
|
||||
modelData,
|
||||
Boolean(ctx.connectResult?.coldRestore),
|
||||
modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen
|
||||
)
|
||||
)
|
||||
if (modelSnapshot.pendingEscapeTailAnsi) {
|
||||
// Why last: re-arm the dangling mid-escape after the reset so the live continuation completes it (#7329).
|
||||
session.writeReplayData(modelSnapshot.pendingEscapeTailAnsi)
|
||||
}
|
||||
// Why: main sampled its delivery backlog with the snapshot; the baseline drops/slices deferred and live chunks the snapshot already covers.
|
||||
session.setRestoredSnapshotBaseline(ctx.ptyId, modelSnapshot)
|
||||
session.recordRendererOrderedSeq(modelSnapshot)
|
||||
session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration)
|
||||
if (ctx.connectResult?.coldRestore && !isRemoteRuntimePtyId(ctx.ptyId)) {
|
||||
window.api.pty.ackColdRestore(ctx.ptyId)
|
||||
}
|
||||
} else if (ctx.connectResult?.replay) {
|
||||
session.rememberReattachPayloadAgentSignal(ctx.connectResult.replay, {
|
||||
fullScreenReplay: true
|
||||
})
|
||||
// Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication.
|
||||
session.writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`)
|
||||
// Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack.
|
||||
// A constructor-fresh mirror (window reload) first demotes to unproven:
|
||||
// the replay window proves nothing about negotiations that predate it.
|
||||
if (!session.kittyKeyboardModes.hasProvenBaseline) {
|
||||
session.kittyKeyboardModes.resetForSnapshot()
|
||||
}
|
||||
session.kittyKeyboardModes.scanReplay(ctx.connectResult.replay)
|
||||
session.writeReplayData(
|
||||
`${ctx.connectResult.coldRestore ? RESET_GRAPHIC_RENDITION : ''}${ctx.connectResult.replay}`
|
||||
)
|
||||
session.writeReplayData(
|
||||
session.reattachReplayResetSequence(
|
||||
ctx.connectResult.replay,
|
||||
Boolean(ctx.connectResult.coldRestore),
|
||||
ctx.connectResult.isAlternateScreen
|
||||
)
|
||||
)
|
||||
session.sendFocusedReattachFocusInAfterReplay(ctx.ptyId, ctx.attemptGeneration)
|
||||
if (ctx.connectResult.coldRestore) {
|
||||
if (!isRemoteRuntimePtyId(ctx.ptyId)) {
|
||||
window.api.pty.ackColdRestore(ctx.ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ctx.connectResult?.coldRestore) {
|
||||
let destinationRows = session.pane.terminal.rows
|
||||
try {
|
||||
const proposedDestination = session.pane.fitAddon.proposeDimensions()
|
||||
if (
|
||||
proposedDestination &&
|
||||
Number.isFinite(proposedDestination.rows) &&
|
||||
proposedDestination.rows > 0
|
||||
) {
|
||||
destinationRows = Math.max(destinationRows, proposedDestination.rows)
|
||||
}
|
||||
} catch {
|
||||
// The current xterm grid remains a safe lower bound for blanking.
|
||||
}
|
||||
// Why: shrinking first would promote clipped stale viewport rows into scrollback, beyond the reach of a later viewport-only clear.
|
||||
session.writeReplayData(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[H`)
|
||||
await waitForTerminalReplayWritesParsed(session.pane.terminal)
|
||||
if (!ctx.isCurrentReattachPayload()) {
|
||||
return
|
||||
}
|
||||
const coldRestoreDimensions = resolvePositiveTerminalDimensions(
|
||||
ctx.connectResult.coldRestore.cols,
|
||||
ctx.connectResult.coldRestore.rows
|
||||
)
|
||||
if (
|
||||
coldRestoreDimensions &&
|
||||
(session.pane.terminal.cols !== coldRestoreDimensions.cols ||
|
||||
session.pane.terminal.rows !== coldRestoreDimensions.rows)
|
||||
) {
|
||||
// Why: recovered ANSI cursor positions belong to the checkpoint's grid; keep this layout-only resize from reaching the fresh PTY.
|
||||
session.suppressStructuralReplayPtyResize = true
|
||||
try {
|
||||
session.pane.terminal.resize(coldRestoreDimensions.cols, coldRestoreDimensions.rows)
|
||||
} finally {
|
||||
session.suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
// Why: recorded scrollback is raw PTY output that may hold query sequences; xterm.write would auto-reply into the new shell's stdin. See replay-guard.ts.
|
||||
session.writeReplayData(`${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}`)
|
||||
const preparedStartup = ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()
|
||||
const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup)
|
||||
if (didPrepareResume) {
|
||||
if (ctx.connectResult.agentResumeUnavailable) {
|
||||
// Why: main dropped the resume argv, so this pane is a NEW session —
|
||||
// the plain restored banner would claim the old one came back.
|
||||
session.showSessionRestoredBanner('resume-unavailable')
|
||||
} else if (preparedStartup?.hasSleepingRecord) {
|
||||
session.showSessionRestoredBanner()
|
||||
}
|
||||
session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup)
|
||||
}
|
||||
// Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes.
|
||||
session.writeReplayData(POST_REPLAY_MODE_RESET)
|
||||
// Why: the dead run's kitty flags died with it and its scrollback was never scanned — the fresh shell starts at zero.
|
||||
session.kittyKeyboardModes.reset()
|
||||
session.consumeRestoredViewportBlankingMarker()
|
||||
// Why: a taller destination fit must not pull recovered rows back into the fresh shell's viewport after source-grid replay.
|
||||
session.writeFreshShellViewportBlanking(Math.max(destinationRows, session.pane.terminal.rows))
|
||||
if (!isRemoteRuntimePtyId(ctx.ptyId)) {
|
||||
window.api.pty.ackColdRestore(ctx.ptyId)
|
||||
}
|
||||
if (didPrepareResume && !ctx.coldRestoreStartup) {
|
||||
session.schedulePendingStartupCommandDelivery()
|
||||
}
|
||||
}
|
||||
if (ctx.hasStructuralReplay || ctx.prefetchedParkModelSnapshot) {
|
||||
await waitForTerminalReplayWritesParsed(session.pane.terminal)
|
||||
if (!ctx.isCurrentReattachPayload()) {
|
||||
return
|
||||
}
|
||||
ctx.reattachPayloadApplied = true
|
||||
}
|
||||
}
|
||||
|
||||
session.fitAfterReattachRestore = async (): Promise<void> => {
|
||||
if (!ctx.isCurrentReattachPayload()) {
|
||||
return
|
||||
}
|
||||
const reattachPtyId = session.transport.getPtyId()
|
||||
if (!reattachPtyId) {
|
||||
return
|
||||
}
|
||||
if (!getFitOverrideForPty(reattachPtyId)) {
|
||||
const gridPush = session.createReattachGridPush(ctx.attemptGeneration, reattachPtyId)
|
||||
const fit = safeFitAndThen(session.pane, 'reattach-pty-resize', gridPush.continuation, {
|
||||
shouldContinue: gridPush.shouldContinue,
|
||||
retryIfUnmeasurable: true,
|
||||
// Why only this caller: a restored floating workspace is display:none until the
|
||||
// user opens it, so dropping the grid push strands the PTY at the replay grid.
|
||||
deferIfHidden: true
|
||||
})
|
||||
session.pendingReattachFit = fit
|
||||
try {
|
||||
// Why: reattach resize is fire-and-forget, so the continuation itself requests the
|
||||
// applied-grid verification — it is the only point reached by both the immediate
|
||||
// and the deferred-until-revealed path.
|
||||
await fit.completion
|
||||
} finally {
|
||||
if (session.pendingReattachFit === fit) {
|
||||
session.pendingReattachFit = null
|
||||
}
|
||||
}
|
||||
} else if (ctx.isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) {
|
||||
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { blocksCodexPaneInput } from '../../codex-restart-notice-state'
|
||||
|
||||
let codexRestartNoticePresenceSource: Record<
|
||||
string,
|
||||
{ previousAccountLabel: string; nextAccountLabel: string }
|
||||
> | null = null
|
||||
let codexRestartNoticePresence = false
|
||||
|
||||
export function hasCodexRestartNotices(
|
||||
noticesByPtyId: Record<string, { previousAccountLabel: string; nextAccountLabel: string }>
|
||||
): boolean {
|
||||
if (codexRestartNoticePresenceSource !== noticesByPtyId) {
|
||||
codexRestartNoticePresenceSource = noticesByPtyId
|
||||
codexRestartNoticePresence = Object.keys(noticesByPtyId).length > 0
|
||||
}
|
||||
return codexRestartNoticePresence
|
||||
}
|
||||
|
||||
export function isCodexPaneStale(args: {
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
panePtyId: string | null
|
||||
}): boolean {
|
||||
const state = useAppStore.getState()
|
||||
const { codexRestartNoticeByPtyId } = state
|
||||
if (!hasCodexRestartNotices(codexRestartNoticeByPtyId)) {
|
||||
return false
|
||||
}
|
||||
// Why: a bound pane's own record is the last word — its ptyId is exactly the
|
||||
// shell its keystrokes reach. `tab.ptyId` holds one sibling's id, not this
|
||||
// pane's, so in a split tab consulting it would kill this pane's keyboard over
|
||||
// that sibling's notice, with no prompt on screen once the sibling is answered.
|
||||
if (args.panePtyId) {
|
||||
return blocksCodexPaneInput(codexRestartNoticeByPtyId[args.panePtyId])
|
||||
}
|
||||
|
||||
// Why: only an unbound pane needs the tab's persisted id. Both transports
|
||||
// refuse writes while their pty binding is null, so a keystroke here arms
|
||||
// recovery — a coarser signal than a bound pane's own record, but the tab's id
|
||||
// is the only evidence available of which shell this pane is about to own.
|
||||
const tab = (state.tabsByWorktree[args.worktreeId] ?? []).find((entry) => entry.id === args.tabId)
|
||||
if (tab?.ptyId && blocksCodexPaneInput(codexRestartNoticeByPtyId[tab.ptyId])) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
+109
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
import { detectAgentStatusFromTitle, isClaudeAgent } from '@/lib/agent-status'
|
||||
import { useAppStore } from '@/store'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import {
|
||||
isFreshNonDoneAgentStatus,
|
||||
type AgentStatusEntry,
|
||||
type AgentType
|
||||
} from '../../../../../shared/agent-status-types'
|
||||
import { registerAgentHookTerminalLifecycleHandler } from '../agent-hook-terminal-lifecycle'
|
||||
import type { AgentCompletionStatusSnapshot } from '../agent-completion-coordinator-types'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner'
|
||||
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../../../../shared/agent-process-recognition'
|
||||
import type { TuiAgent } from '../../../../../shared/tui-agent'
|
||||
import { isTuiAgent } from '../../../../../shared/tui-agent-config'
|
||||
|
||||
import { MANUAL_AGENT_COMMAND_MAX_CHARS } from './pty-connect-limits'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installCommandInferredPaneAgent(session: ConnectPanePtySession): void {
|
||||
// Why: infer pane ownership from a manually typed agent command (e.g. `omp`) by
|
||||
// shadowing the shell's current command line, for generic terminals where no
|
||||
// launch metadata exists. Consumed by getAuthoritativePaneAgent below.
|
||||
session.commandInferredPaneAgent = null
|
||||
session.pendingShellCommandLine = ''
|
||||
session.pendingShellCommandCursor = 0
|
||||
session.commandInferredPaneAgentGeneration = 0
|
||||
session.shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
session.startAcceptedInferredCommand = (_agent: TuiAgent): void => {}
|
||||
session.requestKnownWindowsShiftEnterReconfirmation = (): void => {}
|
||||
session.resetPendingShellCommandLine = (): void => {
|
||||
session.pendingShellCommandLine = ''
|
||||
session.pendingShellCommandCursor = 0
|
||||
}
|
||||
session.rememberCommandInferredPaneAgent = (): void => {
|
||||
const commandLine = session.pendingShellCommandLine.trim()
|
||||
session.resetPendingShellCommandLine()
|
||||
const candidateAgent = commandLine
|
||||
? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null)
|
||||
: null
|
||||
const state = useAppStore.getState()
|
||||
const registeredLaunchAgent =
|
||||
state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType
|
||||
// Why: input inside a live TUI can spell another agent command; process or
|
||||
// pane-scoped launch ownership is stronger than typed shell inference.
|
||||
const nextAgent =
|
||||
state.paneForegroundAgentByPaneKey[session.cacheKey]?.agent ||
|
||||
isTuiAgent(registeredLaunchAgent)
|
||||
? null
|
||||
: candidateAgent
|
||||
session.commandInferredPaneAgent = nextAgent
|
||||
session.commandInferredPaneAgentGeneration += 1
|
||||
if (nextAgent) {
|
||||
session.startAcceptedInferredCommand(nextAgent)
|
||||
}
|
||||
}
|
||||
session.clearCommandInferredPaneAgent = (): void => {
|
||||
session.commandInferredPaneAgent = null
|
||||
session.resetPendingShellCommandLine()
|
||||
session.commandInferredPaneAgentGeneration += 1
|
||||
}
|
||||
session.clearCommandInferredPaneAgentAfterPtySideEffects = (): void => {
|
||||
const generation = session.commandInferredPaneAgentGeneration
|
||||
session.resetPendingShellCommandLine()
|
||||
queueMicrotask(() => {
|
||||
setTimeout(() => {
|
||||
if (session.commandInferredPaneAgentGeneration === generation) {
|
||||
session.clearCommandInferredPaneAgent()
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
session.appendPendingShellCommandInput = (text: string): void => {
|
||||
const available = MANUAL_AGENT_COMMAND_MAX_CHARS - session.pendingShellCommandLine.length
|
||||
if (available <= 0) {
|
||||
session.shellCommandInferenceSuspendedUntilCommandEnd = true
|
||||
return
|
||||
}
|
||||
const inserted = text.slice(0, available)
|
||||
session.pendingShellCommandLine =
|
||||
session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor) +
|
||||
inserted +
|
||||
session.pendingShellCommandLine.slice(session.pendingShellCommandCursor)
|
||||
session.pendingShellCommandCursor += inserted.length
|
||||
if (inserted.length < text.length) {
|
||||
session.shellCommandInferenceSuspendedUntilCommandEnd = true
|
||||
}
|
||||
}
|
||||
session.deletePendingShellCommandWord = (): void => {
|
||||
const beforeCursor = session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor)
|
||||
const afterCursor = session.pendingShellCommandLine.slice(session.pendingShellCommandCursor)
|
||||
const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '')
|
||||
session.pendingShellCommandLine = nextBeforeCursor + afterCursor
|
||||
session.pendingShellCommandCursor = nextBeforeCursor.length
|
||||
}
|
||||
session.cancelSuspendedShellCommandInference = (): void => {
|
||||
if (!session.shellCommandInferenceSuspendedUntilCommandEnd) {
|
||||
return
|
||||
}
|
||||
session.shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
session.resetPendingShellCommandLine()
|
||||
}
|
||||
session.deletePendingShellCommandCharacter = (): void => {
|
||||
if (session.pendingShellCommandCursor === 0) {
|
||||
return
|
||||
}
|
||||
session.pendingShellCommandLine =
|
||||
session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor - 1) +
|
||||
session.pendingShellCommandLine.slice(session.pendingShellCommandCursor)
|
||||
session.pendingShellCommandCursor -= 1
|
||||
}
|
||||
session.deletePendingShellCommandCharacterAtCursor = (): void => {
|
||||
if (session.pendingShellCommandCursor >= session.pendingShellCommandLine.length) {
|
||||
return
|
||||
}
|
||||
session.pendingShellCommandLine =
|
||||
session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor) +
|
||||
session.pendingShellCommandLine.slice(session.pendingShellCommandCursor + 1)
|
||||
}
|
||||
session.movePendingShellCommandCursor = (delta: number): void => {
|
||||
session.pendingShellCommandCursor = Math.min(
|
||||
session.pendingShellCommandLine.length,
|
||||
Math.max(0, session.pendingShellCommandCursor + delta)
|
||||
)
|
||||
}
|
||||
session.consumeShellCommandCsiSequence = (data: string, index: number): number | null => {
|
||||
if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') {
|
||||
return null
|
||||
}
|
||||
let cursor = index + 2
|
||||
while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) {
|
||||
cursor += 1
|
||||
}
|
||||
const final = data[cursor]
|
||||
if (!final || !/[~A-Za-z]/.test(final)) {
|
||||
return null
|
||||
}
|
||||
const params = data.slice(index + 2, cursor)
|
||||
// Why: only emulate a bare one-column move. Parameterized/modified cursor keys
|
||||
// (e.g. Ctrl+Left `\x1b[1;5D` = word-jump) move the real cursor by more than
|
||||
// one, so tracking them as ±1 would desync the shadow line — fall through to
|
||||
// reset instead of silently corrupting the sampled command.
|
||||
if (final === 'D' && params === '') {
|
||||
session.movePendingShellCommandCursor(-1)
|
||||
} else if (final === 'C' && params === '') {
|
||||
session.movePendingShellCommandCursor(1)
|
||||
} else if (final === 'H' || (final === '~' && params === '1')) {
|
||||
session.pendingShellCommandCursor = 0
|
||||
} else if (final === 'F' || (final === '~' && params === '4')) {
|
||||
session.pendingShellCommandCursor = session.pendingShellCommandLine.length
|
||||
} else if (final === '~' && params === '3') {
|
||||
session.deletePendingShellCommandCharacterAtCursor()
|
||||
} else if (final === '~' && (params === '200' || params === '201')) {
|
||||
// Bracketed paste wrappers are terminal framing, not shell command text.
|
||||
} else {
|
||||
session.resetPendingShellCommandLine()
|
||||
}
|
||||
return cursor + 1
|
||||
}
|
||||
session.getLivePaneAgentTitle = (): string | null => {
|
||||
const state = useAppStore.getState()
|
||||
const runtimeTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
|
||||
const tabTitle = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === session.deps.tabId
|
||||
)?.title
|
||||
return runtimeTitle ?? tabTitle ?? null
|
||||
}
|
||||
// Why: a pane-scoped explicit row only counts as current ownership evidence
|
||||
// when it is fresh and not already `done` — a stale or completed row is a
|
||||
// leftover from a prior agent that may no longer own the shell.
|
||||
session.isFreshActivePaneAgentEntry = (
|
||||
entry: AgentStatusEntry | undefined
|
||||
): entry is AgentStatusEntry => {
|
||||
return isFreshNonDoneAgentStatus(entry)
|
||||
}
|
||||
session.shouldSuppressTitleCompletionForFreshHook = (
|
||||
title: string,
|
||||
activeHookStatus: AgentStatusEntry | undefined
|
||||
): boolean => {
|
||||
if (
|
||||
detectAgentStatusFromTitle(title) === 'working' ||
|
||||
!isFreshNonDoneAgentStatus(activeHookStatus)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const explicitTitleAgentType = resolveCommittedTitleAgentType(title)
|
||||
const activeHookAgentForTitle = resolveCompatibleAgentTypeForOwner(
|
||||
activeHookStatus?.agentType,
|
||||
explicitTitleAgentType
|
||||
)
|
||||
const titleNamesDifferentKnownAgent =
|
||||
explicitTitleAgentType &&
|
||||
activeHookStatus?.agentType &&
|
||||
activeHookStatus.agentType !== 'unknown' &&
|
||||
activeHookAgentForTitle !== explicitTitleAgentType
|
||||
return !titleNamesDifferentKnownAgent
|
||||
}
|
||||
session.pendingSuppressedTitleSideEffects = null
|
||||
session.clearSuppressedTitleSideEffects = (): void => {
|
||||
session.pendingSuppressedTitleSideEffects = null
|
||||
}
|
||||
session.applyAgentCompletionSideEffects = (
|
||||
title: string,
|
||||
agentType: AgentType | undefined
|
||||
): void => {
|
||||
const settings = useAppStore.getState().settings
|
||||
if (
|
||||
(agentType === 'claude' || isClaudeAgent(title)) &&
|
||||
(settings === null || settings.promptCacheTimerEnabled)
|
||||
) {
|
||||
session.deps.setCacheTimerStartedAt(session.cacheKey, Date.now())
|
||||
}
|
||||
session.setFocusReportSuppressionForAgentCompletion(title, agentType)
|
||||
session.queueAgentIdleTerminalModeReset()
|
||||
}
|
||||
session.preserveSuppressedTitleSideEffects = (
|
||||
title: string,
|
||||
activeHookStatus: AgentStatusEntry
|
||||
): void => {
|
||||
session.pendingSuppressedTitleSideEffects = {
|
||||
title,
|
||||
agentType: activeHookStatus.agentType
|
||||
}
|
||||
if (activeHookStatus.state === 'waiting' || activeHookStatus.state === 'blocked') {
|
||||
session.suppressNativeWindowsIdleCodexFocusReports = false
|
||||
session.queueAgentIdleTerminalModeReset()
|
||||
}
|
||||
}
|
||||
session.handleAgentHookTerminalLifecycle = (payload: AgentCompletionStatusSnapshot): void => {
|
||||
const pending = session.pendingSuppressedTitleSideEffects
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
const payloadAgentForPending = resolveCompatibleAgentTypeForOwner(
|
||||
payload.agentType,
|
||||
pending.agentType
|
||||
)
|
||||
const belongsToPendingAgent =
|
||||
!pending.agentType ||
|
||||
pending.agentType === 'unknown' ||
|
||||
!payload.agentType ||
|
||||
payload.agentType === 'unknown' ||
|
||||
payloadAgentForPending === pending.agentType
|
||||
if (!belongsToPendingAgent || payload.state === 'working') {
|
||||
session.clearSuppressedTitleSideEffects()
|
||||
return
|
||||
}
|
||||
if (payload.state === 'done') {
|
||||
session.applyAgentCompletionSideEffects(pending.title, payload.agentType ?? pending.agentType)
|
||||
session.clearSuppressedTitleSideEffects()
|
||||
return
|
||||
}
|
||||
if (payload.state === 'waiting' || payload.state === 'blocked') {
|
||||
session.suppressNativeWindowsIdleCodexFocusReports = false
|
||||
session.queueAgentIdleTerminalModeReset()
|
||||
}
|
||||
}
|
||||
session.unregisterAgentHookTerminalLifecycle = registerAgentHookTerminalLifecycleHandler(
|
||||
session.cacheKey,
|
||||
session.handleAgentHookTerminalLifecycle
|
||||
)
|
||||
session.hasFreshPaneAgentSurface = (): boolean => {
|
||||
const entry = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
|
||||
if (session.isFreshActivePaneAgentEntry(entry)) {
|
||||
return true
|
||||
}
|
||||
const liveTitle = session.getLivePaneAgentTitle()
|
||||
return detectAgentStatusFromTitle(liveTitle ?? '') !== null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyConnectionDeps } from '../pty-connection-types'
|
||||
|
||||
// Why: connectPanePty is a single closure factory. The bag lets extracted
|
||||
// installers share the same mutable bindings without changing call order.
|
||||
export type ConnectPanePtySession = {
|
||||
pane: ManagedPane
|
||||
manager: PaneManager
|
||||
deps: PtyConnectionDeps
|
||||
// oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract
|
||||
[key: string]: any
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import type { PtyConnectionDeps } from '../pty-connection-types'
|
||||
import {
|
||||
captureTerminalPaneRecoveryGeneration,
|
||||
registerTerminalPaneRecoveryInstance
|
||||
} from '../terminal-pane-recovery'
|
||||
import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { createTerminalStructuralReplayCoordinator } from '@/lib/pane-manager/terminal-structural-replay-coordinator'
|
||||
import { makePaneKey } from '../../../../../shared/stable-pane-id'
|
||||
import type { AgentType } from '../../../../../shared/agent-status-types'
|
||||
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
|
||||
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { exposeE2eTerminalPtyOutputDebug } from './e2e-terminal-pty-harness'
|
||||
import type { PanePtyBinding } from './pane-pty-binding'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import {
|
||||
isAgentTaskCompleteNotificationEnabled,
|
||||
isAgentTaskCompleteTrackingEnabled
|
||||
} from './agent-task-complete-settings'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
import { installRunDeferredConnect } from './run-deferred-connect'
|
||||
|
||||
import { installSleepingRecordAccess } from './sleeping-record-access'
|
||||
import { installShellCommandInference } from './shell-command-inference'
|
||||
import { installInterruptInputIntent } from './interrupt-input-intent'
|
||||
import { installTerminalKeydownFit } from './terminal-keydown-fit'
|
||||
import { installPtyExitHibernate } from './pty-exit-hibernate'
|
||||
import { installTitleSpawnBell } from './title-spawn-bell'
|
||||
import { installAgentTaskCompleteNotify } from './agent-task-complete-notify'
|
||||
import { installDirectSshRetryStatus } from './direct-ssh-retry-status'
|
||||
import { installPtyInputRecovery } from './pty-input-recovery'
|
||||
import { installPtyInputForward } from './pty-input-forward'
|
||||
import { installPtyResizeGeometry } from './pty-resize-geometry'
|
||||
import { installSessionReconcileDispose } from './session-reconcile-dispose'
|
||||
|
||||
export function connectPanePty(
|
||||
pane: ManagedPane,
|
||||
manager: PaneManager,
|
||||
deps: PtyConnectionDeps
|
||||
): PanePtyBinding {
|
||||
const session = { pane, manager, deps } as ConnectPanePtySession
|
||||
session.shouldRefreshForegroundSynchronously = (): boolean =>
|
||||
!session.manager.hasWebglRenderer(session.pane.id)
|
||||
// Why: recovery ownership belongs to this xterm instance. A request that
|
||||
// settles after remount must not remount its already-replaced successor.
|
||||
session.terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(session.deps.tabId)
|
||||
session.terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(session.deps.tabId)
|
||||
session.mountFollowsTerminalPark = session.deps.mountFollowsTerminalPark
|
||||
session.authoritativeReattachGeneration = 0
|
||||
exposeE2eTerminalPtyOutputDebug()
|
||||
session.disposed = false
|
||||
session.structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(
|
||||
session.pane.terminal
|
||||
)
|
||||
session.connectFrame = null
|
||||
session.connectFallbackTimer = null
|
||||
session.startupGridSettleHandle = null
|
||||
session.startupGridSettledForConnect = false
|
||||
session.connectStarted = false
|
||||
session.unregisterBacklogRecovery = null
|
||||
session.unregisterDocumentVisibilityRecovery = null
|
||||
session.cancelHiddenOutputSnapshotScrollRestore = (): void => {}
|
||||
session.pendingHiddenSnapshotFit = null
|
||||
session.pendingReattachFit = null
|
||||
session.cancelFreshSpawnFollowReset = (): void => {}
|
||||
session.cleanupHiddenOutputRestoreDeferredRetry = (): void => {}
|
||||
session.cleanupHiddenOutputRestoreForegroundDeadline = (): void => {}
|
||||
session.cleanupHiddenOutputRestoreFloodRepaint = (): void => {}
|
||||
session.resetRendererOrderedSeqForPtyExit = () => {}
|
||||
session.cleanupStartupDraftPasteTimers = (): void => {}
|
||||
session.unregisterE2ePtyDataInjection = (): void => {}
|
||||
session.startupInjectTimer = null
|
||||
session.agentTaskCompleteNotificationGraceTimer = null
|
||||
session.agentTaskCompleteNotificationMaxTimer = null
|
||||
session.agentTaskCompleteStatusUnsubscribe = null
|
||||
session.agentTaskCompleteSettingsUnsubscribe = null
|
||||
session.agentTaskCompleteNotificationGeneration = 0
|
||||
session.wasAgentTaskCompleteTrackingEnabled = isAgentTaskCompleteTrackingEnabled()
|
||||
session.requiresFreshWorkingForAgentTaskCompleteNotification =
|
||||
!session.wasAgentTaskCompleteTrackingEnabled
|
||||
session.wasAgentTaskCompleteOsNotificationEnabled = isAgentTaskCompleteNotificationEnabled()
|
||||
session.terminalBellNotificationTimer = null
|
||||
session.pendingTerminalBellNotification = false
|
||||
session.reattachIdleAgentCursorResetTimer = null
|
||||
session.alternateScreenBackgroundRepaintTimer = null
|
||||
session.shiftEnterReconfirmTimer = null
|
||||
session.synchronizedForegroundOutputActive = false
|
||||
// Why: carries up to one marker-length-1 of trailing bytes so a ConPTY-split DEC 2026 marker is still detected (#8754).
|
||||
session.synchronizedForegroundMarkerTail = ''
|
||||
// Why: tracks the keystroke proximity captured when the current synchronized
|
||||
// foreground frame opened, so a split end marker that lands after the redraw
|
||||
// window still drains on the fast path instead of the 1s coalesce fallback.
|
||||
session.synchronizedForegroundFrameInteractive = false
|
||||
session.suppressStructuralReplayPtyResize = false
|
||||
// Why: hidden-delivery gate sync is wired up alongside the deferred PTY
|
||||
// output plumbing inside the connect frame; lifecycle hooks (visibility
|
||||
// flips, exit, dispose) run before/after it exists, so start with no-ops.
|
||||
session.syncHiddenRendererPtyDelivery = () => {}
|
||||
session.releaseHiddenRendererPtyDelivery = () => {}
|
||||
session.handleRemoteOutputPauseChanged = () => {}
|
||||
session.handleRendererOwnedAgentStatus = () => {}
|
||||
session.remoteOutputGatedPtyId = null
|
||||
session.remoteOutputFactConsumerPtyId = null
|
||||
session.suppressViewportClaimTerminalResize = false
|
||||
// Why: idle callbacks are registered before the deferred PTY output plumbing
|
||||
// exists. Start with the shared scheduler, then switch to the PTY writer
|
||||
// below so hidden-tab resets keep backlog-recovery callbacks and byte order.
|
||||
session.idleAgentTerminalModeReset = RESET_TERMINAL_CURSOR_STYLE
|
||||
session.suppressNativeWindowsIdleCodexFocusReports = false
|
||||
session.setFocusReportSuppressionForAgentCompletion = (
|
||||
title: string | undefined,
|
||||
agentType: AgentType | undefined
|
||||
): void => {
|
||||
const titleAgentType = resolveCommittedTitleAgentType(title ?? '')
|
||||
session.suppressNativeWindowsIdleCodexFocusReports =
|
||||
agentType && agentType !== 'unknown' ? agentType === 'codex' : titleAgentType === 'codex'
|
||||
}
|
||||
session.queueAgentIdleTerminalModeReset = (): void => {
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
writeTerminalOutput(session.pane.terminal, session.idleAgentTerminalModeReset, {
|
||||
foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
})
|
||||
}
|
||||
// Why: passphrase-gate waits register a teardown here so dispose() can
|
||||
// actively unsubscribe + resolve them. Without this, a pane disposed
|
||||
// mid-wait leaks its zustand subscriber and the surrounding async IIFE
|
||||
// forever, since the subscriber's `disposed` check only fires when the
|
||||
// store next emits — which may never happen after disconnect.
|
||||
session.waitTeardowns = []
|
||||
// Why: startup commands must only run once — in the pane they were
|
||||
// targeted at. Capture `deps.startup` into a local and clear the field on
|
||||
// the (already spread-copied) `deps` so nothing else inside this function
|
||||
// can accidentally re-read it. The caller is responsible for clearing its
|
||||
// own outer reference, since `deps` here is a shallow copy and our
|
||||
// mutation does not propagate back.
|
||||
session.paneStartup = session.deps.startup ?? null
|
||||
session.deps.startup = undefined
|
||||
|
||||
// Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay.
|
||||
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
|
||||
session.cacheKey = makePaneKey(session.deps.tabId, session.pane.leafId)
|
||||
// Why: mirrors the kitty keyboard flags the pane's application negotiates.
|
||||
// Fed only from application output (live PTY bytes + daemon replay
|
||||
// payloads), never from renderer-generated resets, so it reflects what the
|
||||
// application expects even after defensive renderer-side kitty wipes.
|
||||
session.kittyKeyboardModes = (() => {
|
||||
const existing = session.deps.paneKittyKeyboardModesRef.current.get(session.pane.id)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = new TerminalKittyKeyboardModeTracker()
|
||||
session.deps.paneKittyKeyboardModesRef.current.set(session.pane.id, created)
|
||||
return created
|
||||
})()
|
||||
installSleepingRecordAccess(session)
|
||||
installShellCommandInference(session)
|
||||
installInterruptInputIntent(session)
|
||||
installTerminalKeydownFit(session)
|
||||
installPtyExitHibernate(session)
|
||||
installTitleSpawnBell(session)
|
||||
installAgentTaskCompleteNotify(session)
|
||||
installDirectSshRetryStatus(session)
|
||||
installPtyInputRecovery(session)
|
||||
installPtyInputForward(session)
|
||||
installPtyResizeGeometry(session)
|
||||
installRunDeferredConnect(session)
|
||||
return installSessionReconcileDispose(session)
|
||||
}
|
||||
+89
@@ -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} `)
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { PtyReplayDataMeta } from '../pty-transport'
|
||||
import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../../../../shared/terminal-color-scheme-protocol'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { executeTerminalStartupCommandPaste } from '../terminal-startup-command-paste'
|
||||
import { getTerminalPasteSshRemotePlatform } from '../terminal-paste-ssh-platform'
|
||||
import { resolveTerminalPasteRuntime } from '../terminal-paste-runtime'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
|
||||
import { shouldKeepHiddenStartupRendererQueriesLive } from './hidden-startup-renderer-query'
|
||||
import type { FreshSpawnOptions, ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import { bindCaptureTransportOutputCallbacks } from './transport-output-callbacks'
|
||||
|
||||
import { bindReplayDataDrain } from './replay-data-drain'
|
||||
import { bindStartFreshSpawn } from './fresh-spawn-start'
|
||||
|
||||
import { bindFreshSpawnFollowReset } from './fresh-spawn-follow-reset'
|
||||
|
||||
export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySession): void {
|
||||
session.applyColdRestoreAgentResumeStartup = (
|
||||
startup: ColdRestoreAgentResumeStartup | null
|
||||
): boolean => {
|
||||
if (!startup) {
|
||||
return false
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
state.registerAgentLaunchConfig(session.cacheKey, startup.launchConfig, {
|
||||
agentType: startup.agent,
|
||||
launchToken: startup.launchToken,
|
||||
tabId: session.deps.tabId,
|
||||
leafId: session.pane.leafId
|
||||
})
|
||||
return true
|
||||
}
|
||||
session.clearSleepingRecordAfterColdRestoreSpawn = (
|
||||
startup: ColdRestoreAgentResumeStartup | null
|
||||
): void => {
|
||||
if (startup && !startup.useLiveEntry && startup.sleepingRecordEntry) {
|
||||
session.clearSleepingRecordProviderDuplicates(
|
||||
useAppStore.getState(),
|
||||
startup.sleepingRecordEntry
|
||||
)
|
||||
}
|
||||
}
|
||||
session.mergeStartupEnvWithPaneIdentity = (
|
||||
env: Record<string, string> | undefined
|
||||
): Record<string, string> | undefined =>
|
||||
env
|
||||
? {
|
||||
...env,
|
||||
...session.paneIdentityEnv,
|
||||
...(env.ORCA_AGENT_LAUNCH_TOKEN
|
||||
? { ORCA_AGENT_LAUNCH_TOKEN: env.ORCA_AGENT_LAUNCH_TOKEN }
|
||||
: {})
|
||||
}
|
||||
: undefined
|
||||
session.startFreshColdRestoreAgentResume = (
|
||||
startup: ColdRestoreAgentResumeStartup | null = session.buildColdRestoreAgentResumeStartup(),
|
||||
options: FreshSpawnOptions = {}
|
||||
): Promise<string | null> => {
|
||||
session.applyColdRestoreAgentResumeStartup(startup)
|
||||
return session.startFreshSpawn(startup, options)
|
||||
}
|
||||
// Why: the hibernation wake fires from noteVisibilityResume in the outer
|
||||
// connection scope, long after this deferred-connect closure has run.
|
||||
session.wakeHibernatedAgentPane = () => session.startFreshColdRestoreAgentResume()
|
||||
const isStartupPasteTargetCurrent = (ptyId: string | null): boolean =>
|
||||
!session.disposed &&
|
||||
session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport &&
|
||||
session.transport.getPtyId() === ptyId
|
||||
const runTerminalPasteStartupCommand = async (command: string): Promise<boolean> => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
const result = await executeTerminalStartupCommandPaste({
|
||||
command,
|
||||
pane: session.pane,
|
||||
ptyId,
|
||||
runtime: resolveTerminalPasteRuntime({
|
||||
platform: CLIENT_PLATFORM,
|
||||
ptyId,
|
||||
connectionId: session.connectionId,
|
||||
remotePlatform: getTerminalPasteSshRemotePlatform(session.connectionId),
|
||||
transport: session.transport,
|
||||
isWindowsConpty: session.isNativeWindowsConpty
|
||||
}),
|
||||
transport: session.transport,
|
||||
isTargetCurrent: isStartupPasteTargetCurrent
|
||||
})
|
||||
if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) {
|
||||
return false
|
||||
}
|
||||
return session.transport.sendInput('\r')
|
||||
}
|
||||
session.schedulePendingStartupCommandDelivery = (): void => {
|
||||
const startup = session.pendingStartupCommand
|
||||
if (!startup) {
|
||||
return
|
||||
}
|
||||
if (session.startupInjectTimer !== null) {
|
||||
clearTimeout(session.startupInjectTimer)
|
||||
}
|
||||
session.startupInjectTimer = setTimeout(() => {
|
||||
session.startupInjectTimer = null
|
||||
void (async () => {
|
||||
if (session.pendingStartupCommand !== startup || session.disposed) {
|
||||
return
|
||||
}
|
||||
if (session.shouldDeliverStartupViaTerminalPaste) {
|
||||
await waitForTerminalOutputParsed(session.pane.terminal)
|
||||
}
|
||||
if (session.pendingStartupCommand !== startup || session.disposed) {
|
||||
return
|
||||
}
|
||||
const command = startup.command
|
||||
const submitted = session.shouldDeliverStartupViaTerminalPaste
|
||||
? await runTerminalPasteStartupCommand(command)
|
||||
: session.transport.sendInput(`${command}\r`)
|
||||
if (submitted) {
|
||||
session.armStartupDraftReadinessObservation()
|
||||
} else {
|
||||
session.releaseUnattemptedStartupDraftPasteDelivery()
|
||||
}
|
||||
session.pendingStartupCommand = null
|
||||
})()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
session.freshSpawnFollowResetDisposables = []
|
||||
session.cancelFreshSpawnFollowReset = (): void => {
|
||||
for (const disposable of session.freshSpawnFollowResetDisposables) {
|
||||
disposable.dispose()
|
||||
}
|
||||
session.freshSpawnFollowResetDisposables = []
|
||||
}
|
||||
bindFreshSpawnFollowReset(session)
|
||||
bindStartFreshSpawn(session)
|
||||
bindReplayDataDrain(session)
|
||||
session.replayDataCallback = (
|
||||
data: string,
|
||||
meta: PtyReplayDataMeta = {},
|
||||
streamGeneration = session.transportStreamGeneration
|
||||
): void => {
|
||||
session.pendingReplayData = {
|
||||
data,
|
||||
clearBeforeReplay: meta.clearBeforeReplay !== false,
|
||||
ptyId: session.transport.getPtyId(),
|
||||
generation: (session.replayPayloadGeneration += 1),
|
||||
streamGeneration,
|
||||
...(meta.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } : {}),
|
||||
...(meta.kittyKeyboardFlags !== undefined && meta.snapshotSeq !== undefined
|
||||
? {
|
||||
kittyKeyboardFlags: meta.kittyKeyboardFlags,
|
||||
snapshotSeq: meta.snapshotSeq
|
||||
}
|
||||
: {})
|
||||
}
|
||||
session.scheduleReplayDataDrain()
|
||||
}
|
||||
|
||||
bindCaptureTransportOutputCallbacks(session)
|
||||
session.setRestoredSnapshotBaseline = function (
|
||||
ptyId: string,
|
||||
snapshot: { seq?: number; pendingDeliveryStartSeq?: number }
|
||||
): void {
|
||||
if (typeof snapshot.seq !== 'number') {
|
||||
session.clearRestoredSnapshotBaseline()
|
||||
return
|
||||
}
|
||||
const windowStartSeq =
|
||||
typeof snapshot.pendingDeliveryStartSeq === 'number'
|
||||
? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq)
|
||||
: null
|
||||
if (windowStartSeq !== null && windowStartSeq >= snapshot.seq) {
|
||||
// Why: main reported an empty undelivered backlog — no chunk at or
|
||||
// below the snapshot seq can ever arrive again (delivery is once and
|
||||
// in order) and a future pending-cap trim re-arms the out-of-band
|
||||
// marker. Arming a baseline anyway would misread live chunks from a
|
||||
// foreign seq domain (restarted counter / synthetic injection) as
|
||||
// duplicates or trim gaps and silently drop genuinely-new output.
|
||||
session.clearRestoredSnapshotBaseline()
|
||||
return
|
||||
}
|
||||
session.restoredSnapshotBaselineSeq = snapshot.seq
|
||||
session.restoredSnapshotBaselinePtyId = ptyId
|
||||
session.restoredSnapshotExpectedStartSeq = snapshot.seq
|
||||
session.restoredSnapshotDeliveryWindowStartSeq = windowStartSeq
|
||||
}
|
||||
|
||||
session.clearRestoredSnapshotBaseline = function (): void {
|
||||
session.restoredSnapshotBaselineSeq = null
|
||||
session.restoredSnapshotBaselinePtyId = null
|
||||
session.restoredSnapshotExpectedStartSeq = null
|
||||
session.restoredSnapshotDeliveryWindowStartSeq = null
|
||||
}
|
||||
session.foregroundImmediateBudgetChars = 0
|
||||
session.foregroundImmediateBudgetWindowStart = 0
|
||||
session.foregroundRewriteChunkEndedWithCarriageReturn = false
|
||||
session.foregroundRewriteCsiScanTail = ''
|
||||
session.mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
session.shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(
|
||||
session.paneStartup
|
||||
)
|
||||
session.hiddenStartupRendererQueryPending = ''
|
||||
session.hiddenRendererStateDirty = false
|
||||
session.rendererOrderedPtyId = null
|
||||
session.rendererOrderedSeq = null
|
||||
session.rendererChannelSeqPtyId = null
|
||||
session.rendererChannelSeq = null
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { resolveSshPaneConnectGate } from '../ssh-pane-connect-gate'
|
||||
|
||||
import {
|
||||
type UserInitiatedSshConnectOutcome,
|
||||
isSshSessionExpiredError,
|
||||
sshPromptConnectOutcomeForStatus,
|
||||
waitForSshConnection
|
||||
} from './ssh-session-connect'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
import { runDeferredSessionReattachChoice } from './deferred-session-reattach-choice'
|
||||
|
||||
export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
// Why: trigger the deferred SSH connect per-tab (not per-target) so multiple tabs for one target reattach independently.
|
||||
// Must run before session-id resolution: the SSH provider isn't registered until connect succeeds.
|
||||
if (session.connectionId) {
|
||||
const storeState = useAppStore.getState()
|
||||
// Why: a removed SSH target (ghost workspace) would fail reattach with a spurious "file an issue" banner for an expected action, so skip it (runtime-owned targets exempt).
|
||||
// A present map missing this id = target removed; an absent map = not yet hydrated (test stubs), so don't treat it as gone.
|
||||
if (
|
||||
!isRuntimeOwnedSshTargetId(session.connectionId) &&
|
||||
storeState.sshTargetLabels instanceof Map &&
|
||||
!storeState.sshTargetLabels.has(session.connectionId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const restoredLeafSessionId =
|
||||
session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId
|
||||
? (session.deps.restoredPtyIdByLeafId[session.deps.restoredLeafId] ?? null)
|
||||
: null
|
||||
const gate = resolveSshPaneConnectGate({
|
||||
connectionId: session.connectionId,
|
||||
sshStatus: storeState.sshConnectionStates.get(session.connectionId)?.status,
|
||||
isDeferredTarget: storeState.deferredSshReconnectTargets.includes(session.connectionId),
|
||||
restoredLeafSessionId,
|
||||
deferredTabSessionId: storeState.deferredSshSessionIdsByTabId[session.deps.tabId],
|
||||
tabPtyId: storeState.tabsByWorktree[session.deps.worktreeId]?.find(
|
||||
(t) => t.id === session.deps.tabId
|
||||
)?.ptyId,
|
||||
hasLeafSessionMap: Boolean(
|
||||
session.deps.restoredPtyIdByLeafId &&
|
||||
Object.keys(session.deps.restoredPtyIdByLeafId).length > 0
|
||||
)
|
||||
})
|
||||
const pendingSessionId = gate.pendingSessionId
|
||||
console.warn(
|
||||
`[pty-connection] SSH tab=${session.deps.tabId} connectionId=${session.connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}`
|
||||
)
|
||||
const legacyWorkerOwnsPane = session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) {
|
||||
// Paint main's parked model while SSH recovery continues off the render path.
|
||||
session.prepaintParkedSshSnapshot(pendingSessionId)
|
||||
void (async () => {
|
||||
// Why: for a passphrase target with no cached credential, don't auto-fire ssh.connect — a prompt popping just from focusing a tab / Cmd+J would surprise the user.
|
||||
// Wait for a user-initiated connect first; no-passphrase targets return false here and auto-connect as before.
|
||||
let needsPrompt = false
|
||||
try {
|
||||
needsPrompt = await window.api.ssh.needsPassphrasePrompt({
|
||||
targetId: session.connectionId
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[pty-connection] needsPassphrasePrompt probe failed:', err)
|
||||
// Why: on probe failure fall through to auto-connect rather than stranding the tab — a stuck tab is worse than a surprising prompt.
|
||||
}
|
||||
if (session.disposed || !session.capturedDirectSshRetryLeaseMatches()) {
|
||||
return
|
||||
}
|
||||
if (needsPrompt) {
|
||||
const alreadyConnected =
|
||||
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status ===
|
||||
'connected'
|
||||
if (!alreadyConnected) {
|
||||
// Wait for the user-driven connect (sidebar card control or terminal reconnect overlay → passphrase → ssh.connect) to complete.
|
||||
// Why: resolve on terminal-failure statuses too ('auth-failed'/'error'/'reconnection-failed') so it can't hang forever if the user cancels or the connect fails.
|
||||
const outcome = await new Promise<UserInitiatedSshConnectOutcome>((resolve) => {
|
||||
// Why: 'disconnected' counts as terminal only after a non-disconnected status was seen (a real connect attempt that returned to 'disconnected').
|
||||
// Treating the entry-time 'disconnected' as terminal would skip the gate, defeating the passphrase-prompt deferral.
|
||||
let sawNonDisconnected =
|
||||
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status !==
|
||||
'disconnected' &&
|
||||
useAppStore.getState().sshConnectionStates.get(session.connectionId)?.status !==
|
||||
undefined
|
||||
let resolvedOutcome: UserInitiatedSshConnectOutcome = 'cancelled'
|
||||
let settled = false
|
||||
const finish = (nextOutcome: UserInitiatedSshConnectOutcome): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
resolvedOutcome = nextOutcome
|
||||
settled = true
|
||||
unsub()
|
||||
const idx = session.waitTeardowns.indexOf(teardown)
|
||||
if (idx !== -1) {
|
||||
session.waitTeardowns.splice(idx, 1)
|
||||
}
|
||||
resolve(resolvedOutcome)
|
||||
}
|
||||
const teardown = (): void => finish('cancelled')
|
||||
// Why: register a teardown so dispose() can unsubscribe+resolve if the session.pane is torn down mid-wait.
|
||||
// Else the zustand subscriber + async IIFE leak: the callback only checks `session.disposed` when it next fires, which may never happen.
|
||||
session.waitTeardowns.push(teardown)
|
||||
const unsub = useAppStore.subscribe((state) => {
|
||||
if (session.disposed) {
|
||||
finish('cancelled')
|
||||
return
|
||||
}
|
||||
const status = state.sshConnectionStates.get(session.connectionId)?.status
|
||||
if (status && status !== 'disconnected') {
|
||||
sawNonDisconnected = true
|
||||
}
|
||||
const nextOutcome = sshPromptConnectOutcomeForStatus(status, sawNonDisconnected)
|
||||
if (nextOutcome) {
|
||||
finish(nextOutcome)
|
||||
}
|
||||
})
|
||||
// Why: re-read state after subscribing to catch a status change that landed between the alreadyConnected check and the subscribe — else we'd wait forever.
|
||||
if (session.disposed) {
|
||||
finish('cancelled')
|
||||
return
|
||||
}
|
||||
const currentStatus = useAppStore
|
||||
.getState()
|
||||
.sshConnectionStates.get(session.connectionId)?.status
|
||||
const currentOutcome = sshPromptConnectOutcomeForStatus(
|
||||
currentStatus,
|
||||
sawNonDisconnected
|
||||
)
|
||||
if (currentOutcome) {
|
||||
finish(currentOutcome)
|
||||
}
|
||||
})
|
||||
if (session.disposed || !session.capturedDirectSshRetryLeaseMatches()) {
|
||||
return
|
||||
}
|
||||
if (outcome === 'cancelled') {
|
||||
return
|
||||
}
|
||||
if (outcome === 'failed') {
|
||||
session.reportError('SSH connection failed')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: wait for the shared SSH connection (multiple panes/tabs may need it) before PTY reattach, rather than returning early when it's in-flight.
|
||||
const connectResult = await waitForSshConnection(session.connectionId)
|
||||
if (session.disposed || !session.capturedDirectSshRetryLeaseMatches()) {
|
||||
return
|
||||
}
|
||||
if (!connectResult.connected) {
|
||||
session.reportError(`SSH connection failed: ${connectResult.error}`)
|
||||
return
|
||||
}
|
||||
useAppStore.getState().removeDeferredSshReconnectTarget(session.connectionId)
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
if (pendingSessionId) {
|
||||
if (session.isLegacyWorkerAutomaticResumeBlocked()) {
|
||||
if (session.attachRetainedLegacyPty(pendingSessionId)) {
|
||||
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
|
||||
scheduleRuntimeGraphSync()
|
||||
}
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
`[pty-connection] Attempting reattach for tab=${session.deps.tabId} sessionId=${pendingSessionId}`
|
||||
)
|
||||
// Why: the saved remote PTY id is single-use restore metadata; clear it before attach so remounts don't keep retrying an expired session.
|
||||
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
|
||||
// Why: pre-signal SSH-deferred reattach too so the cooperation gate applies uniformly to remote sessions (Electron preserves the declare→connect order).
|
||||
// See docs/mobile-prefer-renderer-scrollback.md.
|
||||
const preSignalPromise =
|
||||
session.runtimeEnvironmentId || isRemoteRuntimePtyId(pendingSessionId)
|
||||
? Promise.resolve(null)
|
||||
: window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null)
|
||||
let expiredReattachError = false
|
||||
const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup()
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks((message) => {
|
||||
if (isSshSessionExpiredError(message)) {
|
||||
expiredReattachError = true
|
||||
return
|
||||
}
|
||||
if (!session.isCapturedDirectSshReattachCurrent(pendingSessionId)) {
|
||||
return
|
||||
}
|
||||
session.reportError(message)
|
||||
})
|
||||
session.beginReattachLiveDataDeferral(outputCallbacks.generation)
|
||||
session.transportConnectInFlightSince = Date.now()
|
||||
const reattachPromise = session.transport.connect({
|
||||
url: '',
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
sessionId: pendingSessionId,
|
||||
...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}),
|
||||
...(coldRestoreStartup?.env
|
||||
? { env: session.mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) }
|
||||
: {}),
|
||||
...(coldRestoreStartup?.launchConfig
|
||||
? { launchConfig: coldRestoreStartup.launchConfig }
|
||||
: {}),
|
||||
...(coldRestoreStartup?.resumeProviderSession
|
||||
? { resumeProviderSession: coldRestoreStartup.resumeProviderSession }
|
||||
: {}),
|
||||
...(coldRestoreStartup?.launchToken
|
||||
? { launchToken: coldRestoreStartup.launchToken }
|
||||
: {}),
|
||||
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
|
||||
...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
...(session.directSshRetryAttempt
|
||||
? { admitPtyId: session.claimCapturedDirectSshRetryPty }
|
||||
: {}),
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
void Promise.resolve(reattachPromise)
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
session.transportConnectInFlightSince = null
|
||||
})
|
||||
const trackedReattachPromise = Promise.resolve(reattachPromise)
|
||||
.then(async (result) => {
|
||||
if (outputCallbacks.generation !== session.transportStreamGeneration) {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty
|
||||
.clearPendingPaneSerializer(session.cacheKey, gen)
|
||||
.catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
`[pty-connection] Reattach result for tab=${session.deps.tabId}:`,
|
||||
result
|
||||
? {
|
||||
sessionExpired: (result as Record<string, unknown>).sessionExpired,
|
||||
replay: !!(result as Record<string, unknown>).replay
|
||||
}
|
||||
: 'undefined'
|
||||
)
|
||||
if (!result && expiredReattachError) {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty
|
||||
.clearPendingPaneSerializer(session.cacheKey, gen)
|
||||
.catch(() => {})
|
||||
}
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
if (session.rejectObsoleteDirectSshReattach(pendingSessionId)) {
|
||||
return
|
||||
}
|
||||
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, pendingSessionId)
|
||||
session.deps.clearTabPtyId(session.deps.tabId, pendingSessionId)
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
return
|
||||
}
|
||||
const accepted = await session.handleReattachResult(
|
||||
result,
|
||||
pendingSessionId,
|
||||
coldRestoreStartup,
|
||||
outputCallbacks.generation
|
||||
)
|
||||
session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
if (!accepted) {
|
||||
await window.api.pty
|
||||
.clearPendingPaneSerializer(session.cacheKey, gen)
|
||||
.catch(() => {})
|
||||
} else if (!isRemoteRuntimePtyId(pendingSessionId)) {
|
||||
const settledPtyId =
|
||||
result && typeof result === 'object' && 'id' in result
|
||||
? result.id
|
||||
: (session.transport.getPtyId() ?? pendingSessionId)
|
||||
const hasRestorePayload =
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
('snapshot' in result || 'replay' in result || 'coldRestore' in result)
|
||||
await (hasRestorePayload
|
||||
? session.settlePaneSerializerAfterReplay(settledPtyId, gen)
|
||||
: window.api.pty.settlePaneSerializer(session.cacheKey, gen))
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty
|
||||
.clearPendingPaneSerializer(session.cacheKey, gen)
|
||||
.catch(() => {})
|
||||
}
|
||||
console.warn(`[pty-connection] Reattach FAILED for tab=${session.deps.tabId}:`, err)
|
||||
if (
|
||||
session.disposed ||
|
||||
outputCallbacks.generation !== session.transportStreamGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (session.rejectObsoleteDirectSshReattach(pendingSessionId)) {
|
||||
return
|
||||
}
|
||||
if (isSshSessionExpiredError(err)) {
|
||||
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, pendingSessionId)
|
||||
session.deps.clearTabPtyId(session.deps.tabId, pendingSessionId)
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
return
|
||||
}
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
})
|
||||
session.armDirectSshPaneRetryTimeout(
|
||||
trackedReattachPromise,
|
||||
session.directSshRetryAttempt
|
||||
)
|
||||
} else {
|
||||
session.startFreshColdRestoreAgentResume()
|
||||
}
|
||||
})()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runDeferredSessionReattachChoice(session)
|
||||
}
|
||||
+225
@@ -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()
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics'
|
||||
import { recordPtyConnectDiagnostic } from './pty-connect-limits'
|
||||
import { isSshSessionExpiredError } from './ssh-session-connect'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function startDeferredSessionReattach(
|
||||
session: ConnectPanePtySession,
|
||||
deferredReattachSessionId: string
|
||||
): void {
|
||||
session.allowInitialIdleCacheSeed = true
|
||||
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> REATTACH ${deferredReattachSessionId}`)
|
||||
session.prepaintParkedSshSnapshot(deferredReattachSessionId)
|
||||
|
||||
// Why: pre-signal (declare) before the reattach connect so the cooperation gate suppresses the daemon seed for this paneKey; Electron preserves IPC order.
|
||||
// See docs/mobile-prefer-renderer-scrollback.md (Renderer-side prerequisite requirement #4).
|
||||
const preSignalPromise =
|
||||
session.runtimeEnvironmentId || isRemoteRuntimePtyId(deferredReattachSessionId)
|
||||
? Promise.resolve(null)
|
||||
: window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null)
|
||||
|
||||
let expiredReattachError = false
|
||||
const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks((message) => {
|
||||
if (isSshSessionExpiredError(message)) {
|
||||
expiredReattachError = true
|
||||
return
|
||||
}
|
||||
if (!session.isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) {
|
||||
return
|
||||
}
|
||||
session.reportError(message)
|
||||
})
|
||||
session.beginReattachLiveDataDeferral(outputCallbacks.generation)
|
||||
session.transportConnectInFlightSince = Date.now()
|
||||
const reattachPromise = session.transport.connect({
|
||||
url: '',
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
sessionId: deferredReattachSessionId,
|
||||
...(coldRestoreStartup?.command ? { command: coldRestoreStartup.command } : {}),
|
||||
...(coldRestoreStartup?.env
|
||||
? { env: session.mergeStartupEnvWithPaneIdentity(coldRestoreStartup.env) }
|
||||
: {}),
|
||||
...(coldRestoreStartup?.launchConfig ? { launchConfig: coldRestoreStartup.launchConfig } : {}),
|
||||
...(coldRestoreStartup?.resumeProviderSession
|
||||
? { resumeProviderSession: coldRestoreStartup.resumeProviderSession }
|
||||
: {}),
|
||||
...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}),
|
||||
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
|
||||
...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
...(session.directSshRetryAttempt
|
||||
? { admitPtyId: session.claimCapturedDirectSshRetryPty }
|
||||
: {}),
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
|
||||
void Promise.resolve(reattachPromise)
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
session.transportConnectInFlightSince = null
|
||||
})
|
||||
const trackedReattachPromise = Promise.resolve(reattachPromise)
|
||||
.then(async (result) => {
|
||||
if (outputCallbacks.generation !== session.transportStreamGeneration) {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!result && expiredReattachError) {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
if (session.rejectObsoleteDirectSshReattach(deferredReattachSessionId)) {
|
||||
return
|
||||
}
|
||||
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId)
|
||||
session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId)
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
return
|
||||
}
|
||||
const accepted = await session.handleReattachResult(
|
||||
result,
|
||||
deferredReattachSessionId,
|
||||
coldRestoreStartup,
|
||||
outputCallbacks.generation
|
||||
)
|
||||
session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
if (!accepted) {
|
||||
await window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
} else if (!isRemoteRuntimePtyId(deferredReattachSessionId)) {
|
||||
const settledPtyId =
|
||||
result && typeof result === 'object' && 'id' in result
|
||||
? result.id
|
||||
: (session.transport.getPtyId() ?? deferredReattachSessionId)
|
||||
const hasRestorePayload =
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
('snapshot' in result || 'replay' in result || 'coldRestore' in result)
|
||||
await (hasRestorePayload
|
||||
? session.settlePaneSerializerAfterReplay(settledPtyId, gen)
|
||||
: window.api.pty.settlePaneSerializer(session.cacheKey, gen))
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (outputCallbacks.generation !== session.transportStreamGeneration) {
|
||||
return
|
||||
}
|
||||
if (session.rejectObsoleteDirectSshReattach(deferredReattachSessionId)) {
|
||||
return
|
||||
}
|
||||
warnTerminalLifecycleAnomaly('restored PTY reattach threw', {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
leafId: session.deps.restoredLeafId ?? session.pane.leafId,
|
||||
paneId: session.pane.id,
|
||||
ptyId: deferredReattachSessionId,
|
||||
reason: message
|
||||
})
|
||||
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId)
|
||||
session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId)
|
||||
if (session.connectionId && isSshSessionExpiredError(err)) {
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
return
|
||||
}
|
||||
session.reportError(message)
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
})
|
||||
session.armDirectSshPaneRetryTimeout(trackedReattachPromise, session.directSshRetryAttempt)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery'
|
||||
|
||||
export type DirectSshRetryLease = Pick<
|
||||
DirectSshPaneRetryAttempt,
|
||||
'attemptId' | 'authority' | 'tabGeneration'
|
||||
>
|
||||
@@ -0,0 +1,291 @@
|
||||
import { reportWorkerTerminalUserInput } from '@/lib/worker-terminal-takeover-report'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import {
|
||||
getCachedWindowsTerminalCapabilities,
|
||||
hasCachedWindowsTerminalCapabilities
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
import { requestTerminalWritePipelineProbe } from '@/lib/pane-manager/terminal-write-pipeline-health'
|
||||
import {
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL,
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
} from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { subscribeToTerminalUserInput } from '../terminal-user-input-signal'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import {
|
||||
isLocalNativeWindowsConpty,
|
||||
resolveWindowsShellOverride
|
||||
} from '@/lib/pane-manager/windows-pty-compatibility'
|
||||
import { shouldSuppressCodexAutoApprovalStatus } from '../codex-auto-approval-notification-suppression'
|
||||
import { createCommandCodeOutputStatusDetector } from '../../../../../shared/command-code-output-status'
|
||||
import { readInFlightCommandCodeTurn } from '../parked-terminal-command-status'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
|
||||
import {
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from '../../../../../shared/agent-title-owner'
|
||||
import { isMainTerminalSideEffectAuthorityForPty } from '../terminal-side-effect-facts-handler'
|
||||
import { isRendererHiddenPtyDeliveryGateEnabled } from '../terminal-hidden-delivery-gate'
|
||||
import {
|
||||
markRendererOwnedAgentStatusWrite,
|
||||
registerRendererOwnedAgentStatusPane
|
||||
} from '../renderer-owned-agent-status-registry'
|
||||
|
||||
import { DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS } from './pty-connect-limits'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { resolveLatestAgentDoneStartedAt } from './agent-done-started-at'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import type { DirectSshRetryLease } from './direct-ssh-retry-lease'
|
||||
|
||||
export function installDirectSshRetryStatus(session: ConnectPanePtySession): void {
|
||||
session.armDirectSshPaneRetryTimeout = (
|
||||
promise: Promise<unknown>,
|
||||
attempt: DirectSshRetryLease | undefined
|
||||
): void => {
|
||||
if (!attempt || session.disposed || session.directSshPaneRetryTimedPromises.has(promise)) {
|
||||
return
|
||||
}
|
||||
session.directSshPaneRetryTimedPromises.add(promise)
|
||||
const timer = setTimeout(() => {
|
||||
session.directSshPaneRetrySettlementTimers.delete(timer)
|
||||
if (session.directSshPaneRetrySettlementCancelled) {
|
||||
return
|
||||
}
|
||||
session.settleDirectSshPaneRetryAttempt(attempt, 'timed-out')
|
||||
}, DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS)
|
||||
session.directSshPaneRetrySettlementTimers.add(timer)
|
||||
void promise
|
||||
.finally(() => {
|
||||
session.directSshPaneRetrySettlementTimers.delete(timer)
|
||||
clearTimeout(timer)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
session.shellOverride = session.tab?.shellOverride
|
||||
// Why: a serve/remote-runtime pane has no SSH connectionId and a Linux cwd, so
|
||||
// the native-Windows ConPTY heuristic misfires on a Windows client and wrongly
|
||||
// enables ConPTY synchronized-output protection, which strips an agent's
|
||||
// transient cursor-show (?25h) and leaves the cursor invisible. The execution
|
||||
// host is the authoritative signal: only a 'local' host is a local native PTY.
|
||||
session.executionHostId = session.terminalOwnerUnresolved
|
||||
? ('runtime:unresolved-owner' as const)
|
||||
: getExecutionHostIdForWorktree(session.state, session.deps.worktreeId)
|
||||
session.isNativeWindowsConpty = isLocalNativeWindowsConpty({
|
||||
userAgent: navigator.userAgent,
|
||||
connectionId: session.connectionId,
|
||||
cwd: session.deps.cwd,
|
||||
// Why: main folds the global Windows shell into its spawn classification
|
||||
// (pty.ts effectiveShellOverride); fold it here too so both sides treat
|
||||
// a global-WSL default identically (terminal-query-authority.md ConPTY).
|
||||
shellOverride: resolveWindowsShellOverride(
|
||||
session.shellOverride,
|
||||
session.state.settings?.terminalWindowsShell
|
||||
),
|
||||
executionHostId: session.executionHostId
|
||||
})
|
||||
if (session.isNativeWindowsConpty) {
|
||||
// Why: Windows ConPTY agent turns can leave renderer keyboard modes armed
|
||||
// after completion, corrupting plain input with encoded bytes.
|
||||
session.idleAgentTerminalModeReset = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`
|
||||
}
|
||||
session.shouldApplyNativeWindowsRewriteRefresh = session.isNativeWindowsConpty
|
||||
session.shouldApplyWindowsRendererUnicodeRefresh = CLIENT_PLATFORM === 'win32'
|
||||
session.shouldProtectNativeWindowsSynchronizedOutput = session.isNativeWindowsConpty
|
||||
session.unsubscribeWindowsDoneTerminalModeReset = null
|
||||
if (session.isNativeWindowsConpty) {
|
||||
const initialAgentStatus = session.state.agentStatusByPaneKey[session.cacheKey]
|
||||
let lastAgentDoneStartedAt = resolveLatestAgentDoneStartedAt(initialAgentStatus)
|
||||
if (
|
||||
!initialAgentStatus &&
|
||||
session.paneStartup?.telemetry?.launch_source === 'sidebar' &&
|
||||
session.paneStartup.telemetry.request_kind === 'resume' &&
|
||||
(session.paneStartup.launchAgent === 'codex' ||
|
||||
session.paneStartup.telemetry.agent_kind === 'codex')
|
||||
) {
|
||||
// Why: history resumes open on a completed Codex composer without a done
|
||||
// row, so arm the same Windows stale-focus guard until work starts again.
|
||||
session.suppressNativeWindowsIdleCodexFocusReports = true
|
||||
}
|
||||
if (initialAgentStatus?.state === 'done') {
|
||||
session.setFocusReportSuppressionForAgentCompletion(undefined, initialAgentStatus.agentType)
|
||||
}
|
||||
session.unsubscribeWindowsDoneTerminalModeReset = useAppStore.subscribe((nextState) => {
|
||||
const nextAgentStatus = nextState.agentStatusByPaneKey[session.cacheKey]
|
||||
const nextAgentStatusState = nextAgentStatus?.state
|
||||
if (nextAgentStatusState === 'done') {
|
||||
session.setFocusReportSuppressionForAgentCompletion(undefined, nextAgentStatus.agentType)
|
||||
} else if (nextAgentStatusState) {
|
||||
session.suppressNativeWindowsIdleCodexFocusReports = false
|
||||
}
|
||||
const nextAgentDoneStartedAt = resolveLatestAgentDoneStartedAt(nextAgentStatus)
|
||||
// Why: a NEW completed turn — same-state `done` pings keep stateStartedAt, so they no-op.
|
||||
if (
|
||||
nextAgentDoneStartedAt !== undefined &&
|
||||
nextAgentDoneStartedAt !== lastAgentDoneStartedAt
|
||||
) {
|
||||
session.queueAgentIdleTerminalModeReset()
|
||||
}
|
||||
lastAgentDoneStartedAt = nextAgentDoneStartedAt
|
||||
})
|
||||
}
|
||||
|
||||
session.localWindowsTerminalCapabilities = hasCachedWindowsTerminalCapabilities()
|
||||
? getCachedWindowsTerminalCapabilities()
|
||||
: null
|
||||
session.projectRuntime =
|
||||
!session.tab?.forceHostRuntime && !session.connectionId && session.runtimeEnvironmentId === null
|
||||
? getLocalProjectExecutionRuntimeContext(session.state, session.deps.worktreeId, undefined, {
|
||||
wslAvailable: session.localWindowsTerminalCapabilities?.wslAvailable,
|
||||
availableWslDistros: session.localWindowsTerminalCapabilities?.wslDistros ?? null
|
||||
})
|
||||
: undefined
|
||||
session.shouldOwnAgentStatusInRenderer = session.runtimeEnvironmentId !== null
|
||||
// Why: the host also mirrors agent status for this pane through session.tabs.
|
||||
// Claiming here (decided once at transport creation, like the side-effect
|
||||
// authority below) lets the mirror keep this renderer's byte-derived status
|
||||
// instead of overwriting/deleting it on every republication.
|
||||
session.releaseRendererOwnedAgentStatusPane =
|
||||
session.runtimeEnvironmentId !== null
|
||||
? registerRendererOwnedAgentStatusPane(session.cacheKey, session.runtimeEnvironmentId)
|
||||
: null
|
||||
session.handleRendererOwnedAgentStatus = (payload): void => {
|
||||
if (
|
||||
shouldSuppressCodexAutoApprovalStatus(payload, {
|
||||
paneKey: session.cacheKey,
|
||||
tabId: session.deps.tabId,
|
||||
...(session.launchToken ? { launchToken: session.launchToken } : {})
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
const currentState = useAppStore.getState()
|
||||
const routing = session.resolveCurrentAgentStatusRouting()
|
||||
if (!routing) {
|
||||
return
|
||||
}
|
||||
const title = currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
|
||||
const authoritativePaneAgent = session.getAuthoritativePaneAgent()
|
||||
const agentType = resolveCompatibleAgentTypeForOwner(payload.agentType, authoritativePaneAgent)
|
||||
const statusPayload = agentType === payload.agentType ? payload : { ...payload, agentType }
|
||||
const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
|
||||
const statusTitle = resolvedStatusTitle
|
||||
? normalizeCompatibleAgentTitleForOwner(
|
||||
resolvedStatusTitle,
|
||||
agentType ?? authoritativePaneAgent
|
||||
)
|
||||
: resolvedStatusTitle
|
||||
// Why: proves the claim — only a pane that really produced byte-derived
|
||||
// status may fence the host mirror out of its store key.
|
||||
markRendererOwnedAgentStatusWrite(session.cacheKey)
|
||||
if (session.launchToken) {
|
||||
currentState.setAgentStatus(
|
||||
session.cacheKey,
|
||||
statusPayload,
|
||||
statusTitle,
|
||||
undefined,
|
||||
routing,
|
||||
{
|
||||
launchToken: session.launchToken
|
||||
}
|
||||
)
|
||||
} else {
|
||||
currentState.setAgentStatus(session.cacheKey, statusPayload, statusTitle, undefined, routing)
|
||||
}
|
||||
if (payload.state === 'working' && session.syncAgentTaskCompleteTrackingEnabled()) {
|
||||
session.requiresFreshWorkingForAgentTaskCompleteNotification = false
|
||||
}
|
||||
const storedStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
|
||||
const notificationPayload =
|
||||
typeof storedStatus?.stateStartedAt === 'number'
|
||||
? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt }
|
||||
: statusPayload
|
||||
// Why: hook lifecycle owns deferred side effects even when alerts are disabled.
|
||||
session.agentCompletionCoordinator.observeHookStatus(notificationPayload)
|
||||
if (payload.state === 'working' && session.pendingTerminalBellNotification) {
|
||||
session.scheduleTerminalBellNotification()
|
||||
}
|
||||
}
|
||||
// Why: when main holds side-effect authority for this PTY's bytes, the
|
||||
// transport must NOT register title/bell/agent byte parsers — the
|
||||
// pty:sideEffect fact consumer below is the single policy consumer.
|
||||
// Decided once at transport creation so a fact never has two consumers.
|
||||
session.mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: session.state.settings,
|
||||
runtimeEnvironmentId: session.runtimeEnvironmentId
|
||||
})
|
||||
// Why: Phase-4 hidden-delivery gate — only meaningful under main authority
|
||||
// (renderer byte parsers need bytes otherwise). Decided once at pane
|
||||
// creation: it picks which path records a mode-2031 subscription (main's
|
||||
// fact vs the byte scan), which must have exactly one owner.
|
||||
session.hiddenDeliveryGateActive =
|
||||
session.mainSideEffectAuthority &&
|
||||
isRendererHiddenPtyDeliveryGateEnabled(session.state.settings)
|
||||
// Why: structural per-PTY gate predicate (authority on + gate on + bytes
|
||||
// transit local main, which implies snapshot-backed). Shared by the hidden
|
||||
// mark sync and mode-2031 subscription recording so the recorder can never
|
||||
// disagree with what main may drop — and never depends on the racy hidden
|
||||
// mark (a fact can outrun the pty:data task that sets it).
|
||||
session.isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string =>
|
||||
session.hiddenDeliveryGateActive && Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId)
|
||||
// Why (byte-parser mode only): with main authority the Command Code scrape
|
||||
// runs in main's per-PTY tracker and arrives as command-code facts; running
|
||||
// the byte detector too would double-drive the seed/settle policy above.
|
||||
session.commandCodeOutputStatusDetector = session.mainSideEffectAuthority
|
||||
? null
|
||||
: createCommandCodeOutputStatusDetector({
|
||||
startupCommand: session.paneStartup?.command,
|
||||
// Why the seed: a reveal remount recreates this detector long past the banner
|
||||
// (and with no startup command); a turn parked mid-flight must still arm the
|
||||
// scrape so its return to the idle composer completes the row.
|
||||
inFlightTurn: readInFlightCommandCodeTurn(session.cacheKey),
|
||||
onWorking: session.seedCommandCodeOutputWorkingStatus,
|
||||
onDone: session.scheduleCommandCodeOutputDoneStatus
|
||||
})
|
||||
session.shouldDeliverStartupViaTerminalPaste = session.paneStartup?.delivery === 'terminal-paste'
|
||||
session.shouldUseProviderSshStartupDelivery =
|
||||
Boolean(session.connectionId) && !session.shouldDeliverStartupViaTerminalPaste
|
||||
session.hadExistingPaneTransportAtConnect = session.deps.paneTransportsRef.current.size > 0
|
||||
session.lastTerminalInputAt = Number.NEGATIVE_INFINITY
|
||||
session.hasReceivedPtyOutput = false
|
||||
session.deferredReattachLiveData = null
|
||||
session.reattachLiveDataDeferralDepth = 0
|
||||
session.deferredReattachLiveDataOwners = new Map<number, { failed: boolean }>()
|
||||
session.transportStreamGeneration = 0
|
||||
session.markTerminalInputSent = (): void => {
|
||||
session.lastTerminalInputAt = performance.now()
|
||||
// Why: input must probe a wedged xterm even when the PTY produces no renderer output.
|
||||
requestTerminalWritePipelineProbe(session.pane.terminal)
|
||||
}
|
||||
session.recordTerminalInputForHibernation = (): void => {
|
||||
useAppStore.getState().recordTerminalInput(session.cacheKey)
|
||||
}
|
||||
// Why: onData mixes real user input with xterm's parser auto-replies (focus
|
||||
// reports, DA/DSR/CPR responses). Recording those replies as activity makes
|
||||
// the hibernation planner treat a pane hidden after its agent finished as
|
||||
// "input after done" forever. The core user-input signal fires only for real
|
||||
// input, so hibernation activity records from it; onData recording remains
|
||||
// solely as the fallback when the internal API is unavailable.
|
||||
session.recordRealUserTerminalInput = (): void => {
|
||||
session.recordTerminalInputForHibernation()
|
||||
// Takeover must never fire from the onData fallback below: it mixes in auto-replies.
|
||||
reportWorkerTerminalUserInput(session.cacheKey, session.runtimeEnvironmentId)
|
||||
}
|
||||
session.userInputActivityDisposable = subscribeToTerminalUserInput(
|
||||
session.pane.terminal,
|
||||
session.recordRealUserTerminalInput
|
||||
)
|
||||
session.recordTerminalInputForHibernationFallback = (): void => {
|
||||
if (session.userInputActivityDisposable === null) {
|
||||
session.recordTerminalInputForHibernation()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import type { PtyBufferSnapshot } from '../pty-transport'
|
||||
import type { PtyDataMeta } from '../pty-dispatcher'
|
||||
|
||||
type E2eTerminalPtyDataInjectionApi = {
|
||||
inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean
|
||||
keys: () => string[]
|
||||
}
|
||||
|
||||
type E2eTerminalPtyDataInjectionWindow = Window & {
|
||||
__terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi
|
||||
__terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi
|
||||
}
|
||||
|
||||
const e2eTerminalPtyDataInjectors = new Map<string, (data: string, meta?: PtyDataMeta) => void>()
|
||||
|
||||
type E2eTerminalHiddenSnapshotOverrideApi = {
|
||||
setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void
|
||||
resolve: (ptyId: string) => void
|
||||
clear: (ptyId: string) => void
|
||||
}
|
||||
|
||||
type E2eTerminalHiddenSnapshotOverride = {
|
||||
promise: Promise<PtyBufferSnapshot | null>
|
||||
resolve: () => void
|
||||
}
|
||||
|
||||
const e2eTerminalHiddenSnapshotOverrides = new Map<string, E2eTerminalHiddenSnapshotOverride>()
|
||||
|
||||
// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes
|
||||
// either never reach the renderer (delivery gate) or ride the background
|
||||
// scheduler queue.
|
||||
type E2eTerminalPtyOutputDebugSnapshot = {
|
||||
hiddenRendererSkipCount: number
|
||||
hiddenRendererSkippedChars: number
|
||||
}
|
||||
|
||||
type E2eTerminalPtyOutputDebugApi = {
|
||||
reset: () => void
|
||||
snapshot: () => E2eTerminalPtyOutputDebugSnapshot
|
||||
}
|
||||
|
||||
type E2eTerminalPtyOutputDebugWindow = Window & {
|
||||
__terminalPtyOutputDebug?: E2eTerminalPtyOutputDebugApi
|
||||
}
|
||||
|
||||
const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = {
|
||||
hiddenRendererSkipCount: 0,
|
||||
hiddenRendererSkippedChars: 0
|
||||
}
|
||||
|
||||
export function resetE2eTerminalPtyOutputDebug(): void {
|
||||
e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount = 0
|
||||
e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars = 0
|
||||
}
|
||||
|
||||
export function exposeE2eTerminalPtyOutputDebug(): void {
|
||||
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
const target = window as E2eTerminalPtyOutputDebugWindow
|
||||
target.__terminalPtyOutputDebug ??= {
|
||||
reset: resetE2eTerminalPtyOutputDebug,
|
||||
snapshot: () => ({ ...e2eTerminalPtyOutputDebugState })
|
||||
}
|
||||
}
|
||||
|
||||
export function recordHiddenRendererSkip(chars: number): void {
|
||||
if (!e2eConfig.exposeStore) {
|
||||
return
|
||||
}
|
||||
exposeE2eTerminalPtyOutputDebug()
|
||||
e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount += 1
|
||||
e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars += chars
|
||||
}
|
||||
|
||||
export function exposeE2eTerminalPtyDataInjection(): void {
|
||||
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
// Why: a real PTY can coalesce tiny TUI redraws before E2E sees them. This
|
||||
// e2e-only seam lets tests replay the renderer-side data callback exactly.
|
||||
const target = window as E2eTerminalPtyDataInjectionWindow
|
||||
target.__terminalPtyDataInjection ??= {
|
||||
inject: (paneKey, data, meta) => {
|
||||
const inject = e2eTerminalPtyDataInjectors.get(paneKey)
|
||||
if (!inject) {
|
||||
return false
|
||||
}
|
||||
inject(data, meta)
|
||||
return true
|
||||
},
|
||||
keys: () => [...e2eTerminalPtyDataInjectors.keys()]
|
||||
}
|
||||
target.__terminalHiddenSnapshotOverride ??= {
|
||||
setPending: (ptyId, snapshot) => {
|
||||
let resolve = (): void => {}
|
||||
const wait = new Promise<void>((nextResolve) => {
|
||||
resolve = nextResolve
|
||||
})
|
||||
e2eTerminalHiddenSnapshotOverrides.set(ptyId, {
|
||||
promise: wait.then(() => snapshot),
|
||||
resolve
|
||||
})
|
||||
},
|
||||
resolve: (ptyId) => {
|
||||
e2eTerminalHiddenSnapshotOverrides.get(ptyId)?.resolve()
|
||||
},
|
||||
clear: (ptyId) => {
|
||||
e2eTerminalHiddenSnapshotOverrides.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerE2eTerminalPtyDataInjection(
|
||||
paneKey: string,
|
||||
inject: (data: string, meta?: PtyDataMeta) => void
|
||||
): () => void {
|
||||
if (!e2eConfig.exposeStore) {
|
||||
return () => {}
|
||||
}
|
||||
exposeE2eTerminalPtyDataInjection()
|
||||
e2eTerminalPtyDataInjectors.set(paneKey, inject)
|
||||
return () => {
|
||||
if (e2eTerminalPtyDataInjectors.get(paneKey) === inject) {
|
||||
e2eTerminalPtyDataInjectors.delete(paneKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readE2eHiddenSnapshotOverride(
|
||||
ptyId: string
|
||||
): Promise<PtyBufferSnapshot | null> | null {
|
||||
if (!e2eConfig.exposeStore) {
|
||||
return null
|
||||
}
|
||||
const override = e2eTerminalHiddenSnapshotOverrides.get(ptyId)
|
||||
if (!override) {
|
||||
return null
|
||||
}
|
||||
// Why: visual E2E needs to hold a hidden restore snapshot in flight so a
|
||||
// newer live TUI frame can race it deterministically.
|
||||
return override.promise.finally(() => {
|
||||
if (e2eTerminalHiddenSnapshotOverrides.get(ptyId) === override) {
|
||||
e2eTerminalHiddenSnapshotOverrides.delete(ptyId)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048
|
||||
export const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024
|
||||
export const FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS = 150
|
||||
// Why: a submit repaint can take longer than one keystroke echo to fully
|
||||
// arrive, so a synchronized frame that *began* this close to a keystroke stays
|
||||
// latency-sensitive even when ConPTY splits its end marker past the redraw
|
||||
// window — the keystroke is the "user is here, paint now" signal, not the
|
||||
// late closing chunk.
|
||||
export const FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS = 400
|
||||
// Why: OpenTUI can emit many tiny redraws that each look interactive but
|
||||
// collectively starve timers unless foreground writes have a rolling budget.
|
||||
export const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024
|
||||
export const FOREGROUND_BUDGET_WINDOW_MS = 500
|
||||
export const INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS = 32 * 1024
|
||||
export const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs'
|
||||
import { redactPtyIdForDiagnostics } from '../../../../../shared/pty-delivery-diagnostics'
|
||||
import {
|
||||
terminalRewriteOutputRenderRefreshDecision,
|
||||
terminalRewriteOutputPrefersRenderRefresh,
|
||||
windowsEastAsianOutputPrefersRenderRefresh
|
||||
} from '@/lib/pane-manager/terminal-complex-script'
|
||||
import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
|
||||
import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner'
|
||||
import { registerPtyModelRestoreNeededHandler } from '../pty-model-restore-channel'
|
||||
import {
|
||||
acquireHiddenRendererPtyDeliveryClaim,
|
||||
declareRendererPtyDeliveryVisible
|
||||
} from '../pty-renderer-delivery-claims'
|
||||
|
||||
import { HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS } from './hidden-output-restore-limits'
|
||||
import {
|
||||
FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS,
|
||||
FOREGROUND_INTERACTIVE_REDRAW_CHARS,
|
||||
FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS,
|
||||
FOREGROUND_IMMEDIATE_BUDGET_CHARS,
|
||||
FOREGROUND_BUDGET_WINDOW_MS
|
||||
} from './foreground-output-budgets'
|
||||
import {
|
||||
shouldWritePtyOutputForeground,
|
||||
consumeInactiveForegroundImmediateBudget
|
||||
} from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindForegroundOutputRefresh(session: ConnectPanePtySession): void {
|
||||
session.noteHiddenOutputRestoreFloodBackpressure = function (): void {
|
||||
session.hiddenOutputRestoreFloodSuppressedUntil =
|
||||
Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (ptyId === null) {
|
||||
return
|
||||
}
|
||||
session.clearHiddenOutputRestoreFloodRepaintTimer()
|
||||
session.hiddenOutputRestoreFloodRepaintTimer = setTimeout(() => {
|
||||
session.hiddenOutputRestoreFloodRepaintTimer = null
|
||||
if (session.disposed || session.transport.getPtyId() !== ptyId) {
|
||||
return
|
||||
}
|
||||
// Why one repaint: flood-dropped bytes leave a gap the live stream can't heal; once quiet, one snapshot restore repaints from main's authoritative buffer.
|
||||
session.repaintAfterFloodWhenFollowingOutput(ptyId)
|
||||
}, HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS)
|
||||
}
|
||||
|
||||
// Why: main reports dropped renderer-bound bytes out-of-band, routed per PTY by pty-model-restore-channel.ts.
|
||||
function handleModelRestoreNeededMarker(): void {
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
recordTerminalFreezeBreadcrumb('restore-marker', {
|
||||
id: redactPtyIdForDiagnostics(session.transport.getPtyId() ?? '')
|
||||
})
|
||||
// Why: dropped bytes invalidate cross-chunk carry — a partial OSC-9999 prefix spanning the gap would corrupt the next live chunk.
|
||||
session.transport.resetCrossChunkParserState?.()
|
||||
// Why gated (rc.7.perf loop): on a visible session.pane these markers come from our own restore starving ACKs; re-arming per marker kept the fetch loop alive all flood, so defer to one post-flood repaint.
|
||||
if (session.isForegroundRestoreBackpressureContext()) {
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
return
|
||||
}
|
||||
// Why the emulator too: it carries state across chunks exactly like the
|
||||
// parser does. If the gap swallowed the `ESC[22m` closing a bold run,
|
||||
// every cell written afterwards inherits it. This marker is the one point
|
||||
// where "bytes were dropped" is known, so ground the pen here rather than
|
||||
// relying on each recovery path to remember (STA-4042).
|
||||
// Why after the backpressure return and not before: under flood these
|
||||
// markers arrive continuously, and writing per marker would add work in
|
||||
// exactly the case that guard exists to damp. The flood path repaints via
|
||||
// session.buildMainModelSnapshotReplayWrites, which grounds the pen itself, so
|
||||
// nothing is lost by skipping it here.
|
||||
session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
|
||||
// Why: a marker during an in-flight restore means that snapshot may predate the drop, so a fresh one must follow; capture BEFORE the mark, which starts a restore synchronously on a visible session.pane.
|
||||
const restoreWasInFlight = session.hiddenOutputRestoreInFlight !== null
|
||||
session.markHiddenOutputRestoreNeeded()
|
||||
if (restoreWasInFlight) {
|
||||
session.hiddenOutputRestoreFreshSnapshotNeeded = true
|
||||
}
|
||||
}
|
||||
|
||||
function syncModelRestoreNeededSubscription(ptyId: string | null): void {
|
||||
if (session.modelRestoreSubscribedPtyId === ptyId) {
|
||||
return
|
||||
}
|
||||
session.unregisterModelRestoreNeeded?.()
|
||||
session.unregisterModelRestoreNeeded = null
|
||||
session.modelRestoreSubscribedPtyId = ptyId
|
||||
// Why: markers exist only for PTYs whose bytes transit local main;
|
||||
// remote-runtime transports are structurally unaffected.
|
||||
if (!ptyId || isRemoteRuntimePtyId(ptyId)) {
|
||||
return
|
||||
}
|
||||
session.unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler(
|
||||
ptyId,
|
||||
handleModelRestoreNeededMarker
|
||||
)
|
||||
}
|
||||
|
||||
session.handleRemoteOutputPauseChanged = (paused, supported): void => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (!ptyId || !isRemoteRuntimePtyId(ptyId)) {
|
||||
return
|
||||
}
|
||||
if (!paused) {
|
||||
const wasGated = session.remoteOutputGatedPtyId === ptyId
|
||||
if (wasGated) {
|
||||
session.remoteOutputGatedPtyId = null
|
||||
if (!session.mainSideEffectAuthority) {
|
||||
session.dropSideEffectFactConsumer()
|
||||
}
|
||||
}
|
||||
if (wasGated && session.hiddenOutputRestorePtyId === ptyId) {
|
||||
session.requestHiddenOutputRestoreIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (session.remoteOutputGatedPtyId !== ptyId) {
|
||||
session.remoteOutputGatedPtyId = ptyId
|
||||
}
|
||||
if (supported && session.remoteOutputFactConsumerPtyId !== ptyId) {
|
||||
session.registerSideEffectFactConsumerForPty(ptyId, true)
|
||||
session.remoteOutputFactConsumerPtyId = ptyId
|
||||
}
|
||||
session.markHiddenOutputRestoreNeeded()
|
||||
}
|
||||
|
||||
session.syncHiddenRendererPtyDelivery = (): void => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
syncModelRestoreNeededSubscription(ptyId)
|
||||
if (session.remoteOutputGatedPtyId !== null && session.remoteOutputGatedPtyId !== ptyId) {
|
||||
session.remoteOutputGatedPtyId = null
|
||||
if (!session.mainSideEffectAuthority) {
|
||||
session.dropSideEffectFactConsumer()
|
||||
}
|
||||
}
|
||||
if (isRemoteRuntimePtyId(ptyId) && session.canUseHiddenOutputSnapshot(ptyId)) {
|
||||
session.transport.setOutputPaused?.(
|
||||
!session.disposed && !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (session.hiddenDeliverySyncedPtyId !== null && session.hiddenDeliverySyncedPtyId !== ptyId) {
|
||||
session.releaseHiddenDeliveryClaim?.()
|
||||
session.releaseHiddenDeliveryClaim = null
|
||||
session.hiddenDeliverySyncedPtyId = null
|
||||
}
|
||||
if (
|
||||
!session.isHiddenDeliveryGateManagedPty(ptyId) ||
|
||||
!session.canUseHiddenOutputSnapshot(ptyId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const shouldHide =
|
||||
!session.disposed && !shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
const isFirstSyncForPty = session.hiddenDeliverySyncedPtyId !== ptyId
|
||||
session.hiddenDeliverySyncedPtyId = ptyId
|
||||
if (shouldHide) {
|
||||
if (!session.releaseHiddenDeliveryClaim) {
|
||||
session.releaseHiddenDeliveryClaim = acquireHiddenRendererPtyDeliveryClaim(ptyId)
|
||||
}
|
||||
} else if (session.releaseHiddenDeliveryClaim) {
|
||||
session.releaseHiddenDeliveryClaim()
|
||||
session.releaseHiddenDeliveryClaim = null
|
||||
} else if (isFirstSyncForPty) {
|
||||
// Why: clear unconditionally on first sync — a stale main-side hidden bit can survive a renderer reload for daemon-backed PTYs that keep their session id.
|
||||
declareRendererPtyDeliveryVisible(ptyId)
|
||||
}
|
||||
}
|
||||
session.releaseHiddenRendererPtyDelivery = (): void => {
|
||||
session.transport.setOutputPaused?.(false)
|
||||
if (session.remoteOutputGatedPtyId !== null) {
|
||||
session.remoteOutputGatedPtyId = null
|
||||
if (!session.mainSideEffectAuthority) {
|
||||
session.dropSideEffectFactConsumer()
|
||||
}
|
||||
}
|
||||
session.releaseHiddenDeliveryClaim?.()
|
||||
session.releaseHiddenDeliveryClaim = null
|
||||
session.hiddenDeliverySyncedPtyId = null
|
||||
session.unregisterModelRestoreNeeded?.()
|
||||
session.unregisterModelRestoreNeeded = null
|
||||
session.modelRestoreSubscribedPtyId = null
|
||||
}
|
||||
|
||||
session.beforeTerminalOutputWrite = function (data: string): void {
|
||||
// Why: shaping must register before xterm parses the RTL bytes that need it.
|
||||
ensureArabicShapingJoinerForText(session.pane.terminal, data)
|
||||
recordTerminalOutput(session.pane.terminal)
|
||||
}
|
||||
|
||||
session.consumeForegroundImmediateBudget = function (dataLength: number): boolean {
|
||||
const now = performance.now()
|
||||
if (now - session.foregroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) {
|
||||
session.foregroundImmediateBudgetChars = 0
|
||||
session.foregroundImmediateBudgetWindowStart = now
|
||||
}
|
||||
if (session.foregroundImmediateBudgetChars + dataLength > FOREGROUND_IMMEDIATE_BUDGET_CHARS) {
|
||||
return false
|
||||
}
|
||||
session.foregroundImmediateBudgetChars += dataLength
|
||||
return true
|
||||
}
|
||||
|
||||
session.isActiveSplitPane = function (): boolean {
|
||||
if (!session.deps.isActiveRef.current) {
|
||||
return false
|
||||
}
|
||||
const activePane = session.manager.getActivePane?.() ?? null
|
||||
return activePane ? activePane.id === session.pane.id : true
|
||||
}
|
||||
|
||||
session.isLatencySensitiveForegroundOutput = function (data: string): boolean {
|
||||
if (!session.isActiveSplitPane()) {
|
||||
// Why: many visible split panes each emit tiny TUI frames; a shared budget keeps them live without letting aggregate xterm work starve typing in the active session.pane.
|
||||
if (data.includes('\x1b[')) {
|
||||
return false
|
||||
}
|
||||
return consumeInactiveForegroundImmediateBudget(data.length)
|
||||
}
|
||||
if (data.length <= FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS) {
|
||||
return session.consumeForegroundImmediateBudget(data.length)
|
||||
}
|
||||
const recentInput =
|
||||
performance.now() - session.lastTerminalInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS
|
||||
if (
|
||||
recentInput &&
|
||||
data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS &&
|
||||
data.includes('\x1b[')
|
||||
) {
|
||||
return session.consumeForegroundImmediateBudget(data.length)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
session.containsNonAsciiOutput = function (data: string): boolean {
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
if (data.charCodeAt(index) > 0x7f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function containsWindowsRewriteControl(data: string): boolean {
|
||||
return data.includes('\r') || terminalRewriteOutputPrefersRenderRefresh(data)
|
||||
}
|
||||
|
||||
session.foregroundRewriteOutputPrefersRenderRefresh = function (data: string): boolean {
|
||||
const decision = terminalRewriteOutputRenderRefreshDecision(data, {
|
||||
previousChunkEndsWithCarriageReturn: session.foregroundRewriteChunkEndedWithCarriageReturn,
|
||||
previousRewriteCsiScanTail: session.foregroundRewriteCsiScanTail
|
||||
})
|
||||
session.foregroundRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn
|
||||
session.foregroundRewriteCsiScanTail = decision.nextRewriteCsiScanTail
|
||||
return decision.prefersRenderRefresh
|
||||
}
|
||||
|
||||
session.shouldForceForegroundRenderRefresh = function (data: string): {
|
||||
refresh: boolean
|
||||
inPlaceRewrite: boolean
|
||||
} {
|
||||
const rewriteOutputPrefersRenderRefresh =
|
||||
session.foregroundRewriteOutputPrefersRenderRefresh(data)
|
||||
const recentInput =
|
||||
performance.now() - session.lastTerminalInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS
|
||||
if (session.foregroundRendererRiskOutputPrefersRenderRefresh(data)) {
|
||||
return {
|
||||
refresh: true,
|
||||
inPlaceRewrite: rewriteOutputPrefersRenderRefresh
|
||||
}
|
||||
}
|
||||
if (rewriteOutputPrefersRenderRefresh) {
|
||||
// Why: xterm's buffer is right but in-place redraw cells stay stale in the renderer until a repaint (resize fixes it).
|
||||
return { refresh: true, inPlaceRewrite: true }
|
||||
}
|
||||
if (
|
||||
windowsEastAsianOutputPrefersRenderRefresh(data, {
|
||||
isWindowsClient: session.shouldApplyWindowsRendererUnicodeRefresh,
|
||||
isNativeWindowsConpty: session.shouldApplyNativeWindowsRewriteRefresh,
|
||||
hadRecentInput: recentInput,
|
||||
maxInteractiveRedrawChars: FOREGROUND_INTERACTIVE_REDRAW_CHARS
|
||||
})
|
||||
) {
|
||||
// Why: CJK/Korean from Microsoft Pinyin commits and native ConPTY output can leave stale wide-glyph cells in the Windows DOM renderer.
|
||||
return { refresh: true, inPlaceRewrite: false }
|
||||
}
|
||||
return {
|
||||
refresh:
|
||||
session.shouldApplyNativeWindowsRewriteRefresh &&
|
||||
session.containsNonAsciiOutput(data) &&
|
||||
containsWindowsRewriteControl(data),
|
||||
inPlaceRewrite: false
|
||||
}
|
||||
}
|
||||
|
||||
// Why record-only: DECSET 2031 subscribes to future color changes, it is not a query —
|
||||
// the protocol's query is `CSI ?996n`. fish arms 2031 for the ~1ms it paints a prompt, so
|
||||
// any reply lands after the withdrawal and paints `?997;1n` as literal text (#9993).
|
||||
// Why here and not in xterm's CSI handler: xterm batches several PTY chunks into one
|
||||
// synchronous parse, so a handler cannot tell where a chunk ended. One raw chunk in,
|
||||
// one order-aware final state out.
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { isDocumentVisibilityProvenStale } from '../stale-document-visibility'
|
||||
import {
|
||||
FOREGROUND_BUDGET_WINDOW_MS,
|
||||
INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS
|
||||
} from './foreground-output-budgets'
|
||||
|
||||
export const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256
|
||||
export const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h'
|
||||
export const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l'
|
||||
export const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1
|
||||
export const CURSOR_SHOW_SEQUENCE = '\x1b[?25h'
|
||||
export const CURSOR_HIDE_SEQUENCE = '\x1b[?25l'
|
||||
export const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I'
|
||||
export const TERMINAL_FOCUS_OUT_SEQUENCE = '\x1b[O'
|
||||
export const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l'
|
||||
export const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250
|
||||
export const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350
|
||||
|
||||
let inactiveForegroundImmediateBudgetChars = 0
|
||||
let inactiveForegroundImmediateBudgetWindowStart = 0
|
||||
|
||||
export function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean {
|
||||
if (!isPaneVisible) {
|
||||
return false
|
||||
}
|
||||
if (typeof document === 'undefined') {
|
||||
return true
|
||||
}
|
||||
// Why: Electron can keep visible panes mounted while the whole app is
|
||||
// backgrounded. Treat hidden documents like background tabs so Chromium
|
||||
// timer throttling cannot pin terminal writes on the renderer foreground path.
|
||||
if (document.visibilityState === 'visible') {
|
||||
return true
|
||||
}
|
||||
// Why: macOS occlusion tracking can wedge visibilityState at 'hidden' after
|
||||
// display sleep; proven-stale means real user input contradicted it, so the
|
||||
// hidden-delivery gate must not keep dropping a watched pane's bytes.
|
||||
return isDocumentVisibilityProvenStale()
|
||||
}
|
||||
|
||||
export type SynchronizedForegroundScan = {
|
||||
started: boolean
|
||||
ended: boolean
|
||||
active: boolean
|
||||
markerTail: string
|
||||
}
|
||||
|
||||
// Why the carried tail: ConPTY can split \x1b[?2026l across chunks; scanning the raw
|
||||
// chunk alone left the foreground DEC 2026 latch stuck open so every later chunk was
|
||||
// held instead of coalesced, freezing the visible pane (#8754). Mirrors the hidden path.
|
||||
export function scanSynchronizedForegroundOutput(
|
||||
data: string,
|
||||
markerTail: string,
|
||||
wasActive: boolean
|
||||
): SynchronizedForegroundScan {
|
||||
const scanData = markerTail ? `${markerTail}${data}` : data
|
||||
const currentChunkStartIndex = scanData.length - data.length
|
||||
let active = wasActive
|
||||
let started = false
|
||||
let ended = false
|
||||
let offset = 0
|
||||
|
||||
while (offset < scanData.length) {
|
||||
const startIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE, offset)
|
||||
const endIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE, offset)
|
||||
if (startIndex === -1 && endIndex === -1) {
|
||||
break
|
||||
}
|
||||
if (endIndex !== -1 && (startIndex === -1 || endIndex < startIndex)) {
|
||||
active = false
|
||||
if (endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length > currentChunkStartIndex) {
|
||||
ended = true
|
||||
}
|
||||
offset = endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
|
||||
continue
|
||||
}
|
||||
active = true
|
||||
if (startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length > currentChunkStartIndex) {
|
||||
started = true
|
||||
}
|
||||
offset = startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length
|
||||
}
|
||||
|
||||
return {
|
||||
started,
|
||||
ended,
|
||||
active,
|
||||
// Why length-1: a full marker can never hide in the tail, so no marker is counted twice.
|
||||
markerTail: scanData.slice(-SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS)
|
||||
}
|
||||
}
|
||||
|
||||
export function containsCursorPositionSequence(data: string): boolean {
|
||||
let offset = data.indexOf('\x1b[')
|
||||
while (offset !== -1) {
|
||||
let index = offset + 2
|
||||
while (index < data.length) {
|
||||
const char = data[index]
|
||||
if (char === 'G' || char === 'H' || char === 'f') {
|
||||
return true
|
||||
}
|
||||
if ((char < '0' || char > '9') && char !== ';') {
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
offset = data.indexOf('\x1b[', offset + 2)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function containsCursorRestore(data: string): boolean {
|
||||
const hideIndex = data.indexOf(CURSOR_HIDE_SEQUENCE)
|
||||
const showIndex = data.lastIndexOf(CURSOR_SHOW_SEQUENCE)
|
||||
return hideIndex !== -1 && showIndex > hideIndex && containsCursorPositionSequence(data)
|
||||
}
|
||||
|
||||
export function consumeInactiveForegroundImmediateBudget(dataLength: number): boolean {
|
||||
const now = performance.now()
|
||||
if (now - inactiveForegroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) {
|
||||
inactiveForegroundImmediateBudgetChars = 0
|
||||
inactiveForegroundImmediateBudgetWindowStart = now
|
||||
}
|
||||
if (
|
||||
inactiveForegroundImmediateBudgetChars + dataLength >
|
||||
INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS
|
||||
) {
|
||||
return false
|
||||
}
|
||||
inactiveForegroundImmediateBudgetChars += dataLength
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { replayIntoTerminal, replayIntoTerminalAsync } from '../replay-guard'
|
||||
import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script'
|
||||
import {
|
||||
buildPostReplayLiveAgentReattachReset,
|
||||
POST_REPLAY_MODE_RESET,
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE
|
||||
} from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { buildFreshShellViewportBlankingSequence } from '../terminal-restored-viewport'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import {
|
||||
getTerminalScrollIntentKind,
|
||||
markTerminalFollowOutput
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild'
|
||||
|
||||
import { TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS } from './foreground-output-scan'
|
||||
import type { FreshSpawnOptions } from './fresh-spawn-types'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import { bindStartFreshSpawn } from './fresh-spawn-start'
|
||||
|
||||
export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void {
|
||||
session.resetFreshSpawnFollowOutput = (): void => {
|
||||
session.cancelFreshSpawnFollowReset()
|
||||
markTerminalFollowOutput(session.pane.terminal)
|
||||
let nativeFollowResetComplete = false
|
||||
const tryResetNativeFollow = (): void => {
|
||||
if (
|
||||
session.disposed ||
|
||||
getTerminalScrollIntentKind(session.pane.terminal) !== 'followOutput' ||
|
||||
deferTerminalGeometryMutationDuringRebuild(
|
||||
session.pane.terminal,
|
||||
'fresh-spawn-follow-reset',
|
||||
tryResetNativeFollow
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
session.pane.terminal.scrollToBottom()
|
||||
nativeFollowResetComplete = true
|
||||
session.cancelFreshSpawnFollowReset()
|
||||
} catch (err) {
|
||||
if (!(err instanceof TypeError && /dimensions/.test(err.message))) {
|
||||
session.cancelFreshSpawnFollowReset()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
tryResetNativeFollow()
|
||||
if (!nativeFollowResetComplete) {
|
||||
// Why: xterm's browser viewport can reject scrolling while its renderer
|
||||
// is detached; the first render/resize is the earliest safe native retry.
|
||||
session.freshSpawnFollowResetDisposables = [
|
||||
session.pane.terminal.onRender(tryResetNativeFollow),
|
||||
session.pane.terminal.onResize(tryResetNativeFollow)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
bindStartFreshSpawn(session)
|
||||
function trailingIncompleteCsiSequence(data: string): string {
|
||||
const escapeIndex = data.lastIndexOf('\x1b')
|
||||
if (escapeIndex === -1) {
|
||||
return ''
|
||||
}
|
||||
const tail = data.slice(escapeIndex)
|
||||
if (tail === '\x1b') {
|
||||
return tail
|
||||
}
|
||||
if (!tail.startsWith('\x1b[')) {
|
||||
return ''
|
||||
}
|
||||
for (let index = 2; index < tail.length; index++) {
|
||||
const code = tail.charCodeAt(index)
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return tail.slice(-TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS)
|
||||
}
|
||||
|
||||
session.foregroundRendererRiskOutputPrefersRenderRefresh = function (data: string): boolean {
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
const scanData = session.foregroundRefreshRiskScanTail
|
||||
? `${session.foregroundRefreshRiskScanTail}${data}`
|
||||
: data
|
||||
const prefersRefresh =
|
||||
(scanData.includes('\x1b[') || session.containsNonAsciiOutput(scanData)) &&
|
||||
terminalOutputPrefersRenderRefresh(scanData)
|
||||
session.foregroundRefreshRiskScanTail = trailingIncompleteCsiSequence(scanData)
|
||||
return prefersRefresh
|
||||
}
|
||||
|
||||
// The replay path uses the guard so xterm auto-replies to embedded query
|
||||
// sequences don't leak into the shell. xterm.write() buffers internally
|
||||
// regardless of DOM visibility and the guard stays engaged via the
|
||||
// write-completion callback until xterm finishes parsing.
|
||||
session.writeReplayData = (data: string): void => {
|
||||
// Why: drain any queued background bytes BEFORE the replay paint, so the
|
||||
// scheduler's deferred drain cannot land older bytes on top of the replay.
|
||||
flushTerminalOutput(session.pane.terminal)
|
||||
replayIntoTerminal(session.pane, session.deps.replayingPanesRef, data, {
|
||||
breadcrumbIdentity: {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
ptyId: session.transport.getPtyId()
|
||||
},
|
||||
shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously,
|
||||
shouldReleaseRenderPause: () => session.deps.isVisibleRef.current
|
||||
})
|
||||
}
|
||||
|
||||
session.writeReplayDataAsync = (data: string): Promise<void> => {
|
||||
// Why: WebGL must be rebuilt after xterm has parsed replay bytes, not
|
||||
// merely after the write was queued.
|
||||
flushTerminalOutput(session.pane.terminal)
|
||||
return replayIntoTerminalAsync(session.pane, session.deps.replayingPanesRef, data, {
|
||||
breadcrumbIdentity: {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
ptyId: session.transport.getPtyId()
|
||||
},
|
||||
shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously,
|
||||
shouldReleaseRenderPause: () => session.deps.isVisibleRef.current
|
||||
})
|
||||
}
|
||||
|
||||
session.reattachReplayResetSequence = (
|
||||
payload: string,
|
||||
ownerProcessEnded = false,
|
||||
isAlternateScreen?: boolean
|
||||
): string => {
|
||||
// Why a cold restore overrides the agent signal: liveness is read from the
|
||||
// session.pane's status and title, both of which are persisted, so after a cold
|
||||
// restore they describe the process that died. Preserving "its" modes arms
|
||||
// mouse, focus and paste reporting against the fresh shell that replaces it,
|
||||
// which then prints the reports as junk at the prompt (#12101).
|
||||
if (ownerProcessEnded) {
|
||||
return POST_REPLAY_MODE_RESET
|
||||
}
|
||||
if (session.shouldPreserveAgentReattachModes()) {
|
||||
return buildPostReplayLiveAgentReattachReset(payload)
|
||||
}
|
||||
// Why: an alt-screen session.pane is a live TUI Orca just does not recognise as an agent, and the
|
||||
// replay already re-armed its mouse modes — keep them instead of wiping them (#8291).
|
||||
return (isAlternateScreen ?? session.kittyKeyboardModes.isAlternateScreen)
|
||||
? POST_REPLAY_REATTACH_RESET_KEEP_MOUSE
|
||||
: POST_REPLAY_REATTACH_RESET
|
||||
}
|
||||
|
||||
session.consumeRestoredViewportBlankingMarker = (): boolean => {
|
||||
return session.deps.restoredViewportBlankingPanesRef?.current.delete(session.pane.id) ?? false
|
||||
}
|
||||
|
||||
session.writeFreshShellViewportBlanking = (rows = session.pane.terminal.rows): void => {
|
||||
session.writeReplayData(buildFreshShellViewportBlankingSequence(rows))
|
||||
}
|
||||
|
||||
session.prepareFreshShellViewportForSpawn = (options: FreshSpawnOptions): void => {
|
||||
const hadRestoredViewport = session.consumeRestoredViewportBlankingMarker()
|
||||
if (!options.forceBlankRestoredViewport && !hadRestoredViewport) {
|
||||
return
|
||||
}
|
||||
// Why: fresh Windows ConPTY output paints at screen coordinates, so
|
||||
// restored session.rows must leave the viewport before the first prompt redraw.
|
||||
session.writeFreshShellViewportBlanking()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { hasPtySerializer } from '../pty-buffer-serializer'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
|
||||
import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice'
|
||||
import { pendingSpawnByPaneKey } from './pty-connect-limits'
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import type {
|
||||
PendingStartupCommand,
|
||||
FreshSpawnOptions,
|
||||
ColdRestoreAgentResumeStartup
|
||||
} from './fresh-spawn-types'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
session.startFreshSpawn = (
|
||||
startupOverride?: PendingStartupCommand | null,
|
||||
options: FreshSpawnOptions = {}
|
||||
): Promise<string | null> => {
|
||||
if (session.isLegacyWorkerAutomaticResumeBlocked()) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]?.isDeleting) {
|
||||
// Why: the worktree is being deleted; its PTYs were just killed for the
|
||||
// filesystem teardown. A fresh shell must not spawn into a directory the
|
||||
// removal is about to delete (main fences it anyway), and the session.pane is
|
||||
// about to unmount — so skip the doomed respawn instead of racing it.
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
session.authoritativeReattachGeneration += 1
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
// Why: a canceled old replay clear can preserve xterm's native
|
||||
// isUserScrolling flag. A replacement shell must start in follow mode.
|
||||
session.resetFreshSpawnFollowOutput()
|
||||
// Why: a fresh spawn is a new process with kitty keyboard flags at
|
||||
// zero. The exit-handler reset alone is not enough: a late exit from a
|
||||
// replaced PTY takes the stale-transport early return and skips it, so
|
||||
// a restart-in-place would leak the old TUI's flags into a fresh shell.
|
||||
session.kittyKeyboardModes.reset()
|
||||
session.prepareFreshShellViewportForSpawn(options)
|
||||
const coldRestoreOverride =
|
||||
startupOverride && 'launchConfig' in startupOverride
|
||||
? (startupOverride as ColdRestoreAgentResumeStartup)
|
||||
: null
|
||||
// Why: pre-signal the main process so its cooperation gate suppresses
|
||||
// the daemon-snapshot seed for this paneKey. We issue declare and the
|
||||
// spawn back-to-back without awaiting, because Electron's
|
||||
// ipcRenderer→ipcMain channel preserves order across consecutive invoke
|
||||
// calls from the same renderer. The cooperation gate at pty:spawn time
|
||||
// sees pendingByPaneKey populated. Settle/clear later echoes the gen
|
||||
// token captured here. See docs/mobile-prefer-renderer-scrollback.md.
|
||||
const preSignalPromise = session.runtimeEnvironmentId
|
||||
? Promise.resolve(null)
|
||||
: window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null)
|
||||
|
||||
session.transportConnectInFlightSince = Date.now()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError)
|
||||
const spawnedRaw = session.transport.connect({
|
||||
url: '',
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
...(startupOverride?.command ? { command: startupOverride.command } : {}),
|
||||
...(session.connectionId &&
|
||||
startupOverride?.command &&
|
||||
!session.shouldDeliverStartupViaTerminalPaste
|
||||
? { commandDelivery: 'provider' as const }
|
||||
: {}),
|
||||
...(session.connectionId && startupOverride?.command
|
||||
? { startupCommandDelivery: 'shell-ready' as const }
|
||||
: {}),
|
||||
...(startupOverride?.env
|
||||
? { env: session.mergeStartupEnvWithPaneIdentity(startupOverride.env) }
|
||||
: {}),
|
||||
...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}),
|
||||
...(coldRestoreOverride
|
||||
? { resumeProviderSession: coldRestoreOverride.resumeProviderSession }
|
||||
: {}),
|
||||
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
|
||||
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
|
||||
...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
|
||||
void Promise.resolve(spawnedRaw)
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
session.transportConnectInFlightSince = null
|
||||
})
|
||||
const trackedPromise: Promise<string | null> = Promise.resolve(spawnedRaw)
|
||||
.then(async (spawnedPtyId) => {
|
||||
if (outputCallbacks.generation !== session.transportStreamGeneration) {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
return null
|
||||
}
|
||||
const resolvedPtyId =
|
||||
spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId
|
||||
? spawnedPtyId.id
|
||||
: typeof spawnedPtyId === 'string'
|
||||
? spawnedPtyId
|
||||
: session.transport.getPtyId()
|
||||
if (resolvedPtyId && !session.claimCapturedDirectSshRetryPty(resolvedPtyId)) {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
return null
|
||||
}
|
||||
const connectResult =
|
||||
spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId
|
||||
? spawnedPtyId
|
||||
: null
|
||||
if (connectResult?.isReattach) {
|
||||
session.pendingStartupCommand = null
|
||||
const accepted = await session.handleReattachResult(
|
||||
connectResult,
|
||||
null,
|
||||
coldRestoreOverride,
|
||||
outputCallbacks.generation
|
||||
)
|
||||
session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (accepted && resolvedPtyId && typeof gen === 'number') {
|
||||
void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
} else if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
return accepted ? resolvedPtyId : null
|
||||
}
|
||||
if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) {
|
||||
session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, {
|
||||
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
|
||||
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {})
|
||||
})
|
||||
}
|
||||
if (resolvedPtyId) {
|
||||
if (
|
||||
spawnedPtyId &&
|
||||
typeof spawnedPtyId === 'object' &&
|
||||
spawnedPtyId.startupCwdFallback?.kind === 'worktree'
|
||||
) {
|
||||
writeTerminalOutput(session.pane.terminal, STARTUP_CWD_FALLBACK_NOTICE, {
|
||||
foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
})
|
||||
}
|
||||
if (
|
||||
spawnedPtyId &&
|
||||
typeof spawnedPtyId === 'object' &&
|
||||
spawnedPtyId.agentResumeUnavailable
|
||||
) {
|
||||
// Why: main dropped the resume argv, so this session.pane is a NEW session —
|
||||
// the plain restored banner would claim the old one came back.
|
||||
session.showSessionRestoredBanner('resume-unavailable')
|
||||
} else if (coldRestoreOverride?.hasSleepingRecord) {
|
||||
session.showSessionRestoredBanner()
|
||||
}
|
||||
session.clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride)
|
||||
} else if (
|
||||
session.paneStartup?.launchConfig ||
|
||||
(startupOverride && 'launchConfig' in startupOverride)
|
||||
) {
|
||||
// Why: delayed draft/follow-up delivery keys off this launch
|
||||
// registry. If spawn produced no PTY, the launch is no longer a
|
||||
// viable delivery target and must not wait for a future session.pane.
|
||||
session.clearRegisteredStartupLaunchConfig()
|
||||
}
|
||||
if (
|
||||
resolvedPtyId &&
|
||||
spawnedPtyId &&
|
||||
typeof spawnedPtyId === 'object' &&
|
||||
'id' in spawnedPtyId &&
|
||||
session.activePanePtyBinding !== resolvedPtyId &&
|
||||
session.transport.getPtyId() === resolvedPtyId
|
||||
) {
|
||||
// Why: daemon createOrAttach can turn an apparent fresh spawn into
|
||||
// a reattach; the session.transport skips onPtySpawn there to preserve recency.
|
||||
session.bindActivePanePty(resolvedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
}
|
||||
if (resolvedPtyId) {
|
||||
session.reconcilePtySizeAfterSpawn(resolvedPtyId, session.cols, session.rows)
|
||||
}
|
||||
const gen = await preSignalPromise
|
||||
if (resolvedPtyId && (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId))) {
|
||||
if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) {
|
||||
session.registerPaneSerializerFor(resolvedPtyId)
|
||||
}
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
} else if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
if (resolvedPtyId && session.connectionId) {
|
||||
if (
|
||||
session.shouldUseProviderSshStartupDelivery &&
|
||||
(startupOverride?.command || session.paneStartup?.command)
|
||||
) {
|
||||
session.armStartupDraftReadinessObservation()
|
||||
}
|
||||
session.schedulePendingStartupCommandDelivery()
|
||||
}
|
||||
session.finishReattachLiveDataDeferral(Boolean(resolvedPtyId), outputCallbacks.generation)
|
||||
return resolvedPtyId
|
||||
})
|
||||
.catch(async () => {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
if (
|
||||
session.paneStartup?.launchConfig ||
|
||||
(startupOverride && 'launchConfig' in startupOverride)
|
||||
) {
|
||||
session.clearRegisteredStartupLaunchConfig()
|
||||
}
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingSpawnByPaneKey.get(session.pendingSpawnKey) === trackedPromise) {
|
||||
pendingSpawnByPaneKey.delete(session.pendingSpawnKey)
|
||||
}
|
||||
})
|
||||
session.armDirectSshPaneRetryTimeout(trackedPromise, session.directSshRetryAttempt)
|
||||
void trackedPromise.then((spawnedPtyId) => {
|
||||
if (spawnedPtyId) {
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (
|
||||
session.disposed ||
|
||||
session.transport.getPtyId() ||
|
||||
pendingSpawnByPaneKey.has(session.pendingSpawnKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
|
||||
})
|
||||
})
|
||||
// Why: split panes in the same tab can spawn concurrently. Key by session.pane
|
||||
// as well as tab so a remount cannot attach to a sibling setup session.pane's PTY.
|
||||
pendingSpawnByPaneKey.set(session.pendingSpawnKey, trackedPromise)
|
||||
return trackedPromise
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
AgentProviderSessionMetadata,
|
||||
ResumableTuiAgent,
|
||||
SleepingAgentSessionRecord
|
||||
} from '../../../../../shared/agent-session-resume'
|
||||
import type { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
|
||||
|
||||
export type PendingStartupCommand = {
|
||||
command: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export type FreshSpawnOptions = {
|
||||
forceBlankRestoredViewport?: boolean
|
||||
}
|
||||
|
||||
export type ColdRestoreAgentResumeStartup = PendingStartupCommand & {
|
||||
agent: ResumableTuiAgent
|
||||
resumeProviderSession: AgentProviderSessionMetadata
|
||||
launchConfig: NonNullable<ReturnType<typeof buildAgentResumeStartupPlan>>['launchConfig']
|
||||
launchToken: string
|
||||
useLiveEntry: boolean
|
||||
hasSleepingRecord: boolean
|
||||
sleepingRecordEntry: { paneKey: string; record: SleepingAgentSessionRecord } | null
|
||||
}
|
||||
+99
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
@@ -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.
|
||||
*/
|
||||
}
|
||||
+164
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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'
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { isTerminalWritePipelineCertifiedDead } from '@/lib/pane-manager/terminal-write-pipeline-health'
|
||||
import { requestTerminalPaneRecovery } from '../terminal-pane-recovery'
|
||||
import { registerStaleDocumentVisibilityRecovery } from '../stale-document-visibility'
|
||||
import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { registerTerminalBacklogRecovery } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import {
|
||||
cancelScheduledHiddenOutputRestore,
|
||||
scheduleHiddenOutputRestore
|
||||
} from '../hidden-output-restore-scheduler'
|
||||
import { resolveHiddenRestoreScrollbackRows } from '../terminal-hidden-restore-scrollback'
|
||||
import type { PtyBufferSnapshot } from '../pty-transport'
|
||||
|
||||
import {
|
||||
HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS,
|
||||
HIDDEN_OUTPUT_RESTORE_REMOTE_OUTCOME_MAX_ATTEMPTS,
|
||||
HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS
|
||||
} from './hidden-output-restore-limits'
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
type HiddenOutputSnapshotResult =
|
||||
| { kind: 'snapshot'; snapshot: PtyBufferSnapshot }
|
||||
| { kind: 'retry-worthy'; source: 'host' | 'local' }
|
||||
| { kind: 'permanently-unavailable' }
|
||||
| { kind: 'unknown-legacy-host' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession): void {
|
||||
session.requestHiddenOutputRestoreIfNeeded = function (opts?: {
|
||||
bypassScheduler?: boolean
|
||||
}): boolean {
|
||||
// Why: once the write pipeline is probe-certified dead a restore can never parse; recovery owns the session.pane and the remount gets a fresh xterm + restore.
|
||||
if (isTerminalWritePipelineCertifiedDead(session.pane.terminal)) {
|
||||
// Why the re-kick: certification's recovery request can be budget-declined or cancelled by a sibling remount; without this, a revealed dead session.pane keeps the stale frame forever.
|
||||
if (!session.certifiedDeadRestoreRecoveryRequested && !session.disposed) {
|
||||
session.certifiedDeadRestoreRecoveryRequested = true
|
||||
const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null
|
||||
void requestTerminalPaneRecovery({
|
||||
tabId: session.deps.tabId,
|
||||
ptyId: session.transport.getPtyId() ?? storePtyId,
|
||||
reason: 'restore-blocked',
|
||||
terminalRecoveryGeneration: session.terminalRecoveryGeneration,
|
||||
terminalRecoveryInstanceId: session.terminalRecoveryInstance.id
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
session.resetHiddenOutputRestoreIfPtyChanged()
|
||||
const ptyId = session.hiddenOutputRestorePtyId ?? session.transport.getPtyId()
|
||||
if (
|
||||
!session.hiddenOutputRestoreNeeded &&
|
||||
session.hiddenOutputRestorePendingChunks.length === 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!session.canUseHiddenOutputSnapshot(ptyId)) {
|
||||
return false
|
||||
}
|
||||
session.hiddenOutputRestorePtyId = ptyId
|
||||
if (session.hiddenOutputRestoreInFlight) {
|
||||
session.armHiddenOutputRestoreForegroundDeadline()
|
||||
return true
|
||||
}
|
||||
if (!opts?.bypassScheduler) {
|
||||
const priority = session.isActiveSplitPane() ? 'active' : 'inactive'
|
||||
if (priority === 'inactive') {
|
||||
if (!session.hiddenOutputRestoreScheduled) {
|
||||
session.hiddenOutputRestoreScheduled = true
|
||||
const scheduledPtyId = ptyId
|
||||
const scheduledGeneration = session.hiddenOutputRestoreGeneration
|
||||
// Why: resume can reveal many split panes at once; spread inactive replays across frames so xterm scrollback replay doesn't block return.
|
||||
scheduleHiddenOutputRestore(
|
||||
session.pane.terminal,
|
||||
() => {
|
||||
session.hiddenOutputRestoreScheduled = false
|
||||
if (
|
||||
session.disposed ||
|
||||
session.hiddenOutputRestoreGeneration !== scheduledGeneration ||
|
||||
session.hiddenOutputRestorePtyId !== scheduledPtyId ||
|
||||
session.transport.getPtyId() !== scheduledPtyId ||
|
||||
!session.canUseHiddenOutputSnapshot(scheduledPtyId) ||
|
||||
(!session.hiddenOutputRestoreNeeded &&
|
||||
session.hiddenOutputRestorePendingChunks.length === 0) ||
|
||||
!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
) {
|
||||
return
|
||||
}
|
||||
session.requestHiddenOutputRestoreIfNeeded({ bypassScheduler: true })
|
||||
},
|
||||
priority
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
cancelScheduledHiddenOutputRestore(session.pane.terminal)
|
||||
session.hiddenOutputRestoreScheduled = false
|
||||
}
|
||||
session.clearHiddenOutputRestoreDeferredRetryTimer()
|
||||
session.hiddenOutputRestoreRetryDeferred = false
|
||||
|
||||
session.hiddenOutputRestoreInFlight = (async () => {
|
||||
// Backstop (rc.7.perf loop): bound how many snapshot fetch+replay rounds one task burns before yielding to the live stream.
|
||||
let restoreIterations = 0
|
||||
while (!session.disposed) {
|
||||
const currentPtyId = session.hiddenOutputRestorePtyId
|
||||
if (currentPtyId === null) {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
return
|
||||
}
|
||||
if (!session.canUseHiddenOutputSnapshot(currentPtyId)) {
|
||||
if (session.hiddenOutputRestorePtyId === currentPtyId) {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
}
|
||||
// Remote-only path: the session.transport swapped PTYs mid-restore, which is a
|
||||
// stream change, not proof the hidden bytes are unrecoverable.
|
||||
if (
|
||||
!session.rearmRemoteHiddenOutputRestoreInsteadOfWarning(
|
||||
currentPtyId,
|
||||
'restore-pty-swapped'
|
||||
)
|
||||
) {
|
||||
session.writeRestoreUnavailableWarning()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (session.transport.getPtyId() !== currentPtyId) {
|
||||
if (session.hiddenOutputRestorePtyId === currentPtyId) {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
}
|
||||
return
|
||||
}
|
||||
const restoreGeneration = session.hiddenOutputRestoreGeneration
|
||||
session.hiddenOutputRestoreNeeded = false
|
||||
let snapshotResult: HiddenOutputSnapshotResult
|
||||
try {
|
||||
snapshotResult = await session.serializeHiddenOutputSnapshot(currentPtyId, {
|
||||
scrollbackRows: resolveHiddenRestoreScrollbackRows(
|
||||
session.pane.terminal.options.scrollback
|
||||
)
|
||||
})
|
||||
} catch {
|
||||
snapshotResult =
|
||||
!isRemoteRuntimePtyId(currentPtyId) ||
|
||||
session.hiddenOutputRestoreLegacyPtyId === currentPtyId ||
|
||||
typeof session.transport.serializeBufferOutcome !== 'function'
|
||||
? { kind: 'unavailable' }
|
||||
: // Why 'host': the only reject here is the request timeout — the frame went out and the host stayed silent.
|
||||
{ kind: 'retry-worthy', source: 'host' }
|
||||
}
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
const restoreGenerationChanged = session.hiddenOutputRestoreGeneration !== restoreGeneration
|
||||
const restorePtyChanged =
|
||||
session.transport.getPtyId() !== currentPtyId ||
|
||||
session.hiddenOutputRestorePtyId !== currentPtyId
|
||||
if (restoreGenerationChanged || restorePtyChanged) {
|
||||
// Why: the snapshot belongs to the requested PTY; after reattach it's stale, and a stale generation may be an abandoned timeout superseded by a newer restore.
|
||||
if (restorePtyChanged && session.hiddenOutputRestorePtyId === currentPtyId) {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (snapshotResult.kind === 'retry-worthy') {
|
||||
let budgetExhausted: boolean
|
||||
if (snapshotResult.source === 'host') {
|
||||
session.hiddenOutputRestoreRemoteOutcomeAttempts += 1
|
||||
budgetExhausted =
|
||||
session.hiddenOutputRestoreRemoteOutcomeAttempts >=
|
||||
HIDDEN_OUTPUT_RESTORE_REMOTE_OUTCOME_MAX_ATTEMPTS
|
||||
} else {
|
||||
session.hiddenOutputRestoreLocalGateAttempts += 1
|
||||
budgetExhausted =
|
||||
session.hiddenOutputRestoreLocalGateAttempts >=
|
||||
HIDDEN_OUTPUT_RESTORE_LOCAL_GATE_MAX_ATTEMPTS
|
||||
}
|
||||
if (budgetExhausted) {
|
||||
session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, {
|
||||
rearmRemote: false
|
||||
})
|
||||
return
|
||||
}
|
||||
session.hiddenOutputRestoreNeeded = true
|
||||
session.hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true })
|
||||
return
|
||||
}
|
||||
if (snapshotResult.kind === 'permanently-unavailable') {
|
||||
session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, {
|
||||
rearmRemote: false
|
||||
})
|
||||
return
|
||||
}
|
||||
if (snapshotResult.kind === 'unknown-legacy-host') {
|
||||
session.hiddenOutputRestoreLegacyPtyId = currentPtyId
|
||||
session.armHiddenOutputRestoreForegroundDeadline()
|
||||
}
|
||||
if (snapshotResult.kind !== 'snapshot') {
|
||||
session.hiddenOutputRestoreNeeded = true
|
||||
session.hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
session.hiddenOutputRestoreRetryDeferred = true
|
||||
session.scheduleHiddenOutputRestoreDeferredRetry()
|
||||
return
|
||||
}
|
||||
const snapshot = snapshotResult.snapshot
|
||||
session.hiddenOutputRestoreDeferredRetryAttempts = 0
|
||||
session.hiddenOutputRestoreRemoteAbandonCycles = 0
|
||||
session.hiddenOutputRestoreRemoteOutcomeAttempts = 0
|
||||
session.hiddenOutputRestoreLocalGateAttempts = 0
|
||||
restoreIterations += 1
|
||||
await session.applyMainBufferSnapshot(snapshot)
|
||||
if (
|
||||
session.disposed ||
|
||||
session.hiddenOutputRestoreGeneration !== restoreGeneration ||
|
||||
session.hiddenOutputRestorePtyId !== currentPtyId ||
|
||||
session.transport.getPtyId() !== currentPtyId
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: everything at/before snapshot.seq is now painted; chunks still draining from main's ACK backlog below it are duplicates to suppress.
|
||||
session.setRestoredSnapshotBaseline(currentPtyId, snapshot)
|
||||
session.hiddenOutputRestoreReplayingSnapshot = null
|
||||
const needsFreshSnapshot = session.hiddenOutputRestoreFreshSnapshotNeeded
|
||||
session.hiddenOutputRestoreFreshSnapshotNeeded = false
|
||||
const drainOutcome = session.drainPendingLiveChunksAfterSnapshot(snapshot.seq)
|
||||
if (drainOutcome === 'drained' && !needsFreshSnapshot) {
|
||||
session.hiddenOutputRestoreNeeded = false
|
||||
session.hiddenOutputRestorePtyId = null
|
||||
session.clearHiddenOutputRestoreForegroundDeadlineTimer()
|
||||
return
|
||||
}
|
||||
if (!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) {
|
||||
// Why: hidden bytes arriving during the snapshot aren't in renderer memory; leave recovery pending for reveal, don't loop snapshots in a throttled tab.
|
||||
session.hiddenOutputRestoreNeeded = true
|
||||
return
|
||||
}
|
||||
if (drainOutcome === 'overflow') {
|
||||
// Cut 1 (rc.7.perf loop): a FOREGROUND queue overflow means the stream outruns fetch+replay; re-fetching starves ACKs, so abandon and heal with one post-flood repaint.
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true })
|
||||
return
|
||||
}
|
||||
if (restoreIterations >= HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS) {
|
||||
// Backstop: re-looping this many times means the stream is winning the race.
|
||||
warnTerminalLifecycleAnomaly('hidden output restore hit its iteration cap', {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
leafId: session.pane.leafId,
|
||||
paneId: session.pane.id,
|
||||
ptyId: currentPtyId,
|
||||
reason: drainOutcome
|
||||
})
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
session.abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true })
|
||||
return
|
||||
}
|
||||
session.hiddenOutputRestoreNeeded = true
|
||||
}
|
||||
})()
|
||||
const hiddenOutputRestoreTask = session.hiddenOutputRestoreInFlight
|
||||
let trackedHiddenOutputRestore: Promise<void>
|
||||
trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => {
|
||||
if (session.hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) {
|
||||
session.hiddenOutputRestoreInFlight = null
|
||||
}
|
||||
// Why: after dispose the task body exits immediately, so re-arming here
|
||||
// resolves instantly and re-enters this handler — an unbounded promise
|
||||
// chain that eats the heap. The pane is gone; there is nothing to restore.
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
session.hiddenOutputRestorePendingChunks.length > 0 ||
|
||||
session.hiddenOutputRestorePendingOverflow
|
||||
) {
|
||||
session.hiddenOutputRestoreNeeded = true
|
||||
session.armHiddenOutputRestoreForegroundDeadline()
|
||||
}
|
||||
if (
|
||||
!session.hiddenOutputRestoreRetryDeferred &&
|
||||
session.hiddenOutputRestoreNeeded &&
|
||||
shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
) {
|
||||
session.requestHiddenOutputRestoreIfNeeded()
|
||||
}
|
||||
})
|
||||
session.hiddenOutputRestoreInFlight = trackedHiddenOutputRestore
|
||||
return true
|
||||
}
|
||||
|
||||
session.unregisterBacklogRecovery = registerTerminalBacklogRecovery(session.pane.terminal, () => {
|
||||
// Why: clear the hidden-delivery bit BEFORE the restore snapshot request; bytes arriving in between are reconciled by the seq guard.
|
||||
session.syncHiddenRendererPtyDelivery()
|
||||
return session.requestHiddenOutputRestoreIfNeeded()
|
||||
})
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
typeof document.addEventListener === 'function' &&
|
||||
typeof document.removeEventListener === 'function'
|
||||
) {
|
||||
const onDocumentVisibilityChange = (): void => {
|
||||
// Why: document hide/show flips the foreground predicate with no session.pane lifecycle event; re-sync the hidden-delivery gate both ways.
|
||||
session.syncHiddenRendererPtyDelivery()
|
||||
if (shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) {
|
||||
session.requestHiddenOutputRestoreIfNeeded()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onDocumentVisibilityChange)
|
||||
// Why: on stale macOS occlusion (visibilityState wedged 'hidden'), user input forces a resync — no visibilitychange fires, else the gate drops bytes forever.
|
||||
const unregisterStaleVisibilityRecovery = registerStaleDocumentVisibilityRecovery(
|
||||
onDocumentVisibilityChange
|
||||
)
|
||||
session.unregisterDocumentVisibilityRecovery = () => {
|
||||
document.removeEventListener('visibilitychange', onDocumentVisibilityChange)
|
||||
unregisterStaleVisibilityRecovery()
|
||||
}
|
||||
}
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { waitForTerminalReplayWritesParsed } from '../replay-guard'
|
||||
import {
|
||||
POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
RESET_AFTER_BYTE_GAP
|
||||
} from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import {
|
||||
discardTerminalOutput,
|
||||
writeTerminalOutput
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
|
||||
import {
|
||||
buildMainModelSnapshotReplayWrites,
|
||||
hasPositiveTerminalDimensions,
|
||||
readProposedTerminalCols,
|
||||
shouldSkipAltFrameForWidthMismatch
|
||||
} from '../terminal-snapshot-replay-paint'
|
||||
|
||||
import { HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING } from './hidden-output-restore-limits'
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): void {
|
||||
session.writeRestoreUnavailableWarning = function (): void {
|
||||
// The reset must parse before both the warning and any foreground drain.
|
||||
session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
|
||||
if (!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) {
|
||||
return
|
||||
}
|
||||
writeTerminalOutput(session.pane.terminal, HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING, {
|
||||
foreground: true,
|
||||
beforeWrite: session.beforeTerminalOutputWrite
|
||||
})
|
||||
}
|
||||
|
||||
session.applyMainBufferSnapshot = async function (snapshot: {
|
||||
data: string
|
||||
frameRestoreAnsi?: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
pendingDeliveryStartSeq?: number
|
||||
alternateScreen?: boolean
|
||||
scrollbackAnsi?: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
kittyKeyboardFlags?: number
|
||||
}): Promise<void> {
|
||||
const restorePtyId = session.transport.getPtyId()
|
||||
const restoreGeneration = session.hiddenOutputRestoreGeneration
|
||||
if (session.hiddenOutputSnapshotScrollRestore) {
|
||||
session.cancelSnapshotScrollRestore()
|
||||
}
|
||||
const scrollRestore = {
|
||||
ptyId: restorePtyId,
|
||||
generation: restoreGeneration,
|
||||
valid: true,
|
||||
started: false
|
||||
}
|
||||
session.hiddenOutputSnapshotScrollRestore = scrollRestore
|
||||
const colsBeforeReplay = session.pane.terminal.cols
|
||||
const rowsBeforeReplay = session.pane.terminal.rows
|
||||
const hasSnapshotDimensions = hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows)
|
||||
let skippedAltFrame = false
|
||||
try {
|
||||
await session.structuralReplayCoordinator.run(
|
||||
async () => {
|
||||
if (
|
||||
!scrollRestore.valid ||
|
||||
session.disposed ||
|
||||
session.transport.getPtyId() !== scrollRestore.ptyId ||
|
||||
session.hiddenOutputRestoreGeneration !== scrollRestore.generation
|
||||
) {
|
||||
return
|
||||
}
|
||||
scrollRestore.started = true
|
||||
if (typeof snapshot.seq === 'number') {
|
||||
session.hiddenOutputRestoreReplayingSnapshot = {
|
||||
seq: snapshot.seq,
|
||||
...(typeof snapshot.pendingDeliveryStartSeq === 'number'
|
||||
? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
discardTerminalOutput(session.pane.terminal)
|
||||
if (
|
||||
hasSnapshotDimensions &&
|
||||
(session.pane.terminal.cols !== snapshot.cols ||
|
||||
session.pane.terminal.rows !== snapshot.rows)
|
||||
) {
|
||||
// Why: xterm parses writes later; hold snapshot dimensions until the FIFO sentinel completes so serialized wraps stay exact.
|
||||
session.suppressStructuralReplayPtyResize = true
|
||||
try {
|
||||
session.pane.terminal.resize(snapshot.cols, snapshot.rows)
|
||||
} finally {
|
||||
session.suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
// Why here and not from the writes below: the mirror tracks what the
|
||||
// APPLICATION negotiated, so it must see the snapshot's own bytes
|
||||
// before adopting the flags main proved for this boundary.
|
||||
session.applySnapshotKittyKeyboardModes(
|
||||
`${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`,
|
||||
{
|
||||
kittyKeyboardFlags: snapshot.kittyKeyboardFlags,
|
||||
snapshotSeq: snapshot.seq
|
||||
}
|
||||
)
|
||||
// Why shared: the SSH reattach model paint inlines the same
|
||||
// choreography (coordinator nesting would deadlock there); one
|
||||
// builder keeps the alt-screen branches from drifting.
|
||||
skippedAltFrame =
|
||||
snapshot.alternateScreen === true &&
|
||||
snapshot.frameRestoreAnsi !== undefined &&
|
||||
shouldSkipAltFrameForWidthMismatch(
|
||||
snapshot.cols,
|
||||
readProposedTerminalCols(session.pane)
|
||||
)
|
||||
// Why: an imageless success is not proof the session.pane is empty; normal replay clears screen and scrollback.
|
||||
const snapshotCarriesNoImage =
|
||||
snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi
|
||||
if (snapshotCarriesNoImage) {
|
||||
// Why still ground: a restore only runs because bytes were dropped, so
|
||||
// the gap's pen outlives a snapshot that cannot repaint over it. The
|
||||
// live-path constant, not the replay baseline — nothing repaints here,
|
||||
// so the session.pane is handed back to whatever is still running.
|
||||
session.writeReplayData(RESET_AFTER_BYTE_GAP)
|
||||
} else {
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
|
||||
skipAltFrame: skippedAltFrame,
|
||||
paneOnAlternateScreen: session.isPaneOnAlternateScreen()
|
||||
})) {
|
||||
session.writeReplayData(replayChunk)
|
||||
}
|
||||
}
|
||||
// Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup).
|
||||
session.writeReplayData(
|
||||
session.hasLiveAgentReattachStatusOrTitleSignal()
|
||||
? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET
|
||||
: POST_REPLAY_LIVE_SNAPSHOT_RESET
|
||||
)
|
||||
if (snapshot.pendingEscapeTailAnsi) {
|
||||
// Why last: snapshot taken mid-escape; re-arm as the FINAL replay write (any later ESC aborts it) so the live tail completes it, not render literally (Bug E / #7329).
|
||||
session.writeReplayData(snapshot.pendingEscapeTailAnsi)
|
||||
}
|
||||
session.hiddenRendererStateDirty = false
|
||||
session.recordRendererOrderedSeq(snapshot)
|
||||
recordTerminalOutput(session.pane.terminal)
|
||||
await waitForTerminalReplayWritesParsed(session.pane.terminal)
|
||||
},
|
||||
{
|
||||
shouldRestore: () =>
|
||||
scrollRestore.valid &&
|
||||
!session.disposed &&
|
||||
session.transport.getPtyId() === scrollRestore.ptyId &&
|
||||
session.hiddenOutputRestoreGeneration === scrollRestore.generation,
|
||||
afterRestore: async () => {
|
||||
const isCurrentRestore = (): boolean =>
|
||||
scrollRestore.valid &&
|
||||
!session.disposed &&
|
||||
session.transport.getPtyId() === scrollRestore.ptyId &&
|
||||
session.hiddenOutputRestoreGeneration === scrollRestore.generation
|
||||
if (!isCurrentRestore()) {
|
||||
return
|
||||
}
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
if (!currentPtyId) {
|
||||
return
|
||||
}
|
||||
if (getFitOverrideForPty(currentPtyId)) {
|
||||
safeFit(session.pane)
|
||||
if (skippedAltFrame && !isRemoteRuntimePtyId(currentPtyId)) {
|
||||
window.api.pty.signal(currentPtyId, 'SIGWINCH')
|
||||
}
|
||||
return
|
||||
}
|
||||
const fit = safeFitAndThen(
|
||||
session.pane,
|
||||
'hidden-snapshot-pty-resize',
|
||||
() => {
|
||||
if (!isCurrentRestore() || session.transport.getPtyId() !== currentPtyId) {
|
||||
return
|
||||
}
|
||||
const replayChangedDimensions = hasSnapshotDimensions
|
||||
? session.pane.terminal.cols !== snapshot.cols ||
|
||||
session.pane.terminal.rows !== snapshot.rows
|
||||
: session.pane.terminal.cols !== colsBeforeReplay ||
|
||||
session.pane.terminal.rows !== rowsBeforeReplay
|
||||
if (skippedAltFrame) {
|
||||
session.pulseVisibleLocalPtySizeForTuiRepaint(currentPtyId)
|
||||
return
|
||||
}
|
||||
if (replayChangedDimensions && session.isRendererPtyResizeAuthoritative()) {
|
||||
session.transport.resize(session.pane.terminal.cols, session.pane.terminal.rows)
|
||||
if (!isRemoteRuntimePtyId(currentPtyId)) {
|
||||
// Why: redundant SIGWINCH makes alt-screen TUIs rebuild their scroll viewport to the top on tab return.
|
||||
window.api.pty.signal(currentPtyId, 'SIGWINCH')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
shouldContinue: isCurrentRestore,
|
||||
retryIfUnmeasurable: true,
|
||||
// A skipped frame is blank until this fit pushes the final
|
||||
// grid/SIGWINCH, so carry the continuation through reveal.
|
||||
deferIfHidden: true
|
||||
}
|
||||
)
|
||||
session.pendingHiddenSnapshotFit = fit
|
||||
try {
|
||||
await fit.completion
|
||||
} finally {
|
||||
if (session.pendingHiddenSnapshotFit === fit) {
|
||||
session.pendingHiddenSnapshotFit = null
|
||||
}
|
||||
}
|
||||
if (isCurrentRestore()) {
|
||||
session.scheduleReattachIdleAgentCursorReset()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
if (session.hiddenOutputSnapshotScrollRestore === scrollRestore) {
|
||||
session.hiddenOutputSnapshotScrollRestore = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../../../../shared/terminal-kitty-keyboard-flags'
|
||||
import {
|
||||
containsStatefulRendererQuery,
|
||||
extractHiddenStartupRendererQueryData
|
||||
} from '../../../../../shared/terminal-reply-query-extraction'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import {
|
||||
resolveTerminalColorSchemeMode,
|
||||
scanMode2031ReplyDecision
|
||||
} from '../../../../../shared/terminal-color-scheme-protocol'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { installTerminalLiveScrollbackRestore } from '@/lib/pane-manager/terminal-live-scrollback-restore'
|
||||
import type { PtyDataMeta } from '../pty-dispatcher'
|
||||
import { sendTerminalOscColorQueryReplies } from '../terminal-capability-replies'
|
||||
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import { bindWritePtyOutputToXterm } from './write-pty-output-to-xterm'
|
||||
|
||||
import { bindAbandonHiddenOutputRestore } from './hidden-output-restore-abandon'
|
||||
import { bindHiddenOutputRestoreChunk } from './hidden-output-restore-chunk'
|
||||
import { bindHiddenStartupRendererQueryWrite } from './hidden-startup-renderer-query-write'
|
||||
|
||||
import { bindHiddenOutputRestoreDrain } from './hidden-output-restore-drain'
|
||||
|
||||
export function bindHiddenOutputSeqAndSkip(session: ConnectPanePtySession): void {
|
||||
session.observeLiveMode2031Chunk = function (data: string): void {
|
||||
// Gate-managed PTYs never see these bytes; main's '2031-subscribe' fact records them.
|
||||
if (session.isHiddenDeliveryGateManagedPty(session.transport.getPtyId())) {
|
||||
return
|
||||
}
|
||||
const result = scanMode2031ReplyDecision(session.mode2031ReplyScanState, data)
|
||||
session.mode2031ReplyScanState = result.state
|
||||
if (result.decision === 'unsubscribed') {
|
||||
session.deps.paneMode2031Ref.current.delete(session.pane.id)
|
||||
session.deps.paneLastThemeModeRef.current.delete(session.pane.id)
|
||||
}
|
||||
if (result.decision !== 'subscribed') {
|
||||
return
|
||||
}
|
||||
const settings = useAppStore.getState().settings
|
||||
// Why seed the mode: maybePushMode2031Flip only pushes on a change, so an unseeded
|
||||
// subscription would read as a flip on the next unrelated appearance re-apply.
|
||||
session.deps.paneMode2031Ref.current.set(session.pane.id, true)
|
||||
session.deps.paneLastThemeModeRef.current.set(
|
||||
session.pane.id,
|
||||
resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
|
||||
)
|
||||
}
|
||||
|
||||
// Why installed here: the handler observes CSI 3 J inside xterm's parse, so a
|
||||
// redraw split across PTY chunks is reported once, with the pane's rows for
|
||||
// this write already applied.
|
||||
session.liveScrollbackRestore?.dispose()
|
||||
session.liveScrollbackRestore = installTerminalLiveScrollbackRestore(session.pane.terminal)
|
||||
|
||||
// Why read it live: `?1049` is not a no-op when the pane is already on the
|
||||
// target buffer — xterm still runs restoreCursor and swaps the kitty flag
|
||||
// registers — so the replay prologue must only switch when it truly differs.
|
||||
//
|
||||
// Best-effort by design. xterm parses asynchronously, so this sees only
|
||||
// PARSED writes; a `?1049` transition still queued reads stale. Draining
|
||||
// first with a write sentinel was tried and reverted: it puts the repaint
|
||||
// behind xterm's queue, so a wedged terminal stalls recovery, and it breaks
|
||||
// the disposal/cancellation ordering the restore paths rely on. A stale read
|
||||
// costs one mis-scoped buffer switch; the barrier costs the repaint.
|
||||
session.isPaneOnAlternateScreen = function (): boolean {
|
||||
return session.pane.terminal.buffer.active.type === 'alternate'
|
||||
}
|
||||
|
||||
bindWritePtyOutputToXterm(session)
|
||||
session.markHiddenOutputRestoreNeeded = function (): void {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (!session.canUseHiddenOutputSnapshot(ptyId)) {
|
||||
return
|
||||
}
|
||||
if (session.hiddenOutputRestorePtyId !== null && session.hiddenOutputRestorePtyId !== ptyId) {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
}
|
||||
session.hiddenOutputRestorePtyId = ptyId
|
||||
session.hiddenOutputRestoreNeeded = true
|
||||
if (shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) {
|
||||
session.requestHiddenOutputRestoreIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
session.shouldSkipHiddenRendererOutput = function (foreground: boolean, data: string): boolean {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (
|
||||
foreground ||
|
||||
(!session.shouldSnapshotHiddenCodexOutput && session.remoteOutputGatedPtyId !== ptyId) ||
|
||||
!session.canUseHiddenOutputSnapshot(ptyId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: CPR/DECRQM replies depend on ordered state; keep a clean stateful-query chunk live, but after skipped bytes avoid stale replies.
|
||||
return session.hiddenRendererStateDirty || !containsStatefulRendererQuery(data)
|
||||
}
|
||||
|
||||
session.writeHiddenStartupRendererQueries = function (data: string): void {
|
||||
const extracted = extractHiddenStartupRendererQueryData(
|
||||
data,
|
||||
session.hiddenStartupRendererQueryPending
|
||||
)
|
||||
session.hiddenStartupRendererQueryPending = extracted.pending
|
||||
if (extracted.oscColorQueryData) {
|
||||
// Why: Codex's startup palette probe has a 100ms budget; answer hidden color queries immediately so scheduling/remote-input debounce (#7329) can't miss it.
|
||||
sendTerminalOscColorQueryReplies(
|
||||
extracted.oscColorQueryData,
|
||||
session.pane.terminal,
|
||||
session.sendDesktopQueryReplyImmediate
|
||||
)
|
||||
}
|
||||
if (extracted.statelessQueryData) {
|
||||
session.writePtyOutputToXterm(extracted.statelessQueryData, false, {
|
||||
hiddenStartupRendererQuery: true
|
||||
})
|
||||
}
|
||||
// Stateful hidden queries need ordered terminal state; if the hidden xterm is dirty, skip rather than send stale CPR/DECRQM.
|
||||
}
|
||||
|
||||
bindHiddenStartupRendererQueryWrite(session)
|
||||
bindHiddenOutputRestoreChunk(session)
|
||||
session.applySnapshotKittyKeyboardModes = function (
|
||||
snapshotData: string,
|
||||
snapshot: { kittyKeyboardFlags?: number; snapshotSeq?: number }
|
||||
): void {
|
||||
const proven =
|
||||
snapshot.snapshotSeq === undefined
|
||||
? undefined
|
||||
: parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags)
|
||||
if (proven === undefined) {
|
||||
// Why the demotion: a mirror grounded in this PTY's stream keeps its
|
||||
// state, but a constructor-fresh tracker (window reload) holds a
|
||||
// known-zero that was never proven for the reattached PTY — demote it
|
||||
// so the serializer cannot republish it downstream as host-proven
|
||||
// inactive.
|
||||
if (!session.kittyKeyboardModes.hasProvenBaseline) {
|
||||
session.kittyKeyboardModes.resetForSnapshot()
|
||||
}
|
||||
session.kittyKeyboardModes.scanReplay(snapshotData)
|
||||
return
|
||||
}
|
||||
session.kittyKeyboardModes.resetForSnapshot()
|
||||
session.kittyKeyboardModes.scanReplay(snapshotData)
|
||||
session.kittyKeyboardModes.restoreSnapshotFlags(proven)
|
||||
// Why in the same critical section: without the baseline a quiet session.pane
|
||||
// could not publish a coherent snapshot until unrelated output arrived.
|
||||
session.recordRendererOrderedSeq({ seq: snapshot.snapshotSeq })
|
||||
}
|
||||
|
||||
session.recordRendererOrderedSeq = function (meta?: Pick<PtyDataMeta, 'seq'>): void {
|
||||
if (typeof meta?.seq !== 'number') {
|
||||
return
|
||||
}
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (!ptyId) {
|
||||
return
|
||||
}
|
||||
if (session.rendererOrderedPtyId !== ptyId) {
|
||||
session.rendererOrderedPtyId = ptyId
|
||||
session.rendererOrderedSeq = meta.seq
|
||||
return
|
||||
}
|
||||
session.rendererOrderedSeq = Math.max(session.rendererOrderedSeq ?? 0, meta.seq)
|
||||
}
|
||||
|
||||
session.resetRendererOrderedSeqForPtyExit = (exitedPtyId: string): void => {
|
||||
// Why: an exit ends this ptyId's seq domain; a revived id restarts main's counter, so both seq high-water marks (ordered + restored baseline) must reset here or they drop revived bytes as duplicates.
|
||||
if (session.restoredSnapshotBaselinePtyId === exitedPtyId) {
|
||||
session.clearRestoredSnapshotBaseline()
|
||||
}
|
||||
if (session.rendererOrderedPtyId === exitedPtyId) {
|
||||
session.rendererOrderedPtyId = null
|
||||
session.rendererOrderedSeq = null
|
||||
}
|
||||
if (session.rendererChannelSeqPtyId === exitedPtyId) {
|
||||
session.rendererChannelSeqPtyId = null
|
||||
session.rendererChannelSeq = null
|
||||
}
|
||||
}
|
||||
|
||||
session.observeRendererOrderedSeqRegression = function (meta: PtyDataMeta | undefined): void {
|
||||
if (typeof meta?.seq !== 'number') {
|
||||
return
|
||||
}
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (!ptyId) {
|
||||
return
|
||||
}
|
||||
if (session.rendererChannelSeqPtyId !== ptyId) {
|
||||
session.rendererChannelSeqPtyId = ptyId
|
||||
session.rendererChannelSeq = meta.seq
|
||||
return
|
||||
}
|
||||
if (session.rendererChannelSeq !== null && meta.seq < session.rendererChannelSeq) {
|
||||
// Why: pty:data is FIFO, so seq regresses only when a session revived without an observed exit and restarted its counter; drop the stale baseline.
|
||||
if (session.rendererOrderedPtyId === ptyId) {
|
||||
session.rendererOrderedPtyId = null
|
||||
session.rendererOrderedSeq = null
|
||||
}
|
||||
}
|
||||
session.rendererChannelSeq = meta.seq
|
||||
}
|
||||
|
||||
session.getHiddenRendererDataAfterOrderedSeq = function (
|
||||
data: string,
|
||||
meta: PtyDataMeta | undefined
|
||||
): string | null {
|
||||
if (
|
||||
session.rendererOrderedPtyId === null ||
|
||||
session.rendererOrderedSeq === null ||
|
||||
session.transport.getPtyId() !== session.rendererOrderedPtyId
|
||||
) {
|
||||
return data
|
||||
}
|
||||
return session.getChunkDataAfterSnapshot(
|
||||
{ data, seq: meta?.seq, rawLength: meta?.rawLength },
|
||||
session.rendererOrderedSeq
|
||||
)
|
||||
}
|
||||
|
||||
// 'drained' = painted all queued bytes; 'overflow' = queue blew its cap (stream outran fetch+replay); 'refetch' = offsets unmappable, need a fresher snapshot.
|
||||
bindHiddenOutputRestoreDrain(session)
|
||||
bindAbandonHiddenOutputRestore(session)
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { isHostAnsweredSnapshotRetryCause } from '@/runtime/remote-runtime-terminal-multiplexer'
|
||||
import { onTerminalScrollIntentFollowOutput } from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { readE2eHiddenSnapshotOverride } from './e2e-terminal-pty-harness'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { PtyBufferSnapshot } from '../pty-transport'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
type HiddenOutputSnapshotResult =
|
||||
| { kind: 'snapshot'; snapshot: PtyBufferSnapshot }
|
||||
| { kind: 'retry-worthy'; source: 'host' | 'local' }
|
||||
| { kind: 'permanently-unavailable' }
|
||||
| { kind: 'unknown-legacy-host' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
export function bindSerializeHiddenOutputSnapshot(session: ConnectPanePtySession): void {
|
||||
session.serializeHiddenOutputSnapshot = async function (
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number }
|
||||
): Promise<HiddenOutputSnapshotResult> {
|
||||
const e2eSnapshot = readE2eHiddenSnapshotOverride(ptyId)
|
||||
if (e2eSnapshot) {
|
||||
const snapshot = await e2eSnapshot
|
||||
return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unavailable' }
|
||||
}
|
||||
if (session.canUseMainBufferSnapshot(ptyId)) {
|
||||
const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, opts)
|
||||
return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unavailable' }
|
||||
}
|
||||
if (
|
||||
session.transport.getPtyId() !== ptyId ||
|
||||
typeof session.transport.serializeBuffer !== 'function'
|
||||
) {
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (
|
||||
session.hiddenOutputRestoreLegacyPtyId === ptyId ||
|
||||
typeof session.transport.serializeBufferOutcome !== 'function'
|
||||
) {
|
||||
const snapshot = await session.transport.serializeBuffer(opts)
|
||||
return snapshot ? { kind: 'snapshot', snapshot } : { kind: 'unknown-legacy-host' }
|
||||
}
|
||||
try {
|
||||
const outcome = await session.transport.serializeBufferOutcome(opts)
|
||||
if (outcome.availability.kind === 'snapshot') {
|
||||
// A success frame with no image is still the host's own answer to a request it received.
|
||||
return outcome.snapshot
|
||||
? { kind: 'snapshot', snapshot: outcome.snapshot }
|
||||
: { kind: 'retry-worthy', source: 'host' }
|
||||
}
|
||||
if (outcome.availability.kind === 'retry-worthy') {
|
||||
return {
|
||||
kind: 'retry-worthy',
|
||||
source: isHostAnsweredSnapshotRetryCause(outcome.availability.cause) ? 'host' : 'local'
|
||||
}
|
||||
}
|
||||
if (outcome.availability.kind === 'permanently-unavailable') {
|
||||
return { kind: 'permanently-unavailable' }
|
||||
}
|
||||
return { kind: 'unknown-legacy-host' }
|
||||
} catch {
|
||||
// Why 'host': the reject path is the request timeout — the frame went out and the host stayed silent.
|
||||
return { kind: 'retry-worthy', source: 'host' }
|
||||
}
|
||||
}
|
||||
|
||||
// Why: hidden/parked panes used to mark hidden only at the first
|
||||
// session.dataCallback sync, leaving a spawn-time window where neither side
|
||||
// answered queries (the spawn-time DA1 loss). Declaring hidden on the
|
||||
// spawn IPC lets main mark the PTY before its first byte — including
|
||||
// codex spawns: the model responder answers their startup probes from
|
||||
// byte zero now that the 10s renderer query window is gone.
|
||||
// Remote-runtime PTYs are never gate-markable (no local main transit).
|
||||
session.shouldDeclareHiddenAtSpawn = function (): boolean {
|
||||
return (
|
||||
session.hiddenDeliveryGateActive &&
|
||||
!session.runtimeEnvironmentId &&
|
||||
!session.disposed &&
|
||||
!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hidden-delivery gate sync (Phase 4) ─────────────────────────────
|
||||
// Why: marks this session.pane's PTY hidden in main while no visible view needs
|
||||
// its bytes; main then drops delivery after model ingestion and reveal
|
||||
// restores from the snapshot. The marked id is tracked locally so PTY
|
||||
// changes (reattach/restart) can never leave a stale id gated.
|
||||
session.hiddenDeliverySyncedPtyId = null
|
||||
session.releaseHiddenDeliveryClaim = null
|
||||
session.modelRestoreSubscribedPtyId = null
|
||||
session.unregisterModelRestoreNeeded = null
|
||||
|
||||
function isHiddenOutputRestoreFloodSuppressed(): boolean {
|
||||
return Date.now() < session.hiddenOutputRestoreFloodSuppressedUntil
|
||||
}
|
||||
|
||||
// True when a drop/gap signal on a visible session.pane is attributable to this
|
||||
// session.pane's OWN restore backpressure (a restore is replaying right now, or
|
||||
// one was just cut off for outrunning the stream). Such signals must not
|
||||
// re-arm restores — that is the rc.7.perf feedback loop.
|
||||
session.isForegroundRestoreBackpressureContext = function (): boolean {
|
||||
return (
|
||||
shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) &&
|
||||
(session.hiddenOutputRestoreInFlight !== null || isHiddenOutputRestoreFloodSuppressed())
|
||||
)
|
||||
}
|
||||
|
||||
session.clearHiddenOutputRestoreFloodRepaintTimer = function (): void {
|
||||
session.cancelHiddenOutputRestoreFloodRepaintPark?.()
|
||||
session.cancelHiddenOutputRestoreFloodRepaintPark = null
|
||||
if (session.hiddenOutputRestoreFloodRepaintTimer === null) {
|
||||
return
|
||||
}
|
||||
clearTimeout(session.hiddenOutputRestoreFloodRepaintTimer)
|
||||
session.hiddenOutputRestoreFloodRepaintTimer = null
|
||||
}
|
||||
session.cleanupHiddenOutputRestoreFloodRepaint = session.clearHiddenOutputRestoreFloodRepaintTimer
|
||||
|
||||
// Why: the repaint discards the buffer and replays a full snapshot, which repositions the viewport; a user reading scrollback must not be yanked to the bottom, so hold it until their own scroll intent returns to follow-output.
|
||||
session.repaintAfterFloodWhenFollowingOutput = function (ptyId: string): void {
|
||||
session.cancelHiddenOutputRestoreFloodRepaintPark?.()
|
||||
session.cancelHiddenOutputRestoreFloodRepaintPark = null
|
||||
let repainted = false
|
||||
const cancelPark = onTerminalScrollIntentFollowOutput(session.pane.terminal, () => {
|
||||
repainted = true
|
||||
session.cancelHiddenOutputRestoreFloodRepaintPark = null
|
||||
if (session.disposed || session.transport.getPtyId() !== ptyId) {
|
||||
return
|
||||
}
|
||||
session.markHiddenOutputRestoreNeeded()
|
||||
})
|
||||
if (!repainted) {
|
||||
session.cancelHiddenOutputRestoreFloodRepaintPark = cancelPark
|
||||
}
|
||||
}
|
||||
|
||||
session.resetHiddenOutputRestoreFloodSuppression = function (): void {
|
||||
session.hiddenOutputRestoreFloodSuppressedUntil = 0
|
||||
session.clearHiddenOutputRestoreFloodRepaintTimer()
|
||||
}
|
||||
|
||||
// Extends the suppression window; every backpressure signal resets the timer so the deferred repaint fires once, SUPPRESS_MS after the last signal.
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { PtyBufferSnapshot } from '../pty-transport'
|
||||
import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../../../../shared/terminal-color-scheme-protocol'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { discardTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { cancelTerminalScrollIntentBufferRebuildCompletions } from '@/lib/pane-manager/terminal-scroll-intent-rebuild'
|
||||
import { DeferredReattachLiveDataQueue } from '../deferred-reattach-live-data-queue'
|
||||
import { resolveHiddenRestoreScrollbackRows } from '../terminal-hidden-restore-scrollback'
|
||||
import {
|
||||
decideSshReattachPaintSource,
|
||||
memoizeSshReattachModelSnapshotProbe,
|
||||
resolveSshReattachModelSnapshotWithTimeout,
|
||||
shouldFetchSshReattachModelSnapshot
|
||||
} from '../ssh-reattach-model-restore'
|
||||
|
||||
import {
|
||||
HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS,
|
||||
HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX
|
||||
} from './hidden-output-restore-limits'
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { recordHiddenRendererSkip } from './e2e-terminal-pty-harness'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import { bindHiddenOutputRestoreSnapshot } from './hidden-output-restore-snapshot'
|
||||
import { bindHiddenOutputRestoreRequest } from './hidden-output-restore-request'
|
||||
import { bindLiveDataCallback } from './live-data-callback'
|
||||
|
||||
import { bindReattachLiveDataDeferral } from './reattach-live-data-deferral'
|
||||
|
||||
export function bindHiddenRestoreStateAndSshProbe(session: ConnectPanePtySession): void {
|
||||
session.scheduleHiddenOutputRestoreDeferredRetry = function (): void {
|
||||
if (
|
||||
session.disposed ||
|
||||
session.hiddenOutputRestoreDeferredRetryTimer !== null ||
|
||||
!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
session.hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX
|
||||
) {
|
||||
const ptyId = session.hiddenOutputRestorePtyId
|
||||
if (ptyId !== null) {
|
||||
session.abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
|
||||
} else {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
session.writeRestoreUnavailableWarning()
|
||||
}
|
||||
return
|
||||
}
|
||||
session.hiddenOutputRestoreDeferredRetryAttempts += 1
|
||||
// Why: a null snapshot usually means remote output was still mutating; retry after one quiet tick instead of spinning.
|
||||
session.hiddenOutputRestoreDeferredRetryTimer = setTimeout(() => {
|
||||
session.hiddenOutputRestoreDeferredRetryTimer = null
|
||||
if (session.disposed || !session.hiddenOutputRestoreNeeded) {
|
||||
return
|
||||
}
|
||||
session.hiddenOutputRestoreRetryDeferred = false
|
||||
session.requestHiddenOutputRestoreIfNeeded()
|
||||
}, HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS)
|
||||
}
|
||||
|
||||
session.clearHiddenOutputRestoreState = function (): void {
|
||||
session.cancelSnapshotScrollRestore()
|
||||
session.clearPendingLiveChunksDuringRestore()
|
||||
// Re-arm budget is per PTY stream, like the rest of this state.
|
||||
session.hiddenOutputRestoreRemoteAbandonCycles = 0
|
||||
session.hiddenOutputRestoreRemoteOutcomeAttempts = 0
|
||||
session.hiddenOutputRestoreLocalGateAttempts = 0
|
||||
session.hiddenStartupRendererQueryPending = ''
|
||||
session.hiddenRendererStateDirty = false
|
||||
session.hiddenOutputRestoreNeeded = false
|
||||
session.hiddenOutputRestorePtyId = null
|
||||
session.hiddenOutputRestoreReplayingSnapshot = null
|
||||
session.hiddenOutputRestoreGeneration += 1
|
||||
}
|
||||
|
||||
session.cancelSnapshotScrollRestore = function (): void {
|
||||
session.pendingHiddenSnapshotFit?.cancel()
|
||||
session.pendingHiddenSnapshotFit = null
|
||||
const scrollRestore = session.hiddenOutputSnapshotScrollRestore
|
||||
if (!scrollRestore) {
|
||||
return
|
||||
}
|
||||
scrollRestore.valid = false
|
||||
session.hiddenOutputSnapshotScrollRestore = null
|
||||
if (scrollRestore.started) {
|
||||
cancelTerminalScrollIntentBufferRebuildCompletions(session.pane.terminal)
|
||||
}
|
||||
// Why: invalidation suppresses restoration, but queued bytes still own the bracket until their FIFO sentinels prove parsing finished.
|
||||
}
|
||||
session.cancelHiddenOutputSnapshotScrollRestore = session.cancelSnapshotScrollRestore
|
||||
|
||||
session.clearPaneMode2031State = function (): void {
|
||||
session.deps.paneMode2031Ref.current.delete(session.pane.id)
|
||||
session.deps.paneLastThemeModeRef.current.delete(session.pane.id)
|
||||
// A partial CSI prefix belongs to the stream that produced it; carrying it into a
|
||||
// replacement PTY would splice two unrelated byte ranges into one sequence.
|
||||
session.mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
}
|
||||
|
||||
session.pulseVisibleLocalPtySizeForTuiRepaint = function (ptyId: string): void {
|
||||
if (
|
||||
!session.isRendererPtyResizeAuthoritative() ||
|
||||
session.shouldSuppressDesktopPtyResize() ||
|
||||
isRemoteRuntimePtyId(ptyId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const cols = session.pane.terminal.cols
|
||||
const rows = session.pane.terminal.rows
|
||||
if (cols <= 2 || rows <= 0) {
|
||||
return
|
||||
}
|
||||
// Why: a hidden alt-screen TUI can miss the same-size restore SIGWINCH; a one-column pulse makes the repaint observable to the child.
|
||||
session.transport.resize(cols - 1, rows)
|
||||
session.transport.resize(cols, rows)
|
||||
}
|
||||
|
||||
session.skipBackgroundAlternateScreenOutput = function (data: string): void {
|
||||
session.writeHiddenStartupRendererQueries(data)
|
||||
session.hiddenRendererStateDirty = true
|
||||
recordHiddenRendererSkip(data.length)
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (!ptyId || session.alternateScreenBackgroundRepaintTimer !== null) {
|
||||
return
|
||||
}
|
||||
session.pulseVisibleLocalPtySizeForTuiRepaint(ptyId)
|
||||
session.alternateScreenBackgroundRepaintTimer = setTimeout(() => {
|
||||
session.alternateScreenBackgroundRepaintTimer = null
|
||||
}, 100)
|
||||
}
|
||||
|
||||
session.resetHiddenOutputRestoreIfPtyChanged = function (): void {
|
||||
if (session.hiddenOutputRestorePtyId === null) {
|
||||
return
|
||||
}
|
||||
if (session.transport.getPtyId() !== session.hiddenOutputRestorePtyId) {
|
||||
// Why: renderer backlog is tied to the old PTY stream; after reattach it must not delay or replay before the new PTY.
|
||||
session.clearHiddenOutputRestoreState()
|
||||
session.clearRestoredSnapshotBaseline()
|
||||
session.clearPaneMode2031State()
|
||||
// Why: flood-backpressure evidence is per PTY stream too.
|
||||
session.resetHiddenOutputRestoreFloodSuppression()
|
||||
discardTerminalOutput(session.pane.terminal)
|
||||
}
|
||||
}
|
||||
|
||||
bindHiddenOutputRestoreSnapshot(session)
|
||||
bindHiddenOutputRestoreRequest(session)
|
||||
bindLiveDataCallback(session)
|
||||
session.beginReattachLiveDataDeferral = (
|
||||
ownerGeneration = session.transportStreamGeneration
|
||||
): void => {
|
||||
session.reattachLiveDataDeferralDepth += 1
|
||||
if (session.reattachLiveDataDeferralDepth === 1) {
|
||||
session.deferredReattachLiveData = new DeferredReattachLiveDataQueue()
|
||||
session.deferredReattachLiveDataOwners = new Map()
|
||||
}
|
||||
if (!session.deferredReattachLiveDataOwners.has(ownerGeneration)) {
|
||||
session.deferredReattachLiveDataOwners.set(ownerGeneration, { failed: false })
|
||||
}
|
||||
}
|
||||
|
||||
session.beginReattachLiveDataDeferralIfUnowned = (
|
||||
ownerGeneration = session.transportStreamGeneration
|
||||
): void => {
|
||||
if (!session.deferredReattachLiveDataOwners.has(ownerGeneration)) {
|
||||
session.beginReattachLiveDataDeferral(ownerGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
bindReattachLiveDataDeferral(session)
|
||||
session.isCapturedDirectSshReattachCurrent = (ptyId: string): boolean =>
|
||||
!session.directSshRetryAttempt || session.capturedDirectSshRetryStateMatches(ptyId)
|
||||
session.rejectObsoleteDirectSshReattach = (ptyId: string | null | undefined): boolean => {
|
||||
if (
|
||||
!session.directSshRetryAttempt ||
|
||||
(ptyId && session.claimCapturedDirectSshRetryPty(ptyId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
session.transport.detach?.({ preserveExitObserver: false })
|
||||
return true
|
||||
}
|
||||
|
||||
let parkedSshSnapshotPrefetch: {
|
||||
ptyId: string
|
||||
fetch: () => Promise<PtyBufferSnapshot | null>
|
||||
} | null = null
|
||||
|
||||
const createSshMainModelSnapshotProbe = (
|
||||
ptyId: string
|
||||
): (() => Promise<PtyBufferSnapshot | null>) =>
|
||||
memoizeSshReattachModelSnapshotProbe(async (): Promise<PtyBufferSnapshot | null> => {
|
||||
const sshParkingEnabled = useAppStore.getState().settings?.terminalSshViewParking !== false
|
||||
if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) {
|
||||
return null
|
||||
}
|
||||
const snapshot = await resolveSshReattachModelSnapshotWithTimeout(
|
||||
window.api.pty.getMainBufferSnapshot(ptyId, {
|
||||
scrollbackRows: resolveHiddenRestoreScrollbackRows(
|
||||
session.pane.terminal.options.scrollback
|
||||
)
|
||||
})
|
||||
)
|
||||
return snapshot &&
|
||||
decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) ===
|
||||
'main-model-snapshot'
|
||||
? snapshot
|
||||
: null
|
||||
})
|
||||
|
||||
session.getSshMainModelSnapshotProbe = (
|
||||
ptyId: string
|
||||
): (() => Promise<PtyBufferSnapshot | null>) => {
|
||||
if (parkedSshSnapshotPrefetch?.ptyId !== ptyId) {
|
||||
parkedSshSnapshotPrefetch = { ptyId, fetch: createSshMainModelSnapshotProbe(ptyId) }
|
||||
}
|
||||
return parkedSshSnapshotPrefetch.fetch
|
||||
}
|
||||
}
|
||||
+166
@@ -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
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { containsCsiRendererQuery } from '../../../../../shared/terminal-reply-query-extraction'
|
||||
import { isKnownTuiAgentTerminalStartupCommand } from '../terminal-startup-command-classifier'
|
||||
import type { PtyConnectionDeps } from '../pty-connection-types'
|
||||
|
||||
export function shouldKeepHiddenStartupRendererQueriesLive(
|
||||
startup: PtyConnectionDeps['startup']
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') ||
|
||||
isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
export function containsHiddenStartupRendererQuery(data: string): boolean {
|
||||
// Why: hidden Codex startup must not live-render ordinary redraw floods, but
|
||||
// query chunks still need xterm's built-in terminal replies to unblock TUIs.
|
||||
return containsCsiRendererQuery(data) || data.includes('\x1b]10;?') || data.includes('\x1b]11;?')
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
|
||||
import { useAppStore } from '@/store'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import type { AgentStatusEntry } from '../../../../../shared/agent-status-types'
|
||||
import { createAgentInterruptInference } from '../agent-interrupt-inference'
|
||||
import { createAgentQuestionAnsweredInference } from '../agent-question-answered-inference'
|
||||
import type { AgentInterruptInputIntent } from '../../../../../shared/agent-interrupt-intent'
|
||||
import { markTerminalBracketedPasteInterrupted } from '../terminal-bracketed-paste'
|
||||
|
||||
import { REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS } from './foreground-output-scan'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
import { installPaneAgentIdentity } from './pane-agent-identity'
|
||||
|
||||
export function installInterruptInputIntent(session: ConnectPanePtySession): void {
|
||||
session.scheduleReattachIdleAgentCursorReset = (): void => {
|
||||
const status = detectAgentStatusFromTitle(session.getCurrentTerminalTitle() ?? '')
|
||||
if (status !== 'idle' && status !== 'permission') {
|
||||
return
|
||||
}
|
||||
session.clearReattachIdleAgentCursorResetTimer()
|
||||
session.reattachIdleAgentCursorResetTimer = setTimeout(() => {
|
||||
session.reattachIdleAgentCursorResetTimer = null
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
const latestStatus = detectAgentStatusFromTitle(session.getCurrentTerminalTitle() ?? '')
|
||||
if (latestStatus !== 'idle' && latestStatus !== 'permission') {
|
||||
return
|
||||
}
|
||||
// Why: restored idle agent TUIs can repaint after reattach SIGWINCH and
|
||||
// reapply DECSCUSR steady-bar; the normal working→idle reset will not
|
||||
// fire because the agent was already idle before Orca restarted.
|
||||
session.queueAgentIdleTerminalModeReset()
|
||||
}, REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS)
|
||||
}
|
||||
session.interruptInference = createAgentInterruptInference({
|
||||
paneKey: session.cacheKey,
|
||||
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[session.cacheKey],
|
||||
inferInterrupt: (request) => {
|
||||
// Why: the explicit hook row is the authority for an in-flight agent turn.
|
||||
// Codex can reset its terminal title while handling Ctrl+C/Escape, so title
|
||||
// state must not veto clearing the row's working state.
|
||||
return window.api.agentStatus
|
||||
.inferInterrupt(request)
|
||||
.then((applied) => {
|
||||
if (applied) {
|
||||
session.clearInferredInterruptWorkingTitle()
|
||||
}
|
||||
return applied
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[agent-interrupt] inferInterrupt failed:', err)
|
||||
return false
|
||||
})
|
||||
}
|
||||
})
|
||||
session.questionAnsweredInference = createAgentQuestionAnsweredInference({
|
||||
paneKey: session.cacheKey,
|
||||
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[session.cacheKey],
|
||||
inferQuestionAnswered: (request) =>
|
||||
window.api.agentStatus.inferQuestionAnswered(request).catch((err) => {
|
||||
console.warn('[agent-question] inferQuestionAnswered failed:', err)
|
||||
return false
|
||||
})
|
||||
})
|
||||
session.dropCommandFinishedStatusIfSameTurn = (
|
||||
entry: AgentStatusEntry | undefined,
|
||||
options?: { allowInferredInterrupt?: boolean }
|
||||
): void => {
|
||||
const state = useAppStore.getState()
|
||||
if (!entry) {
|
||||
// Why: an Orca-started agent can exit before its first hook status. The
|
||||
// launch registry was still created up front, so clear it on command exit.
|
||||
state.clearAgentLaunchConfig(session.cacheKey)
|
||||
return
|
||||
}
|
||||
const current = state.agentStatusByPaneKey[session.cacheKey]
|
||||
if (!current) {
|
||||
state.clearAgentLaunchConfig(session.cacheKey)
|
||||
return
|
||||
}
|
||||
const unchanged =
|
||||
current.state === entry.state &&
|
||||
current.prompt === entry.prompt &&
|
||||
current.updatedAt === entry.updatedAt &&
|
||||
current.stateStartedAt === entry.stateStartedAt &&
|
||||
current.agentType === entry.agentType
|
||||
const inferredFromEntry =
|
||||
options?.allowInferredInterrupt === true &&
|
||||
current.state === 'done' &&
|
||||
current.interrupted === true &&
|
||||
current.prompt === entry.prompt &&
|
||||
current.agentType === entry.agentType &&
|
||||
current.stateHistory?.some(
|
||||
(history) =>
|
||||
history.state === entry.state &&
|
||||
history.prompt === entry.prompt &&
|
||||
history.startedAt === entry.stateStartedAt
|
||||
) === true
|
||||
if (!unchanged && !inferredFromEntry) {
|
||||
return
|
||||
}
|
||||
state.dropAgentStatus(session.cacheKey)
|
||||
}
|
||||
session.pendingTerminalInputIntent = null
|
||||
session.clearPendingTerminalInputIntentTimer = null
|
||||
session.clearPendingTerminalInputIntent = (): void => {
|
||||
session.pendingTerminalInputIntent = null
|
||||
if (session.clearPendingTerminalInputIntentTimer !== null) {
|
||||
clearTimeout(session.clearPendingTerminalInputIntentTimer)
|
||||
session.clearPendingTerminalInputIntentTimer = null
|
||||
}
|
||||
}
|
||||
session.setPendingTerminalInputIntent = (intent: AgentInterruptInputIntent): void => {
|
||||
session.clearPendingTerminalInputIntent()
|
||||
session.pendingTerminalInputIntent = intent
|
||||
session.clearPendingTerminalInputIntentTimer = setTimeout(() => {
|
||||
session.clearPendingTerminalInputIntent()
|
||||
}, 0)
|
||||
}
|
||||
session.inputMatchesIntent = (intent: AgentInterruptInputIntent, data: string): boolean => {
|
||||
return (
|
||||
(intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03')
|
||||
)
|
||||
}
|
||||
session.inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => {
|
||||
if (data === '\x03') {
|
||||
return 'ctrl-c'
|
||||
}
|
||||
if (data === '\x1b') {
|
||||
return 'plain-escape'
|
||||
}
|
||||
return null
|
||||
}
|
||||
session.observeSentTerminalInputIntent = (
|
||||
data: string,
|
||||
intent = session.pendingTerminalInputIntent
|
||||
): void => {
|
||||
if (intent && session.inputMatchesIntent(intent, data)) {
|
||||
session.interruptInference.observeInputIntent(intent)
|
||||
session.observeTitleOnlyInterrupt()
|
||||
}
|
||||
}
|
||||
session.observeAcceptedTerminalInput = (
|
||||
data: string,
|
||||
intent: AgentInterruptInputIntent | null = null
|
||||
): void => {
|
||||
if (intent === 'ctrl-c' || data === '\x03') {
|
||||
markTerminalBracketedPasteInterrupted(session.pane.terminal)
|
||||
}
|
||||
// Why: every delivered-input path funnels through here, so this is where a
|
||||
// submit keystroke into a waiting AskUserQuestion pane becomes the
|
||||
// "question answered" signal no hook will ever deliver.
|
||||
session.questionAnsweredInference.observeSentTerminalInput(data)
|
||||
}
|
||||
session.pendingTerminalInputWrite = null
|
||||
session.sequencedInterruptStatusBaseline = 0
|
||||
session.setPendingTerminalInputWrite = (promise: Promise<boolean | null>): void => {
|
||||
session.pendingTerminalInputWrite = promise
|
||||
void promise.finally(() => {
|
||||
if (session.pendingTerminalInputWrite === promise) {
|
||||
session.pendingTerminalInputWrite = null
|
||||
}
|
||||
})
|
||||
}
|
||||
session.flushPendingInterruptInference = (): boolean | Promise<boolean> => {
|
||||
const pendingWrite = session.pendingTerminalInputWrite
|
||||
if (!pendingWrite) {
|
||||
return session.interruptInference.flushPending()
|
||||
}
|
||||
return pendingWrite.then((immediateResult) => {
|
||||
return immediateResult ?? session.interruptInference.flushPending()
|
||||
})
|
||||
}
|
||||
installPaneAgentIdentity(session)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { containsStatefulRendererQuery } from '../../../../../shared/terminal-reply-query-extraction'
|
||||
import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-delivery-credit'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity'
|
||||
import { observeTerminalBracketedPasteModeOutput } from '../terminal-bracketed-paste'
|
||||
import type { PtyDataMeta } from '../pty-dispatcher'
|
||||
import { sendTerminalOscColorQueryReplies } from '../terminal-capability-replies'
|
||||
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { registerE2eTerminalPtyDataInjection } from './e2e-terminal-pty-harness'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindLiveDataCallback(session: ConnectPanePtySession): void {
|
||||
session.dataCallback = (
|
||||
data: string,
|
||||
meta?: PtyDataMeta,
|
||||
streamGeneration = session.transportStreamGeneration
|
||||
): void => {
|
||||
if (streamGeneration !== session.transportStreamGeneration) {
|
||||
return
|
||||
}
|
||||
if (session.deferredReattachLiveData !== null) {
|
||||
const ackCredit = takeCurrentTerminalDeliveryCredit()
|
||||
session.deferredReattachLiveData.enqueue({
|
||||
data,
|
||||
ptyId: session.transport.getPtyId(),
|
||||
streamGeneration,
|
||||
...(meta ? { meta } : {}),
|
||||
...(ackCredit ? { ackCredit } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
if (data.length > 0) {
|
||||
session.hasReceivedPtyOutput = true
|
||||
recordAgentHibernationPaneOutput(session.cacheKey)
|
||||
// Why: output is the agent-start signal that ends the relaxed no-evidence process-scan cadence (a starting agent always prints).
|
||||
session.agentCompletionCoordinator.observeOutputActivity()
|
||||
}
|
||||
session.observeStartupDraftPasteReadiness(data)
|
||||
session.resetHiddenOutputRestoreIfPtyChanged()
|
||||
session.observeLiveMode2031Chunk(data)
|
||||
if (meta?.droppedOutput === true) {
|
||||
// Why gated (rc.7.perf loop): a visible session.pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel.
|
||||
if (meta?.background !== true && session.isForegroundRestoreBackpressureContext()) {
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
} else {
|
||||
// Why: main dropped buffered output at the pending cap, so the stream has a gap; repaint from the main-owned snapshot instead of writing on.
|
||||
session.markHiddenOutputRestoreNeeded()
|
||||
if (data) {
|
||||
// The sentinel can carry query bytes carved from the bulk drop (extractDroppedPtyQueryBytes in main); replies must still flow.
|
||||
session.salvageRendererQueriesFromDiscardedRestoreData(data)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
session.respondToTerminalPixelSizeQueries(data)
|
||||
observeTerminalBracketedPasteModeOutput(session.pane.terminal, data)
|
||||
// Why: under main side-effect authority these facts arrive via pty:sideEffect; byte-scanning here would double-fire. Remote PTYs / kill-switch-off keep this path.
|
||||
if (!session.mainSideEffectAuthority) {
|
||||
for (const link of session.observeTerminalGitHubPRLink(data)) {
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(session.deps.worktreeId, link)
|
||||
}
|
||||
session.commandLifecycle.handlePtyData(data)
|
||||
}
|
||||
session.commandCodeOutputStatusDetector?.observe(data)
|
||||
const codexBackfillNotice = session.codexBackfillErrorDetector?.observe(data)
|
||||
if (codexBackfillNotice) {
|
||||
session.reportError(codexBackfillNotice)
|
||||
}
|
||||
// Why: split panes have visible-but-inactive panes the user watches; throttle only when the session.pane or whole document is hidden.
|
||||
const foreground =
|
||||
shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) && meta?.background !== true
|
||||
// Why: latch the hidden-delivery gate from the byte path too, covering a PTY id that arrives after the initial sync (no-op when current).
|
||||
if (!foreground) {
|
||||
session.syncHiddenRendererPtyDelivery()
|
||||
}
|
||||
// Post-restore reconciliation: drop chunks the snapshot covers, force a fresh restore for unmappable seq gaps; runs after byte observers, before any xterm write.
|
||||
const reconciliation = session.reconcileChunkAgainstRestoredSnapshot(data, meta)
|
||||
if (reconciliation.action === 'drop-duplicate') {
|
||||
return
|
||||
}
|
||||
if (reconciliation.action === 'force-fresh-restore') {
|
||||
// Why gated (rc.7.perf loop): foreground-flood seq gaps are our own backpressure drops; snapshot-per-gap IS the loop, so retire the baseline and heal post-flood.
|
||||
if (foreground && session.isForegroundRestoreBackpressureContext()) {
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
session.clearRestoredSnapshotBaseline()
|
||||
// fall through with the ORIGINAL data/meta — post-gap bytes are new
|
||||
} else {
|
||||
// Why: capture in-flight BEFORE the mark — on a visible session.pane the mark starts the restore synchronously and must not flag itself.
|
||||
const restoreWasInFlight = session.hiddenOutputRestoreInFlight !== null
|
||||
session.markHiddenOutputRestoreNeeded()
|
||||
if (restoreWasInFlight) {
|
||||
session.hiddenOutputRestoreFreshSnapshotNeeded = true
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
data = reconciliation.data
|
||||
meta = reconciliation.meta
|
||||
}
|
||||
// Why: a hidden Codex query can split just before visibility flips; hand xterm the completed query while other bytes still follow restore.
|
||||
const pendingForegroundQuery = foreground
|
||||
? session.takeHiddenStartupRendererQueryPendingForForeground(data)
|
||||
: null
|
||||
const rendererData = pendingForegroundQuery?.remainingData ?? data
|
||||
const rendererMeta = session.metaAfterConsumingCurrentChars(
|
||||
meta,
|
||||
pendingForegroundQuery?.consumedCurrentChars ?? 0
|
||||
)
|
||||
session.observeRendererOrderedSeqRegression(meta)
|
||||
const orderedRendererData = foreground
|
||||
? rendererData
|
||||
: session.getHiddenRendererDataAfterOrderedSeq(rendererData, rendererMeta)
|
||||
if (orderedRendererData === null) {
|
||||
// Why: renderer filtering can't map cleaned text back to raw seq offsets; rebuild from main instead of risking stale bytes.
|
||||
session.markHiddenOutputRestoreNeeded()
|
||||
session.schedulePendingStartupCommandDelivery()
|
||||
return
|
||||
}
|
||||
if (!foreground && orderedRendererData.length === 0) {
|
||||
session.recordRendererOrderedSeq(rendererMeta)
|
||||
session.schedulePendingStartupCommandDelivery()
|
||||
return
|
||||
}
|
||||
if (pendingForegroundQuery?.statelessQueryData) {
|
||||
session.writePtyOutputToXterm(pendingForegroundQuery.statelessQueryData, true, {
|
||||
hiddenStartupRendererQuery: true
|
||||
})
|
||||
}
|
||||
if (pendingForegroundQuery?.oscColorQueryData) {
|
||||
sendTerminalOscColorQueryReplies(
|
||||
pendingForegroundQuery.oscColorQueryData,
|
||||
session.pane.terminal,
|
||||
// Why: OSC color reply sent immediately so the remote debounce can't delay it past the program's read window (#7329).
|
||||
session.sendDesktopQueryReplyImmediate
|
||||
)
|
||||
}
|
||||
const restoreAppliesToCurrentPty =
|
||||
session.hiddenOutputRestorePtyId !== null &&
|
||||
session.transport.getPtyId() === session.hiddenOutputRestorePtyId
|
||||
const skipBackgroundAlternateScreenFrame =
|
||||
meta?.background === true &&
|
||||
shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) &&
|
||||
session.pane.terminal.buffer.active.type === 'alternate' &&
|
||||
!containsStatefulRendererQuery(orderedRendererData)
|
||||
if (skipBackgroundAlternateScreenFrame) {
|
||||
session.skipBackgroundAlternateScreenOutput(orderedRendererData)
|
||||
} else if (session.shouldSkipHiddenRendererOutput(foreground, orderedRendererData)) {
|
||||
session.skipHiddenRendererOutput(orderedRendererData)
|
||||
} else if (
|
||||
(session.hiddenOutputRestoreNeeded || session.hiddenOutputRestoreInFlight) &&
|
||||
restoreAppliesToCurrentPty
|
||||
) {
|
||||
if (foreground) {
|
||||
if (pendingForegroundQuery?.statefulQueryData) {
|
||||
session.queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData)
|
||||
}
|
||||
session.queueLiveChunkDuringRestore(orderedRendererData, rendererMeta)
|
||||
session.requestHiddenOutputRestoreIfNeeded()
|
||||
} else if (session.hiddenOutputRestoreInFlight) {
|
||||
session.hiddenOutputRestoreNeeded = true
|
||||
session.hiddenOutputRestoreFreshSnapshotNeeded = true
|
||||
}
|
||||
// Why: hidden chunks with a restore already latched are dropped; the reveal snapshot covers their bytes.
|
||||
} else {
|
||||
// Why: hidden panes normally get no bytes (main drops post-ingestion); stragglers ride the bounded background queue, overflow latches restore.
|
||||
if (pendingForegroundQuery?.statefulQueryData) {
|
||||
session.writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, {
|
||||
hiddenStartupRendererQuery: true
|
||||
})
|
||||
}
|
||||
session.writePtyOutputToXterm(orderedRendererData, foreground)
|
||||
if (foreground) {
|
||||
session.recordRendererOrderedSeq(rendererMeta)
|
||||
}
|
||||
}
|
||||
|
||||
session.schedulePendingStartupCommandDelivery()
|
||||
}
|
||||
session.unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(
|
||||
session.cacheKey,
|
||||
(data, meta) => {
|
||||
if (!session.disposed) {
|
||||
session.dataCallback(data, meta)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../../shared/protocol-version'
|
||||
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
|
||||
import { REMOTE_PTY_ID_PREFIX } from './pty-connect-limits'
|
||||
|
||||
export function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
|
||||
return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX)
|
||||
}
|
||||
|
||||
export function canRestorePairedParkedTerminal(ptyId: string): boolean {
|
||||
const environmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
return (
|
||||
environmentId !== null &&
|
||||
useAppStore
|
||||
.getState()
|
||||
.runtimeStatusByEnvironmentId.get(environmentId)
|
||||
?.status?.capabilities?.includes(TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) === true
|
||||
)
|
||||
}
|
||||
|
||||
// Why: daemon session IDs use the format `${worktreeId}@@${shortUuid}`.
|
||||
// This validates that a session ID actually belongs to the given worktree,
|
||||
// preventing cross-workspace contamination during restore.
|
||||
export function isSessionOwnedByWorktree(sessionId: string, worktreeId: string): boolean {
|
||||
const separatorIdx = sessionId.lastIndexOf('@@')
|
||||
if (separatorIdx === -1) {
|
||||
return true
|
||||
}
|
||||
return sessionId.slice(0, separatorIdx) === worktreeId
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { agentTypeToIconAgent } from '@/lib/agent-status'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { replayIntoTerminal } from '../replay-guard'
|
||||
import { POST_REPLAY_REATTACH_RESET } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
|
||||
import { createTerminalCommandLifecycle } from '../terminal-command-lifecycle'
|
||||
import { createPaneForegroundAgentTracker } from '../pane-foreground-agent-tracker'
|
||||
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
|
||||
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
|
||||
import type { TuiAgent } from '../../../../../shared/tui-agent'
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config'
|
||||
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
// Why: the 133;D confirmation guard and the visible-pane resampler both key off
|
||||
// "does this pane expect an agent"; derive each signal once so the two callers
|
||||
// can't drift and silently reintroduce the icon bug this fix closes.
|
||||
session.paneHasLiveHookAgentIcon = (state: ReturnType<typeof useAppStore.getState>): boolean => {
|
||||
const entry = state.agentStatusByPaneKey[session.cacheKey]
|
||||
return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType))
|
||||
}
|
||||
session.paneExpectsLaunchAgent = (state: ReturnType<typeof useAppStore.getState>): boolean => {
|
||||
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === session.deps.tabId
|
||||
)
|
||||
const registeredLaunchAgent =
|
||||
state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType
|
||||
return Boolean(
|
||||
tab?.launchAgent ??
|
||||
session.paneStartup?.launchAgent ??
|
||||
session.paneStartup?.initialAgentStatus?.agent ??
|
||||
(isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined)
|
||||
)
|
||||
}
|
||||
// Why: the concrete TUI agent a fresh spawn is expected to launch, used to seed
|
||||
// a command-start confirmation on no-OSC shells (Git Bash/cmd) that never emit
|
||||
// one. Returns null when the expectation isn't a recognized TUI agent.
|
||||
session.resolveExpectedLaunchTuiAgent = (): TuiAgent | null => {
|
||||
const state = useAppStore.getState()
|
||||
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === session.deps.tabId
|
||||
)
|
||||
const candidate =
|
||||
tab?.launchAgent ??
|
||||
session.paneStartup?.launchAgent ??
|
||||
session.paneStartup?.initialAgentStatus?.agent ??
|
||||
state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType
|
||||
return isTuiAgent(candidate) ? candidate : null
|
||||
}
|
||||
// Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a
|
||||
// full-screen agent's leaked nested-shell 133;D can't clear its tab identity,
|
||||
// even on a restore where no command-start read has recorded evidence yet.
|
||||
session.paneHasKnownAgentIdentity = (): boolean => {
|
||||
const state = useAppStore.getState()
|
||||
const registeredLaunchAgent =
|
||||
state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType
|
||||
return (
|
||||
Boolean(state.paneForegroundAgentByPaneKey[session.cacheKey]?.agent) ||
|
||||
session.paneHasLiveHookAgentIcon(state) ||
|
||||
isTuiAgent(registeredLaunchAgent)
|
||||
)
|
||||
}
|
||||
// Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints
|
||||
// it on exit, so a confirmed return-to-shell must clear a title that still
|
||||
// names an agent — otherwise the tab reads "grok" over a bare prompt. Only
|
||||
// reset an agent-named title; user/shell-set titles are left untouched.
|
||||
session.clearStaleAgentTabTitleOnConfirmedShell = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
|
||||
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === session.deps.tabId
|
||||
)
|
||||
const title = currentTitle ?? tab?.title
|
||||
if (!title || resolveCommittedTitleAgentType(title) === null) {
|
||||
return
|
||||
}
|
||||
const neutralTitle = session.neutralTerminalTitle()
|
||||
session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, neutralTitle)
|
||||
if (session.manager.getActivePane()?.id === session.pane.id) {
|
||||
session.deps.updateTabTitle(session.deps.tabId, neutralTitle)
|
||||
}
|
||||
}
|
||||
session.deferredCommandFinishedStatusDrop = null
|
||||
session.visibleForegroundSamplePending = false
|
||||
session.visibleForegroundSampleSettled = false
|
||||
session.settleDeferredCommandFinishedStatusDrop = (): void => {
|
||||
const dropStatus = session.deferredCommandFinishedStatusDrop
|
||||
session.deferredCommandFinishedStatusDrop = null
|
||||
dropStatus?.()
|
||||
}
|
||||
session.isForegroundTrackingAllowed = (id: string): boolean => {
|
||||
if (isRemoteRuntimePtyId(id) || parseAppSshPtyId(id) !== null) {
|
||||
return false
|
||||
}
|
||||
if (!navigator.userAgent.includes('Windows')) {
|
||||
return true
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === session.deps.tabId
|
||||
)
|
||||
// Why: WSL and remote-runtime panes can never authorize native ConPTY
|
||||
// bytes, so do not pay for Windows process scans that cannot affect routing.
|
||||
return isLocalNativeWindowsConpty({
|
||||
userAgent: navigator.userAgent,
|
||||
connectionId: getConnectionId(session.deps.worktreeId) ?? null,
|
||||
cwd: session.deps.cwd,
|
||||
shellOverride: tab?.shellOverride,
|
||||
executionHostId: getExecutionHostIdForWorktree(state, session.deps.worktreeId)
|
||||
})
|
||||
}
|
||||
session.paneForegroundAgentTracker = createPaneForegroundAgentTracker({
|
||||
getPtyId: () => session.transport.getPtyId(),
|
||||
isTrackablePtyId: session.isForegroundTrackingAllowed,
|
||||
readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id),
|
||||
confirmForegroundProcess: (id) => window.api.pty.confirmForegroundProcess(id),
|
||||
publish: (entry) => useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry),
|
||||
hasKnownAgentIdentity: session.paneHasKnownAgentIdentity,
|
||||
onConfirmedShellForeground: (reason) => {
|
||||
session.clearStaleAgentTabTitleOnConfirmedShell()
|
||||
// Why: a hard-killed agent leaves mouse/focus/kitty modes armed, and the
|
||||
// surviving shell then receives pointer moves as typed SGR reports; the
|
||||
// replay guard keeps xterm's auto-replies from leaking to the shell.
|
||||
replayIntoTerminal(session.pane, session.deps.replayingPanesRef, POST_REPLAY_REATTACH_RESET, {
|
||||
breadcrumbIdentity: {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
ptyId: session.transport.getPtyId()
|
||||
},
|
||||
shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously
|
||||
})
|
||||
if (reason === 'visible-pty') {
|
||||
useAppStore.getState().clearAgentLaunchConfig(session.cacheKey)
|
||||
return
|
||||
}
|
||||
session.settleDeferredCommandFinishedStatusDrop()
|
||||
},
|
||||
onCommandFinishedUnavailable: session.settleDeferredCommandFinishedStatusDrop,
|
||||
onVisibleForegroundSettled: (outcome) => {
|
||||
session.visibleForegroundSamplePending = false
|
||||
session.visibleForegroundSampleSettled = outcome !== 'inconclusive'
|
||||
}
|
||||
})
|
||||
// Why: one command-finished policy whether the signal arrives as bytes
|
||||
// (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact —
|
||||
// routing both through this handler keeps the drop/interrupt semantics
|
||||
// identical across authority modes.
|
||||
session.handleCommandFinished = (bestEffortExitCode: number | null): void => {
|
||||
session.clearCommandInferredPaneAgentAfterPtySideEffects()
|
||||
session.visibleForegroundSamplePending = false
|
||||
const shouldDeferStatusDrop = session.paneForegroundAgentTracker.onCommandFinished()
|
||||
// Why: the finished command may have moved HEAD or the index (e.g.
|
||||
// `git checkout`); nudge git UI now instead of waiting for a poll.
|
||||
dispatchTerminalCommandFinishedEvent(session.deps.worktreeId, bestEffortExitCode)
|
||||
const state = useAppStore.getState()
|
||||
const entry = state.agentStatusByPaneKey[session.cacheKey]
|
||||
const inferenceResult = session.flushPendingInterruptInference()
|
||||
const dropStatus = (): void => {
|
||||
if (inferenceResult === true) {
|
||||
session.dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true })
|
||||
return
|
||||
}
|
||||
if (inferenceResult instanceof Promise) {
|
||||
void inferenceResult.then((applied) => {
|
||||
session.dropCommandFinishedStatusIfSameTurn(entry, {
|
||||
allowInferredInterrupt: applied === true
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
session.dropCommandFinishedStatusIfSameTurn(entry)
|
||||
}
|
||||
if (shouldDeferStatusDrop) {
|
||||
// Why: keep the concrete pane identity routable while the local process
|
||||
// check distinguishes a leaked nested-shell D from a genuine agent exit.
|
||||
session.deferredCommandFinishedStatusDrop = dropStatus
|
||||
return
|
||||
}
|
||||
session.deferredCommandFinishedStatusDrop = null
|
||||
dropStatus()
|
||||
}
|
||||
session.sampleVisiblePaneForegroundAgent = (forceRoutingConfirmation = false): void => {
|
||||
if (
|
||||
!session.deps.isVisibleRef.current ||
|
||||
session.visibleForegroundSamplePending ||
|
||||
session.visibleForegroundSampleSettled
|
||||
) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const foreground = state.paneForegroundAgentByPaneKey[session.cacheKey]
|
||||
// Why: a daemon reattach may restore display identity without current
|
||||
// routing authority. Only fresh evidence can suppress its confirmation.
|
||||
if (foreground?.agent && foreground.routingTrusted === true) {
|
||||
return
|
||||
}
|
||||
if (!forceRoutingConfirmation && session.paneHasLiveHookAgentIcon(state)) {
|
||||
return
|
||||
}
|
||||
const expectsAgent = session.paneExpectsLaunchAgent(state)
|
||||
// Why: a completed local process ladder is stronger than stale tab/startup
|
||||
// launch metadata. Command-start clears this mark if the pane becomes busy.
|
||||
if (foreground?.shellForeground) {
|
||||
return
|
||||
}
|
||||
// Why: tab launch metadata can leak across split panes; rebuild pane-scoped
|
||||
// identity from local process state, with remote/SSH excluded by the tracker.
|
||||
session.visibleForegroundSamplePending =
|
||||
session.paneForegroundAgentTracker.onVisiblePtyBound(expectsAgent)
|
||||
}
|
||||
session.startAcceptedInferredCommand = (agent) => {
|
||||
session.paneForegroundAgentTracker.onCommandStarted(agent)
|
||||
}
|
||||
session.requestKnownWindowsShiftEnterReconfirmation = () => {
|
||||
const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[session.cacheKey]
|
||||
// Why: daemon reattach/launch metadata is display-only until a live
|
||||
// provider read confirms it. Submit/interrupt/title-exit evidence must
|
||||
// revoke that launch-only hint too, otherwise Shift+Enter can route bytes
|
||||
// to an agent that already exited before confirmation ever ran.
|
||||
if (
|
||||
!foreground?.agent ||
|
||||
TUI_AGENT_CONFIG[foreground.agent].windowsShiftEnterEncoding !== 'csi-u'
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: cmd.exe and Git Bash have no OSC command boundaries. Keep the icon
|
||||
// as a hint, but revoke bytes until one current provider confirmation lands.
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
agent: foreground.agent,
|
||||
routingRevoked: true,
|
||||
shellForeground: false
|
||||
})
|
||||
session.visibleForegroundSamplePending = false
|
||||
session.visibleForegroundSampleSettled = false
|
||||
// Why: hook rows can suppress display-only sampling, but cannot restore
|
||||
// byte authority after this function explicitly revoked routing trust.
|
||||
session.sampleVisiblePaneForegroundAgent(true)
|
||||
}
|
||||
session.commandLifecycle = createTerminalCommandLifecycle({
|
||||
onCommandStarted: () => {
|
||||
// Why: a new command invalidates cleanup waiting on the previous D; only
|
||||
// a later confirmed shell boundary may retire this pane's live identity.
|
||||
session.deferredCommandFinishedStatusDrop = null
|
||||
session.visibleForegroundSamplePending = false
|
||||
session.visibleForegroundSampleSettled = false
|
||||
// Why: typed commands can be aliases, so they only widen the bounded
|
||||
// process-confirmation window; they never become routing evidence.
|
||||
session.paneForegroundAgentTracker.onCommandStarted(session.commandInferredPaneAgent)
|
||||
},
|
||||
onCommandFinished: session.handleCommandFinished
|
||||
})
|
||||
// Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect —
|
||||
// it stays attached in every authority mode.
|
||||
session.commandLifecycle.attachXtermConsumer(session.pane.terminal)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import type { HasPty } from '../terminal-dead-session-reconcile'
|
||||
|
||||
export type PanePtyBinding = IDisposable & {
|
||||
syncProcessTracking: () => void
|
||||
noteVisibilityResume: () => void
|
||||
reassertPtySizeAfterWindowWake: () => void
|
||||
/** Navigation-free hibernation wake: fires the armed cold-restore --resume
|
||||
* without the size-reassert/foreground-sample side effects of a real reveal.
|
||||
* Used by the mobile wake fanout so a hidden hibernated pane resumes with no
|
||||
* desktop hidden→visible transition. Returns the sleeping record's provider
|
||||
* session claim key when this pane started (or latched) the in-place wake,
|
||||
* so the follow-up generic resume never launches the same session twice. */
|
||||
wakeHibernatedAgentIfArmed: (claimedProviderSessions?: Set<string>) => string | null
|
||||
/** Re-sample process identity when the pane gains intra-tab focus: the tab
|
||||
* icon follows the active leaf, and a shell-marked entry on a still-running
|
||||
* agent pane has no OSC boundary left to correct it. */
|
||||
sampleForegroundAgentOnFocus: () => void
|
||||
/** Reconfirm after direct shortcut input, which bypasses PTY onData. */
|
||||
requestWindowsShiftEnterReconfirmation: () => void
|
||||
reconcileIfSessionDead: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
|
||||
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { notifyCodexPaneBoundForStaleSweep } from '@/lib/codex-stale-pane-sweep'
|
||||
import { createTerminalGitHubPRLinkDetector } from '../../../../../shared/terminal-github-pr-link-detector'
|
||||
import { setRendererPtyVisibilityClaim } from '../pty-renderer-delivery-claims'
|
||||
import { AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS } from '../agent-task-complete-policy'
|
||||
|
||||
import {
|
||||
isAgentTaskCompleteNotificationEnabled,
|
||||
isAgentTaskCompleteTrackingEnabled
|
||||
} from './agent-task-complete-settings'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installPanePtyVisibilityBind(session: ConnectPanePtySession): void {
|
||||
session.observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector()
|
||||
session.reportPanePtyVisibility = (ptyId: string | null | undefined, visible: boolean): void => {
|
||||
if (!ptyId || isRemoteRuntimePtyId(ptyId)) {
|
||||
// Why: remote-runtime PTYs use a relay path outside main's local
|
||||
// renderer-visibility registry, so reporting them here is misleading.
|
||||
return
|
||||
}
|
||||
setRendererPtyVisibilityClaim(session.transport, ptyId, visible)
|
||||
}
|
||||
session.bindActivePanePty = (
|
||||
ptyId: string,
|
||||
options: {
|
||||
seedInitialAgentStatus?: boolean
|
||||
updateTabPtyId?: 'always' | 'if-missing'
|
||||
replacePtyId?: string
|
||||
sampleVisibleForegroundAgent?: boolean
|
||||
} = {}
|
||||
): void => {
|
||||
if (session.activePanePtyBinding && session.activePanePtyBinding !== ptyId) {
|
||||
session.reportPanePtyVisibility(session.activePanePtyBinding, false)
|
||||
}
|
||||
session.setPanePtyFitBinding(ptyId)
|
||||
session.activePanePtyBinding = ptyId
|
||||
session.reportPanePtyVisibility(ptyId, session.deps.isVisibleRef.current)
|
||||
// Why: record bind time on the spawn/attach chokepoint so the reconcile
|
||||
// guard knows this binding is newer than any pre-bind snapshot.
|
||||
session.activePanePtyBindingBoundAt = performance.now()
|
||||
session.registerSideEffectFactConsumerForPty(ptyId)
|
||||
session.syncHiddenRendererPtyDelivery()
|
||||
session.deps.syncPanePtyLayoutBinding(session.pane.id, ptyId)
|
||||
notifyCodexPaneBoundForStaleSweep(ptyId)
|
||||
const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId] ?? []
|
||||
const directSshRetryAttemptId =
|
||||
session.capturedDirectSshRetryPtyAccepted && session.directSshRetryAttempt
|
||||
? session.directSshRetryAttempt.attemptId
|
||||
: undefined
|
||||
if (
|
||||
directSshRetryAttemptId ||
|
||||
options.updateTabPtyId !== 'if-missing' ||
|
||||
!tabPtyIds.includes(ptyId)
|
||||
) {
|
||||
if (directSshRetryAttemptId) {
|
||||
session.deps.updateTabPtyId(
|
||||
session.deps.tabId,
|
||||
ptyId,
|
||||
options.replacePtyId,
|
||||
directSshRetryAttemptId
|
||||
)
|
||||
} else if (options.replacePtyId) {
|
||||
session.deps.updateTabPtyId(session.deps.tabId, ptyId, options.replacePtyId)
|
||||
} else {
|
||||
session.deps.updateTabPtyId(session.deps.tabId, ptyId)
|
||||
}
|
||||
}
|
||||
if (options.seedInitialAgentStatus) {
|
||||
session.applyInitialAgentStatus()
|
||||
}
|
||||
// Spawn/attach completion is when a pane gains a concrete PTY ID. The initial
|
||||
// frame-level sync often runs before that async result arrives.
|
||||
scheduleRuntimeGraphSync()
|
||||
session.agentCompletionCoordinator.startProcessTracking()
|
||||
// Why: fresh spawns normally rely on a future OSC 133 command-start read to
|
||||
// identify the launched agent; only adopted or restored PTYs may already be
|
||||
// inside Codex with no new foreground signal. But no-OSC shells (Git Bash,
|
||||
// cmd.exe) never emit a command-start, so an expected-agent pane spawned into
|
||||
// one would never gain fresh evidence authorizing trusted Windows CSI-u
|
||||
// routing for the launched agent, so Shift+Enter could submit (#7620, #9703).
|
||||
// Seed the SAME command-start confirmation the manually-typed launch path
|
||||
// uses (onCommandStarted): its bounded retry ladder spans agent boot, and a
|
||||
// miss publishes shellForeground:false — recoverable by later focus/reveal
|
||||
// samples. A visible-pty sample would instead let a slow boot latch a
|
||||
// known-agent shell-confirm that clears launch identity and blocks recovery.
|
||||
// A real OSC 133;C, if it arrives, simply supersedes this.
|
||||
if (options.sampleVisibleForegroundAgent === true) {
|
||||
session.sampleVisiblePaneForegroundAgent()
|
||||
} else if (options.seedInitialAgentStatus === true) {
|
||||
const freshSpawnLaunchAgent = session.resolveExpectedLaunchTuiAgent()
|
||||
if (freshSpawnLaunchAgent) {
|
||||
session.paneForegroundAgentTracker.onCommandStarted(freshSpawnLaunchAgent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.onPtySpawn = (ptyId: string): void => {
|
||||
if (!session.claimCapturedDirectSshRetryPty(ptyId)) {
|
||||
// Why: this callback proves a fresh process was created, so rejecting its obsolete lease must also retire it.
|
||||
queueMicrotask(() => {
|
||||
if (session.transport.getPtyId() === ptyId) {
|
||||
session.transport.disconnect()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: record that this exact PTY was freshly spawned (not reattached), so a
|
||||
// newborn shell that dies before any interaction (e.g. failing direnv on a
|
||||
// just-created worktree) can be kept visible rather than tearing down the
|
||||
// worktree. Reattach/coldRestore skip onPtySpawn (pty-transport.ts).
|
||||
session.spawnedFreshPtyId = ptyId
|
||||
// Why: Command Code has no prompt-start hook. Seed the visible working row
|
||||
// once the PTY exists, then let real hook events refine or complete it.
|
||||
session.bindActivePanePty(ptyId, { seedInitialAgentStatus: true })
|
||||
}
|
||||
session.onPtyRebind = (ptyId: string, replacedPtyId: string): void => {
|
||||
if (!session.canAdoptCapturedDirectSshRetryPty(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: provider handle rotation keeps the existing pane/session generation;
|
||||
// replace its stale store identity without fresh-spawn exit semantics.
|
||||
session.bindActivePanePty(ptyId, { replacePtyId: replacedPtyId })
|
||||
}
|
||||
// ─── Attention signal: BEL ────────────────────────────────────────────
|
||||
//
|
||||
// BEL (0x07) is the attention signal. A BEL raises tab- and worktree-level
|
||||
// indicators, and fires an OS notification. The experimental pane marker
|
||||
// clears when the user interacts with the exact pane.
|
||||
//
|
||||
// The one case where BEL falsely fires is when a crashed TUI left DEC
|
||||
// private mode 1004 (focus event reporting) enabled — pane clicks then
|
||||
// emit `\e[I`/`\e[O` into the shell, zsh treats them as unbound keys and
|
||||
// rings the bell. This is specific to terminals with cross-restart
|
||||
// persistence (as we have); our fix is to reset 1004 and friends after
|
||||
// scrollback replay so the mode state matches the fresh shell
|
||||
// underneath. See POST_REPLAY_MODE_RESET in shared/terminal-mode-reset-profiles.ts.
|
||||
session.onBell = (): void => {
|
||||
// Why: restored Claude Code sessions have been observed to emit a real
|
||||
// standalone BEL some time after daemon snapshot reattach, even when Orca
|
||||
// did not just forward focus/control input. Treat the BEL as authoritative
|
||||
// PTY output here; any product-side suppression should be an explicit UX
|
||||
// decision higher up, not a transport-layer guess.
|
||||
session.deps.markWorktreeUnread(session.deps.worktreeId)
|
||||
session.deps.markTerminalTabUnread(session.deps.tabId)
|
||||
if (useAppStore.getState().settings?.experimentalTerminalAttention === true) {
|
||||
session.deps.markTerminalPaneUnread(session.cacheKey)
|
||||
}
|
||||
// Why: agent CLIs often emit BEL in the same completion burst as their
|
||||
// working->idle title change. Delay only the OS notification so the richer
|
||||
// agent-complete notification can win the main-process worktree cooldown.
|
||||
session.pendingTerminalBellNotification = true
|
||||
if (!session.hasPendingAgentTaskCompleteNotification()) {
|
||||
session.scheduleTerminalBellNotification()
|
||||
}
|
||||
}
|
||||
|
||||
session.clearTerminalBellNotificationTimer = (): void => {
|
||||
if (session.terminalBellNotificationTimer !== null) {
|
||||
clearTimeout(session.terminalBellNotificationTimer)
|
||||
session.terminalBellNotificationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
session.scheduleTerminalBellNotification = (): void => {
|
||||
if (session.terminalBellNotificationTimer !== null) {
|
||||
return
|
||||
}
|
||||
session.terminalBellNotificationTimer = setTimeout(() => {
|
||||
session.terminalBellNotificationTimer = null
|
||||
if (session.disposed) {
|
||||
session.pendingTerminalBellNotification = false
|
||||
return
|
||||
}
|
||||
if (session.hasPendingAgentTaskCompleteNotification()) {
|
||||
return
|
||||
}
|
||||
session.pendingTerminalBellNotification = false
|
||||
session.deps.dispatchNotification({ source: 'terminal-bell', paneKey: session.cacheKey })
|
||||
}, AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS)
|
||||
}
|
||||
|
||||
session.hasPendingAgentTaskCompleteNotification = (): boolean =>
|
||||
isAgentTaskCompleteNotificationEnabled() &&
|
||||
(session.agentCompletionCoordinator.hasPendingHookDoneCompletion() ||
|
||||
session.agentTaskCompleteNotificationGraceTimer !== null ||
|
||||
session.agentTaskCompleteNotificationMaxTimer !== null ||
|
||||
session.agentTaskCompleteStatusUnsubscribe !== null)
|
||||
|
||||
session.clearPendingAgentTaskCompleteNotification = (): void => {
|
||||
if (session.agentTaskCompleteNotificationGraceTimer !== null) {
|
||||
clearTimeout(session.agentTaskCompleteNotificationGraceTimer)
|
||||
session.agentTaskCompleteNotificationGraceTimer = null
|
||||
}
|
||||
if (session.agentTaskCompleteNotificationMaxTimer !== null) {
|
||||
clearTimeout(session.agentTaskCompleteNotificationMaxTimer)
|
||||
session.agentTaskCompleteNotificationMaxTimer = null
|
||||
}
|
||||
if (session.agentTaskCompleteStatusUnsubscribe !== null) {
|
||||
session.agentTaskCompleteStatusUnsubscribe()
|
||||
session.agentTaskCompleteStatusUnsubscribe = null
|
||||
}
|
||||
}
|
||||
|
||||
session.syncAgentTaskCompleteTrackingEnabled = (): boolean => {
|
||||
const enabled = isAgentTaskCompleteTrackingEnabled()
|
||||
const osNotificationsEnabled = isAgentTaskCompleteNotificationEnabled()
|
||||
if (
|
||||
!osNotificationsEnabled &&
|
||||
session.wasAgentTaskCompleteOsNotificationEnabled &&
|
||||
session.pendingTerminalBellNotification
|
||||
) {
|
||||
session.scheduleTerminalBellNotification()
|
||||
}
|
||||
if (!enabled && session.wasAgentTaskCompleteTrackingEnabled) {
|
||||
// Why: disabling every completion consumer is an event-time boundary.
|
||||
// Drop pending alerts while preserving accepted-hook lifecycle state.
|
||||
session.agentTaskCompleteNotificationGeneration += 1
|
||||
session.requiresFreshWorkingForAgentTaskCompleteNotification = true
|
||||
session.clearPendingAgentTaskCompleteNotification()
|
||||
if (session.pendingTerminalBellNotification) {
|
||||
session.scheduleTerminalBellNotification()
|
||||
}
|
||||
} else if (enabled && !session.wasAgentTaskCompleteTrackingEnabled) {
|
||||
// Why: a pane may have observed work while all completion consumers were
|
||||
// disabled. Re-enabling should not let the next idle event report old work.
|
||||
session.requiresFreshWorkingForAgentTaskCompleteNotification = true
|
||||
}
|
||||
session.wasAgentTaskCompleteTrackingEnabled = enabled
|
||||
session.wasAgentTaskCompleteOsNotificationEnabled = osNotificationsEnabled
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { serializeWithAbsoluteCursor } from '../../../../../shared/terminal-serialize-absolute-cursor'
|
||||
import { isTerminalWritePipelineCertifiedDead } from '@/lib/pane-manager/terminal-write-pipeline-health'
|
||||
import { registerPtySerializer, registerPtyTitleSource } from '../pty-buffer-serializer'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import {
|
||||
discardTerminalOutput,
|
||||
waitForTerminalOutputParsed
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindRegisterPaneSerializer(session: ConnectPanePtySession): void {
|
||||
session.registerPaneSerializerFor = (ptyId: string): void => {
|
||||
// Why: StrictMode mounts panes twice; the first mount is session.disposed
|
||||
// before the second runs, but its pty:spawn IPC may have resolved by
|
||||
// the time `session.disposed` flips. Without this guard, the session.disposed first
|
||||
// mount would register against a torn-down xterm and replace the live
|
||||
// second-mount registration via owner-token shadowing.
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
const unregisterSerializer = registerPtySerializer(
|
||||
ptyId,
|
||||
async (opts) => {
|
||||
try {
|
||||
if (isTerminalWritePipelineCertifiedDead(session.pane.terminal)) {
|
||||
return null
|
||||
}
|
||||
await waitForTerminalOutputParsed(session.pane.terminal)
|
||||
// Certification can land while the serializer waits for an older
|
||||
// write; never publish a fossil frame from a dead renderer.
|
||||
if (isTerminalWritePipelineCertifiedDead(session.pane.terminal)) {
|
||||
return null
|
||||
}
|
||||
// Why: alt-screen TUIs (vim, claude-code) hold transient state in
|
||||
// the alternate screen. The hydration path requests
|
||||
// altScreenForcesZeroRows so normal-buffer scrollback isn't bled
|
||||
// into the seed when the user is mid-TUI; the read-fallback path
|
||||
// omits it because it wants the user's currently-visible content.
|
||||
const alt = session.pane.terminal.buffer.active.type === 'alternate'
|
||||
// Why serializeWithAbsoluteCursor: SerializeAddon's relative
|
||||
// cursor restore lands one column short when replay of a
|
||||
// margin-filling final row leaves the target wrap-pending.
|
||||
const data =
|
||||
opts?.altScreenForcesZeroRows && alt
|
||||
? serializeWithAbsoluteCursor(session.pane.serializeAddon, session.pane.terminal, {
|
||||
scrollback: 0
|
||||
})
|
||||
: serializeWithAbsoluteCursor(session.pane.serializeAddon, session.pane.terminal, {
|
||||
scrollback: opts?.scrollbackRows
|
||||
})
|
||||
const orderedSeq =
|
||||
session.rendererOrderedPtyId === ptyId ? session.rendererOrderedSeq : null
|
||||
// Why snapshotFlags and not `flags`: this session.pane may itself have
|
||||
// consumed an old-host snapshot that proved nothing, and its
|
||||
// conservative `0` fallback must not be republished downstream as
|
||||
// a host-proven inactive protocol.
|
||||
const provenKittyFlags = session.kittyKeyboardModes.hasProvenBaseline
|
||||
? session.kittyKeyboardModes.snapshotFlags
|
||||
: undefined
|
||||
return {
|
||||
data,
|
||||
cols: session.pane.terminal.cols,
|
||||
rows: session.pane.terminal.rows,
|
||||
...(orderedSeq !== null ? { seq: orderedSeq } : {}),
|
||||
...(orderedSeq !== null && provenKittyFlags !== undefined
|
||||
? { kittyKeyboardFlags: provenKittyFlags }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
() => {
|
||||
session.clearHiddenOutputRestoreState()
|
||||
discardTerminalOutput(session.pane.terminal)
|
||||
clearTerminalScrollbackAndFollowOutput(session.pane.terminal)
|
||||
}
|
||||
)
|
||||
const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) =>
|
||||
session.pane.terminal.onTitleChange(handler)
|
||||
)
|
||||
const origOnDataDisposableDispose = session.onDataDisposable.dispose.bind(
|
||||
session.onDataDisposable
|
||||
)
|
||||
session.onDataDisposable.dispose = () => {
|
||||
unregisterTitleSource()
|
||||
unregisterSerializer()
|
||||
origOnDataDisposableDispose()
|
||||
}
|
||||
}
|
||||
|
||||
session.replayWriteQueue = Promise.resolve()
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { SessionRestoredBannerReason } from '../session-restored-banner-pane-state'
|
||||
import { hasPtySerializer } from '../pty-buffer-serializer'
|
||||
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import { isExpectedAgentProcess } from '../../../../../shared/agent-process-recognition'
|
||||
import { resolveDraftPasteReadyTimeoutMs } from '../../../../../shared/draft-paste-ready-timeout'
|
||||
import { createDraftPasteReadyScanner } from '../../../../../shared/draft-paste-ready-scanner'
|
||||
import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content'
|
||||
import { writeTerminalPastePtyInput } from '../terminal-pty-paste-writer'
|
||||
|
||||
import { STARTUP_DRAFT_PASTE_QUIET_MS } from './pty-connect-limits'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindSettlePaneSerializer(session: ConnectPanePtySession): void {
|
||||
session.settlePaneSerializerAfterReplay = async (
|
||||
ptyId: string,
|
||||
generation: number
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await session.replayWriteQueue
|
||||
if (session.disposed || session.transport.getPtyId() !== ptyId) {
|
||||
await window.api.pty
|
||||
.clearPendingPaneSerializer(session.cacheKey, generation)
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
await waitForTerminalOutputParsed(session.pane.terminal)
|
||||
if (!session.disposed && session.transport.getPtyId() === ptyId) {
|
||||
await window.api.pty.settlePaneSerializer(session.cacheKey, generation)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Clear below so a failed parser/replay cannot leave the session.pane generation pending.
|
||||
}
|
||||
await window.api.pty.clearPendingPaneSerializer(session.cacheKey, generation).catch(() => {})
|
||||
}
|
||||
session.reportRemoteRendererSerializerReady = (): void => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (!ptyId || !isRemoteRuntimePtyId(ptyId)) {
|
||||
return
|
||||
}
|
||||
if (!hasPtySerializer(ptyId)) {
|
||||
session.registerPaneSerializerFor(ptyId)
|
||||
}
|
||||
// Why: onSubscribed follows the snapshot callback, but replay drains
|
||||
// asynchronously; join it and xterm's parser before reporting readiness.
|
||||
void session.replayWriteQueue
|
||||
.then(() => waitForTerminalOutputParsed(session.pane.terminal))
|
||||
.then(() => {
|
||||
if (!session.disposed && session.transport.getPtyId() === ptyId) {
|
||||
void window.api.pty.reportRendererSerializerReady?.(ptyId)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// Why: local and ordinary SSH startup commands are provider-owned so delivery
|
||||
// survives renderer replacement. Only explicit terminal-paste stays renderer-owned.
|
||||
session.pendingStartupCommand = session.shouldDeliverStartupViaTerminalPaste
|
||||
? session.paneStartup?.command
|
||||
? { command: session.paneStartup.command }
|
||||
: null
|
||||
: null
|
||||
const startupDraftReadyScanner = session.ownsStartupDraftPaste
|
||||
? createDraftPasteReadyScanner(
|
||||
session.startupDraftAgentConfig?.draftPasteReadySignal ??
|
||||
'render-quiet-after-bracketed-paste'
|
||||
)
|
||||
: null
|
||||
let startupDraftReadinessArmed = false
|
||||
let startupDraftPasteSettled = !session.ownsStartupDraftPaste
|
||||
let startupDraftPasteInFlight = false
|
||||
let startupDraftInputRecorded = false
|
||||
let startupDraftQuietTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let startupDraftHardTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const clearStartupDraftPasteTimers = (): void => {
|
||||
if (startupDraftQuietTimer !== null) {
|
||||
clearTimeout(startupDraftQuietTimer)
|
||||
startupDraftQuietTimer = null
|
||||
}
|
||||
if (startupDraftHardTimer !== null) {
|
||||
clearTimeout(startupDraftHardTimer)
|
||||
startupDraftHardTimer = null
|
||||
}
|
||||
}
|
||||
session.cleanupStartupDraftPasteTimers = clearStartupDraftPasteTimers
|
||||
const getStartupDraftPtyId = (): string | null => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (
|
||||
!ptyId ||
|
||||
session.disposed ||
|
||||
session.deps.paneTransportsRef.current.get(session.pane.id) !== session.transport ||
|
||||
session.transport.getPtyId() !== ptyId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return ptyId
|
||||
}
|
||||
const sendStartupDraftPaste = (): void => {
|
||||
if (
|
||||
!session.startupDraftPrompt ||
|
||||
startupDraftPasteSettled ||
|
||||
startupDraftPasteInFlight ||
|
||||
!startupDraftReadinessArmed
|
||||
) {
|
||||
return
|
||||
}
|
||||
const ptyId = getStartupDraftPtyId()
|
||||
if (!ptyId) {
|
||||
return
|
||||
}
|
||||
startupDraftPasteInFlight = true
|
||||
startupDraftPasteSettled = true
|
||||
session.startupDraftPasteAttempted = true
|
||||
session.cleanupStartupDraftPasteTimers()
|
||||
const settings = getSettingsForWorktreeRuntimeOwner(
|
||||
useAppStore.getState(),
|
||||
session.deps.worktreeId
|
||||
)
|
||||
// Why: xterm focus reports share this session.transport queue. Bypassing it can
|
||||
// race CSI I against the draft on ConPTY and expose a literal `[I` prefix.
|
||||
void sendAgentDraftPasteContent(settings, ptyId, session.startupDraftPrompt, async (data) => {
|
||||
const accepted = await writeTerminalPastePtyInput(session.transport, data)
|
||||
if (accepted && !startupDraftInputRecorded) {
|
||||
// Why: this session.transport write bypasses xterm's user-input signal; keep
|
||||
// the composed draft from being discarded by later hibernation.
|
||||
startupDraftInputRecorded = true
|
||||
session.recordTerminalInputForHibernation()
|
||||
}
|
||||
return accepted
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
startupDraftPasteInFlight = false
|
||||
})
|
||||
}
|
||||
const deliverStartupDraftIfAgentOwnsPty = async (): Promise<void> => {
|
||||
if (!session.startupDraftAgentConfig || startupDraftPasteSettled) {
|
||||
return
|
||||
}
|
||||
const ptyId = getStartupDraftPtyId()
|
||||
if (!ptyId) {
|
||||
return
|
||||
}
|
||||
const settings = getSettingsForWorktreeRuntimeOwner(
|
||||
useAppStore.getState(),
|
||||
session.deps.worktreeId
|
||||
)
|
||||
try {
|
||||
const process = await inspectRuntimeTerminalProcess(settings, ptyId)
|
||||
const foreground = process.foregroundProcess?.toLowerCase() ?? ''
|
||||
if (
|
||||
getStartupDraftPtyId() === ptyId &&
|
||||
isExpectedAgentProcess(foreground, session.startupDraftAgentConfig.expectedProcess)
|
||||
) {
|
||||
sendStartupDraftPaste()
|
||||
}
|
||||
} catch {
|
||||
// Best-effort fallback; the primary path is the PTY readiness marker.
|
||||
}
|
||||
}
|
||||
const armStartupDraftHardTimer = (): void => {
|
||||
if (!startupDraftReadyScanner || startupDraftPasteSettled || startupDraftHardTimer !== null) {
|
||||
return
|
||||
}
|
||||
startupDraftHardTimer = setTimeout(() => {
|
||||
startupDraftHardTimer = null
|
||||
void deliverStartupDraftIfAgentOwnsPty()
|
||||
}, resolveDraftPasteReadyTimeoutMs(session.startupDraftAgent))
|
||||
}
|
||||
const armStartupDraftQuietTimer = (): void => {
|
||||
if (!startupDraftReadyScanner || startupDraftPasteSettled) {
|
||||
return
|
||||
}
|
||||
if (startupDraftQuietTimer !== null) {
|
||||
clearTimeout(startupDraftQuietTimer)
|
||||
}
|
||||
startupDraftQuietTimer = setTimeout(() => {
|
||||
startupDraftQuietTimer = null
|
||||
sendStartupDraftPaste()
|
||||
}, STARTUP_DRAFT_PASTE_QUIET_MS)
|
||||
}
|
||||
session.armStartupDraftReadinessObservation = (): void => {
|
||||
if (!startupDraftReadyScanner || startupDraftReadinessArmed) {
|
||||
return
|
||||
}
|
||||
startupDraftReadinessArmed = true
|
||||
armStartupDraftHardTimer()
|
||||
}
|
||||
session.observeStartupDraftPasteReadiness = (data: string): void => {
|
||||
if (!startupDraftReadyScanner || !startupDraftReadinessArmed || startupDraftPasteSettled) {
|
||||
return
|
||||
}
|
||||
const scanned = startupDraftReadyScanner.observe(data)
|
||||
if (scanned.ready) {
|
||||
sendStartupDraftPaste()
|
||||
return
|
||||
}
|
||||
if (scanned.armQuietTimer) {
|
||||
armStartupDraftQuietTimer()
|
||||
}
|
||||
}
|
||||
if (
|
||||
session.ownsStartupDraftPaste &&
|
||||
!session.connectionId &&
|
||||
!session.shouldDeliverStartupViaTerminalPaste
|
||||
) {
|
||||
session.armStartupDraftReadinessObservation()
|
||||
}
|
||||
let sessionRestoredBannerShown: SessionRestoredBannerReason | null = null
|
||||
session.showSessionRestoredBanner = (reason: SessionRestoredBannerReason = 'restored'): void => {
|
||||
// Why: a plain 'restored' banner must not latch out the later 'resume-unavailable'
|
||||
// upgrade — the session.pane would keep claiming a session it never got back.
|
||||
if (
|
||||
sessionRestoredBannerShown === reason ||
|
||||
sessionRestoredBannerShown === 'resume-unavailable'
|
||||
) {
|
||||
return
|
||||
}
|
||||
sessionRestoredBannerShown = reason
|
||||
session.deps.onShowSessionRestoredBanner(session.pane.id, reason)
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type PendingHiddenOutputRestoreChunk = {
|
||||
data: string
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
|
||||
export const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
|
||||
export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
|
||||
// Why: relay requests expire at 30s; leave one second for their fallback before re-arming locally.
|
||||
export const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000
|
||||
export const REMOTE_PTY_ID_PREFIX = 'remote:'
|
||||
export const PTY_CONNECT_DIAG_LIMIT = 200
|
||||
export const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096
|
||||
export const STARTUP_DRAFT_PASTE_QUIET_MS = 1500
|
||||
|
||||
export function recordPtyConnectDiagnostic(message: string): void {
|
||||
if (!e2eConfig.exposeStore) {
|
||||
return
|
||||
}
|
||||
console.log(`[pty-connect] ${message}`)
|
||||
const target = globalThis as Record<string, unknown>
|
||||
const diag = (target.__ptyConnectDiag ??= [] as string[]) as string[]
|
||||
diag.push(message)
|
||||
if (diag.length > PTY_CONNECT_DIAG_LIMIT) {
|
||||
diag.splice(0, diag.length - PTY_CONNECT_DIAG_LIMIT)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
consumeCommittedPtyShutdownExit,
|
||||
deferPtyShutdownExit,
|
||||
isHostPtySleepPending
|
||||
} from '../pty-shutdown-exit-deferral'
|
||||
import { replayIntoTerminal } from '../replay-guard'
|
||||
import { POST_REPLAY_MODE_RESET } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import {
|
||||
getProviderSessionClaimKey,
|
||||
isPassiveCompletedHibernationEvidence
|
||||
} from '@/lib/sleeping-agent-pane-ownership'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installPtyExitHibernate(session: ConnectPanePtySession): void {
|
||||
session.focusSurvivingPtyPaneAfterKeptExit = (): void => {
|
||||
if (session.manager.getActivePane()?.id !== session.pane.id) {
|
||||
return
|
||||
}
|
||||
const hasPtyBinding = (paneId: number): boolean =>
|
||||
Boolean(session.deps.paneTransportsRef.current.get(paneId)?.getPtyId())
|
||||
const repairedActiveLeafId =
|
||||
useAppStore.getState().terminalLayoutsByTabId[session.deps.tabId]?.activeLeafId ?? null
|
||||
const repairedActivePaneId = repairedActiveLeafId
|
||||
? session.manager.getNumericIdForLeaf(repairedActiveLeafId)
|
||||
: null
|
||||
const targetPaneId =
|
||||
repairedActivePaneId !== null &&
|
||||
repairedActivePaneId !== session.pane.id &&
|
||||
hasPtyBinding(repairedActivePaneId)
|
||||
? repairedActivePaneId
|
||||
: (session.manager
|
||||
.getPanes()
|
||||
.find((candidate) => candidate.id !== session.pane.id && hasPtyBinding(candidate.id))
|
||||
?.id ?? null)
|
||||
if (targetPaneId !== null) {
|
||||
// Why: when a newborn split PTY dies before output/input, the pane stays
|
||||
// mounted for diagnostics; move live focus to the sibling that still owns a PTY.
|
||||
session.manager.setActivePane(targetPaneId, {
|
||||
focus: session.deps.isActiveRef.current && session.deps.isVisibleRef.current
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the transport's own exit handler (pty-transport.ts) normally makes
|
||||
// onExit run-at-most-once by clearing connected/ptyId + unregistering BEFORE
|
||||
// calling it. reconcileIfSessionDead drives onExit directly (bypassing that),
|
||||
// so this guards the body so reconcile and any racing real/synthetic pty:exit
|
||||
// for the same id close the pane exactly once. Scoped to the exiting ptyId
|
||||
// (not a bare boolean): an intentional suppressed restart keeps the pane
|
||||
// mounted and rebinds to a NEW ptyId, and that replacement's later real exit
|
||||
// must still run — a one-shot boolean would strand the pane on rebind.
|
||||
session.handledExitPtyId = null
|
||||
// Why: tracks the ptyId of a genuine fresh spawn — onPtySpawn fires only for
|
||||
// fresh spawns, never reattach/coldRestore (pty-transport.ts). Lets the
|
||||
// sole-pane exit branch tell "this newborn shell died on its own" from "a
|
||||
// reattached persisted session was already dead", so a failing .envrc/direnv
|
||||
// on a brand-new worktree keeps its dead terminal visible instead of bouncing
|
||||
// the user to Landing.
|
||||
session.spawnedFreshPtyId = null
|
||||
// Why: hibernation suppresses its kill's exit while the pane is hidden, so
|
||||
// onExit must not tear the pane down — but the pane still owes the user a
|
||||
// wake. Remember the hibernated PTY and exact record; the visibility-resume
|
||||
// hook consumes both and cannot accidentally adopt a later stale record.
|
||||
session.hibernatedWakeTarget = null
|
||||
session.wakeHibernatedAgentPane = null
|
||||
// Why: a mobile wake can land after the sleeping record is written but
|
||||
// before the suppressed kill exit arms the wake target. The phone never
|
||||
// reveals the desktop pane, so without a latch the edge-triggered wake would
|
||||
// be dropped and the phone left on a frozen terminal.
|
||||
session.pendingHibernatedWakeTarget = null
|
||||
// Why: transport.connect settles asynchronously. Repeated mobile activation
|
||||
// must keep claiming this provider session until the replacement PTY either
|
||||
// exists (and clears the sleep record) or the spawn fails and can be retried.
|
||||
session.hibernatedWakeInFlightClaimKey = null
|
||||
// Why: reveal is the normal wake trigger, but a reveal that lands *during* the
|
||||
// in-flight hibernation kill runs noteVisibilityResume before onExit arms the
|
||||
// wake. Sharing the guarded consume lets both the reveal hook and the
|
||||
// arm-time foreground check resume the pane exactly once.
|
||||
session.consumeHibernatedAgentWake = (claimedProviderSessions?: Set<string>): string | null => {
|
||||
const target = session.hibernatedWakeTarget
|
||||
if (!target || session.disposed) {
|
||||
return null
|
||||
}
|
||||
if (session.deps.paneTransportsRef.current.get(session.pane.id) !== session.transport) {
|
||||
return null
|
||||
}
|
||||
const currentRecord = session.getSleepingRecordForPane(useAppStore.getState())?.record
|
||||
if (currentRecord !== target.record) {
|
||||
session.hibernatedWakeTarget = null
|
||||
session.pendingHibernatedWakeTarget = null
|
||||
return null
|
||||
}
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
// Why: a real pty:exit clears the transport's ptyId before onExit while a
|
||||
// reconcile-driven exit leaves it bound; both mean "nothing respawned since
|
||||
// hibernation". A different non-null id means another flow (e.g. an
|
||||
// intentional restart) already rebound the pane — its spawn wins.
|
||||
if (currentPtyId !== null && currentPtyId !== target.ptyId) {
|
||||
session.hibernatedWakeTarget = null
|
||||
session.pendingHibernatedWakeTarget = null
|
||||
return null
|
||||
}
|
||||
if (!session.wakeHibernatedAgentPane) {
|
||||
return null
|
||||
}
|
||||
const claimKey = getProviderSessionClaimKey(target.record)
|
||||
if (claimedProviderSessions?.has(claimKey)) {
|
||||
return null
|
||||
}
|
||||
// Why: one wake event can visit multiple mounted legacy/stable panes for
|
||||
// the same provider session. Claim synchronously before any spawn starts.
|
||||
claimedProviderSessions?.add(claimKey)
|
||||
session.hibernatedWakeTarget = null
|
||||
session.pendingHibernatedWakeTarget = null
|
||||
session.hibernatedWakeInFlightClaimKey = claimKey
|
||||
// Why: reveal is the wake signal for a hibernated pane. Resume the recorded
|
||||
// agent session (or fall back to a fresh shell) instead of leaving the
|
||||
// frozen frame with no PTY behind it.
|
||||
void session
|
||||
.wakeHibernatedAgentPane()
|
||||
.then((spawnedPtyId) => {
|
||||
if (!spawnedPtyId) {
|
||||
// Why: a transient replacement-spawn failure leaves the passive
|
||||
// record owned by this pane. Re-arm the exact target so a later
|
||||
// mobile open can retry instead of stranding the frozen session;
|
||||
// consume revalidates disposal, binding, PTY, and record identity.
|
||||
session.hibernatedWakeTarget = target
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (session.hibernatedWakeInFlightClaimKey === claimKey) {
|
||||
session.hibernatedWakeInFlightClaimKey = null
|
||||
}
|
||||
})
|
||||
return claimKey
|
||||
}
|
||||
session.onExit = (ptyId: string, opts: { preserveRendererBinding?: boolean } = {}): void => {
|
||||
if (session.handledExitPtyId === ptyId) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
session.deps.isPtyShutdownPending(ptyId) ||
|
||||
isHostPtySleepPending(ptyId, session.runtimeEnvironmentId)
|
||||
) {
|
||||
// Why: the transport emits exit once; replay it only after a verified commit so rollback keeps renderer state retryable.
|
||||
deferPtyShutdownExit(ptyId, (settlement) => {
|
||||
if (settlement === 'committed') {
|
||||
session.onExit(ptyId, { preserveRendererBinding: true })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
const preserveRendererBinding =
|
||||
opts.preserveRendererBinding === true ||
|
||||
consumeCommittedPtyShutdownExit(ptyId, session.runtimeEnvironmentId)
|
||||
session.resetRendererOrderedSeqForPtyExit(ptyId)
|
||||
const currentPaneTransport = session.deps.paneTransportsRef.current.get(session.pane.id)
|
||||
if (currentPaneTransport && currentPaneTransport !== session.transport) {
|
||||
// Why: an old transport can deliver a late exit after this pane has
|
||||
// rebound to a replacement PTY; only clear ownership for the exited id.
|
||||
session.handledExitPtyId = ptyId
|
||||
if (!preserveRendererBinding) {
|
||||
session.deps.clearTabPtyId(session.deps.tabId, ptyId)
|
||||
}
|
||||
session.deps.consumeSuppressedPtyExit(ptyId)
|
||||
scheduleRuntimeGraphSync()
|
||||
return
|
||||
}
|
||||
session.handledExitPtyId = ptyId
|
||||
session.agentCompletionCoordinator.dispose()
|
||||
session.dropSideEffectFactConsumer()
|
||||
// Why: main clears gate state on PTY exit too; this only resets the
|
||||
// pane-local marker so a reused pane cannot skip re-marking a new PTY.
|
||||
session.releaseHiddenRendererPtyDelivery()
|
||||
session.clearPanePtyFitBinding()
|
||||
// Why: the negotiating application died with its PTY; any replacement
|
||||
// session starts with kitty keyboard flags at zero.
|
||||
session.kittyKeyboardModes.reset()
|
||||
const isSuppressedExit = session.deps.consumeSuppressedPtyExit(ptyId) || preserveRendererBinding
|
||||
if (!isSuppressedExit) {
|
||||
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, ptyId)
|
||||
}
|
||||
session.deps.clearRuntimePaneTitle(session.deps.tabId, session.pane.id)
|
||||
if (!preserveRendererBinding) {
|
||||
session.deps.clearTabPtyId(session.deps.tabId, ptyId)
|
||||
}
|
||||
// Why: if the PTY exits abruptly (Ctrl-D, crash, shell termination) without
|
||||
// first emitting a non-agent title, the cache timer would persist as stale
|
||||
// state. Clear it unconditionally on PTY exit.
|
||||
session.deps.setCacheTimerStartedAt(session.cacheKey, null)
|
||||
// Why: a dead terminal has no running agent — remove its explicit status
|
||||
// entry so the hover UI only shows what is running *now*.
|
||||
useAppStore.getState().removeAgentStatus(session.cacheKey)
|
||||
useAppStore.getState().clearPaneForegroundAgent(session.cacheKey)
|
||||
// The runtime graph is the CLI's source for live terminal bindings, so
|
||||
// we must republish when a pane loses its PTY instead of waiting for a
|
||||
// broader layout change that may never happen.
|
||||
scheduleRuntimeGraphSync()
|
||||
// Why: intentional restarts suppress the PTY exit ahead of time so the
|
||||
// pane stays mounted and can reconnect in place. Without consuming the
|
||||
// suppression here, split-pane Codex restarts would still close the pane
|
||||
// because this handler runs before the tab-level close logic sees the exit.
|
||||
if (isSuppressedExit) {
|
||||
// Why: the action that suppressed the exit owns whether the leaf binding
|
||||
// is a wake hint or should be discarded; runtime cleanup above is enough.
|
||||
session.manager.setPaneGpuRendering(session.pane.id, true)
|
||||
const sleepingRecordEntry = session.getSleepingRecordForPane(useAppStore.getState())
|
||||
if (
|
||||
sleepingRecordEntry &&
|
||||
isPassiveCompletedHibernationEvidence(sleepingRecordEntry.record)
|
||||
) {
|
||||
// Why: hibernation killed this pane's PTY while hidden. The frozen TUI
|
||||
// frame still has mouse-tracking/bracketed-paste armed, which silently
|
||||
// eats every click and keystroke against a dead transport — disarm the
|
||||
// modes now and arm the reveal-time wake.
|
||||
replayIntoTerminal(session.pane, session.deps.replayingPanesRef, POST_REPLAY_MODE_RESET, {
|
||||
breadcrumbIdentity: {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
ptyId
|
||||
},
|
||||
shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously
|
||||
})
|
||||
session.hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record }
|
||||
const pendingWakeMatches =
|
||||
session.pendingHibernatedWakeTarget?.ptyId === ptyId &&
|
||||
session.pendingHibernatedWakeTarget.record === sleepingRecordEntry.record
|
||||
if (session.pendingHibernatedWakeTarget && !pendingWakeMatches) {
|
||||
session.pendingHibernatedWakeTarget = null
|
||||
}
|
||||
if (session.deps.isVisibleRef.current || pendingWakeMatches) {
|
||||
// Why: a reveal (or a mobile wake) that raced this kill already ran
|
||||
// before the exit landed, so it saw nothing armed. Consume the wake
|
||||
// now (deferred off the exit handler) so the pane still resumes
|
||||
// without needing a second hide/reveal or wake event.
|
||||
queueMicrotask(() => {
|
||||
session.consumeHibernatedAgentWake()
|
||||
})
|
||||
}
|
||||
} else if (session.pendingHibernatedWakeTarget?.ptyId === ptyId) {
|
||||
session.pendingHibernatedWakeTarget = null
|
||||
}
|
||||
return
|
||||
}
|
||||
session.manager.setPaneGpuRendering(session.pane.id, true)
|
||||
const panes = session.manager.getPanes()
|
||||
if (panes.length <= 1) {
|
||||
// Why: a worktree's sole newborn terminal can die on shell startup — e.g.
|
||||
// a PR branch ships an .envrc whose direnv command fails, so the login
|
||||
// shell exits non-zero immediately. Routing that through onPtyExitRef
|
||||
// closes the only tab, which deactivates the worktree (setActiveWorktree
|
||||
// (null)) and strands the user on the Landing screen for a worktree that
|
||||
// was just created. Keep the dead pane mounted instead (mirrors the
|
||||
// freshly-split guard below) so the direnv error stays visible and the
|
||||
// worktree stays active. Gated on a genuine fresh spawn (onPtySpawn fired
|
||||
// for this ptyId — reattach/coldRestore skip it) that the user never typed
|
||||
// into, so a reattached-dead session or an explicit `exit` still tears
|
||||
// down as before.
|
||||
if (session.spawnedFreshPtyId === ptyId && !Number.isFinite(session.lastTerminalInputAt)) {
|
||||
return
|
||||
}
|
||||
session.deps.onPtyExitRef.current(ptyId)
|
||||
return
|
||||
}
|
||||
if (
|
||||
session.deps.isVisibleRef.current &&
|
||||
session.hadExistingPaneTransportAtConnect &&
|
||||
!session.restoredPtyIdForTransport &&
|
||||
!Number.isFinite(session.lastTerminalInputAt) &&
|
||||
!session.hasReceivedPtyOutput
|
||||
) {
|
||||
// Why: a freshly split pane can lose its newborn PTY during setup; keep
|
||||
// the split visible so the failed session does not immediately collapse.
|
||||
// Hidden panes must close instead: the hidden-delivery gate withholds
|
||||
// their bytes, so "no output" is meaningless there, and keeping one
|
||||
// strands a binding-less pane the exit path never revisits — it remounts
|
||||
// as a permanently blank ghost on reveal.
|
||||
session.focusSurvivingPtyPaneAfterKeptExit()
|
||||
return
|
||||
}
|
||||
session.manager.closePane(session.pane.id)
|
||||
}
|
||||
|
||||
// Why: on app restart, restored Claude tabs may already be idle when we first
|
||||
// see their title. The agent status tracker only fires onBecameIdle for
|
||||
// working→idle transitions, so the cache timer would never start for these
|
||||
// sessions. We only allow this one-time seed for reattached PTYs; fresh
|
||||
// Claude launches also start idle, but they have no prompt cache yet.
|
||||
session.hasConsideredInitialCacheTimerSeed = false
|
||||
session.allowInitialIdleCacheSeed = false
|
||||
|
||||
session.resolveCurrentAgentStatusRouting = () => {
|
||||
const ptyId = session.activePanePtyBinding ?? session.transport.getPtyId()
|
||||
const state = useAppStore.getState()
|
||||
if (session.disposed || !ptyId) {
|
||||
return undefined
|
||||
}
|
||||
return resolveLiveAgentStatusConnectionRouting({
|
||||
state,
|
||||
paneKey: session.cacheKey,
|
||||
ptyId,
|
||||
expectedConnectionId: session.worktreeConnectionId,
|
||||
runtimeEnvironmentId:
|
||||
session.transport.getRuntimeEnvironmentId?.() ?? session.runtimeEnvironmentId
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types'
|
||||
import { installTerminalImeCompositionRoute } from '../terminal-ime-composition-route'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply'
|
||||
import { safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer'
|
||||
import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
|
||||
import { getAppliedSizeReadE2eDelayMs } from '../pty-applied-size-read-e2e-delay'
|
||||
import { createPtySizeReassertion } from '../pty-size-reassertion'
|
||||
import { isPaneReplaying } from '../replay-guard'
|
||||
import { shouldDropQuarantinedTerminalInput } from '../terminal-input-quarantine'
|
||||
import {
|
||||
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
|
||||
queuePanePtyResizeIfHeld,
|
||||
type PanePtyResizeHoldFlushDetail
|
||||
} from '@/lib/pane-manager/pane-pty-resize-hold'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
|
||||
import { FOREGROUND_GRID_DRIFT_CHECK_MIN_MS } from './foreground-output-budgets'
|
||||
import { TERMINAL_FOCUS_IN_SEQUENCE, TERMINAL_FOCUS_OUT_SEQUENCE } from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { isCodexPaneStale } from './codex-pane-stale'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installPtyInputForward(session: ConnectPanePtySession): void {
|
||||
session.forwardPtyInput = (data: string): void => {
|
||||
// Why: xterm auto-replies to embedded query sequences (DA1, DECRQM,
|
||||
// OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes
|
||||
// into xterm for scrollback/cold-restore/snapshot, those queries would
|
||||
// otherwise pipe replies into the freshly spawned shell as stray input
|
||||
// ("?1;2c", "2026;2$y", OSC color fragments, ...). The replay sites
|
||||
// engage the guard via replayIntoTerminal; here we drop everything
|
||||
// xterm emits while the guard is active. See replay-guard.ts.
|
||||
if (isPaneReplaying(session.deps.replayingPanesRef, session.pane.id)) {
|
||||
return
|
||||
}
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
// Why: after a Codex account switch, the runtime auth has already moved to
|
||||
// the newly selected account. Stale panes must not keep sending input until
|
||||
// they restart, or work can execute under the wrong account while the UI
|
||||
// still says the pane is stale. Fall back to the tab's persisted PTY ID so
|
||||
// the block still holds during reconnect races before the live transport has
|
||||
// updated its local PTY binding.
|
||||
if (
|
||||
isCodexPaneStale({
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
panePtyId: currentPtyId
|
||||
})
|
||||
) {
|
||||
session.clearPendingTerminalInputIntent()
|
||||
return
|
||||
}
|
||||
// Why: presence-lock input drop. While mobile is the driver for this
|
||||
// PTY, desktop keystrokes must not reach the shell; the visible overlay's
|
||||
// explicit Take back action owns restoring desktop input and dimensions.
|
||||
if (currentPtyId && isPtyLocked(currentPtyId)) {
|
||||
session.clearPendingTerminalInputIntent()
|
||||
return
|
||||
}
|
||||
if (
|
||||
session.isNativeWindowsConpty &&
|
||||
session.suppressNativeWindowsIdleCodexFocusReports &&
|
||||
(data === TERMINAL_FOCUS_IN_SEQUENCE || data === TERMINAL_FOCUS_OUT_SEQUENCE)
|
||||
) {
|
||||
// Why: Codex can leave focus reporting armed after a Windows turn, but
|
||||
// disabling the mode would permanently silence focus events on resume.
|
||||
return
|
||||
}
|
||||
// Why: xterm answers CPR/DSR/DA queries natively through this same onData
|
||||
// stream (mixed with keystrokes). Those replies are latency-critical — a
|
||||
// querying program reads them in raw mode with a short timeout — so send
|
||||
// them immediately, skipping the remote input debounce that would corrupt
|
||||
// them (#7329). They are not user input, so they bypass intent inference and
|
||||
// activity recording below. No pending-intent guard: the only intents are
|
||||
// plain-escape (`\x1b`) and ctrl-c (`\x03`), neither of which can satisfy
|
||||
// isTerminalQueryReply (it requires length >= 3 and a full reply grammar),
|
||||
// so a real keystroke never reaches this branch.
|
||||
if (isTerminalQueryReply(data)) {
|
||||
session.sendDesktopQueryReplyImmediate(data)
|
||||
return
|
||||
}
|
||||
// Why after the query-reply branch: device replies are not user input and
|
||||
// must always reach the shell, or a program querying during reattach hangs.
|
||||
// Why at all: a replaced endpoint reattaches to a fresh shell, so the tail
|
||||
// of the interrupted line would be submitted by the user's own Enter and a
|
||||
// compound command could run its surviving half (#10065 follow-up).
|
||||
if (shouldDropQuarantinedTerminalInput(session.deps.tabId, data)) {
|
||||
session.clearPendingTerminalInputIntent()
|
||||
return
|
||||
}
|
||||
const intent = session.pendingTerminalInputIntent
|
||||
// Why: real xterm can deliver the terminal byte even when our DOM keydown
|
||||
// listener missed the press. Exact Ctrl+C/Escape bytes are still safe to
|
||||
// infer for local/remote acknowledged writes; SSH fire-and-forget remains
|
||||
// excluded because those transports do not expose sendInputAccepted.
|
||||
const acknowledgedIntent = intent ?? session.inferIntentFromExactTerminalInput(data)
|
||||
if (acknowledgedIntent && session.transport.sendInputAccepted) {
|
||||
const interruptStatusBaseline =
|
||||
useAppStore.getState().agentStatusByPaneKey[session.cacheKey] ?? null
|
||||
// Why: equal snapshots retain double-Escape semantics while older snapshots lose ack races.
|
||||
if (session.sequencedInterruptStatusBaseline !== interruptStatusBaseline) {
|
||||
session.sequencedInterruptStatusBaseline = interruptStatusBaseline
|
||||
session.interruptStatusBaselineSequence += 1
|
||||
}
|
||||
const capturedBaselineSequence = session.interruptStatusBaselineSequence
|
||||
session.claimViewportForUserActivity()
|
||||
if (acknowledgedIntent === 'ctrl-c') {
|
||||
// Why: the accepted-write callback is async; let the next command be
|
||||
// inferred if the user cancelled an oversized line and immediately typed.
|
||||
session.cancelSuspendedShellCommandInference()
|
||||
}
|
||||
session.clearPendingTerminalInputIntent()
|
||||
const writePromise = session.transport
|
||||
.sendInputAccepted(data)
|
||||
.then((accepted): boolean | Promise<boolean> | null => {
|
||||
if (accepted) {
|
||||
// Why: rejected writes use transport recovery and must not arm a parser probe.
|
||||
session.markAcceptedTerminalInputSent()
|
||||
session.observeAcceptedShellCommandInput(data)
|
||||
session.observeAcceptedTerminalInput(data, acknowledgedIntent)
|
||||
const immediateResult = session.interruptInference.observeInputIntent(
|
||||
acknowledgedIntent,
|
||||
interruptStatusBaseline,
|
||||
capturedBaselineSequence
|
||||
)
|
||||
session.observeTitleOnlyInterrupt()
|
||||
return immediateResult ?? null
|
||||
}
|
||||
// Why: Esc/Ctrl+C are the first keys users press on a frozen pane;
|
||||
// an unbound-transport reject here must arm recovery too.
|
||||
session.requestRecoveryForUndeliverableInput()
|
||||
return null
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[agent-interrupt] acknowledged terminal input failed:', err)
|
||||
return null
|
||||
})
|
||||
session.setPendingTerminalInputWrite(writePromise)
|
||||
return
|
||||
}
|
||||
if (intent) {
|
||||
session.claimViewportForUserActivity()
|
||||
if (session.transport.sendInput(data)) {
|
||||
session.markAcceptedTerminalInputSent()
|
||||
session.observeAcceptedShellCommandInput(data)
|
||||
session.observeAcceptedTerminalInput(data, intent)
|
||||
} else {
|
||||
session.requestRecoveryForUndeliverableInput()
|
||||
}
|
||||
session.clearPendingTerminalInputIntent()
|
||||
return
|
||||
}
|
||||
session.claimViewportForUserActivity()
|
||||
if (session.transport.sendInput(data)) {
|
||||
session.markAcceptedTerminalInputSent()
|
||||
session.observeAcceptedShellCommandInput(data)
|
||||
session.observeAcceptedTerminalInput(data)
|
||||
session.observeSentTerminalInputIntent(data)
|
||||
} else {
|
||||
session.clearPendingTerminalInputIntent()
|
||||
session.requestRecoveryForUndeliverableInput()
|
||||
}
|
||||
}
|
||||
session.onDataDisposable = session.pane.terminal.onData((data) => {
|
||||
if (session.deps.deferPtyInput) {
|
||||
session.deps.deferPtyInput(session.pane.id, data, session.forwardPtyInput)
|
||||
return
|
||||
}
|
||||
session.forwardPtyInput(data)
|
||||
})
|
||||
session.imeCompositionRouteDisposable = installTerminalImeCompositionRoute({
|
||||
terminalElement: session.pane.terminal.element,
|
||||
terminal: session.pane.terminal,
|
||||
capturedTransport: session.transport,
|
||||
getCurrentTransport: () => session.deps.paneTransportsRef.current.get(session.pane.id)
|
||||
})
|
||||
|
||||
session.shouldSuppressDesktopPtyResize = (): boolean => {
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
return Boolean(
|
||||
currentPtyId && (getFitOverrideForPty(currentPtyId) || isPtyLocked(currentPtyId))
|
||||
)
|
||||
}
|
||||
|
||||
session.isRendererPtyResizeAuthoritative = (): boolean => {
|
||||
if (session.deps.isVisibleRef.current) {
|
||||
return true
|
||||
}
|
||||
// Why: hidden-tab layout churn is not authoritative; visible resume
|
||||
// owns correction, and hidden SIGWINCH can reset full-screen TUIs.
|
||||
return false
|
||||
}
|
||||
|
||||
session.forwardPtyResize = (cols: number, rows: number): void => {
|
||||
if (!session.isRendererPtyResizeAuthoritative()) {
|
||||
return
|
||||
}
|
||||
// Why: when a mobile-fit override is active OR mobile is currently the
|
||||
// driver of this PTY, the PTY is already at phone dims and any desktop
|
||||
// resize is wrong. Suppress resize forwarding to avoid spurious SIGWINCH
|
||||
// signals (TUI flicker / wrap corruption). Both checks are needed:
|
||||
// - getFitOverrideForPty covers the "phone-fit dims" state.
|
||||
// - isPtyLocked covers the broader "mobile driving" state, including
|
||||
// transitions where override may not be set (e.g. legacy code paths).
|
||||
// The pty:resize IPC has a defense-in-depth twin. See
|
||||
// docs/mobile-presence-lock.md.
|
||||
if (session.shouldSuppressDesktopPtyResize()) {
|
||||
return
|
||||
}
|
||||
if (queuePanePtyResizeIfHeld(session.pane.container, cols, rows)) {
|
||||
return
|
||||
}
|
||||
session.transport.resize(cols, rows, { claim: true })
|
||||
}
|
||||
|
||||
session.onHeldPtyResizeFlush = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<PanePtyResizeHoldFlushDetail>).detail
|
||||
if (!detail) {
|
||||
return
|
||||
}
|
||||
session.forwardPtyResize(detail.cols, detail.rows)
|
||||
}
|
||||
session.pane.container.addEventListener(
|
||||
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
|
||||
session.onHeldPtyResizeFlush
|
||||
)
|
||||
|
||||
session.onResizeDisposable = session.pane.terminal.onResize(({ cols, rows }) => {
|
||||
if (session.suppressStructuralReplayPtyResize || session.suppressViewportClaimTerminalResize) {
|
||||
return
|
||||
}
|
||||
session.forwardPtyResize(cols, rows)
|
||||
})
|
||||
|
||||
// Why: renderer resize forwarding is fire-and-forget. A visible pane can
|
||||
// finish with xterm at the right grid while the PTY silently kept an older
|
||||
// grid, so Codex keeps composing against stale columns. Fit first so xterm's
|
||||
// normal onResize can send, then read applied PTY size and repair only drift.
|
||||
session.ptySizeReassertion = createPtySizeReassertion({
|
||||
isDisposed: () => session.disposed,
|
||||
getPtyId: () => session.transport.getPtyId(),
|
||||
isRemotePtyId: isRemoteRuntimePtyId,
|
||||
shouldSuppressDesktopResize: () => session.shouldSuppressDesktopPtyResize(),
|
||||
fitAndRun: (continuation) => safeFitAndThen(session.pane, 'pty-size-reassertion', continuation),
|
||||
getTerminalDimensions: () => ({
|
||||
cols: session.pane.terminal.cols,
|
||||
rows: session.pane.terminal.rows
|
||||
}),
|
||||
getAppliedSize: async (ptyId) => {
|
||||
// Why: e2e seam — delays the read past the reveal fit to reproduce the
|
||||
// busy-daemon/SSH ordering; returns 0 outside e2e builds.
|
||||
const delayMs = getAppliedSizeReadE2eDelayMs()
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
return window.api.pty.getSize(ptyId)
|
||||
},
|
||||
forwardResize: session.forwardPtyResize
|
||||
})
|
||||
// Why built here and not inside session.handleReattachResult: a hidden pane parks this until it is
|
||||
// revealed, and a closure created in that scope would pin the whole reattach payload
|
||||
// (snapshot/replay/coldRestore bytes) for as long as the pane stays hidden. Taking the
|
||||
// generation and pty id by value keeps only connection-level state alive.
|
||||
session.createReattachGridPush = (
|
||||
attemptGeneration: number,
|
||||
reattachPtyId: string
|
||||
): { shouldContinue: () => boolean; continuation: () => void } => {
|
||||
const isCurrent = (): boolean =>
|
||||
!session.disposed &&
|
||||
attemptGeneration === session.transportStreamGeneration &&
|
||||
session.transport.getPtyId() === reattachPtyId
|
||||
return {
|
||||
shouldContinue: isCurrent,
|
||||
continuation: () => {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
// Why re-checked at fire time: the caller's pre-check cannot see a mobile takeover that
|
||||
// lands while the pane waits for a box, and transport.resize here bypasses
|
||||
// forwardPtyResize's own suppression.
|
||||
if (session.shouldSuppressDesktopPtyResize()) {
|
||||
return
|
||||
}
|
||||
const reattachCols = session.pane.terminal.cols
|
||||
const reattachRows = session.pane.terminal.rows
|
||||
if (reattachCols > 0 && reattachRows > 0) {
|
||||
session.transport.resize(reattachCols, reattachRows)
|
||||
}
|
||||
// Why: POSIX only sends SIGWINCH on an actual dimension change; signal explicitly so restored TUIs repaint at the correct cursor after replay.
|
||||
if (!isRemoteRuntimePtyId(reattachPtyId)) {
|
||||
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
|
||||
}
|
||||
// Why here: a deferred reveal resolves the fit handle as incomplete, so an awaited
|
||||
// reassertion at the call site would never run for that path.
|
||||
if (session.deps.isVisibleRef.current) {
|
||||
session.ptySizeReassertion.request({ fit: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
session.pendingForegroundGridDriftCheckRaf = null
|
||||
session.lastForegroundGridDriftCheckAt = Number.NEGATIVE_INFINITY
|
||||
session.readProposedTerminalGrid = (): { cols: number; rows: number } | null => {
|
||||
try {
|
||||
const proposed = session.pane.fitAddon.proposeDimensions()
|
||||
if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) {
|
||||
return null
|
||||
}
|
||||
return proposed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
session.terminalGridDriftedFromFit = (): boolean => {
|
||||
const proposed = session.readProposedTerminalGrid()
|
||||
return Boolean(
|
||||
proposed &&
|
||||
(session.pane.terminal.cols !== proposed.cols || session.pane.terminal.rows !== proposed.rows)
|
||||
)
|
||||
}
|
||||
session.scheduleForegroundGridDriftCheck = (): void => {
|
||||
// Why: mobile-owned PTYs intentionally keep a non-desktop grid; drift
|
||||
// healing would refit xterm even if resize forwarding is later suppressed.
|
||||
if (
|
||||
session.disposed ||
|
||||
!session.deps.isVisibleRef.current ||
|
||||
session.shouldSuppressDesktopPtyResize() ||
|
||||
session.pendingForegroundGridDriftCheckRaf !== null
|
||||
) {
|
||||
return
|
||||
}
|
||||
const now = performance.now()
|
||||
if (now - session.lastForegroundGridDriftCheckAt < FOREGROUND_GRID_DRIFT_CHECK_MIN_MS) {
|
||||
return
|
||||
}
|
||||
session.lastForegroundGridDriftCheckAt = now
|
||||
session.pendingForegroundGridDriftCheckRaf = requestAnimationFrame(() => {
|
||||
session.pendingForegroundGridDriftCheckRaf = null
|
||||
if (
|
||||
session.disposed ||
|
||||
!session.deps.isVisibleRef.current ||
|
||||
session.shouldSuppressDesktopPtyResize() ||
|
||||
!session.terminalGridDriftedFromFit()
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: xterm cell metrics can settle after the DOM box stops resizing, so
|
||||
// ResizeObserver never fires even though FitAddon now proposes more cols.
|
||||
requestStablePaneFit(session.pane as ManagedPaneInternal, () =>
|
||||
session.ptySizeReassertion.request({ fit: false })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Why: observe the outer pane as the layout signal for both desktop drift
|
||||
// healing and mobile take-back. Normal desktop panes compare xterm against
|
||||
// the PTY's applied size; mobile-fit panes only report desktop geometry so
|
||||
// the parked phone-sized PTY is not resized. See docs/mobile-fit-hold.md.
|
||||
session.pendingGeometryReportRaf = null
|
||||
session.lastObservedDesktopGrid = null
|
||||
session.readPaneSize = (): { width: number; height: number } | null => {
|
||||
if (typeof session.pane.container.getBoundingClientRect !== 'function') {
|
||||
return null
|
||||
}
|
||||
const rect = session.pane.container.getBoundingClientRect()
|
||||
return { width: rect.width, height: rect.height }
|
||||
}
|
||||
session.lastObservedPaneSize = session.readPaneSize()
|
||||
session.pendingPaneGeometryChanged = false
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { createIpcPtyTransport } from '../pty-transport'
|
||||
import { createRemoteRuntimePtyTransport } from '../remote-runtime-pty-transport'
|
||||
import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation'
|
||||
import { createUnresolvedOwnerPtyTransport } from '../unresolved-owner-pty-transport'
|
||||
import { recordTerminalTabParkedOnUnresolvedHost } from '@/lib/parked-terminal-host-hydration'
|
||||
import { getFitOverrideForPty, onOverrideChange } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
|
||||
import { isPaneReplaying } from '../replay-guard'
|
||||
import { registerUndeliverableWriteHandler } from '@/lib/pane-manager/terminal-write-pipeline-health'
|
||||
import { requestTerminalPaneRecovery } from '../terminal-pane-recovery'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import { resolveTerminalColorSchemeMode } from '../../../../../shared/terminal-color-scheme-protocol'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { discardTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import {
|
||||
CONPTY_DA1_RESPONSE,
|
||||
createTerminalPixelSizeQueryResponder,
|
||||
installTerminalCapabilityReplyHandlers
|
||||
} from '../terminal-capability-replies'
|
||||
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installPtyInputRecovery(session: ConnectPanePtySession): void {
|
||||
session.markAcceptedTerminalInputSent = (): void => {
|
||||
session.markTerminalInputSent()
|
||||
session.recordTerminalInputForHibernationFallback()
|
||||
}
|
||||
session.terminalTheme = session.pane.terminal.options.theme
|
||||
session.terminalColorQueryReplies = session.terminalTheme
|
||||
? { foreground: session.terminalTheme.foreground, background: session.terminalTheme.background }
|
||||
: undefined
|
||||
session.agentLaunchPreferences = toAgentLaunchPreferences(session.paneStartup?.sessionOptions)
|
||||
session.transportOptions = {
|
||||
cwd: session.deps.cwd,
|
||||
// Why: only fresh local IPC spawns may recover from a saved startup cwd
|
||||
// whose directory was deleted (#7239); remote-runtime and SSH spawns
|
||||
// resolve cwd on another host and must keep exact cwd semantics.
|
||||
...(session.runtimeEnvironmentId === null && !session.connectionId
|
||||
? { cwdFallback: 'worktree' as const }
|
||||
: {}),
|
||||
env: session.paneEnv,
|
||||
...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}),
|
||||
command: session.shouldDeliverStartupViaTerminalPaste
|
||||
? undefined
|
||||
: session.paneStartup?.command,
|
||||
...(session.shouldUseProviderSshStartupDelivery
|
||||
? { commandDelivery: 'provider' as const }
|
||||
: {}),
|
||||
startupCommandDelivery: session.shouldDeliverStartupViaTerminalPaste
|
||||
? undefined
|
||||
: session.connectionId && session.paneStartup?.command
|
||||
? 'shell-ready'
|
||||
: session.paneStartup?.startupCommandDelivery,
|
||||
connectionId: session.connectionId,
|
||||
executionHostId: session.executionHostId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
// Why: closes the SIGKILL race documented in INVESTIGATION.md by letting
|
||||
// main sync-flush the (worktreeId, tabId, leafId → ptyId) binding before
|
||||
// pty:spawn returns. Daemon-host-only: SSH path leaves these undefined
|
||||
// and the main-side guard short-circuits.
|
||||
tabId: session.deps.tabId,
|
||||
leafId: session.pane.leafId,
|
||||
activate: session.deps.isActiveRef.current && session.deps.isVisibleRef.current,
|
||||
...(session.shellOverride ? { shellOverride: session.shellOverride } : {}),
|
||||
...(session.projectRuntime ? { projectRuntime: session.projectRuntime } : {}),
|
||||
...(session.terminalColorQueryReplies
|
||||
? { terminalColorQueryReplies: session.terminalColorQueryReplies }
|
||||
: {}),
|
||||
...(session.paneStartup?.launchConfig
|
||||
? { launchConfig: session.paneStartup.launchConfig }
|
||||
: {}),
|
||||
...(session.paneStartup?.resumeProviderSession
|
||||
? { resumeProviderSession: session.paneStartup.resumeProviderSession }
|
||||
: {}),
|
||||
...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt)
|
||||
? {
|
||||
agentPrompt:
|
||||
session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt
|
||||
}
|
||||
: {}),
|
||||
...(session.paneStartup?.initialAgentStatus?.prompt
|
||||
? { agentPromptDelivery: 'auto-submit' as const }
|
||||
: session.paneStartup?.draftPrompt
|
||||
? { agentPromptDelivery: 'draft' as const }
|
||||
: {}),
|
||||
...(session.paneStartup?.agentArgsOverride !== undefined
|
||||
? { agentArgsOverride: session.paneStartup.agentArgsOverride }
|
||||
: {}),
|
||||
...(session.agentLaunchPreferences
|
||||
? { agentLaunchPreferences: session.agentLaunchPreferences }
|
||||
: {}),
|
||||
...(session.launchToken ? { launchToken: session.launchToken } : {}),
|
||||
...(session.paneStartup?.launchAgent ? { launchAgent: session.paneStartup.launchAgent } : {}),
|
||||
...(session.paneStartup?.telemetry ? { telemetry: session.paneStartup.telemetry } : {}),
|
||||
onPtyExit: session.onExit,
|
||||
onPtySpawn: session.onPtySpawn,
|
||||
onPtyRebind: session.onPtyRebind,
|
||||
...(session.mainSideEffectAuthority
|
||||
? {}
|
||||
: {
|
||||
onTitleChange: session.onTitleChange,
|
||||
onBell: session.onBell,
|
||||
onAgentBecameIdle: session.onAgentBecameIdle,
|
||||
onAgentBecameWorking: session.onAgentBecameWorking,
|
||||
onAgentExited: session.onAgentExited
|
||||
}),
|
||||
// Why: local IPC terminals are now model-owned in main: OrcaRuntimeService
|
||||
// parses OSC 9999 before renderer delivery and forwards through the hook
|
||||
// server with local/SSH identity. Remote-runtime streams do not pass through
|
||||
// local main, so the renderer remains their status owner for now.
|
||||
...(session.shouldOwnAgentStatusInRenderer
|
||||
? { onAgentStatus: session.handleRendererOwnedAgentStatus }
|
||||
: {})
|
||||
}
|
||||
if (session.connectionOwnerHydrating) {
|
||||
// Why: this pane holds an inert transport until its host resolves; register it so
|
||||
// the repos:changed handler remounts it instead of leaving the terminal blank.
|
||||
recordTerminalTabParkedOnUnresolvedHost(session.deps.worktreeId, session.deps.tabId)
|
||||
}
|
||||
session.transport =
|
||||
session.terminalOwnerUnresolved || session.connectionOwnerHydrating
|
||||
? createUnresolvedOwnerPtyTransport(
|
||||
session.terminalOwnerUnresolved
|
||||
? 'Workspace identity is ambiguous across hosts. Refresh projects and try again.'
|
||||
: 'Workspace host is still loading. Retry when the project finishes hydrating.'
|
||||
)
|
||||
: session.runtimeEnvironmentId
|
||||
? createRemoteRuntimePtyTransport(session.runtimeEnvironmentId, session.transportOptions)
|
||||
: createIpcPtyTransport(session.transportOptions)
|
||||
session.canSendDesktopQueryReply = (): boolean => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
return !ptyId || !isPtyLocked(ptyId)
|
||||
}
|
||||
// Why: parser/capability handlers bypass the ordinary onData guard. Keep
|
||||
// desktop silent while the elected mobile xterm owns query replies.
|
||||
session.sendDesktopQueryReplyImmediate = (data: string): boolean =>
|
||||
session.canSendDesktopQueryReply() && session.transport.sendInputImmediate(data)
|
||||
// Why (gate mode only): gate-managed PTYs never see the subscribe bytes, so this fact is
|
||||
// their only cue to record the subscription — without the registry entry a later theme
|
||||
// flip never pushes the CSI 997 update and the TUI keeps a stale theme after reveal.
|
||||
// Record-only: a subscribe is not a query (see session.observeLiveMode2031Chunk, #9993).
|
||||
session.handleHiddenMode2031SubscribeFact = (): void => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (
|
||||
session.disposed ||
|
||||
(!session.isHiddenDeliveryGateManagedPty(ptyId) && session.remoteOutputGatedPtyId !== ptyId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const mode = resolveTerminalColorSchemeMode(
|
||||
useAppStore.getState().settings,
|
||||
getSystemPrefersDark()
|
||||
)
|
||||
session.deps.recordPaneMode2031Subscription?.(session.pane.id, mode)
|
||||
}
|
||||
// Why (gate mode only): the counterpart to the subscribe fact. These panes never
|
||||
// receive the withdrawal bytes — main drops them before delivery — and the chunk
|
||||
// scanner is disabled for them, so this fact is the ONLY observer that can retire
|
||||
// the subscription. Without it a TUI that exits while hidden leaves paneMode2031
|
||||
// set, and the next theme flip pushes CSI 997 into the shell that replaced it
|
||||
// (#9993 via maybePushMode2031Flip).
|
||||
session.handleHiddenMode2031UnsubscribeFact = (): void => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (
|
||||
session.disposed ||
|
||||
(!session.isHiddenDeliveryGateManagedPty(ptyId) && session.remoteOutputGatedPtyId !== ptyId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
session.deps.paneMode2031Ref.current.delete(session.pane.id)
|
||||
session.deps.paneLastThemeModeRef.current.delete(session.pane.id)
|
||||
}
|
||||
session.deps.paneTransportsRef.current.set(session.pane.id, session.transport)
|
||||
session.terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({
|
||||
terminal: session.pane.terminal,
|
||||
parser: session.pane.terminal.parser,
|
||||
// Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode
|
||||
// read window; the remote transport's input debounce would corrupt them
|
||||
// (#7329), so send immediately.
|
||||
sendInput: session.sendDesktopQueryReplyImmediate,
|
||||
isReplaying: () => isPaneReplaying(session.deps.replayingPanesRef, session.pane.id),
|
||||
...(session.isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {})
|
||||
})
|
||||
session.respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder(
|
||||
session.pane.terminal,
|
||||
session.sendDesktopQueryReplyImmediate
|
||||
)
|
||||
|
||||
session.claimViewportForUserActivity = (): void => {
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
if (!currentPtyId || getFitOverrideForPty(currentPtyId)?.mode !== 'remote-desktop-fit') {
|
||||
return
|
||||
}
|
||||
let proposed: { cols: number; rows: number } | undefined
|
||||
try {
|
||||
proposed = session.pane.fitAddon.proposeDimensions()
|
||||
} catch {
|
||||
proposed = undefined
|
||||
}
|
||||
const cols = proposed?.cols ?? session.pane.terminal.cols
|
||||
const rows = proposed?.rows ?? session.pane.terminal.rows
|
||||
if (cols > 0 && rows > 0) {
|
||||
// Why: queuing a claim is not convergence. Keep the pane parked until the
|
||||
// runtime confirms desktop-fit so a transient resize failure retries.
|
||||
session.transport.claimViewport?.(cols, rows)
|
||||
}
|
||||
}
|
||||
session.claimPendingVisibleRemoteViewport = (): void => {
|
||||
if (
|
||||
!session.pendingVisibleRemoteViewportClaim ||
|
||||
!session.deps.isVisibleRef.current ||
|
||||
typeof document === 'undefined' ||
|
||||
document.visibilityState === 'hidden' ||
|
||||
typeof document.hasFocus !== 'function' ||
|
||||
!document.hasFocus()
|
||||
) {
|
||||
return
|
||||
}
|
||||
session.claimViewportForUserActivity()
|
||||
}
|
||||
session.armVisibleRemoteViewportClaim = (): void => {
|
||||
const ptyId = session.transport.getPtyId()
|
||||
if (!ptyId || !isRemoteRuntimePtyId(ptyId)) {
|
||||
session.visibleRemoteViewportClaimPtyId = null
|
||||
session.pendingVisibleRemoteViewportClaim = false
|
||||
return
|
||||
}
|
||||
if (
|
||||
session.visibleRemoteViewportClaimPtyId !== ptyId ||
|
||||
session.pendingVisibleRemoteViewportClaim ||
|
||||
getFitOverrideForPty(ptyId)?.mode === 'remote-desktop-fit'
|
||||
) {
|
||||
session.visibleRemoteViewportClaimPtyId = ptyId
|
||||
session.pendingVisibleRemoteViewportClaim = true
|
||||
}
|
||||
}
|
||||
session.unsubscribeRemoteDesktopActivationClaim = onOverrideChange((event) => {
|
||||
if (event.ptyId !== session.transport.getPtyId() || !isRemoteRuntimePtyId(event.ptyId)) {
|
||||
return
|
||||
}
|
||||
if (event.mode === 'desktop-fit') {
|
||||
session.visibleRemoteViewportClaimPtyId = event.ptyId
|
||||
session.pendingVisibleRemoteViewportClaim = false
|
||||
return
|
||||
}
|
||||
if (event.mode === 'remote-desktop-fit') {
|
||||
if (
|
||||
session.deps.isVisibleRef.current &&
|
||||
session.visibleRemoteViewportClaimPtyId !== event.ptyId
|
||||
) {
|
||||
session.visibleRemoteViewportClaimPtyId = event.ptyId
|
||||
session.pendingVisibleRemoteViewportClaim = true
|
||||
}
|
||||
session.claimPendingVisibleRemoteViewport()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: an unbound transport (detached during a remount/move and never
|
||||
// rebound) silently rejects every keystroke while the PTY stays alive and
|
||||
// the last frame stays painted — the pane looks healthy and eats input
|
||||
// (issue #8104 class). None of the dead-session reconciles cover it because
|
||||
// the PTY is live; recover by remounting the tab over the live PTY.
|
||||
session.transportConnectInFlightSince = null
|
||||
// Why a grace window instead of a plain flag: a connect that never settles
|
||||
// (SSH RPC timeout class, wedged daemon call) would otherwise suppress
|
||||
// input-triggered recovery FOREVER — and such a pane has no output flowing,
|
||||
// so no other detector can fire. Past the grace, undeliverable input may
|
||||
// recover again; the transport's destroyed-check no longer kills a
|
||||
// pre-existing session when a late reattach resolves, so a remount racing
|
||||
// a slow-but-alive connect costs a wasted view rebuild, not a shell.
|
||||
session.TRANSPORT_CONNECT_SETTLE_GRACE_MS = 60_000
|
||||
session.requestRecoveryForUndeliverableInput = (providerRejected = false): void => {
|
||||
if (
|
||||
!providerRejected &&
|
||||
session.transport.isConnected?.() &&
|
||||
session.transport.getPtyId() !== null
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: input rejected while a connect/reattach is still settling is "not
|
||||
// deliverable YET", not a dead binding. Remounting here destroys the
|
||||
// unbound transport, and pty-transport's destroyed check then kills the
|
||||
// PTY the in-flight reattach resolves to — the live shell recovery exists
|
||||
// to preserve. The fossil case this detector targets has no pending
|
||||
// connect, so it still recovers. Same for a late async reject landing
|
||||
// after dispose: the successor pane owns the tab now.
|
||||
const connectStillSettling =
|
||||
session.transportConnectInFlightSince !== null &&
|
||||
Date.now() - session.transportConnectInFlightSince < session.TRANSPORT_CONNECT_SETTLE_GRACE_MS
|
||||
if (connectStillSettling || session.disposed) {
|
||||
return
|
||||
}
|
||||
const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null
|
||||
const undeliverablePtyId = session.transport.getPtyId() ?? storePtyId
|
||||
// Why the split: for a local (daemon/app-SSH) id main's registry can answer,
|
||||
// and a `false` there means the shell really died — the dead-session
|
||||
// reconcile owns that teardown and a remount would race it. For a `remote:`
|
||||
// id main owns no registry entry, so `pty:hasPty` routes to the local
|
||||
// provider and fabricates "dead"; that answer blocked every recovery this
|
||||
// signal exists to trigger (STA-2830). The host's own rejection replaces it.
|
||||
const hostRejectedRemoteInput = providerRejected && isRemoteRuntimePtyId(undeliverablePtyId)
|
||||
void requestTerminalPaneRecovery({
|
||||
tabId: session.deps.tabId,
|
||||
ptyId: undeliverablePtyId,
|
||||
reason: hostRejectedRemoteInput ? 'input-rejected-by-host' : 'input-undeliverable',
|
||||
terminalRecoveryGeneration: session.terminalRecoveryGeneration,
|
||||
terminalRecoveryInstanceId: session.terminalRecoveryInstance.id,
|
||||
// Why: pty:hasPty answers null for ids the local registry doesn't own,
|
||||
// and a disconnected remote pane would otherwise remount-churn on every
|
||||
// cooldown window while typing. Local panes keep the lenient gate.
|
||||
requireAuthoritativeLiveness:
|
||||
Boolean(session.transport.getConnectionId?.()) || isRemoteRuntimePtyId(undeliverablePtyId),
|
||||
// Why only the rejected path: it is the only one whose remount can land on
|
||||
// a fresh shell. A stalled-pipeline remount always reattaches to the same
|
||||
// shell, so its half-typed line is still on screen and intact.
|
||||
endpointReplaced: providerRejected
|
||||
})
|
||||
}
|
||||
// Why: the write-pipeline health watch (scheduler stall probe, replay-guard
|
||||
// wedge certification) detects a dead xterm pipeline; route its verdict to
|
||||
// the same tab remount. Registered per xterm instance — recovery replaces
|
||||
// the instance, which resets certification naturally.
|
||||
session.unregisterUndeliverableWriteHandler = registerUndeliverableWriteHandler(
|
||||
session.pane.terminal,
|
||||
(reason) => {
|
||||
// Certification can arrive while this terminal still owns queued or
|
||||
// detached scheduler work; release its delivery credits immediately.
|
||||
discardTerminalOutput(session.pane.terminal)
|
||||
const storePtyId = useAppStore.getState().ptyIdsByTabId?.[session.deps.tabId]?.[0] ?? null
|
||||
void requestTerminalPaneRecovery({
|
||||
tabId: session.deps.tabId,
|
||||
ptyId: session.transport.getPtyId() ?? storePtyId,
|
||||
reason,
|
||||
terminalRecoveryGeneration: session.terminalRecoveryGeneration,
|
||||
terminalRecoveryInstanceId: session.terminalRecoveryInstance.id
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types'
|
||||
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer'
|
||||
import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
|
||||
import { reconcilePtySizeAcrossFrames } from '../pty-size-reconcile'
|
||||
import { shouldClaimRemoteDesktopViewport } from '../remote-desktop-viewport-claim'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild'
|
||||
import { waitForStableStartupGrid } from '../terminal-startup-grid-settle'
|
||||
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { isSetupSplitGeometryReady } from './setup-split-geometry'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installPtyResizeGeometry(session: ConnectPanePtySession): void {
|
||||
session.handleObservedPaneGeometry = (): void => {
|
||||
session.pendingGeometryReportRaf = null
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
deferTerminalGeometryMutationDuringRebuild(
|
||||
session.pane.terminal,
|
||||
'observed-pane-geometry',
|
||||
session.handleObservedPaneGeometry
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const paneGeometryChanged = session.pendingPaneGeometryChanged
|
||||
session.pendingPaneGeometryChanged = false
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
if (!currentPtyId) {
|
||||
// Why: ResizeObserver may deliver its initial measurement before the
|
||||
// remote binding completes; retain that passive baseline for the first
|
||||
// real focused resize instead of swallowing the user's first claim.
|
||||
const proposed = session.readProposedTerminalGrid()
|
||||
if (proposed) {
|
||||
session.lastObservedDesktopGrid = proposed
|
||||
}
|
||||
return
|
||||
}
|
||||
const fitOverride = getFitOverrideForPty(currentPtyId)
|
||||
if (!fitOverride) {
|
||||
if (session.pane.terminal.cols > 0 && session.pane.terminal.rows > 0) {
|
||||
// Why: record the local grid before a later remote hold parks xterm;
|
||||
// the first real window/split resize can then claim immediately.
|
||||
session.lastObservedDesktopGrid = {
|
||||
cols: session.pane.terminal.cols,
|
||||
rows: session.pane.terminal.rows
|
||||
}
|
||||
}
|
||||
if (session.shouldSuppressDesktopPtyResize()) {
|
||||
return
|
||||
}
|
||||
requestStablePaneFit(session.pane as ManagedPaneInternal, () =>
|
||||
session.ptySizeReassertion.request({ fit: false })
|
||||
)
|
||||
return
|
||||
}
|
||||
let proposed: { cols: number; rows: number } | undefined
|
||||
try {
|
||||
proposed = session.pane.fitAddon.proposeDimensions()
|
||||
} catch {
|
||||
proposed = undefined
|
||||
}
|
||||
if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) {
|
||||
return
|
||||
}
|
||||
const priorProposed = session.lastObservedDesktopGrid
|
||||
session.lastObservedDesktopGrid = proposed
|
||||
if (fitOverride.mode === 'remote-desktop-fit') {
|
||||
if (
|
||||
shouldClaimRemoteDesktopViewport({
|
||||
holdMode: fitOverride.mode,
|
||||
prior: priorProposed,
|
||||
current: proposed,
|
||||
paneGeometryChanged,
|
||||
paneVisible: session.deps.isVisibleRef.current,
|
||||
documentVisible: document.visibilityState !== 'hidden',
|
||||
documentFocused: document.hasFocus()
|
||||
})
|
||||
) {
|
||||
// Why: a focused, visible layout change is genuine activity; release
|
||||
// the park and update xterm before claiming so the owner does not keep
|
||||
// rendering the prior owner's stale grid.
|
||||
session.suppressViewportClaimTerminalResize = true
|
||||
try {
|
||||
session.pane.terminal.resize(proposed.cols, proposed.rows)
|
||||
} finally {
|
||||
session.suppressViewportClaimTerminalResize = false
|
||||
}
|
||||
session.transport.resize(proposed.cols, proposed.rows, { claim: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isRemoteRuntimePtyId(currentPtyId)) {
|
||||
session.transport.resize(proposed.cols, proposed.rows)
|
||||
} else {
|
||||
window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows)
|
||||
}
|
||||
}
|
||||
session.geometryReportObserver =
|
||||
typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => {
|
||||
const paneSize = session.readPaneSize()
|
||||
if (
|
||||
paneSize &&
|
||||
session.lastObservedPaneSize &&
|
||||
(paneSize.width !== session.lastObservedPaneSize.width ||
|
||||
paneSize.height !== session.lastObservedPaneSize.height)
|
||||
) {
|
||||
session.pendingPaneGeometryChanged = true
|
||||
}
|
||||
session.lastObservedPaneSize = paneSize
|
||||
if (session.pendingGeometryReportRaf !== null) {
|
||||
return
|
||||
}
|
||||
session.pendingGeometryReportRaf = requestAnimationFrame(
|
||||
session.handleObservedPaneGeometry
|
||||
)
|
||||
})
|
||||
// Why: pane.xtermContainer is created later in pane-lifecycle's
|
||||
// attachWebgl/initial-fit path; pane.container is always present at the
|
||||
// moment connectPanePty runs (it's the .pane element). Both report the
|
||||
// same layout signal — when the outer pane resizes, the inner xterm
|
||||
// container resizes too — so this is the safe element to observe.
|
||||
if (session.geometryReportObserver && session.pane.container instanceof Element) {
|
||||
session.geometryReportObserver.observe(session.pane.container)
|
||||
}
|
||||
|
||||
// Why: the deferred-rAF fit can spawn the PTY at a stale width when the pane's
|
||||
// real (e.g. split/narrower) layout has not settled by the first frame — the
|
||||
// PTY is born at the wide window width while xterm later reflows to the pane
|
||||
// width. The corrective onResize is then dropped (isRendererPtyResizeAuthoritative()
|
||||
// is false mid-mount), pinning process.stdout.columns forever and garbling
|
||||
// TUIs. The reconcile re-fits across frames until the grid settles and forces
|
||||
// the PTY to xterm's dimensions; the spawn-time sync is authoritative by
|
||||
// definition so it bypasses the visibility gate (but not the mobile-fit
|
||||
// override, which legitimately parks the PTY at phone dims). See
|
||||
// pty-size-reconcile.ts for the convergence loop.
|
||||
session.ptySizeReconcileHandle = null
|
||||
session.liveScrollbackRestore = null
|
||||
session.reconcilePtySizeAfterSpawn = (
|
||||
ptyId: string,
|
||||
spawnCols: number,
|
||||
spawnRows: number
|
||||
): void => {
|
||||
session.ptySizeReconcileHandle?.cancel()
|
||||
session.ptySizeReconcileHandle = reconcilePtySizeAcrossFrames({
|
||||
spawnCols,
|
||||
spawnRows,
|
||||
isAlive: () => !session.disposed && session.transport.getPtyId() === ptyId,
|
||||
// Mobile legitimately parks the PTY at phone dims; skip those frames
|
||||
// (neither fit nor forward) instead of cancelling the reconcile window.
|
||||
isParked: () => Boolean(getFitOverrideForPty(ptyId)) || isPtyLocked(ptyId),
|
||||
// Once the renderer resize is authoritative (pane visible), the live
|
||||
// onResize owns future corrections, so the reconcile can hand off after
|
||||
// the grid stabilizes. While hidden it keeps watching for a late settle.
|
||||
isAuthoritative: () => session.isRendererPtyResizeAuthoritative(),
|
||||
measure: () => {
|
||||
if (!safeFit(session.pane)) {
|
||||
return null
|
||||
}
|
||||
const cols = session.pane.terminal.cols
|
||||
const rows = session.pane.terminal.rows
|
||||
return cols > 0 && rows > 0 ? { cols, rows } : null
|
||||
},
|
||||
resize: (cols, rows) => {
|
||||
if (!session.shouldSuppressDesktopPtyResize()) {
|
||||
session.transport.resize(cols, rows)
|
||||
}
|
||||
},
|
||||
// Why: confirm the PTY actually applied the size we forwarded before the
|
||||
// reconcile hands off. transport.resize is fire-and-forget for daemon/SSH
|
||||
// PTYs, so the loop can otherwise settle on a size the PTY dropped, leaving
|
||||
// it pinned wide while xterm shows narrow — the mount-time desync. Skip
|
||||
// remote-runtime PTYs (separate viewport channel; pty:getSize never tracks
|
||||
// them) so they fall back to the grid-stable handoff.
|
||||
getAppliedSize: isRemoteRuntimePtyId(ptyId) ? undefined : () => window.api.pty.getSize(ptyId),
|
||||
requestFrame: (callback) => requestAnimationFrame(callback),
|
||||
cancelFrame: (handle) => {
|
||||
if (typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(handle)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Defer PTY spawn/attach to next frame so FitAddon has time to calculate
|
||||
// the correct terminal dimensions from the laid-out container.
|
||||
session.cancelScheduledConnectFrame = (): void => {
|
||||
if (session.connectFrame !== null) {
|
||||
if (typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(session.connectFrame)
|
||||
}
|
||||
session.connectFrame = null
|
||||
}
|
||||
}
|
||||
session.measureStartupGrid = (): { cols: number; rows: number } | null => {
|
||||
if (!safeFit(session.pane)) {
|
||||
return null
|
||||
}
|
||||
const cols = session.pane.terminal.cols
|
||||
const rows = session.pane.terminal.rows
|
||||
return cols > 0 && rows > 0 ? { cols, rows } : null
|
||||
}
|
||||
session.shouldSettleStartupGridBeforeConnect = (): boolean =>
|
||||
Boolean(session.paneStartup?.command) &&
|
||||
session.deps.isVisibleRef.current &&
|
||||
!session.connectionId &&
|
||||
session.runtimeEnvironmentId === null
|
||||
session.isStartupGridReadyForConnect = (): boolean => {
|
||||
const setupSplitDirection = session.paneStartup?.waitForSetupSplitDirection
|
||||
if (!setupSplitDirection) {
|
||||
return true
|
||||
}
|
||||
// Why: the setup split reparents the main pane before its xterm grid
|
||||
// necessarily reflects the new flex geometry; wait for both to agree.
|
||||
return isSetupSplitGeometryReady(session.pane, session.manager, setupSplitDirection)
|
||||
}
|
||||
session.settleStartupGridBeforeConnect = (connect: () => void): void => {
|
||||
session.startupGridSettleHandle?.cancel()
|
||||
let settledSynchronously = false
|
||||
// Why: local startup commands can launch a TUI before the split-pane grid
|
||||
// has settled; spawn from a briefly stable grid so the TUI paints cleanly.
|
||||
const handle = waitForStableStartupGrid({
|
||||
isAlive: () => !session.disposed,
|
||||
isReadyToSettle: session.paneStartup?.waitForSetupSplitDirection
|
||||
? session.isStartupGridReadyForConnect
|
||||
: undefined,
|
||||
measure: session.measureStartupGrid,
|
||||
onSettled: () => {
|
||||
settledSynchronously = true
|
||||
session.startupGridSettleHandle = null
|
||||
connect()
|
||||
},
|
||||
requestFrame: (callback) => requestAnimationFrame(callback),
|
||||
cancelFrame: (handle) => {
|
||||
if (typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(handle)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!settledSynchronously) {
|
||||
session.startupGridSettleHandle = handle
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { deliverTerminalDataWithDeferredCredit } from '@/lib/pane-manager/terminal-delivery-credit'
|
||||
import { waitForTerminalReplayWritesParsed } from '../replay-guard'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { MAX_DEFERRED_REATTACH_LIVE_CHARS } from '../deferred-reattach-live-data-queue'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindReattachLiveDataDeferral(session: ConnectPanePtySession): void {
|
||||
session.finishReattachLiveDataDeferral = (
|
||||
deliver: boolean,
|
||||
acceptedGeneration = session.transportStreamGeneration
|
||||
): void => {
|
||||
if (session.reattachLiveDataDeferralDepth <= 0) {
|
||||
return
|
||||
}
|
||||
if (!deliver) {
|
||||
const owner = session.deferredReattachLiveDataOwners.get(acceptedGeneration)
|
||||
if (owner) {
|
||||
owner.failed = true
|
||||
}
|
||||
}
|
||||
session.reattachLiveDataDeferralDepth -= 1
|
||||
if (session.reattachLiveDataDeferralDepth > 0) {
|
||||
return
|
||||
}
|
||||
const queue = session.deferredReattachLiveData
|
||||
session.deferredReattachLiveData = null
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
const currentGeneration = session.transportStreamGeneration
|
||||
const currentOwner = session.deferredReattachLiveDataOwners.get(currentGeneration)
|
||||
session.deferredReattachLiveDataOwners = new Map()
|
||||
if (session.disposed || !queue) {
|
||||
queue?.discard()
|
||||
return
|
||||
}
|
||||
const chunks = queue.takeAll()
|
||||
// Why: paint the authoritative replay first, then admit deferred live chunks so the replay clear can't erase newer output.
|
||||
let deliveredDeferredChunks = 0
|
||||
for (const chunk of chunks) {
|
||||
if (
|
||||
chunk.ptyId !== currentPtyId ||
|
||||
chunk.streamGeneration !== currentGeneration ||
|
||||
currentOwner?.failed === true
|
||||
) {
|
||||
chunk.ackCredit?.()
|
||||
continue
|
||||
}
|
||||
if (chunk.ackCredit) {
|
||||
deliverTerminalDataWithDeferredCredit(chunk.ackCredit, () => {
|
||||
session.dataCallback(chunk.data, chunk.meta, chunk.streamGeneration)
|
||||
})
|
||||
} else {
|
||||
session.dataCallback(chunk.data, chunk.meta, chunk.streamGeneration)
|
||||
}
|
||||
deliveredDeferredChunks += 1
|
||||
}
|
||||
if (deliveredDeferredChunks > 0) {
|
||||
// Why: replay restores the viewport before these newer bytes parse; settle the deferred slice, then apply the latest user intent.
|
||||
flushTerminalOutput(session.pane.terminal, { maxChars: MAX_DEFERRED_REATTACH_LIVE_CHARS })
|
||||
void waitForTerminalReplayWritesParsed(session.pane.terminal).then(() => {
|
||||
if (
|
||||
session.disposed ||
|
||||
!session.deps.isVisibleRef.current ||
|
||||
session.transport.getPtyId() !== currentPtyId ||
|
||||
session.transportStreamGeneration !== currentGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
enforceTerminalCurrentScrollIntent(session.pane.terminal)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport'
|
||||
import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
|
||||
|
||||
// Per-call locals for applyReattachPayload so overlapping reattaches stay isolated.
|
||||
export type ReattachPayloadContext = {
|
||||
isCurrentReattachPayload: () => boolean
|
||||
connectResult: PtyConnectResult | null
|
||||
ptyId: string
|
||||
attemptGeneration: number
|
||||
prefetchedParkModelSnapshot: PtyBufferSnapshot | null
|
||||
revealFollowsTerminalPark: boolean
|
||||
fetchSshMainModelReattachSnapshot: () => Promise<PtyBufferSnapshot | null>
|
||||
hasStructuralReplay: boolean
|
||||
coldRestoreStartup?: ColdRestoreAgentResumeStartup | null
|
||||
reattachPayloadApplied: boolean
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport'
|
||||
import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { notifyCodexPaneBoundForStaleSweep } from '@/lib/codex-stale-pane-sweep'
|
||||
import { isPassiveCompletedHibernationEvidence } from '@/lib/sleeping-agent-pane-ownership'
|
||||
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
|
||||
import { resolveHiddenRestoreScrollbackRows } from '../terminal-hidden-restore-scrollback'
|
||||
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
import type { ReattachPayloadContext } from './reattach-payload-context'
|
||||
import { bindApplyReattachPayload } from './apply-reattach-payload'
|
||||
|
||||
export function bindHandleReattachResult(session: ConnectPanePtySession): void {
|
||||
session.handleReattachResult = async (
|
||||
result: PtyConnectResult | string | void,
|
||||
staleSessionId?: string | null,
|
||||
coldRestoreStartup?: ColdRestoreAgentResumeStartup | null,
|
||||
attemptGeneration = session.transportStreamGeneration
|
||||
): Promise<boolean> => {
|
||||
session.authoritativeReattachGeneration += 1
|
||||
if (session.disposed) {
|
||||
return false
|
||||
}
|
||||
if (attemptGeneration !== session.transportStreamGeneration) {
|
||||
return false
|
||||
}
|
||||
const connectResult =
|
||||
result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null
|
||||
|
||||
if (connectResult?.exitedBeforeAttach) {
|
||||
// Why: the session.transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach.
|
||||
return true
|
||||
}
|
||||
|
||||
const retryPtyId =
|
||||
connectResult?.id ??
|
||||
(typeof result === 'string' ? result : (staleSessionId ?? session.transport.getPtyId()))
|
||||
if (session.rejectObsoleteDirectSshReattach(retryPtyId)) {
|
||||
// Why: an obsolete reattach must stop consuming frames without killing the durable PTY a newer lease may adopt.
|
||||
return false
|
||||
}
|
||||
const ptyId =
|
||||
connectResult?.id ?? (typeof result === 'string' ? result : session.transport.getPtyId())
|
||||
if (!ptyId) {
|
||||
warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
leafId: session.deps.restoredLeafId ?? session.pane.leafId,
|
||||
paneId: session.pane.id,
|
||||
ptyId: staleSessionId ?? null
|
||||
})
|
||||
// Why: a stale restored session can fail reattach after mount; don't leave xterm alive without a backing PTY.
|
||||
if (staleSessionId) {
|
||||
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId)
|
||||
} else {
|
||||
session.deps.syncPanePtyLayoutBinding(session.pane.id, null)
|
||||
}
|
||||
if (staleSessionId) {
|
||||
session.deps.clearTabPtyId(session.deps.tabId, staleSessionId)
|
||||
}
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
return false
|
||||
}
|
||||
session.registerEffectiveLaunchConfig(connectResult?.launchConfig, {
|
||||
...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}),
|
||||
...(connectResult?.launchAgent
|
||||
? { launchAgent: connectResult.launchAgent }
|
||||
: coldRestoreStartup
|
||||
? { launchAgent: coldRestoreStartup.agent }
|
||||
: {})
|
||||
})
|
||||
if (connectResult?.sessionExpired) {
|
||||
if (staleSessionId) {
|
||||
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId)
|
||||
} else {
|
||||
session.deps.syncPanePtyLayoutBinding(session.pane.id, null)
|
||||
}
|
||||
if (staleSessionId) {
|
||||
session.deps.clearTabPtyId(session.deps.tabId, staleSessionId)
|
||||
}
|
||||
// Why: SSH sleep/reconnect can invalidate the relay PTY while the tab stays mounted; replace the dead lease in-place, not a stale overlay.
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
return false
|
||||
}
|
||||
const isCurrentReattachPayload = (): boolean => {
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
return (
|
||||
!session.disposed &&
|
||||
attemptGeneration === session.transportStreamGeneration &&
|
||||
currentPtyId === ptyId
|
||||
)
|
||||
}
|
||||
if (!isCurrentReattachPayload()) {
|
||||
return false
|
||||
}
|
||||
// Strict precedence snapshot > replay > coldRestore: paint exactly one, else overlapping tails duplicate TUI output on worktree switch.
|
||||
const hasStructuralReplay = Boolean(
|
||||
connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore
|
||||
)
|
||||
const resumeComesFromPassiveHibernation = Boolean(
|
||||
coldRestoreStartup &&
|
||||
!coldRestoreStartup.useLiveEntry &&
|
||||
coldRestoreStartup.sleepingRecordEntry &&
|
||||
isPassiveCompletedHibernationEvidence(coldRestoreStartup.sleepingRecordEntry.record)
|
||||
)
|
||||
// Why: reattach drops startup commands; only passive hibernation is authority to retire an empty adopted shell and resume its provider session.
|
||||
if (!hasStructuralReplay && connectResult?.isReattach && resumeComesFromPassiveHibernation) {
|
||||
session.transport.disconnect()
|
||||
if (staleSessionId) {
|
||||
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId)
|
||||
session.deps.clearTabPtyId(session.deps.tabId, staleSessionId)
|
||||
} else {
|
||||
session.deps.syncPanePtyLayoutBinding(session.pane.id, null)
|
||||
}
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
return false
|
||||
}
|
||||
session.setPanePtyFitBinding(ptyId)
|
||||
session.reportPanePtyVisibility(ptyId, session.deps.isVisibleRef.current)
|
||||
session.registerSideEffectFactConsumerForPty(ptyId)
|
||||
session.syncHiddenRendererPtyDelivery()
|
||||
session.deps.syncPanePtyLayoutBinding(session.pane.id, ptyId)
|
||||
notifyCodexPaneBoundForStaleSweep(ptyId)
|
||||
if (session.capturedDirectSshRetryPtyAccepted && session.directSshRetryAttempt) {
|
||||
session.deps.updateTabPtyId(
|
||||
session.deps.tabId,
|
||||
ptyId,
|
||||
undefined,
|
||||
session.directSshRetryAttempt.attemptId
|
||||
)
|
||||
} else {
|
||||
session.deps.updateTabPtyId(session.deps.tabId, ptyId)
|
||||
}
|
||||
session.agentCompletionCoordinator.startProcessTracking()
|
||||
session.sampleVisiblePaneForegroundAgent()
|
||||
|
||||
// Why: mobile streaming needs xterm's exact screen state; install the serializer + lastTitle source for main-process hydration parity.
|
||||
session.registerPaneSerializerFor(ptyId)
|
||||
|
||||
// Why (C1 SSH parking): main's headless model holds ~5k rows for SSH ptys
|
||||
// while the relay replay is a 100KiB raw-byte tail; prefer the model on
|
||||
// reveal. Only a non-empty 'headless'-sourced snapshot qualifies — the
|
||||
// renderer-serializer fallback has no mounted xterm after a park. The
|
||||
// paint happens inline in the snapshot-branch style: session.applyMainBufferSnapshot
|
||||
// would nest session.structuralReplayCoordinator.run inside the reattach task and
|
||||
// deadlock on the coordinator's tail chain.
|
||||
// Memoized: the prefetch and the payload task share one probe result, so a
|
||||
// null prefetch can never buy a second timeout before the relay paint.
|
||||
const fetchSshMainModelReattachSnapshot = session.getSshMainModelSnapshotProbe(ptyId)
|
||||
// Why consume-once: only the first reattach of a reveal remount may pay
|
||||
// the probe; a later in-place reconnect on this same mount must not buy a
|
||||
// second timeout before the relay paint.
|
||||
const revealFollowsTerminalPark =
|
||||
session.mountFollowsTerminalPark &&
|
||||
(connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId))
|
||||
session.mountFollowsTerminalPark = false
|
||||
// Why: ordinary parking destroys xterm. Rebuild from the authoritative
|
||||
// host snapshot before releasing queued live bytes; null falls back to
|
||||
// the subscribe screen without keeping the old xterm mounted.
|
||||
let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null
|
||||
if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) {
|
||||
if (parseAppSshPtyId(ptyId)) {
|
||||
prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot()
|
||||
} else {
|
||||
try {
|
||||
const result = await session.serializeHiddenOutputSnapshot(ptyId, {
|
||||
scrollbackRows: resolveHiddenRestoreScrollbackRows(
|
||||
session.pane.terminal.options.scrollback
|
||||
)
|
||||
})
|
||||
prefetchedParkModelSnapshot = result.kind === 'snapshot' ? result.snapshot : null
|
||||
} catch {
|
||||
prefetchedParkModelSnapshot = null
|
||||
}
|
||||
}
|
||||
if (!isCurrentReattachPayload()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const reattachPayload: ReattachPayloadContext = {
|
||||
isCurrentReattachPayload,
|
||||
connectResult,
|
||||
ptyId,
|
||||
attemptGeneration,
|
||||
prefetchedParkModelSnapshot,
|
||||
revealFollowsTerminalPark,
|
||||
fetchSshMainModelReattachSnapshot,
|
||||
hasStructuralReplay,
|
||||
coldRestoreStartup,
|
||||
reattachPayloadApplied: !hasStructuralReplay && prefetchedParkModelSnapshot === null
|
||||
}
|
||||
bindApplyReattachPayload(session, reattachPayload)
|
||||
if (hasStructuralReplay || prefetchedParkModelSnapshot) {
|
||||
await session.structuralReplayCoordinator.run(session.applyReattachPayload, {
|
||||
shouldRestore: isCurrentReattachPayload,
|
||||
afterRestore: session.fitAfterReattachRestore
|
||||
})
|
||||
} else {
|
||||
await session.applyReattachPayload()
|
||||
await session.fitAfterReattachRestore()
|
||||
}
|
||||
if (!isCurrentReattachPayload() || !reattachPayload.reattachPayloadApplied) {
|
||||
return false
|
||||
}
|
||||
session.scheduleReattachIdleAgentCursorReset()
|
||||
|
||||
scheduleRuntimeGraphSync()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
|
||||
import {
|
||||
CURSOR_SHOW_SEQUENCE,
|
||||
TERMINAL_FOCUS_IN_SEQUENCE,
|
||||
FOCUS_REPORTING_DISABLE_SEQUENCE
|
||||
} from './foreground-output-scan'
|
||||
import {
|
||||
parsedViewportShowsParkedCursorAgentScreen,
|
||||
terminalHasFocusReportingEnabled
|
||||
} from './cursor-agent-reattach-screen'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindReplayDataDrain(session: ConnectPanePtySession): void {
|
||||
session.sendFocusedReattachFocusInAfterReplay = (
|
||||
expectedPtyId: string | null = session.transport.getPtyId(),
|
||||
expectedStreamGeneration = session.transportStreamGeneration
|
||||
): void => {
|
||||
const scheduledGeneration = session.reattachReplayPayloadSignalGeneration
|
||||
void waitForTerminalOutputParsed(session.pane.terminal).then(() => {
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
if (
|
||||
session.disposed ||
|
||||
expectedStreamGeneration !== session.transportStreamGeneration ||
|
||||
currentPtyId !== expectedPtyId
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: a newer replay frame owns the judgment; its own post-parse
|
||||
// callback will re-evaluate against its own viewport.
|
||||
if (scheduledGeneration !== session.reattachReplayPayloadSignalGeneration) {
|
||||
return
|
||||
}
|
||||
// Why: the replay-byte signal also matches a dead run's screen — in
|
||||
// scrollback or still painted above a fresh shell prompt. The parsed
|
||||
// viewport is the ground truth; unless it shows a parked-cursor
|
||||
// cursor-agent screen and no status/title corroborates, downgrade to
|
||||
// the plain-shell behavior (drop focus reporting, skip focus-in).
|
||||
if (
|
||||
!session.hasLiveAgentReattachStatusOrTitleSignal() &&
|
||||
session.reattachReplayPayloadHasCursorAgentSignal
|
||||
) {
|
||||
if (parsedViewportShowsParkedCursorAgentScreen(session.pane.terminal) === false) {
|
||||
session.reattachReplayPayloadHasCursorAgentSignal = false
|
||||
// Why: the live-agent reset preserved the payload's ?25l; a plain
|
||||
// shell never re-shows the cursor itself.
|
||||
session.writeReplayData(`${CURSOR_SHOW_SEQUENCE}${FOCUS_REPORTING_DISABLE_SEQUENCE}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Why: a live TUI such as cursor-agent parks the real terminal cursor off
|
||||
// its own input caret and moves it back only on a focus-in. Reattach
|
||||
// reuses the same live PTY and the xterm textarea already holds DOM
|
||||
// focus, so xterm never emits the focus-in the agent needs and the parked
|
||||
// cursor anchors the IME/caret to the wrong cell. Gated on ?1004h so a
|
||||
// bare shell never receives a stray \x1b[I.
|
||||
const sendFocusMode = terminalHasFocusReportingEnabled(session.pane.terminal)
|
||||
if (!session.shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) {
|
||||
return
|
||||
}
|
||||
session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE)
|
||||
})
|
||||
}
|
||||
|
||||
session.pendingReplayData = null
|
||||
session.replayPayloadGeneration = 0
|
||||
let replayDrainQueued = false
|
||||
const drainReplayDataQueue = async (
|
||||
expectedPtyId: string | null,
|
||||
expectedStreamGeneration: number
|
||||
): Promise<boolean> => {
|
||||
let appliedCurrentPayload = false
|
||||
while (session.pendingReplayData !== null) {
|
||||
if (
|
||||
session.pendingReplayData.ptyId !== expectedPtyId ||
|
||||
session.pendingReplayData.streamGeneration !== expectedStreamGeneration
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
session.transport.getPtyId() !== expectedPtyId ||
|
||||
session.transportStreamGeneration !== expectedStreamGeneration
|
||||
) {
|
||||
session.pendingReplayData = null
|
||||
return false
|
||||
}
|
||||
const payload = session.pendingReplayData
|
||||
const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload
|
||||
session.pendingReplayData = null
|
||||
const isCurrentPayload = (): boolean =>
|
||||
!session.disposed &&
|
||||
payload.generation === session.replayPayloadGeneration &&
|
||||
payload.streamGeneration === session.transportStreamGeneration &&
|
||||
session.transport.getPtyId() === payload.ptyId
|
||||
if (!isCurrentPayload()) {
|
||||
continue
|
||||
}
|
||||
// Relay replay buffers may overlap with content already rendered in
|
||||
// xterm. Local eager replay decides this earlier so metadata-only frames
|
||||
// can keep restored scrollback while still using the replay guard.
|
||||
if (clearBeforeReplay) {
|
||||
await session.writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H')
|
||||
if (!isCurrentPayload()) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (clearBeforeReplay || data.length > 0) {
|
||||
// Why: an empty clearing frame is still an authoritative repaint and
|
||||
// must clear a stale agent signal from an earlier payload.
|
||||
session.rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay })
|
||||
}
|
||||
// Why: replayed application bytes carry the live TUI's kitty keyboard
|
||||
// negotiation; the mirror must re-arm from them after a reload. Replay
|
||||
// semantics: relay reconnects redeliver the same window, so pushes
|
||||
// apply as sets to keep the mirrored stack from accumulating frames.
|
||||
session.applySnapshotKittyKeyboardModes(data, payload)
|
||||
await session.writeReplayDataAsync(data)
|
||||
if (!isCurrentPayload()) {
|
||||
continue
|
||||
}
|
||||
if (clearBeforeReplay || data.length > 0) {
|
||||
await session.writeReplayDataAsync(session.reattachReplayResetSequence(data))
|
||||
if (!isCurrentPayload()) {
|
||||
continue
|
||||
}
|
||||
session.sendFocusedReattachFocusInAfterReplay(payload.ptyId, payload.streamGeneration)
|
||||
}
|
||||
// Why: the daemon could not serialize a PTY read that ended mid-escape,
|
||||
// so the emulator shipped the dangling partial separately. Write it LAST
|
||||
// — after the reset, whose ESC would otherwise abort it — so the next
|
||||
// live chunk completes the sequence instead of rendering literally
|
||||
// (#7329). Guarded so a later ESC cannot leave the parser wedged.
|
||||
if (pendingEscapeTailAnsi) {
|
||||
await session.writeReplayDataAsync(pendingEscapeTailAnsi)
|
||||
}
|
||||
if (!isCurrentPayload()) {
|
||||
continue
|
||||
}
|
||||
// Why: remote-runtime snapshots can arrive after WebGL attached to an
|
||||
// empty buffer; rebuilding after replay parses seeds the glyph atlas
|
||||
// from the now-populated xterm state.
|
||||
session.manager.rebuildPaneWebgl(session.pane.id)
|
||||
appliedCurrentPayload = true
|
||||
}
|
||||
return appliedCurrentPayload
|
||||
}
|
||||
session.scheduleReplayDataDrain = (): void => {
|
||||
if (replayDrainQueued) {
|
||||
return
|
||||
}
|
||||
const scheduledPtyId = session.pendingReplayData?.ptyId ?? null
|
||||
replayDrainQueued = true
|
||||
// Why: live bytes are newer than the authoritative replay frame. Hold
|
||||
// them until clear + replay + reset have all parsed, or replay can erase them.
|
||||
const scheduledStreamGeneration =
|
||||
session.pendingReplayData?.streamGeneration ?? session.transportStreamGeneration
|
||||
session.beginReattachLiveDataDeferral(scheduledStreamGeneration)
|
||||
let replayCompleted = false
|
||||
session.replayWriteQueue = session.replayWriteQueue
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
session.structuralReplayCoordinator.run(
|
||||
async () => {
|
||||
replayCompleted = await drainReplayDataQueue(scheduledPtyId, scheduledStreamGeneration)
|
||||
},
|
||||
{
|
||||
shouldRestore: () =>
|
||||
!session.disposed &&
|
||||
session.transport.getPtyId() === scheduledPtyId &&
|
||||
session.transportStreamGeneration === scheduledStreamGeneration
|
||||
}
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
replayCompleted &&= !session.disposed && session.transport.getPtyId() === scheduledPtyId
|
||||
})
|
||||
.finally(() => {
|
||||
replayDrainQueued = false
|
||||
if (session.pendingReplayData !== null) {
|
||||
// Why: preserve the PTY identity captured when the callback fired;
|
||||
// re-reading it here could retag stale bytes for a replacement PTY.
|
||||
session.scheduleReplayDataDrain()
|
||||
}
|
||||
session.finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration)
|
||||
})
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindAttachRetainedLegacyPty(session: ConnectPanePtySession): void {
|
||||
session.attachRetainedLegacyPty = (ptyId: string): boolean => {
|
||||
try {
|
||||
session.authoritativeReattachGeneration += 1
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError)
|
||||
session.transport.attach({
|
||||
existingPtyId: ptyId,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? ptyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createTerminalZeroDimensionsMessage } from '../../../../../shared/terminal-zero-dimensions-diagnostic'
|
||||
import { isWorktreeRemovalFenceError } from '../../../../../shared/worktree/removal-fence-error'
|
||||
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { createCodexBackfillErrorDetector } from '../codex-backfill-error-detector'
|
||||
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import { bindBuildColdRestoreAgentResumeStartup } from './cold-restore-resume-startup'
|
||||
|
||||
import { bindPrepaintParkedSshSnapshot } from './ssh-snapshot-prepaint'
|
||||
import { bindForegroundOutputRefresh } from './foreground-output-refresh'
|
||||
import { bindRegisterPaneSerializer } from './pane-serializer-register'
|
||||
import { bindHandleReattachResult } from './reattach-result-handler'
|
||||
import { bindAttachRetainedLegacyPty } from './retained-legacy-pty-attach'
|
||||
import { runDeferredSessionAttach } from './deferred-session-attach'
|
||||
|
||||
import { bindSerializeHiddenOutputSnapshot } from './hidden-output-snapshot-serialize'
|
||||
import { bindSettlePaneSerializer } from './pane-serializer-settle'
|
||||
|
||||
import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot'
|
||||
import { bindHiddenOutputSeqAndSkip } from './hidden-output-seq-and-skip'
|
||||
import { bindHiddenRestoreStateAndSshProbe } from './hidden-restore-state-and-ssh-probe'
|
||||
|
||||
export function installRunDeferredConnect(session: ConnectPanePtySession): void {
|
||||
session.runDeferredConnect = (): void => {
|
||||
if (session.connectStarted) {
|
||||
return
|
||||
}
|
||||
if (!session.startupGridSettledForConnect && session.shouldSettleStartupGridBeforeConnect()) {
|
||||
session.cancelScheduledConnectFrame()
|
||||
if (session.connectFallbackTimer !== null) {
|
||||
clearTimeout(session.connectFallbackTimer)
|
||||
session.connectFallbackTimer = null
|
||||
}
|
||||
session.settleStartupGridBeforeConnect(() => {
|
||||
session.startupGridSettledForConnect = true
|
||||
session.runDeferredConnect()
|
||||
})
|
||||
return
|
||||
}
|
||||
session.connectStarted = true
|
||||
session.cancelScheduledConnectFrame()
|
||||
if (session.connectFallbackTimer !== null) {
|
||||
clearTimeout(session.connectFallbackTimer)
|
||||
session.connectFallbackTimer = null
|
||||
}
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
safeFit(session.pane)
|
||||
session.cols = session.pane.terminal.cols
|
||||
session.rows = session.pane.terminal.rows
|
||||
|
||||
// Why: if fitAddon resolved to 0×0, the container likely has no layout
|
||||
// dimensions (display:none, unmounted, or zero-size parent). Surface a
|
||||
// diagnostic so the user sees something instead of a blank pane.
|
||||
// Gate on visibility: background/hidden tabs (orchestration workers, CLI
|
||||
// `terminal create` without --focus) legitimately connect at 0×0 because
|
||||
// safeFit skips fitting unmeasurable panes; they refit via the pane resize
|
||||
// observer once shown, so the diagnostic must not fire while hidden.
|
||||
if ((session.cols === 0 || session.rows === 0) && session.deps.isVisibleRef.current) {
|
||||
session.deps.onPtyErrorRef?.current?.(
|
||||
session.pane.id,
|
||||
createTerminalZeroDimensionsMessage(session.cols, session.rows)
|
||||
)
|
||||
}
|
||||
|
||||
session.reportError = (message: string): void => {
|
||||
// Why: the transport connect can reject asynchronously after the pane has been
|
||||
// disposed (e.g. its workspace was deleted) — dropping a late error avoids a toast
|
||||
// racing the unmount. Mirrors the connect scheduler's disposed guard above.
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
if (isWorktreeRemovalFenceError(message)) {
|
||||
// Why: main fences a spawn/reattach whose worktree (or an overlapping
|
||||
// parent/child root) is being deleted. That is expected teardown, not a
|
||||
// user-facing failure — the pane unmounts once removal completes, so never
|
||||
// surface the raw fence error. Covers the parent-removal-fences-child case
|
||||
// that startFreshSpawn's own-worktree isDeleting skip cannot see.
|
||||
return
|
||||
}
|
||||
session.deps.onPtyErrorRef?.current?.(session.pane.id, message)
|
||||
}
|
||||
session.codexBackfillErrorDetector =
|
||||
session.paneStartup?.launchAgent === 'codex' || session.tab?.launchAgent === 'codex'
|
||||
? createCodexBackfillErrorDetector()
|
||||
: null
|
||||
|
||||
// Why: shared registration so both fresh-spawn and reattach paths install
|
||||
// the same SerializeAddon-backed serializer plus the onTitleChange wrapper
|
||||
// that drives lastTitle parity for mobile subscribers. Wires the resulting
|
||||
// unregister into onDataDisposable.dispose so disposal stays a single
|
||||
// teardown point. See docs/mobile-prefer-renderer-scrollback.md.
|
||||
bindRegisterPaneSerializer(session)
|
||||
bindSettlePaneSerializer(session)
|
||||
bindBuildColdRestoreAgentResumeStartup(session)
|
||||
bindDeferredColdRestoreAndSnapshot(session)
|
||||
session.canUseMainBufferSnapshot = function (ptyId: string | null): ptyId is string {
|
||||
return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId)
|
||||
}
|
||||
|
||||
session.canUseHiddenOutputSnapshot = function (ptyId: string | null): ptyId is string {
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
if (session.canUseMainBufferSnapshot(ptyId)) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
session.transport.getPtyId() === ptyId &&
|
||||
typeof session.transport.serializeBuffer === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
bindSerializeHiddenOutputSnapshot(session)
|
||||
bindForegroundOutputRefresh(session)
|
||||
bindHiddenOutputSeqAndSkip(session)
|
||||
bindHiddenRestoreStateAndSshProbe(session)
|
||||
|
||||
bindPrepaintParkedSshSnapshot(session)
|
||||
bindHandleReattachResult(session)
|
||||
bindAttachRetainedLegacyPty(session)
|
||||
runDeferredSessionAttach(session)
|
||||
}
|
||||
|
||||
// Why: Wayland/CI compositors can starve rAF while timers/CDP stay responsive; the terminal must still start its PTY once.
|
||||
session.connectFallbackTimer = setTimeout(session.runDeferredConnect, 250)
|
||||
session.connectFrame = requestAnimationFrame(session.runDeferredConnect)
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
shouldReconcileDeadSession,
|
||||
shouldReconcileMissingSession,
|
||||
type HasPty
|
||||
} from '../terminal-dead-session-reconcile'
|
||||
import { cancelPendingSafeFitContinuations } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { PANE_PTY_RESIZE_HOLD_FLUSH_EVENT } from '@/lib/pane-manager/pane-pty-resize-hold'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { discardTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import {
|
||||
getProviderSessionClaimKey,
|
||||
isPassiveCompletedHibernationEvidence
|
||||
} from '@/lib/sleeping-agent-pane-ownership'
|
||||
import { releaseRendererPtyVisibilityClaim } from '../pty-renderer-delivery-claims'
|
||||
|
||||
import { REMOTE_PTY_ID_PREFIX } from './pty-connect-limits'
|
||||
import { SHIFT_ENTER_RECONFIRM_IDLE_MS } from './foreground-output-scan'
|
||||
import type { PanePtyBinding } from './pane-pty-binding'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installSessionReconcileDispose(session: ConnectPanePtySession): PanePtyBinding {
|
||||
session.reconcileIfSessionDead = (
|
||||
liveSessionIds: Set<string>,
|
||||
snapshotRequestedAt?: number
|
||||
): void => {
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
if (
|
||||
!currentPtyId ||
|
||||
// Why: this exit was already handled — onExit guards it too, but skipping here avoids a redundant shouldReconcile evaluation.
|
||||
session.handledExitPtyId === currentPtyId ||
|
||||
!shouldReconcileDeadSession({
|
||||
ptyId: currentPtyId,
|
||||
connectionId: session.transport.getConnectionId?.(),
|
||||
liveSessionIds,
|
||||
ptyBoundAt: session.activePanePtyBindingBoundAt,
|
||||
snapshotRequestedAt
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
session.onExit(currentPtyId)
|
||||
}
|
||||
|
||||
session.reconcileIfSessionMissing = (
|
||||
hasPty: HasPty,
|
||||
livenessRequestedAt = performance.now()
|
||||
): void => {
|
||||
const requestedPtyId = session.transport.getPtyId()
|
||||
if (
|
||||
!requestedPtyId ||
|
||||
requestedPtyId === session.handledExitPtyId ||
|
||||
requestedPtyId.startsWith(REMOTE_PTY_ID_PREFIX) ||
|
||||
session.transport.getConnectionId?.() != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let livenessPromise: Promise<boolean | null>
|
||||
try {
|
||||
livenessPromise = Promise.resolve(hasPty(requestedPtyId))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
void livenessPromise
|
||||
.then((isLive) => {
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
if (
|
||||
!currentPtyId ||
|
||||
currentPtyId !== requestedPtyId ||
|
||||
session.handledExitPtyId === currentPtyId ||
|
||||
!shouldReconcileMissingSession({
|
||||
ptyId: currentPtyId,
|
||||
connectionId: session.transport.getConnectionId?.(),
|
||||
isLive,
|
||||
ptyBoundAt: session.activePanePtyBindingBoundAt,
|
||||
livenessRequestedAt
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
session.onExit(currentPtyId)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return {
|
||||
syncProcessTracking() {
|
||||
session.agentCompletionCoordinator.startProcessTracking()
|
||||
// Why: the hidden-delivery gate must follow every pane visibility flip.
|
||||
session.syncHiddenRendererPtyDelivery()
|
||||
if (!session.deps.isVisibleRef.current) {
|
||||
session.pendingVisibleRemoteViewportClaim = false
|
||||
}
|
||||
},
|
||||
// Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback.
|
||||
noteVisibilityResume() {
|
||||
session.armVisibleRemoteViewportClaim()
|
||||
session.claimPendingVisibleRemoteViewport()
|
||||
session.ptySizeReassertion.request({ fit: false })
|
||||
session.consumeHibernatedAgentWake()
|
||||
session.requestKnownWindowsShiftEnterReconfirmation()
|
||||
session.sampleVisiblePaneForegroundAgent()
|
||||
},
|
||||
reassertPtySizeAfterWindowWake() {
|
||||
session.armVisibleRemoteViewportClaim()
|
||||
session.claimPendingVisibleRemoteViewport()
|
||||
session.ptySizeReassertion.request({ fit: false })
|
||||
},
|
||||
// Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads.
|
||||
wakeHibernatedAgentIfArmed(claimedProviderSessions) {
|
||||
if (session.hibernatedWakeInFlightClaimKey) {
|
||||
if (claimedProviderSessions?.has(session.hibernatedWakeInFlightClaimKey)) {
|
||||
return null
|
||||
}
|
||||
claimedProviderSessions?.add(session.hibernatedWakeInFlightClaimKey)
|
||||
return session.hibernatedWakeInFlightClaimKey
|
||||
}
|
||||
const consumedClaimKey = session.consumeHibernatedAgentWake(claimedProviderSessions)
|
||||
if (consumedClaimKey) {
|
||||
return consumedClaimKey
|
||||
}
|
||||
// Why: wake arrived mid-hibernation-kill before onExit armed the wake target (transport still bound to the dying PTY).
|
||||
// Only the exact PTY marked for suppressed shutdown may latch — never a stale/manual record beside an ordinary live PTY.
|
||||
const state = useAppStore.getState()
|
||||
const recordEntry = session.getSleepingRecordForPane(state)
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
if (
|
||||
recordEntry &&
|
||||
isPassiveCompletedHibernationEvidence(recordEntry.record) &&
|
||||
currentPtyId !== null &&
|
||||
state.suppressedPtyExitIds[currentPtyId] === true &&
|
||||
!session.disposed &&
|
||||
session.hibernatedWakeTarget === null &&
|
||||
session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport &&
|
||||
session.transport.getPtyId() === currentPtyId
|
||||
) {
|
||||
const claimKey = getProviderSessionClaimKey(recordEntry.record)
|
||||
if (claimedProviderSessions?.has(claimKey)) {
|
||||
return null
|
||||
}
|
||||
claimedProviderSessions?.add(claimKey)
|
||||
session.pendingHibernatedWakeTarget = { ptyId: currentPtyId, record: recordEntry.record }
|
||||
return claimKey
|
||||
}
|
||||
return null
|
||||
},
|
||||
sampleForegroundAgentOnFocus() {
|
||||
session.requestKnownWindowsShiftEnterReconfirmation()
|
||||
session.sampleVisiblePaneForegroundAgent()
|
||||
},
|
||||
requestWindowsShiftEnterReconfirmation() {
|
||||
if (session.shiftEnterReconfirmTimer !== null) {
|
||||
clearTimeout(session.shiftEnterReconfirmTimer)
|
||||
}
|
||||
// Why: confirm the composer only after the Shift+Enter burst goes idle, preserving rapid multiline input.
|
||||
session.shiftEnterReconfirmTimer = setTimeout(() => {
|
||||
session.shiftEnterReconfirmTimer = null
|
||||
session.requestKnownWindowsShiftEnterReconfirmation()
|
||||
session.sampleVisiblePaneForegroundAgent()
|
||||
}, SHIFT_ENTER_RECONFIRM_IDLE_MS)
|
||||
},
|
||||
reconcileIfSessionDead: session.reconcileIfSessionDead,
|
||||
reconcileIfSessionMissing: session.reconcileIfSessionMissing,
|
||||
dispose() {
|
||||
session.disposed = true
|
||||
// Why: a detached client stops observing the pane's bytes, so it must cede
|
||||
// agent-status authority back to the host on the next mirrored snapshot.
|
||||
session.releaseRendererOwnedAgentStatusPane?.()
|
||||
session.directSshPaneRetrySettlementCancelled = true
|
||||
for (const timer of session.directSshPaneRetrySettlementTimers) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
session.directSshPaneRetrySettlementTimers.clear()
|
||||
// Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream.
|
||||
const queue = session.deferredReattachLiveData
|
||||
session.deferredReattachLiveData = null
|
||||
queue?.discard()
|
||||
session.reattachLiveDataDeferralDepth = 0
|
||||
session.deferredReattachLiveDataOwners = new Map()
|
||||
cancelPendingSafeFitContinuations(session.pane)
|
||||
session.pendingHiddenSnapshotFit = null
|
||||
session.pendingReattachFit = null
|
||||
// Why: park/reconnect/remount doesn't advance the recovery epoch, so invalidate this xterm or its delayed retry could hit the next instance.
|
||||
session.terminalRecoveryInstance.unregister()
|
||||
session.unregisterUndeliverableWriteHandler()
|
||||
session.unsubscribeRemoteDesktopActivationClaim()
|
||||
session.cancelHiddenOutputSnapshotScrollRestore()
|
||||
session.liveScrollbackRestore?.dispose()
|
||||
session.liveScrollbackRestore = null
|
||||
session.structuralReplayCoordinator.dispose()
|
||||
session.cancelFreshSpawnFollowReset()
|
||||
// Why: cancel the post-spawn reconcile's pending rAF so a torn-down pane can't keep fitting/resizing after disposal.
|
||||
session.ptySizeReconcileHandle?.cancel()
|
||||
session.ptySizeReconcileHandle = null
|
||||
session.startupGridSettleHandle?.cancel()
|
||||
session.startupGridSettleHandle = null
|
||||
session.ptySizeReassertion.dispose()
|
||||
if (session.pendingForegroundGridDriftCheckRaf !== null) {
|
||||
cancelAnimationFrame(session.pendingForegroundGridDriftCheckRaf)
|
||||
session.pendingForegroundGridDriftCheckRaf = null
|
||||
}
|
||||
// Why: a pane unmount must never leave its PTY delivery gated — the parked watcher or remounted pane re-decides.
|
||||
session.releaseHiddenRendererPtyDelivery()
|
||||
if (session.terminalKeyTargetSupportsEvents) {
|
||||
session.terminalKeyTarget.removeEventListener('keydown', session.onTerminalKeyDown, {
|
||||
capture: true
|
||||
})
|
||||
}
|
||||
session.clearPendingTerminalInputIntent()
|
||||
session.pendingTerminalInputWrite = null
|
||||
session.interruptInference.dispose()
|
||||
session.clearTitleOnlyInterruptTimer()
|
||||
// Why release, not cancel: the pending settle belongs to the turn, not to
|
||||
// this pane — a park mid-settle hands it to the parked watcher instead.
|
||||
session.releaseCommandCodeDoneSettleExecutor()
|
||||
if (session.shiftEnterReconfirmTimer !== null) {
|
||||
clearTimeout(session.shiftEnterReconfirmTimer)
|
||||
session.shiftEnterReconfirmTimer = null
|
||||
}
|
||||
// Why: resolve in-flight passphrase-gate waits so their zustand subscribers + async IIFEs don't hang when the pane is torn down before SSH state changes.
|
||||
while (session.waitTeardowns.length > 0) {
|
||||
const teardown = session.waitTeardowns.pop()
|
||||
teardown?.()
|
||||
}
|
||||
if (session.startupInjectTimer !== null) {
|
||||
clearTimeout(session.startupInjectTimer)
|
||||
session.startupInjectTimer = null
|
||||
}
|
||||
session.cleanupStartupDraftPasteTimers()
|
||||
session.releaseUnattemptedStartupDraftPasteDelivery()
|
||||
session.unregisterAgentHookTerminalLifecycle()
|
||||
session.clearSuppressedTitleSideEffects()
|
||||
session.clearPendingAgentTaskCompleteNotification()
|
||||
session.pendingTerminalBellNotification = false
|
||||
session.clearTerminalBellNotificationTimer()
|
||||
session.clearReattachIdleAgentCursorResetTimer()
|
||||
if (session.alternateScreenBackgroundRepaintTimer !== null) {
|
||||
clearTimeout(session.alternateScreenBackgroundRepaintTimer)
|
||||
session.alternateScreenBackgroundRepaintTimer = null
|
||||
}
|
||||
session.cleanupHiddenOutputRestoreDeferredRetry()
|
||||
session.cleanupHiddenOutputRestoreForegroundDeadline()
|
||||
session.cleanupHiddenOutputRestoreFloodRepaint()
|
||||
session.unregisterBacklogRecovery?.()
|
||||
session.unregisterBacklogRecovery = null
|
||||
session.unregisterDocumentVisibilityRecovery?.()
|
||||
session.unregisterDocumentVisibilityRecovery = null
|
||||
releaseRendererPtyVisibilityClaim(session.transport)
|
||||
// Why: the pane's fact consumer must be gone before a parked-tab watcher takes over this PTY's facts in the same effect flush.
|
||||
session.dropSideEffectFactConsumer()
|
||||
session.clearPanePtyFitBinding()
|
||||
discardTerminalOutput(session.pane.terminal)
|
||||
session.unregisterE2ePtyDataInjection()
|
||||
if (session.agentTaskCompleteSettingsUnsubscribe !== null) {
|
||||
session.agentTaskCompleteSettingsUnsubscribe()
|
||||
session.agentTaskCompleteSettingsUnsubscribe = null
|
||||
}
|
||||
if (session.unsubscribeWindowsDoneTerminalModeReset !== null) {
|
||||
session.unsubscribeWindowsDoneTerminalModeReset()
|
||||
session.unsubscribeWindowsDoneTerminalModeReset = null
|
||||
}
|
||||
if (session.connectFrame !== null) {
|
||||
// Why: cancel the queued connect frame so a disposed pane (StrictMode/split-group remount) can't reattach the PTY and steal the live pane's handler wiring.
|
||||
session.cancelScheduledConnectFrame()
|
||||
}
|
||||
if (session.connectFallbackTimer !== null) {
|
||||
clearTimeout(session.connectFallbackTimer)
|
||||
session.connectFallbackTimer = null
|
||||
}
|
||||
session.imeCompositionRouteDisposable.dispose()
|
||||
session.onDataDisposable.dispose()
|
||||
session.userInputActivityDisposable?.dispose()
|
||||
session.terminalCapabilityRepliesDisposable.dispose()
|
||||
session.onResizeDisposable.dispose()
|
||||
session.pane.container.removeEventListener(
|
||||
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
|
||||
session.onHeldPtyResizeFlush
|
||||
)
|
||||
session.geometryReportObserver?.disconnect()
|
||||
if (session.pendingGeometryReportRaf !== null) {
|
||||
cancelAnimationFrame(session.pendingGeometryReportRaf)
|
||||
session.pendingGeometryReportRaf = null
|
||||
}
|
||||
session.commandLifecycle.dispose()
|
||||
session.deferredCommandFinishedStatusDrop = null
|
||||
session.visibleForegroundSamplePending = false
|
||||
session.visibleForegroundSampleSettled = false
|
||||
session.paneForegroundAgentTracker.dispose()
|
||||
session.agentCompletionCoordinator.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import type { SetupSplitDirection } from '../../../../../shared/worktree/launch-types'
|
||||
|
||||
const SPLIT_GEOMETRY_EPSILON_PX = 1
|
||||
|
||||
export function readElementRect(element: HTMLElement | null | undefined): DOMRect | null {
|
||||
try {
|
||||
return element?.getBoundingClientRect?.() ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function hasVisibleRect(rect: DOMRect | null): rect is DOMRect {
|
||||
return Boolean(
|
||||
rect && rect.width > SPLIT_GEOMETRY_EPSILON_PX && rect.height > SPLIT_GEOMETRY_EPSILON_PX
|
||||
)
|
||||
}
|
||||
|
||||
export function readProposedPaneGrid(pane: ManagedPane): { cols: number; rows: number } | null {
|
||||
try {
|
||||
const dimensions = pane.fitAddon.proposeDimensions()
|
||||
if (!dimensions || dimensions.cols <= 0 || dimensions.rows <= 0) {
|
||||
return null
|
||||
}
|
||||
return dimensions
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isPaneGridAlignedWithFit(pane: ManagedPane): boolean {
|
||||
const proposed = readProposedPaneGrid(pane)
|
||||
return Boolean(
|
||||
proposed && pane.terminal.cols === proposed.cols && pane.terminal.rows === proposed.rows
|
||||
)
|
||||
}
|
||||
|
||||
export function isSetupSplitGeometryReady(
|
||||
pane: ManagedPane,
|
||||
manager: PaneManager,
|
||||
direction: SetupSplitDirection
|
||||
): boolean {
|
||||
const splitElement = pane.container.parentElement
|
||||
const directionClass = direction === 'vertical' ? 'is-vertical' : 'is-horizontal'
|
||||
if (
|
||||
!splitElement?.classList?.contains('pane-split') ||
|
||||
!splitElement.classList.contains(directionClass)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sibling = manager
|
||||
.getPanes()
|
||||
.find(
|
||||
(candidate) => candidate.id !== pane.id && candidate.container.parentElement === splitElement
|
||||
)
|
||||
const splitRect = readElementRect(splitElement)
|
||||
const paneRect = readElementRect(pane.container)
|
||||
const siblingRect = readElementRect(sibling?.container)
|
||||
if (!hasVisibleRect(splitRect) || !hasVisibleRect(paneRect) || !hasVisibleRect(siblingRect)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const splitAxis = direction === 'vertical' ? splitRect.width : splitRect.height
|
||||
const paneAxis = direction === 'vertical' ? paneRect.width : paneRect.height
|
||||
const siblingAxis = direction === 'vertical' ? siblingRect.width : siblingRect.height
|
||||
return (
|
||||
paneAxis > SPLIT_GEOMETRY_EPSILON_PX &&
|
||||
siblingAxis > SPLIT_GEOMETRY_EPSILON_PX &&
|
||||
splitAxis - paneAxis > SPLIT_GEOMETRY_EPSILON_PX &&
|
||||
splitAxis - siblingAxis > SPLIT_GEOMETRY_EPSILON_PX &&
|
||||
isPaneGridAlignedWithFit(pane)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
|
||||
import { useAppStore } from '@/store'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import type { AgentType } from '../../../../../shared/agent-status-types'
|
||||
import { AGENT_INTERRUPT_SETTLE_MS } from '../../../../../shared/agent-interrupt-intent'
|
||||
import { resolvePaneAgentOwner } from '../../../../../shared/pane-agent-owner'
|
||||
|
||||
import { MANUAL_AGENT_COMMAND_MAX_CHARS } from './pty-connect-limits'
|
||||
import {
|
||||
CURSOR_AGENT_REATTACH_HEADER,
|
||||
terminalOwnsDomFocus,
|
||||
hasCursorAgentReattachPayloadScreenSignal
|
||||
} from './cursor-agent-reattach-screen'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installShellCommandInference(session: ConnectPanePtySession): void {
|
||||
session.observeAcceptedShellCommandInput = (data: string): void => {
|
||||
if (
|
||||
data.includes('\r') ||
|
||||
data.includes('\n') ||
|
||||
data.includes('\x03') ||
|
||||
data.includes('\x04')
|
||||
) {
|
||||
// Why: shells without OSC 133 give no command/exit boundary. An accepted
|
||||
// submit or interrupt revokes stale trusted Shift+Enter routing and confirms once.
|
||||
session.requestKnownWindowsShiftEnterReconfirmation()
|
||||
}
|
||||
if (session.commandInferredPaneAgent) {
|
||||
return
|
||||
}
|
||||
// Why: bytes typed inside a live agent TUI are prompt text, not shell
|
||||
// commands, even if they spell another agent binary name.
|
||||
if (session.hasFreshPaneAgentSurface()) {
|
||||
session.resetPendingShellCommandLine()
|
||||
return
|
||||
}
|
||||
if (session.shellCommandInferenceSuspendedUntilCommandEnd) {
|
||||
if (data.includes('\x03') || data.includes('\x15')) {
|
||||
session.shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
session.resetPendingShellCommandLine()
|
||||
}
|
||||
if (data.includes('\r') || data.includes('\n')) {
|
||||
session.shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) {
|
||||
session.resetPendingShellCommandLine()
|
||||
session.shellCommandInferenceSuspendedUntilCommandEnd =
|
||||
!data.includes('\r') && !data.includes('\n')
|
||||
return
|
||||
}
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const char = data[index]!
|
||||
if (char === '\r' || char === '\n') {
|
||||
session.shellCommandInferenceSuspendedUntilCommandEnd = false
|
||||
session.rememberCommandInferredPaneAgent()
|
||||
if (session.commandInferredPaneAgent) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === '\x7f' || char === '\b') {
|
||||
session.deletePendingShellCommandCharacter()
|
||||
continue
|
||||
}
|
||||
if (char === '\x17') {
|
||||
session.deletePendingShellCommandWord()
|
||||
continue
|
||||
}
|
||||
if (char === '\x03' || char === '\x15') {
|
||||
session.resetPendingShellCommandLine()
|
||||
continue
|
||||
}
|
||||
if (char === '\x1b') {
|
||||
const nextIndex = session.consumeShellCommandCsiSequence(data, index)
|
||||
if (nextIndex !== null) {
|
||||
index = nextIndex - 1
|
||||
continue
|
||||
}
|
||||
session.resetPendingShellCommandLine()
|
||||
continue
|
||||
}
|
||||
if (char < ' ') {
|
||||
session.resetPendingShellCommandLine()
|
||||
continue
|
||||
}
|
||||
if (char >= ' ') {
|
||||
session.appendPendingShellCommandInput(char)
|
||||
if (session.shellCommandInferenceSuspendedUntilCommandEnd) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolves the authoritative owner agent type for this pane, checking tab launch,
|
||||
* pane startup, typed command ownership, and store state configuration.
|
||||
*
|
||||
* Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an
|
||||
* OMP-owned pane back to Pi; command ownership covers manually typed `omp`
|
||||
* in generic terminals where launch metadata does not exist.
|
||||
*/
|
||||
session.getAuthoritativePaneAgent = (): AgentType | undefined => {
|
||||
const state = useAppStore.getState()
|
||||
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === session.deps.tabId
|
||||
)
|
||||
return (
|
||||
resolvePaneAgentOwner({
|
||||
launchAgent: tab?.launchAgent,
|
||||
startupLaunchAgent: session.paneStartup?.launchAgent,
|
||||
initialStatusAgent: session.paneStartup?.initialAgentStatus?.agent,
|
||||
commandInferredAgent: session.commandInferredPaneAgent,
|
||||
hookAgent: state.agentStatusByPaneKey[session.cacheKey]?.agentType
|
||||
}) ?? undefined
|
||||
)
|
||||
}
|
||||
// Why: the renderer veto (owner evidence beating a Gemini-looking title) must
|
||||
// use only pane-scoped, CURRENT ownership. getAuthoritativePaneAgent leads
|
||||
// with the tab-shared `tab.launchAgent` and a never-cleared
|
||||
// `paneStartup.launchAgent`, which would let a sibling split pane or a reused
|
||||
// pane keep WebGL for a genuine Gemini terminal (#7428 regression class).
|
||||
// Launch identity is excluded, and the never-clearing startup seed
|
||||
// (`paneStartup.initialAgentStatus`) too; a stale or `done` explicit row is
|
||||
// ignored via the freshness predicate so a reused pane cannot inherit a prior
|
||||
// agent's veto. Only live foreground command inference and a fresh, active
|
||||
// hook row count. A genuine OMP/Pi pane stays protected owner-independently by
|
||||
// the isPiAgentTitle guard inside isGeminiTerminalTitle.
|
||||
session.getPaneScopedRendererOwner = (): AgentType | undefined => {
|
||||
const entry = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
|
||||
return (
|
||||
session.commandInferredPaneAgent ??
|
||||
(session.isFreshActivePaneAgentEntry(entry) ? entry.agentType : undefined)
|
||||
)
|
||||
}
|
||||
session.clearInferredInterruptWorkingTitle = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
|
||||
const statusTitle = state.agentStatusByPaneKey[session.cacheKey]?.terminalTitle
|
||||
const title = currentTitle ?? statusTitle
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
const neutralTitle = session.neutralTerminalTitle()
|
||||
// Why: inferred interrupts update the explicit hook row, but many CLIs leave
|
||||
// their OSC title stuck on a working spinner. Replace only this fallback
|
||||
// title signal with a neutral terminal label so the existing process tracker
|
||||
// can still decide whether an agent TUI is truly alive.
|
||||
session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, neutralTitle)
|
||||
if (session.manager.getActivePane()?.id === session.pane.id) {
|
||||
session.deps.updateTabTitle(session.deps.tabId, neutralTitle)
|
||||
}
|
||||
}
|
||||
session.titleOnlyInterruptTimer = null
|
||||
session.clearTitleOnlyInterruptTimer = (): void => {
|
||||
if (session.titleOnlyInterruptTimer !== null) {
|
||||
clearTimeout(session.titleOnlyInterruptTimer)
|
||||
session.titleOnlyInterruptTimer = null
|
||||
}
|
||||
}
|
||||
session.observeTitleOnlyInterrupt = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
if (state.agentStatusByPaneKey[session.cacheKey]) {
|
||||
return
|
||||
}
|
||||
const runtimeTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
|
||||
const tabTitle = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === session.deps.tabId
|
||||
)?.title
|
||||
const baselineTitle = runtimeTitle ?? tabTitle
|
||||
if (detectAgentStatusFromTitle(baselineTitle ?? '') !== 'working') {
|
||||
return
|
||||
}
|
||||
session.clearTitleOnlyInterruptTimer()
|
||||
session.titleOnlyInterruptTimer = setTimeout(() => {
|
||||
session.titleOnlyInterruptTimer = null
|
||||
if (useAppStore.getState().agentStatusByPaneKey[session.cacheKey]) {
|
||||
return
|
||||
}
|
||||
const currentState = useAppStore.getState()
|
||||
const currentRuntimeTitle =
|
||||
currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
|
||||
const currentTabTitle = (currentState.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === session.deps.tabId
|
||||
)?.title
|
||||
const currentTitle = currentRuntimeTitle ?? currentTabTitle
|
||||
if (
|
||||
currentTitle === baselineTitle &&
|
||||
detectAgentStatusFromTitle(currentTitle ?? '') === 'working'
|
||||
) {
|
||||
// Why: title-only agents such as Pi can miss their own idle title after
|
||||
// Ctrl+C. Clear only an unchanged, acknowledged working title.
|
||||
session.clearInferredInterruptWorkingTitle()
|
||||
}
|
||||
}, AGENT_INTERRUPT_SETTLE_MS)
|
||||
}
|
||||
session.clearReattachIdleAgentCursorResetTimer = (): void => {
|
||||
if (session.reattachIdleAgentCursorResetTimer !== null) {
|
||||
clearTimeout(session.reattachIdleAgentCursorResetTimer)
|
||||
session.reattachIdleAgentCursorResetTimer = null
|
||||
}
|
||||
}
|
||||
session.getCurrentTerminalTitle = (): string | null => {
|
||||
const state = useAppStore.getState()
|
||||
const runtimeTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
|
||||
const tabTitle = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === session.deps.tabId
|
||||
)?.title
|
||||
return runtimeTitle ?? tabTitle ?? null
|
||||
}
|
||||
session.reattachReplayPayloadHasCursorAgentSignal = false
|
||||
// Why: post-parse veto callbacks must only judge the latest replay frame; a
|
||||
// newer frame bumps the generation so a stale callback stands down.
|
||||
session.reattachReplayPayloadSignalGeneration = 0
|
||||
session.rememberReattachPayloadAgentSignal = (
|
||||
data: string,
|
||||
opts: { fullScreenReplay: boolean }
|
||||
): void => {
|
||||
session.reattachReplayPayloadSignalGeneration += 1
|
||||
// Why: ordinary scrollback can mention agent names. Treat replay bytes as
|
||||
// a live Cursor Agent signal only when they look like its restored screen.
|
||||
const signal = hasCursorAgentReattachPayloadScreenSignal(data)
|
||||
// Why: incremental (non-clearing) replay frames repaint only part of the
|
||||
// screen, so their bytes can only add evidence — a full-screen replay is
|
||||
// the authoritative repaint that may clear the flag.
|
||||
session.reattachReplayPayloadHasCursorAgentSignal = opts.fullScreenReplay
|
||||
? signal
|
||||
: session.reattachReplayPayloadHasCursorAgentSignal || signal
|
||||
}
|
||||
session.isCursorAgentNativeTitle = (title: string): boolean => {
|
||||
return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase()
|
||||
}
|
||||
session.hasLiveAgentReattachStatusOrTitleSignal = (): boolean => {
|
||||
// Why: launch ownership (tab.launchAgent) never decays after the agent
|
||||
// exits, so it must not count as liveness here — only live status, live
|
||||
// titles, and the replayed screen shape do.
|
||||
if (useAppStore.getState().agentStatusByPaneKey[session.cacheKey]) {
|
||||
return true
|
||||
}
|
||||
const title = session.getCurrentTerminalTitle() ?? ''
|
||||
// Why: broad token matching (getAgentLabel) fires on titles like
|
||||
// "ssh devin@host"; that surface is too loose to gate mode preservation
|
||||
// and PTY byte injection, so only exact/status titles count here.
|
||||
return detectAgentStatusFromTitle(title) !== null || session.isCursorAgentNativeTitle(title)
|
||||
}
|
||||
session.hasLiveAgentReattachSignal = (): boolean => {
|
||||
return (
|
||||
session.hasLiveAgentReattachStatusOrTitleSignal() ||
|
||||
session.reattachReplayPayloadHasCursorAgentSignal
|
||||
)
|
||||
}
|
||||
session.shouldPreserveAgentReattachModes = (): boolean => {
|
||||
// Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes.
|
||||
// Preserve those modes only when reattach still looks agent-owned.
|
||||
return session.hasLiveAgentReattachSignal()
|
||||
}
|
||||
session.shouldSendFocusedAgentReattachFocusIn = (): boolean => {
|
||||
return terminalOwnsDomFocus(session.pane.terminal) && session.shouldPreserveAgentReattachModes()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { PtyConnectResult } from '../pty-transport'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { parseLegacyNumericPaneKey } from '../../../../../shared/stable-pane-id'
|
||||
import { getProviderSessionClaimKey } from '@/lib/sleeping-agent-pane-ownership'
|
||||
import {
|
||||
agentProviderSessionsEqual,
|
||||
type SleepingAgentSessionRecord
|
||||
} from '../../../../../shared/agent-session-resume'
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../../../../shared/agent-process-recognition'
|
||||
import type { TuiAgent } from '../../../../../shared/tui-agent'
|
||||
import { TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config'
|
||||
import {
|
||||
beginAgentStartupDeliveryAttempt,
|
||||
releaseAgentStartupDeliveryAttempt
|
||||
} from '@/lib/agent-startup-delayed-delivery'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
import { installCommandInferredPaneAgent } from './command-inferred-pane-agent'
|
||||
|
||||
export function installSleepingRecordAccess(session: ConnectPanePtySession): void {
|
||||
session.getSleepingRecordForPane = (
|
||||
state: ReturnType<typeof useAppStore.getState>
|
||||
): { paneKey: string; record: SleepingAgentSessionRecord } | null => {
|
||||
const stableRecord = state.sleepingAgentSessionsByPaneKey[session.cacheKey]
|
||||
if (stableRecord) {
|
||||
return { paneKey: session.cacheKey, record: stableRecord }
|
||||
}
|
||||
const legacyMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter(
|
||||
([paneKey, record]) => {
|
||||
const legacy = parseLegacyNumericPaneKey(paneKey)
|
||||
return (
|
||||
legacy?.tabId === session.deps.tabId &&
|
||||
record.worktreeId === session.deps.worktreeId &&
|
||||
(!record.tabId || record.tabId === session.deps.tabId)
|
||||
)
|
||||
}
|
||||
)
|
||||
const exactLegacyMatch = legacyMatches.find(([paneKey]) => {
|
||||
const legacy = parseLegacyNumericPaneKey(paneKey)
|
||||
return legacy?.numericPaneId === String(session.pane.id)
|
||||
})
|
||||
const providerSessionKeys = new Set(
|
||||
legacyMatches.map(([, record]) => getProviderSessionClaimKey(record))
|
||||
)
|
||||
const oldestLegacyMatch = legacyMatches
|
||||
.slice()
|
||||
.sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0]
|
||||
// Why: duplicate legacy aliases can point at one provider session; consume
|
||||
// the oldest capture as canonical and clear its aliases after resume.
|
||||
const selectedLegacyMatch =
|
||||
exactLegacyMatch ??
|
||||
(providerSessionKeys.size === 1
|
||||
? legacyMatches.length === 1
|
||||
? legacyMatches[0]
|
||||
: oldestLegacyMatch
|
||||
: null)
|
||||
if (!selectedLegacyMatch) {
|
||||
return null
|
||||
}
|
||||
const [paneKey, record] = selectedLegacyMatch
|
||||
return { paneKey, record }
|
||||
}
|
||||
session.isLegacyWorkerAutomaticResumeBlocked = (): boolean =>
|
||||
session.getSleepingRecordForPane(useAppStore.getState())?.record.automaticResumeBlockedBy ===
|
||||
'legacy-orchestration-worker'
|
||||
session.clearSleepingRecordProviderDuplicates = (
|
||||
state: ReturnType<typeof useAppStore.getState>,
|
||||
consumed: { paneKey: string; record: SleepingAgentSessionRecord }
|
||||
): void => {
|
||||
state.clearSleepingAgentSession(consumed.paneKey)
|
||||
for (const [paneKey, record] of Object.entries(state.sleepingAgentSessionsByPaneKey)) {
|
||||
if (
|
||||
paneKey !== consumed.paneKey &&
|
||||
record.worktreeId === consumed.record.worktreeId &&
|
||||
record.agent === consumed.record.agent &&
|
||||
agentProviderSessionsEqual(
|
||||
record.agent,
|
||||
record.providerSession,
|
||||
consumed.record.providerSession
|
||||
)
|
||||
) {
|
||||
// Why: legacy pane aliases can leave multiple sleeping rows for one
|
||||
// provider session; once this pane resumes it, every alias is stale.
|
||||
state.clearSleepingAgentSession(paneKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
session.launchToken = session.paneStartup?.launchConfig
|
||||
? (session.paneStartup.launchToken ?? createBrowserUuid())
|
||||
: undefined
|
||||
session.startupDraftAgent =
|
||||
session.paneStartup?.launchAgent ?? session.paneStartup?.initialAgentStatus?.agent
|
||||
session.startupDraftAgentConfig = session.startupDraftAgent
|
||||
? TUI_AGENT_CONFIG[session.startupDraftAgent]
|
||||
: null
|
||||
session.startupDraftPrompt =
|
||||
typeof session.paneStartup?.draftPrompt === 'string' && session.paneStartup.draftPrompt.trim()
|
||||
? session.paneStartup.draftPrompt
|
||||
: null
|
||||
session.startupDraftPromptNeedsPaste =
|
||||
session.startupDraftPrompt !== null &&
|
||||
!session.startupDraftAgentConfig?.draftPromptFlag &&
|
||||
!session.startupDraftAgentConfig?.draftPromptEnvVar
|
||||
session.startupDraftDeliveryClaimed = false
|
||||
session.startupDraftPasteAttempted = false
|
||||
session.claimStartupDraftPasteDelivery = (): boolean => {
|
||||
if (!session.startupDraftPromptNeedsPaste || session.launchToken === undefined) {
|
||||
return false
|
||||
}
|
||||
if (session.startupDraftDeliveryClaimed) {
|
||||
return true
|
||||
}
|
||||
// Why: launch-bound draft paste needs a launch token; all current
|
||||
// draftPrompt startup callers pair it with launchConfig so this can safely
|
||||
// fence off delayed sidecar delivery before Codex's first composer frame.
|
||||
session.startupDraftDeliveryClaimed = beginAgentStartupDeliveryAttempt({
|
||||
worktreeId: session.deps.worktreeId,
|
||||
tabId: session.deps.tabId,
|
||||
launchToken: session.launchToken
|
||||
})
|
||||
return session.startupDraftDeliveryClaimed
|
||||
}
|
||||
session.releaseUnattemptedStartupDraftPasteDelivery = (): void => {
|
||||
if (
|
||||
!session.startupDraftDeliveryClaimed ||
|
||||
session.startupDraftPasteAttempted ||
|
||||
session.launchToken === undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
releaseAgentStartupDeliveryAttempt({
|
||||
worktreeId: session.deps.worktreeId,
|
||||
tabId: session.deps.tabId,
|
||||
launchToken: session.launchToken
|
||||
})
|
||||
session.startupDraftDeliveryClaimed = false
|
||||
}
|
||||
// Why: reserve before deferred connect so the creation sidecar cannot time out during setup and strand this pane's live scanner.
|
||||
session.ownsStartupDraftPaste = session.claimStartupDraftPasteDelivery()
|
||||
if (session.paneStartup?.launchConfig) {
|
||||
useAppStore
|
||||
.getState()
|
||||
.registerAgentLaunchConfig(session.cacheKey, session.paneStartup.launchConfig, {
|
||||
agentType: session.paneStartup.launchAgent ?? session.paneStartup.initialAgentStatus?.agent,
|
||||
...(session.launchToken ? { launchToken: session.launchToken } : {}),
|
||||
tabId: session.deps.tabId,
|
||||
leafId: session.pane.leafId
|
||||
})
|
||||
} else if (session.paneStartup) {
|
||||
useAppStore.getState().clearAgentLaunchConfig(session.cacheKey)
|
||||
}
|
||||
session.registerEffectiveLaunchConfig = (
|
||||
effectiveLaunchConfig: PtyConnectResult['launchConfig'] | undefined,
|
||||
metadata?: { launchToken?: string; launchAgent?: TuiAgent }
|
||||
): void => {
|
||||
if (!effectiveLaunchConfig) {
|
||||
if (metadata?.launchAgent) {
|
||||
// Why: daemon launch identity can outlive the process while Orca is
|
||||
// closed. Use it to request confirmation, never as current byte authority.
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
agent: metadata.launchAgent,
|
||||
shellForeground: false
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: daemon reattach preserves the pane's exact launch command but not
|
||||
// renderer metadata; recover only allowlisted command identity from it.
|
||||
const persistedLaunchAgent = recognizeAgentProcessFromCommandLine(
|
||||
effectiveLaunchConfig.agentCommand
|
||||
)?.agent
|
||||
useAppStore.getState().registerAgentLaunchConfig(session.cacheKey, effectiveLaunchConfig, {
|
||||
agentType:
|
||||
metadata?.launchAgent ??
|
||||
session.paneStartup?.launchAgent ??
|
||||
session.paneStartup?.initialAgentStatus?.agent ??
|
||||
persistedLaunchAgent,
|
||||
...((metadata?.launchToken ?? session.launchToken)
|
||||
? { launchToken: metadata?.launchToken ?? session.launchToken }
|
||||
: {}),
|
||||
tabId: session.deps.tabId,
|
||||
leafId: session.pane.leafId
|
||||
})
|
||||
}
|
||||
session.clearRegisteredStartupLaunchConfig = (): void => {
|
||||
useAppStore.getState().clearAgentLaunchConfig(session.cacheKey)
|
||||
}
|
||||
session.neutralTerminalTitle = (): string => {
|
||||
const state = useAppStore.getState()
|
||||
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === session.deps.tabId
|
||||
)
|
||||
return tab?.defaultTitle?.trim() || 'Terminal'
|
||||
}
|
||||
installCommandInferredPaneAgent(session)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { SSH_SESSION_EXPIRED_ERROR } from './pty-connect-limits'
|
||||
|
||||
// Why: when multiple panes/tabs need the same deferred SSH connection,
|
||||
// the first one calls ssh.connect() and subsequent ones must wait for it
|
||||
// rather than returning early (which would leave them disconnected). This
|
||||
// helper either connects or waits for an in-flight connect to finish.
|
||||
export type SshConnectResult = { connected: true } | { connected: false; error: string }
|
||||
export type UserInitiatedSshConnectOutcome = 'connected' | 'cancelled' | 'failed'
|
||||
|
||||
const sshConnectPromises = new Map<string, Promise<SshConnectResult>>()
|
||||
|
||||
export function isSshSessionExpiredError(err: unknown): boolean {
|
||||
return (err instanceof Error ? err.message : String(err)).includes(SSH_SESSION_EXPIRED_ERROR)
|
||||
}
|
||||
|
||||
export function sshPromptConnectOutcomeForStatus(
|
||||
status: string | undefined,
|
||||
sawNonDisconnected: boolean
|
||||
): UserInitiatedSshConnectOutcome | null {
|
||||
if (status === 'connected') {
|
||||
return 'connected'
|
||||
}
|
||||
if (status === 'auth-failed' || status === 'error' || status === 'reconnection-failed') {
|
||||
return 'failed'
|
||||
}
|
||||
// Why: this only counts after a real connect attempt; the entry-time
|
||||
// disconnected state just means the user still needs to initiate auth.
|
||||
if (sawNonDisconnected && status === 'disconnected') {
|
||||
return 'cancelled'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function waitForSshConnection(connectionId: string): Promise<SshConnectResult> {
|
||||
const state = useAppStore.getState().sshConnectionStates.get(connectionId)
|
||||
if (state?.status === 'connected') {
|
||||
return { connected: true }
|
||||
}
|
||||
|
||||
const existing = sshConnectPromises.get(connectionId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const promise: Promise<SshConnectResult> = (async (): Promise<SshConnectResult> => {
|
||||
try {
|
||||
await window.api.ssh.connect({ targetId: connectionId })
|
||||
return { connected: true }
|
||||
} catch (err) {
|
||||
console.warn(`Deferred SSH reconnect failed for ${connectionId}:`, err)
|
||||
return {
|
||||
connected: false,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
} finally {
|
||||
sshConnectPromises.delete(connectionId)
|
||||
}
|
||||
})()
|
||||
|
||||
sshConnectPromises.set(connectionId, promise)
|
||||
return promise
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { waitForTerminalReplayWritesParsed } from '../replay-guard'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
|
||||
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
|
||||
import {
|
||||
buildMainModelSnapshotReplayWrites,
|
||||
hasPositiveTerminalDimensions
|
||||
} from '../terminal-snapshot-replay-paint'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindPrepaintParkedSshSnapshot(session: ConnectPanePtySession): void {
|
||||
session.prepaintParkedSshSnapshot = (ptyId: string | null): void => {
|
||||
const parsedPtyId = ptyId ? parseAppSshPtyId(ptyId) : null
|
||||
if (
|
||||
!ptyId ||
|
||||
!session.mountFollowsTerminalPark ||
|
||||
parsedPtyId?.connectionId !== session.connectionId ||
|
||||
!session.capturedDirectSshRetryLeaseMatches()
|
||||
) {
|
||||
return
|
||||
}
|
||||
const capturedGeneration = session.authoritativeReattachGeneration
|
||||
const isCurrent = (): boolean =>
|
||||
!session.disposed &&
|
||||
session.mountFollowsTerminalPark &&
|
||||
session.authoritativeReattachGeneration === capturedGeneration &&
|
||||
session.capturedDirectSshRetryLeaseMatches()
|
||||
const fetchSnapshot = session.getSshMainModelSnapshotProbe(ptyId)
|
||||
void fetchSnapshot()
|
||||
.then(async (snapshot) => {
|
||||
if (!snapshot || !isCurrent()) {
|
||||
return
|
||||
}
|
||||
await session.structuralReplayCoordinator.run(
|
||||
async () => {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const modelData = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
|
||||
session.rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true })
|
||||
if (
|
||||
hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) &&
|
||||
(session.pane.terminal.cols !== snapshot.cols ||
|
||||
session.pane.terminal.rows !== snapshot.rows)
|
||||
) {
|
||||
session.suppressStructuralReplayPtyResize = true
|
||||
try {
|
||||
session.pane.terminal.resize(snapshot.cols, snapshot.rows)
|
||||
} finally {
|
||||
session.suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
session.applySnapshotKittyKeyboardModes(modelData, {
|
||||
kittyKeyboardFlags: snapshot.kittyKeyboardFlags,
|
||||
snapshotSeq: snapshot.seq
|
||||
})
|
||||
// Why keep a too-wide frame: preconnect SSH has no live repaint owner or post-restore fit.
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
|
||||
paneOnAlternateScreen: session.isPaneOnAlternateScreen()
|
||||
})) {
|
||||
session.writeReplayData(replayChunk)
|
||||
}
|
||||
session.writeReplayData(session.reattachReplayResetSequence(modelData))
|
||||
if (snapshot.pendingEscapeTailAnsi) {
|
||||
session.writeReplayData(snapshot.pendingEscapeTailAnsi)
|
||||
}
|
||||
recordTerminalOutput(session.pane.terminal)
|
||||
await waitForTerminalReplayWritesParsed(session.pane.terminal)
|
||||
if (isCurrent()) {
|
||||
session.manager.rebuildPaneWebgl(session.pane.id)
|
||||
}
|
||||
},
|
||||
{ shouldRestore: isCurrent }
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// Why: the notice deliberately omits the rejected path — saved cwds can
|
||||
// contain private repo/user names; the terminal itself shows where it opened.
|
||||
export const STARTUP_CWD_FALLBACK_NOTICE =
|
||||
'\r\n[Orca opened this terminal at the workspace root because its saved start folder no longer exists.]\r\n'
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { bindPanePtyId, getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
|
||||
import { isFreshNonDoneAgentStatus } from '../../../../../shared/agent-status-types'
|
||||
import { isCtrlCKeyEvent, isPlainEscapeKeyEvent } from '../agent-interrupt-inference'
|
||||
import { createAgentCompletionCoordinator } from '../agent-completion-coordinator'
|
||||
import { dispatchAgentHookTerminalLifecycle } from '../agent-hook-terminal-lifecycle'
|
||||
import { createCodexAutoApprovalHookCompletionSuppressor } from '../codex-auto-approval-notification-suppression'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner'
|
||||
import { registerTerminalSideEffectFactConsumer } from '../terminal-side-effect-facts-handler'
|
||||
|
||||
import { isAgentTaskCompleteTrackingEnabled } from './agent-task-complete-settings'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function installTerminalKeydownFit(session: ConnectPanePtySession): void {
|
||||
session.onTerminalKeyDown = (event: KeyboardEvent): void => {
|
||||
if (isPlainEscapeKeyEvent(event)) {
|
||||
session.setPendingTerminalInputIntent('plain-escape')
|
||||
// Why: plain Escape produces real terminal input (\x1b), so it is a
|
||||
// genuine "user is here" signal and must still dismiss attention before
|
||||
// the early return for interrupt-intent inference.
|
||||
session.deps.clearTerminalTabUnread(session.deps.tabId)
|
||||
session.deps.clearTerminalPaneUnread(session.cacheKey)
|
||||
session.deps.clearWorktreeUnread(session.deps.worktreeId)
|
||||
return
|
||||
}
|
||||
if (isCtrlCKeyEvent(event)) {
|
||||
if (!navigator.userAgent.includes('Mac') && session.pane.terminal.hasSelection()) {
|
||||
return
|
||||
}
|
||||
session.setPendingTerminalInputIntent('ctrl-c')
|
||||
}
|
||||
// Why: only treat keydowns that will produce real terminal input as the
|
||||
// "user is here" signal. Modifier-only presses, autorepeat, and Cmd/Ctrl+C
|
||||
// copy chords with an active selection must not dismiss attention on a
|
||||
// sibling pane before the user has seen it.
|
||||
if (
|
||||
event.repeat ||
|
||||
event.key === 'Alt' ||
|
||||
event.key === 'AltGraph' ||
|
||||
event.key === 'Control' ||
|
||||
event.key === 'Meta' ||
|
||||
event.key === 'Shift'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key.toLowerCase() === 'c' &&
|
||||
session.pane.terminal.hasSelection()
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: user shell frameworks (bash-preexec/iTerm2) can replace Orca's
|
||||
// OSC 133;C hook, so a manually launched agent produces no command-start
|
||||
// signal at all. Enter at a shell-foreground prompt is the user-side
|
||||
// equivalent; the sample is gated to panes with no live agent identity
|
||||
// and publishes nothing for an idle shell.
|
||||
if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
session.sampleVisiblePaneForegroundAgent()
|
||||
}
|
||||
session.deps.clearTerminalTabUnread(session.deps.tabId)
|
||||
session.deps.clearTerminalPaneUnread(session.cacheKey)
|
||||
session.deps.clearWorktreeUnread(session.deps.worktreeId)
|
||||
}
|
||||
// Why: infer only from focused xterm key events. Raw PTY bytes cannot
|
||||
// distinguish plain Escape from Alt/meta sequences, and programmatic writes
|
||||
// should not clear agent status.
|
||||
session.terminalKeyTarget = session.pane.terminal.element ?? session.pane.container
|
||||
session.terminalKeyTargetSupportsEvents =
|
||||
typeof session.terminalKeyTarget?.addEventListener === 'function' &&
|
||||
typeof session.terminalKeyTarget?.removeEventListener === 'function'
|
||||
if (session.terminalKeyTargetSupportsEvents) {
|
||||
session.terminalKeyTarget.addEventListener('keydown', session.onTerminalKeyDown, {
|
||||
capture: true
|
||||
})
|
||||
}
|
||||
|
||||
session.visibleRemoteViewportClaimPtyId = null
|
||||
session.pendingVisibleRemoteViewportClaim = false
|
||||
session.setPanePtyFitBinding = (ptyId: string): void => {
|
||||
bindPanePtyId(session.pane.id, ptyId, session.deps.tabId)
|
||||
session.pane.container.dataset.ptyId = ptyId
|
||||
if (
|
||||
session.deps.isVisibleRef.current &&
|
||||
isRemoteRuntimePtyId(ptyId) &&
|
||||
session.visibleRemoteViewportClaimPtyId !== ptyId
|
||||
) {
|
||||
// Why: the initial fit event consumes this activation arm before later peer ownership changes.
|
||||
session.visibleRemoteViewportClaimPtyId = ptyId
|
||||
session.pendingVisibleRemoteViewportClaim = true
|
||||
}
|
||||
// Why: override hydration can arrive before this pane knows its PTY. Once
|
||||
// data-pty-id is bound, safeFit can park xterm at the authoritative grid.
|
||||
if (getFitOverrideForPty(ptyId)) {
|
||||
safeFit(session.pane)
|
||||
}
|
||||
session.claimPendingVisibleRemoteViewport()
|
||||
}
|
||||
session.activePanePtyBinding = null
|
||||
// Why: bind time lets async liveness reconcile ignore a request started
|
||||
// before this PTY bound (newborn race). Null disables the guard (fail-safe).
|
||||
session.activePanePtyBindingBoundAt = null
|
||||
|
||||
// Why: with main side-effect authority on, the pane's title/bell/agent
|
||||
// policy callbacks consume pty:sideEffect facts instead of transport byte
|
||||
// parsers (which stay unregistered) — same policy code, single consumer.
|
||||
// restoreTitleOnRegister replaces the eager-replay title restore: main's
|
||||
// title-only snapshot carries the no-attention-replay rule.
|
||||
session.unregisterSideEffectFactConsumer = null
|
||||
session.registerSideEffectFactConsumerForPty = (
|
||||
ptyId: string,
|
||||
remoteOutputPaused = false
|
||||
): void => {
|
||||
if ((!session.mainSideEffectAuthority && !remoteOutputPaused) || session.disposed) {
|
||||
return
|
||||
}
|
||||
session.unregisterSideEffectFactConsumer?.()
|
||||
session.unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({
|
||||
ptyId,
|
||||
callbacks: {
|
||||
onTitleChange: session.onTitleChange,
|
||||
onBell: session.onBell,
|
||||
onAgentBecameIdle: session.onAgentBecameIdle,
|
||||
onAgentBecameWorking: session.onAgentBecameWorking,
|
||||
onAgentExited: session.onAgentExited,
|
||||
onCommandFinished: session.handleCommandFinished,
|
||||
onPrLink: (link) =>
|
||||
useAppStore
|
||||
.getState()
|
||||
.observeTerminalGitHubPullRequestLink(session.deps.worktreeId, link),
|
||||
// Why: the Command Code settle policy stays here — the done settle
|
||||
// timer must consult the live store row (which hook events and
|
||||
// renderer seeds also write), so main only emits scrape facts.
|
||||
onCommandCodeWorking: session.seedCommandCodeOutputWorkingStatus,
|
||||
onCommandCodeDone: session.scheduleCommandCodeOutputDoneStatus,
|
||||
...(session.shouldOwnAgentStatusInRenderer
|
||||
? { onAgentStatus: (payload) => session.handleRendererOwnedAgentStatus(payload) }
|
||||
: {}),
|
||||
// Why: gated hidden panes never see the subscribe bytes; the fact
|
||||
// replaces the byte scan (and the old post-latch subscribe drop).
|
||||
...(session.hiddenDeliveryGateActive || remoteOutputPaused
|
||||
? {
|
||||
onMode2031Subscribe: session.handleHiddenMode2031SubscribeFact,
|
||||
onMode2031Unsubscribe: session.handleHiddenMode2031UnsubscribeFact
|
||||
}
|
||||
: {})
|
||||
},
|
||||
restoreTitleOnRegister: true
|
||||
})
|
||||
}
|
||||
session.dropSideEffectFactConsumer = (): void => {
|
||||
session.unregisterSideEffectFactConsumer?.()
|
||||
session.unregisterSideEffectFactConsumer = null
|
||||
session.remoteOutputFactConsumerPtyId = null
|
||||
}
|
||||
session.clearPanePtyFitBinding = (): void => {
|
||||
// Why: fit bindings live in a module-level map, so pane teardown must
|
||||
// clear them explicitly instead of relying on DOM removal.
|
||||
bindPanePtyId(session.pane.id, null, session.deps.tabId)
|
||||
session.visibleRemoteViewportClaimPtyId = null
|
||||
session.pendingVisibleRemoteViewportClaim = false
|
||||
session.activePanePtyBinding = null
|
||||
session.activePanePtyBindingBoundAt = null
|
||||
delete session.pane.container.dataset.ptyId
|
||||
delete session.pane.container.dataset.ptyRecoveryState
|
||||
}
|
||||
|
||||
session.agentCompletionCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: session.cacheKey,
|
||||
statusLane: 'pty',
|
||||
getPtyId: () => session.transport.getPtyId(),
|
||||
getSettings: () => useAppStore.getState().settings,
|
||||
inspectProcess: inspectRuntimeTerminalProcess,
|
||||
dispatchHookLifecycle: (payload) =>
|
||||
dispatchAgentHookTerminalLifecycle(session.cacheKey, payload),
|
||||
shouldSuppressProcessReplacementCompletion: (_exited, replacement) => {
|
||||
const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
|
||||
const currentAgentForReplacement = resolveCompatibleAgentTypeForOwner(
|
||||
currentStatus?.agentType,
|
||||
replacement.agent
|
||||
)
|
||||
return (
|
||||
isFreshNonDoneAgentStatus(currentStatus) && currentAgentForReplacement === replacement.agent
|
||||
)
|
||||
},
|
||||
shouldSuppressConfirmedProcessExitCompletion: (exited) => {
|
||||
const currentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
|
||||
const currentAgentForExited = resolveCompatibleAgentTypeForOwner(
|
||||
currentStatus?.agentType,
|
||||
exited.agent
|
||||
)
|
||||
// Why: a replacement hook can lead process visibility by one cadence;
|
||||
// only a different known active owner can veto confirmed old-process exit.
|
||||
return Boolean(
|
||||
isFreshNonDoneAgentStatus(currentStatus) &&
|
||||
currentStatus.agentType &&
|
||||
currentStatus.agentType !== 'unknown' &&
|
||||
currentAgentForExited !== exited.agent
|
||||
)
|
||||
},
|
||||
dispatchCompletion: (title, meta) => {
|
||||
if (meta?.source === 'process-exit') {
|
||||
session.clearSuppressedTitleSideEffects()
|
||||
}
|
||||
if (meta?.terminalIdleConfirmed === true) {
|
||||
// Why: an agent can crash before its done hook; confirmed process death
|
||||
// must still restore cursor and native Windows Kitty keyboard modes.
|
||||
const currentAgentStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
|
||||
if (!isFreshNonDoneAgentStatus(currentAgentStatus)) {
|
||||
session.setFocusReportSuppressionForAgentCompletion(title, meta.agentStatus?.agentType)
|
||||
}
|
||||
session.queueAgentIdleTerminalModeReset()
|
||||
}
|
||||
session.scheduleAgentTaskCompleteNotification(title, {
|
||||
allowDoneDetailAfterGrace: meta?.quietedHookDone,
|
||||
...(meta?.source === 'process-exit' ? { agentCompletionSource: meta.source } : {}),
|
||||
...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {})
|
||||
})
|
||||
},
|
||||
dispatchAttention: (title, meta) =>
|
||||
session.scheduleAgentTaskCompleteNotification(title, {
|
||||
agentStatusSnapshot: meta.agentStatus
|
||||
}),
|
||||
shouldPollProcessCadence: () =>
|
||||
isAgentTaskCompleteTrackingEnabled() && session.deps.isVisibleRef.current,
|
||||
isProcessInspectionCostly: () => {
|
||||
// Why: local Windows inspection forks a powershell.exe whole-process-table
|
||||
// CIM scan per poll (~10-40x heavier than POSIX `ps`); SSH/remote PTYs run
|
||||
// their scans on the remote host, so only local Windows panes relax the
|
||||
// no-evidence cadence.
|
||||
if (!navigator.userAgent.includes('Windows')) {
|
||||
return false
|
||||
}
|
||||
const ptyId = session.transport.getPtyId()
|
||||
return ptyId !== null && !isRemoteRuntimePtyId(ptyId) && parseAppSshPtyId(ptyId) === null
|
||||
},
|
||||
isLive: () => {
|
||||
if (session.disposed) {
|
||||
return false
|
||||
}
|
||||
if (session.transport.getPtyId()) {
|
||||
return true
|
||||
}
|
||||
return (useAppStore.getState().ptyIdsByTabId[session.deps.tabId] ?? []).length > 0
|
||||
},
|
||||
shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(
|
||||
session.cacheKey,
|
||||
() => ({
|
||||
tabId: session.deps.tabId,
|
||||
...(session.launchToken ? { launchToken: session.launchToken } : {})
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { resolvePaneTitleDecision } from '../terminal-title-evidence'
|
||||
import { useAppStore } from '@/store'
|
||||
import { shouldSeedCacheTimerOnInitialTitle } from '../cache-timer-seeding'
|
||||
// Why: a restored pane's stale-account prompt can only be raised once a PTY is
|
||||
// actually attached — nothing is inspectable while the session hydrates.
|
||||
import { shouldSuppressCodexAutoApprovalSyntheticTitle } from '../codex-auto-approval-notification-suppression'
|
||||
import {
|
||||
cancelCommandCodeDoneSettle,
|
||||
openCommandCodeDoneSettle,
|
||||
setCommandCodeDoneSettleExecutor
|
||||
} from '../command-code-done-settle'
|
||||
import { canCommandCodeOutputOwnPane } from '../command-code-output-ownership'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
import { installPanePtyVisibilityBind } from './pane-pty-visibility-bind'
|
||||
|
||||
export function installTitleSpawnBell(session: ConnectPanePtySession): void {
|
||||
session.onTitleChange = (
|
||||
title: string,
|
||||
rawTitle: string,
|
||||
meta?: { staleWorkingTitleClear?: boolean }
|
||||
): void => {
|
||||
// Why: one owner-aware decision drives the display label, the runtime/tab
|
||||
// title, task-completion tracking, and the renderer gate, so raw title text
|
||||
// can no longer disable GPU behind stronger owner evidence (#7428/#7447).
|
||||
const decision = resolvePaneTitleDecision({
|
||||
normalizedTitle: title,
|
||||
rawTitle,
|
||||
displayOwnerAgentType: session.getAuthoritativePaneAgent(),
|
||||
rendererOwnerAgentType: session.getPaneScopedRendererOwner(),
|
||||
userGpuMode: useAppStore.getState().settings?.terminalGpuAcceleration ?? 'auto'
|
||||
})
|
||||
const paneTitle = decision.displayTitle
|
||||
if (
|
||||
shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, {
|
||||
paneKey: session.cacheKey,
|
||||
tabId: session.deps.tabId,
|
||||
...(session.launchToken ? { launchToken: session.launchToken } : {})
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
session.manager.setPaneGpuRendering(session.pane.id, decision.rendererPolicy.gpuEnabled)
|
||||
session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, paneTitle)
|
||||
// Why: a stale-derived cleared title comes from main's unthrottled 3s
|
||||
// timer, not agent output. It must update the visible title but never
|
||||
// feed completion tracking — observeTitle would classify the cleared
|
||||
// title as idle and mint a task-complete for a merely-paused agent.
|
||||
if (!meta?.staleWorkingTitleClear && session.syncAgentTaskCompleteTrackingEnabled()) {
|
||||
const activeHookStatus = useAppStore.getState().agentStatusByPaneKey[session.cacheKey]
|
||||
if (!session.shouldSuppressTitleCompletionForFreshHook(decision.rawTitle, activeHookStatus)) {
|
||||
// Why: display titles still update while hooks are active, but a stale
|
||||
// idle frame must not complete the coordinator turn before hook `done`.
|
||||
session.agentCompletionCoordinator.observeTitle(decision.rawTitle)
|
||||
}
|
||||
}
|
||||
// Why: only the focused pane should drive the tab title — otherwise two
|
||||
// agents in split panes cause rapid title flickering as each emits OSC
|
||||
// sequences. Only the active split's title propagates to the tab. When
|
||||
// focus changes, onActivePaneChange syncs the newly active pane's stored
|
||||
// title to the tab.
|
||||
if (session.manager.getActivePane()?.id === session.pane.id) {
|
||||
session.deps.updateTabTitle(session.deps.tabId, paneTitle)
|
||||
}
|
||||
|
||||
if (!session.hasConsideredInitialCacheTimerSeed) {
|
||||
session.hasConsideredInitialCacheTimerSeed = true
|
||||
const state = useAppStore.getState()
|
||||
if (
|
||||
shouldSeedCacheTimerOnInitialTitle({
|
||||
rawTitle,
|
||||
allowInitialIdleSeed: session.allowInitialIdleCacheSeed,
|
||||
existingTimerStartedAt: state.cacheTimerByKey[session.cacheKey],
|
||||
promptCacheTimerEnabled: state.settings?.promptCacheTimerEnabled ?? null
|
||||
})
|
||||
) {
|
||||
session.deps.setCacheTimerStartedAt(session.cacheKey, Date.now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.applyInitialAgentStatus = (terminalTitle?: string): void => {
|
||||
const initialStatus = session.paneStartup?.initialAgentStatus
|
||||
const routing = session.resolveCurrentAgentStatusRouting()
|
||||
if (!initialStatus || !routing) {
|
||||
return
|
||||
}
|
||||
const statusPayload = {
|
||||
state: 'working' as const,
|
||||
prompt: initialStatus.prompt,
|
||||
agentType: resolveCompatibleAgentTypeForOwner(
|
||||
initialStatus.agent,
|
||||
session.getAuthoritativePaneAgent()
|
||||
)
|
||||
}
|
||||
if (session.paneStartup.launchConfig) {
|
||||
useAppStore
|
||||
.getState()
|
||||
.setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing, {
|
||||
launchConfig: session.paneStartup.launchConfig,
|
||||
...(session.launchToken ? { launchToken: session.launchToken } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
useAppStore
|
||||
.getState()
|
||||
.setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing)
|
||||
}
|
||||
|
||||
session.canApplyCommandCodeOutputStatus = (): boolean => {
|
||||
const state = useAppStore.getState()
|
||||
const foreground = state.paneForegroundAgentByPaneKey[session.cacheKey]
|
||||
return canCommandCodeOutputOwnPane({
|
||||
foregroundAgent: foreground?.agent,
|
||||
shellForeground: foreground?.shellForeground,
|
||||
paneOwnerAgent: session.getAuthoritativePaneAgent(),
|
||||
retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[session.cacheKey]?.agentType
|
||||
})
|
||||
}
|
||||
|
||||
session.seedCommandCodeOutputWorkingStatus = (prompt: string): void => {
|
||||
if (!session.canApplyCommandCodeOutputStatus()) {
|
||||
return
|
||||
}
|
||||
session.clearCommandCodeOutputDoneTimer()
|
||||
const routing = session.resolveCurrentAgentStatusRouting()
|
||||
if (!routing) {
|
||||
return
|
||||
}
|
||||
const currentState = useAppStore.getState()
|
||||
const currentEntry = currentState.agentStatusByPaneKey[session.cacheKey]
|
||||
const currentTitle =
|
||||
currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
|
||||
const normalizedPrompt = prompt.trim()
|
||||
if (
|
||||
currentEntry?.agentType === 'command-code' &&
|
||||
currentEntry.state === 'done' &&
|
||||
(!normalizedPrompt || normalizedPrompt === currentEntry.prompt.trim())
|
||||
) {
|
||||
return
|
||||
}
|
||||
currentState.setAgentStatus(
|
||||
session.cacheKey,
|
||||
{
|
||||
state: 'working',
|
||||
prompt: normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''),
|
||||
agentType: 'command-code'
|
||||
},
|
||||
currentTitle,
|
||||
undefined,
|
||||
routing
|
||||
)
|
||||
}
|
||||
|
||||
// Why the settle window lives outside this binding: park unmounts the pane
|
||||
// mid-settle, so a pane-owned timer would be cancelled with nothing left to
|
||||
// complete the turn — the row would stick at 'working'. Only the row write
|
||||
// (routing + title slot) is pane-local; the deadline transfers to whichever
|
||||
// owner (parked watcher or remounted pane) holds the pane next.
|
||||
session.releaseCommandCodeDoneSettleExecutor = setCommandCodeDoneSettleExecutor(
|
||||
session.cacheKey,
|
||||
(normalizedPrompt) => {
|
||||
const routing = session.resolveCurrentAgentStatusRouting()
|
||||
if (!routing) {
|
||||
return
|
||||
}
|
||||
const currentState = useAppStore.getState()
|
||||
const currentEntry = currentState.agentStatusByPaneKey[session.cacheKey]
|
||||
if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') {
|
||||
return
|
||||
}
|
||||
const currentPrompt = currentEntry.prompt.trim()
|
||||
if (currentPrompt && currentPrompt !== normalizedPrompt) {
|
||||
return
|
||||
}
|
||||
const currentTitle =
|
||||
currentState.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id]
|
||||
currentState.setAgentStatus(
|
||||
session.cacheKey,
|
||||
{
|
||||
state: 'done',
|
||||
prompt: currentPrompt || normalizedPrompt,
|
||||
agentType: 'command-code'
|
||||
},
|
||||
currentTitle,
|
||||
undefined,
|
||||
routing
|
||||
)
|
||||
}
|
||||
)
|
||||
session.clearCommandCodeOutputDoneTimer = (): void =>
|
||||
cancelCommandCodeDoneSettle(session.cacheKey)
|
||||
session.scheduleCommandCodeOutputDoneStatus = (prompt: string): void => {
|
||||
if (!session.canApplyCommandCodeOutputStatus()) {
|
||||
return
|
||||
}
|
||||
const normalizedPrompt = prompt.trim()
|
||||
if (!normalizedPrompt) {
|
||||
cancelCommandCodeDoneSettle(session.cacheKey)
|
||||
return
|
||||
}
|
||||
// Why: Command Code keeps rendering the composer while tools run. Only
|
||||
// complete the row if no active status repaint arrives during this window.
|
||||
openCommandCodeDoneSettle(session.cacheKey, normalizedPrompt)
|
||||
}
|
||||
|
||||
installPanePtyVisibilityBind(session)
|
||||
}
|
||||
+123
@@ -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
|
||||
}
|
||||
+119
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user