From ee0486af6839ec584d465079fa54bd292e0ba4dc Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 01:21:08 -0700 Subject: [PATCH 1/3] docs: record opencode hidden restore investigation --- ...-13-coding-cli-session-restore-research.md | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md b/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md index 9970214aa..9945956a7 100644 --- a/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md +++ b/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md @@ -8,7 +8,7 @@ This is the primary research record for how Freshell should identify, persist, a | --- | --- | --- | --- | --- | | Codex | The rollout-backed root TUI `ThreadId` after the exact provider-reported `.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` file exists and starts with matching `session_meta`. | Fresh `codex --remote` creates a thread before user work; Freshell can capture that pre-durable candidate after installing listeners, then promote only after the exact rollout file proves the same root TUI `ThreadId`. `turn/completed` is the required proof-check boundary, not proof itself. | Pre-creating an app-server thread and launching the TUI with `codex resume ` before the rollout file exists fails with `no rollout found for thread id`. Cwd, time, title, shell snapshot, and bare pre-durable thread id are not durable restore identity. If proof fails after `turn/completed`, Freshell must show a degraded/error state and use only deterministic one-shot repair triggers. | Full long-idle and restart behavior still needs product-level coverage, but the identity contract is known. | | Claude Code | The UUID-backed transcript file under `.claude/projects/*/.jsonl`. | `--session-id ` creates a durable transcript, and `--resume ` restores it. | Titles and names are mutable metadata only. The old title stops resolving after rename. | The proof covers print-mode session creation/resume/rename; broader interactive TUI edge cases are not the source of truth here. | -| OpenCode | The authoritative root `sessionID` from JSON events, the DB row, and `/session/status` after resolving child ids through `parentID` or `parent_id`. | JSON `step_start` session id matches the DB session id; `/session/status` reports authoritative status ids while attached; OpenCode 1.15.3 exposes root-child metadata through events and the DB. | Titles are metadata and do not replace session identity. A flat `/session/status` id is not enough in multi-session TUI state; child ids must be collapsed to their root before ownership classification. No rename subcommand was present in the tested mode. | Long-idle behavior still needs product-level coverage. | +| OpenCode | The authoritative root `sessionID` from JSON events, the DB row, and `/session/status` after resolving child ids through `parentID` or `parent_id`. | JSON `step_start` session id matches the DB session id; `/session/status` reports authoritative status ids while attached; OpenCode 1.15.3 exposes root-child metadata through events and the DB; `opencode --session ` can launch a TUI for the chosen session. | Titles are metadata and do not replace session identity. A flat `/session/status` id is not enough in multi-session TUI state; child ids must be collapsed to their root before ownership classification. Hidden restored OpenCode PTYs must not be started without a live browser terminal attachment or another terminal-emulation owner, because OpenCode's UI is terminal-rendered and has no HTTP framebuffer/snapshot endpoint. No rename subcommand was present in the tested mode. | Full long-idle behavior, an atomic create-and-attach protocol, and server-side terminal emulation/snapshotting remain product-level work. | ## Freshell rules @@ -21,6 +21,8 @@ This is the primary research record for how Freshell should identify, persist, a - For OpenCode, promote only from authoritative provider surfaces: JSON events, the DB/session row, or `/session/status`. - For OpenCode, `/session/status` is a flat process status map, not a root-session map. Freshell must collapse every observed OpenCode session id to its root by using `session.created` or `session.updated` `info.parentID`, or `opencode.db` `session.parent_id`, before classifying ownership or emitting ambiguity. The identity-mapping resolver is valid only for a proven flat schema or a test harness. - For OpenCode, `opencode --session ` proves the process was asked to open an existing session; it does not prove that children disappear from `/session/status`, and it does not prove pane attachment. Restart coverage must assert the restored process, the pane's attached terminal id, and client attachment state. +- For OpenCode TUI panes, visual restore is terminal-state restore. The OpenCode HTTP API can provide session metadata, messages, status, events, and control commands, but no canonical TUI framebuffer was found in OpenCode 1.15.3. Freshell must either start the TUI only when a terminal attachment can own startup control exchanges and replay from seq 1, or add a server-side terminal emulator/snapshot owner. +- For OpenCode, Ctrl-L, resize nudges, delayed redraws, and larger replay budgets are not restore contracts. A replay gap during OpenCode viewport hydration is a restore failure unless Freshell has another authoritative terminal-state snapshot. - Cleanup for probes must never stop real user sessions; only processes tagged with the current temp root and sentinel are safe to stop. ## Implementation learnings from dev integration @@ -36,10 +38,12 @@ The `2026-05-15` squash integration into `/home/user/code/freshell/.worktrees/de - The `2026-05-16` OpenCode restart failure was a production wiring regression, not a disproof of the root-session design. Commit `dc570273` passed `resolveOpencodeSessionRoots` from `server/index.ts` into `wireOpencodeActivityTracker`; commit `36528c75` reconstructed the tracker as `wireOpencodeActivityTracker({ registry })`, dropping the resolver. Current source therefore falls back to the identity-map resolver at `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/server/coding-cli/opencode-activity-tracker.ts:192`, even though the provider root resolver exists at `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/server/coding-cli/providers/opencode.ts:190` and the wiring accepts it at `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/server/coding-cli/opencode-activity-wiring.ts:37`. - The same restart exposed a client boot ordering bug. `/home/user/.freshell/logs/20260516-1446-01-server-debug.production.3001.jsonl:19188` and `/home/user/.freshell/logs/session-lifecycle.production.3001.jsonl:2737` show an attach to stale OpenCode terminal `dPs6gWkZF9-Iq31FZYwMQ`; only after that did `/home/user/.freshell/logs/session-lifecycle.production.3001.jsonl:2740`, `:2744`, and `:2748` request restored OpenCode creates. The stale attach path in `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/src/components/TerminalView.tsx:2324` can run before inventory cleanup in `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/src/store/panesSlice.ts:1579` finishes clearing old terminal ids. - The `terminal_exit_without_durable_session` warnings at `/home/user/.freshell/logs/session-lifecycle.production.3001.jsonl:2734` through `:2736` were observability noise for this restart, not proof that the OpenCode panes lacked session refs before shutdown. `TerminalRegistry.kill()` releases the binding at `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/server/terminal-registry.ts:2790`, `releaseBinding` clears `resumeSessionId` at `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/server/terminal-registry.ts:3007`, and the warning predicate checks `resumeSessionId` afterward at `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/server/terminal-registry.ts:1231`. +- The `2026-05-17` restart failure had a different mechanism from the `2026-05-16` root-resolution regression. The named OpenCode session `ses_1d0ba9968ffeNn5tFfCoX55KmM` and four other restored OpenCode sessions were requested, bound, created, and still running as `opencode --hostname 127.0.0.1 --port --session ` processes. The visible failure was terminal hydration: hidden restored panes started PTYs, did not attach a browser terminal stream on `terminal.created`, and later `viewport_hydrate` attach attempts requested `sinceSeq: 0` after the replay-ring prefix had been evicted. +- Current `/home/user/code/freshell/.worktrees/dev` source confirms that hidden restored panes with no persisted `terminalId` still send `terminal.create`, while the hidden `terminal.created` branch stores the new `terminalId` and defers `terminal.attach`. A focused red test in `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517/test/e2e/opencode-startup-probes.test.tsx` failed before the prototype because hidden restored OpenCode sent no `terminal.attach`; the prototype commit `ddba8fdfc9f03010075203ff4f631de670203251` made it green by attaching immediately after `terminal.created`. A second red/green prototype in `/home/user/code/freshell/.worktrees/opencode-defer-probe` commit `01a98383` proved the alternative policy: do not start hidden restored OpenCode PTYs until the pane becomes visible. ## Scope and provenance -The real-binary provider probes were rerun on `2026-04-26` inside `/home/user/code/freshell/.worktrees/trycycle-codex-session-resilience`. Binary version facts were refreshed on `2026-05-03` inside `/home/user/code/freshell/.worktrees/land-local-main-codex-sidecar-lifecycle`; the Claude Code binary version fact was refreshed again on `2026-05-06` inside `/home/user/code/freshell/.worktrees/codex-sidebar-reopen-corner-origin-pr-20260505` after the installed binary changed. A targeted Codex pre-durable resume and identity-capture experiment was run on `2026-05-13` inside `/home/user/code/freshell/.worktrees/dev` using isolated temp roots. OpenCode root-child and TUI restart facts were refreshed on `2026-05-16` against `/home/user/.opencode/bin/opencode` 1.15.3, an isolated temp root under `/tmp/opencode-probe-20260516-144511-994599`, and upstream OpenCode tag `v1.15.3` at commit `37f89b742907c43b20d38b68eabe65981a59690a` in `/tmp/opencode-upstream`. +The real-binary provider probes were rerun on `2026-04-26` inside `/home/user/code/freshell/.worktrees/trycycle-codex-session-resilience`. Binary version facts were refreshed on `2026-05-03` inside `/home/user/code/freshell/.worktrees/land-local-main-codex-sidecar-lifecycle`; the Claude Code binary version fact was refreshed again on `2026-05-06` inside `/home/user/code/freshell/.worktrees/codex-sidebar-reopen-corner-origin-pr-20260505` after the installed binary changed. A targeted Codex pre-durable resume and identity-capture experiment was run on `2026-05-13` inside `/home/user/code/freshell/.worktrees/dev` using isolated temp roots. OpenCode root-child and TUI restart facts were refreshed on `2026-05-16` against `/home/user/.opencode/bin/opencode` 1.15.3, an isolated temp root under `/tmp/opencode-probe-20260516-144511-994599`, and upstream OpenCode tag `v1.15.3` at commit `37f89b742907c43b20d38b68eabe65981a59690a` in `/tmp/opencode-upstream`. The hidden-pane OpenCode terminal hydration failure and two client lifecycle prototypes were studied on `2026-05-17` in `/home/user/code/freshell/.worktrees/dev`, `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517`, and `/home/user/code/freshell/.worktrees/opencode-defer-probe`. The later version-only refreshes did not re-prove the full behavior contract, so `capturedOn` remains `2026-04-26`; the `2026-05-13` experiment is recorded as a narrow Codex addendum. A Codex source-code study was added on `2026-05-13` against the locally installed `@openai/codex` package and the official upstream `openai/codex` tag `rust-v0.130.0`. @@ -270,6 +274,16 @@ The real-provider harness parses the next section. Keep the `## Machine-readable "opencode.db session.parent_id" ], "postRestartAttachStateMustBeVerified": true, + "tuiVisualRestoreSurface": "terminal-state", + "httpTuiFramebufferAvailable": false, + "hiddenRestoreCreateWithoutAttachIsDeterministic": false, + "viewportHydrateSinceSeq": 0, + "viewportHydrateReplayGapIsRestoreFailure": true, + "redrawNudgesAreRestoreContract": false, + "testedHiddenRestorePolicies": [ + "immediate_attach_after_terminal_created", + "defer_create_until_visible" + ], "titleOnResumeMutatesStoredTitle": false, "sessionSubcommands": [ "list", @@ -770,6 +784,36 @@ The `2026-05-16` probe also launched an OpenCode TUI with `opencode --session

` and, for restored panes, `--session ` from canonical `sessionRef`. Current source derives the restore session at `/home/user/code/freshell/.worktrees/dev/server/ws-handler.ts:2317` through `:2327`, appends OpenCode control flags at `/home/user/code/freshell/.worktrees/dev/server/ws-handler.ts:2814` through `:2861`, and builds OpenCode resume args at `/home/user/code/freshell/.worktrees/dev/server/terminal-registry.ts:93` through `:105`. +- In OpenCode 1.15.3, `--hostname` and `--port` put the TUI in external server mode at `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/thread.ts:192`; `--session` is validated before the renderer starts at `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/thread.ts:213` through `:219` and `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/validate-session.ts:14`. +- The TUI creates an OpenTUI renderer and renders Solid components into terminal control frames at `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/app.tsx:185`. A current PTY probe saw `OSC 10;?`, `OSC 11;?`, startup probe traffic, `CSI ?1049h` alternate-screen entry, bracketed paste, mouse modes, and first paint frames. Freshell's frozen startup fixture also captures the OSC 11 query followed by alternate-screen entry at `/home/user/code/freshell/.worktrees/dev/test/helpers/opencode-startup-probes.ts:1`. +- OpenCode's HTTP API exposes session metadata, messages, status, events, and TUI control routes. No canonical framebuffer, screen snapshot, or render-state endpoint was found in `/tmp/opencode-upstream/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts:36`; the `onSnapshot` hook in `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/worker.ts:69` is a V8 heap snapshot hook, not a terminal screen snapshot. +- Therefore Freshell cannot reconstruct an OpenCode TUI from HTTP after terminal startup frames are missed. The current terminal pane model must preserve terminal state through live attachment and replay from startup, or replace it with a server-side terminal emulator/snapshot owner. + +### 2026-05-17 hidden-pane TUI hydration failure classification + +The `2026-05-17` user-visible failure was not that OpenCode sessions failed to resume. The restored processes, session bindings, and OpenCode HTTP sessions existed; the broken part was terminal viewport hydration for hidden restored panes. + +- `/home/user/.freshell/logs/session-lifecycle.production.3001.jsonl:2874` through `:2888` show restored OpenCode creates for five canonical session ids, including `ses_1d0ba9968ffeNn5tFfCoX55KmM`, followed by `terminal_session_bound` and `terminal_created` for each new terminal id. +- The process table after the restart showed those terminals still running as child processes of the Freshell server, for example `opencode --hostname 127.0.0.1 --port 33995 --session ses_1d0ba9968ffeNn5tFfCoX55KmM`. +- `curl http://127.0.0.1:33995/session` returned the restored OpenCode session with title `Yente reverse port to nanoclaw investigation`, proving the provider process and canonical session were alive. +- The failure signal was `terminal_stream_replay_miss` followed by `terminal_stream_gap` on later `viewport_hydrate` attaches. Examples include `/home/user/.freshell/logs/20260517-0031-01-server-debug.production.3001.jsonl:2843` through `:2844` for restored terminal `V7SooJOfFJjuZmtWTc-1M` with `sinceSeq: 0`, missed seq `1-465`, and `reason: "replay_window_exceeded"`; `:4711` through `:4712` for `itWOwjSsP3-5uZNu9CK4c` with missed seq `1-59862`; and `:9612` through `:9613` for `fp3MKIy0ajGwUIv3k_Q1-` with missed seq `1-20848`. +- Current source explains the failure. Persisted panes are all mounted, including hidden tabs, through `/home/user/code/freshell/.worktrees/dev/src/App.tsx:1224` through `:1226`; hidden terminal panes still send `terminal.create` when they have no `terminalId` at `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:2527` through `:2534`; but the hidden `terminal.created` handler stores the new terminal id and defers attach at `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:2084` through `:2149`. Later `viewport_hydrate` attaches send `sinceSeq: 0` at `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:1629` through `:1632`. +- Server replay is ordered and bounded, not full-history. The replay ring evicts by byte budget at `/home/user/code/freshell/.worktrees/dev/server/terminal-stream/replay-ring.ts:43` through `:78`, and the broker emits `replay_window_exceeded` when `sinceSeq` is older than the retained tail at `/home/user/code/freshell/.worktrees/dev/server/terminal-stream/broker.ts:201` through `:235`. This makes late OpenCode viewport hydration unrecoverable without another terminal-state snapshot. + +Two focused client lifecycle policies were tested against this failure: + +| Policy | Test proof | Product tradeoff | +| --- | --- | --- | +| Immediate hidden attach after `terminal.created` | In `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517`, the red test `attaches hidden restored OpenCode terminals immediately after create` failed before the prototype because no `terminal.attach` was sent. Commit `ddba8fdfc9f03010075203ff4f631de670203251` made the OpenCode startup-probe suite pass by attaching immediately and processing startup probes live. | Preserves the current "prewarm restored panes" behavior, but it is still create-then-attach rather than a formally atomic create-and-attach protocol. | +| Defer hidden restored OpenCode create until visible | In `/home/user/code/freshell/.worktrees/opencode-defer-probe`, the red test `defers hidden restored OpenCode process creation` failed before the prototype because hidden restored OpenCode sent `terminal.create`. Commit `01a98383` made the focused lifecycle suite pass by keeping the restore request unconsumed until reveal. | Deterministically removes hidden-output-before-attach. Hidden restored OpenCode panes become queued restores, not live background terminals, until clicked or otherwise made visible. | + +The tested production recommendation is the defer-create policy for hidden restored OpenCode panes, combined with normal immediate visible create/attach. It matches the requirement that old OpenCode sessions remain listed and resumable when clicked, and avoids pretending a hidden OpenCode TUI can be deterministically reconstructed from replay after missing startup terminal state. If Freshell later wants background live OpenCode restores, the next architecture should be an explicit atomic create-and-attach protocol or server-side terminal emulator/snapshot support, not redraw nudges. + ### 2026-05-16 dev restart failure classification The research premise was incomplete but correct: OpenCode has authoritative root-child metadata, and Freshell must use it. The PR did not fail because OpenCode lacks parent metadata. It failed in current `/home/user/code/freshell/.worktrees/dev` because the root resolver was not wired into the running tracker after integration, and because the client can attempt a stale live-terminal attach before restored creation finishes. @@ -815,4 +859,7 @@ Observed title behavior: - Busy or restore state may only be promoted from the control surface or canonical DB/session events after child ids have been resolved to roots. - `/session/status` alone is insufficient in TUI state because it is a flat status map with no parent metadata. - `opencode --session ` is necessary for restored launch but insufficient as the whole Freshell restore proof; Freshell must verify that the pane attached to the new terminal and that the activity tracker resolved child statuses to the same root. +- Hidden restored OpenCode panes should not start a PTY until visible unless Freshell also creates a live terminal attachment or server-side terminal emulator before OpenCode can emit startup control frames. +- A replay gap during OpenCode `viewport_hydrate` is a visible restore failure, not a condition to repair with Ctrl-L, resize, redraw delay, or a larger replay cap. +- OpenCode HTTP can support a native session browser or timeline UI, but it cannot reconstruct the terminal TUI screen in the tested 1.15.3 surface. - Titles are metadata and do not replace session identity. From d4c82fecff0e503543a5eef97e05a9a24c9edbad Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 13:26:43 -0700 Subject: [PATCH 2/3] fix: recover opencode hydrate replay gaps --- ...-13-coding-cli-session-restore-research.md | 12 +- src/components/TerminalView.tsx | 109 ++++++++++++++++++ .../TerminalView.lifecycle.test.tsx | 104 +++++++++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) diff --git a/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md b/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md index 9945956a7..4911e363a 100644 --- a/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md +++ b/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md @@ -23,6 +23,7 @@ This is the primary research record for how Freshell should identify, persist, a - For OpenCode, `opencode --session ` proves the process was asked to open an existing session; it does not prove that children disappear from `/session/status`, and it does not prove pane attachment. Restart coverage must assert the restored process, the pane's attached terminal id, and client attachment state. - For OpenCode TUI panes, visual restore is terminal-state restore. The OpenCode HTTP API can provide session metadata, messages, status, events, and control commands, but no canonical TUI framebuffer was found in OpenCode 1.15.3. Freshell must either start the TUI only when a terminal attachment can own startup control exchanges and replay from seq 1, or add a server-side terminal emulator/snapshot owner. - For OpenCode, Ctrl-L, resize nudges, delayed redraws, and larger replay budgets are not restore contracts. A replay gap during OpenCode viewport hydration is a restore failure unless Freshell has another authoritative terminal-state snapshot. +- For OpenCode panes already in the bad state, focus/activation can make the pane visible and still fail to reappear. The production logs show visible `viewport_hydrate` attaches with `sinceSeq: 0` followed by `replay_window_exceeded`; the deterministic repair is to retire the stale PTY, wait for `terminal.exit` or invalid-terminal confirmation, and then issue a restored `terminal.create` from the canonical OpenCode `sessionRef`. - Cleanup for probes must never stop real user sessions; only processes tagged with the current temp root and sentinel are safe to stop. ## Implementation learnings from dev integration @@ -40,10 +41,11 @@ The `2026-05-15` squash integration into `/home/user/code/freshell/.worktrees/de - The `terminal_exit_without_durable_session` warnings at `/home/user/.freshell/logs/session-lifecycle.production.3001.jsonl:2734` through `:2736` were observability noise for this restart, not proof that the OpenCode panes lacked session refs before shutdown. `TerminalRegistry.kill()` releases the binding at `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/server/terminal-registry.ts:2790`, `releaseBinding` clears `resumeSessionId` at `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/server/terminal-registry.ts:3007`, and the warning predicate checks `resumeSessionId` afterward at `/home/user/code/freshell/.worktrees/opencode-resilience-postmortem-20260516/server/terminal-registry.ts:1231`. - The `2026-05-17` restart failure had a different mechanism from the `2026-05-16` root-resolution regression. The named OpenCode session `ses_1d0ba9968ffeNn5tFfCoX55KmM` and four other restored OpenCode sessions were requested, bound, created, and still running as `opencode --hostname 127.0.0.1 --port --session ` processes. The visible failure was terminal hydration: hidden restored panes started PTYs, did not attach a browser terminal stream on `terminal.created`, and later `viewport_hydrate` attach attempts requested `sinceSeq: 0` after the replay-ring prefix had been evicted. - Current `/home/user/code/freshell/.worktrees/dev` source confirms that hidden restored panes with no persisted `terminalId` still send `terminal.create`, while the hidden `terminal.created` branch stores the new `terminalId` and defers `terminal.attach`. A focused red test in `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517/test/e2e/opencode-startup-probes.test.tsx` failed before the prototype because hidden restored OpenCode sent no `terminal.attach`; the prototype commit `ddba8fdfc9f03010075203ff4f631de670203251` made it green by attaching immediately after `terminal.created`. A second red/green prototype in `/home/user/code/freshell/.worktrees/opencode-defer-probe` commit `01a98383` proved the alternative policy: do not start hidden restored OpenCode PTYs until the pane becomes visible. +- A follow-up focus/activation investigation corrected the missing acceptance case. `/home/user/.freshell/logs/20260517-0031-01-server-debug.production.3001.jsonl:11362` through `:11364` show focused attach for `V7SooJOfFJjuZmtWTc-1M` with `sinceSeq: 0` followed by `replay_window_exceeded` for missed seq `1-42262`; `:11373` through `:11375` show the same failure for `itWOwjSsP3-5uZNu9CK4c` with missed seq `1-83908`. The red test `recreates a restored OpenCode pane when visible viewport hydration cannot replay startup output` in `/home/user/code/freshell/.worktrees/opencode-focus-activation-repro-20260517/test/unit/client/components/TerminalView.lifecycle.test.tsx` failed before the fix because the pane kept the stale terminal id. It now proves the deterministic repair: send `terminal.kill`, wait for `terminal.exit`, clear the live handle, and create a restored OpenCode terminal with the same canonical `sessionRef`. ## Scope and provenance -The real-binary provider probes were rerun on `2026-04-26` inside `/home/user/code/freshell/.worktrees/trycycle-codex-session-resilience`. Binary version facts were refreshed on `2026-05-03` inside `/home/user/code/freshell/.worktrees/land-local-main-codex-sidecar-lifecycle`; the Claude Code binary version fact was refreshed again on `2026-05-06` inside `/home/user/code/freshell/.worktrees/codex-sidebar-reopen-corner-origin-pr-20260505` after the installed binary changed. A targeted Codex pre-durable resume and identity-capture experiment was run on `2026-05-13` inside `/home/user/code/freshell/.worktrees/dev` using isolated temp roots. OpenCode root-child and TUI restart facts were refreshed on `2026-05-16` against `/home/user/.opencode/bin/opencode` 1.15.3, an isolated temp root under `/tmp/opencode-probe-20260516-144511-994599`, and upstream OpenCode tag `v1.15.3` at commit `37f89b742907c43b20d38b68eabe65981a59690a` in `/tmp/opencode-upstream`. The hidden-pane OpenCode terminal hydration failure and two client lifecycle prototypes were studied on `2026-05-17` in `/home/user/code/freshell/.worktrees/dev`, `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517`, and `/home/user/code/freshell/.worktrees/opencode-defer-probe`. +The real-binary provider probes were rerun on `2026-04-26` inside `/home/user/code/freshell/.worktrees/trycycle-codex-session-resilience`. Binary version facts were refreshed on `2026-05-03` inside `/home/user/code/freshell/.worktrees/land-local-main-codex-sidecar-lifecycle`; the Claude Code binary version fact was refreshed again on `2026-05-06` inside `/home/user/code/freshell/.worktrees/codex-sidebar-reopen-corner-origin-pr-20260505` after the installed binary changed. A targeted Codex pre-durable resume and identity-capture experiment was run on `2026-05-13` inside `/home/user/code/freshell/.worktrees/dev` using isolated temp roots. OpenCode root-child and TUI restart facts were refreshed on `2026-05-16` against `/home/user/.opencode/bin/opencode` 1.15.3, an isolated temp root under `/tmp/opencode-probe-20260516-144511-994599`, and upstream OpenCode tag `v1.15.3` at commit `37f89b742907c43b20d38b68eabe65981a59690a` in `/tmp/opencode-upstream`. The hidden-pane OpenCode terminal hydration failure, the focus/activation repair case, and client lifecycle prototypes were studied on `2026-05-17` in `/home/user/code/freshell/.worktrees/dev`, `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517`, `/home/user/code/freshell/.worktrees/opencode-defer-probe`, and `/home/user/code/freshell/.worktrees/opencode-focus-activation-repro-20260517`. The later version-only refreshes did not re-prove the full behavior contract, so `capturedOn` remains `2026-04-26`; the `2026-05-13` experiment is recorded as a narrow Codex addendum. A Codex source-code study was added on `2026-05-13` against the locally installed `@openai/codex` package and the official upstream `openai/codex` tag `rust-v0.130.0`. @@ -279,6 +281,9 @@ The real-provider harness parses the next section. Keep the `## Machine-readable "hiddenRestoreCreateWithoutAttachIsDeterministic": false, "viewportHydrateSinceSeq": 0, "viewportHydrateReplayGapIsRestoreFailure": true, + "visibleViewportReplayGapRepairPolicy": "kill_old_terminal_then_restore_create_after_exit", + "visibleViewportReplayGapRepairRequiresSessionRef": true, + "visibleViewportReplayGapRepairTestedOn": "2026-05-17", "redrawNudgesAreRestoreContract": false, "testedHiddenRestorePolicies": [ "immediate_attach_after_terminal_created", @@ -802,6 +807,7 @@ The `2026-05-17` user-visible failure was not that OpenCode sessions failed to r - The process table after the restart showed those terminals still running as child processes of the Freshell server, for example `opencode --hostname 127.0.0.1 --port 33995 --session ses_1d0ba9968ffeNn5tFfCoX55KmM`. - `curl http://127.0.0.1:33995/session` returned the restored OpenCode session with title `Yente reverse port to nanoclaw investigation`, proving the provider process and canonical session were alive. - The failure signal was `terminal_stream_replay_miss` followed by `terminal_stream_gap` on later `viewport_hydrate` attaches. Examples include `/home/user/.freshell/logs/20260517-0031-01-server-debug.production.3001.jsonl:2843` through `:2844` for restored terminal `V7SooJOfFJjuZmtWTc-1M` with `sinceSeq: 0`, missed seq `1-465`, and `reason: "replay_window_exceeded"`; `:4711` through `:4712` for `itWOwjSsP3-5uZNu9CK4c` with missed seq `1-59862`; and `:9612` through `:9613` for `fp3MKIy0ajGwUIv3k_Q1-` with missed seq `1-20848`. +- Focus/activation was separately checked because a successful process restore is not enough. `/home/user/.freshell/logs/20260517-0031-01-server-debug.production.3001.jsonl:11362` through `:11364` show the focused attach for `V7SooJOfFJjuZmtWTc-1M` still requesting `sinceSeq: 0` and receiving `replay_window_exceeded` for missed seq `1-42262`; `:11373` through `:11375` show the same focus-time failure for `itWOwjSsP3-5uZNu9CK4c` with missed seq `1-83908`. - Current source explains the failure. Persisted panes are all mounted, including hidden tabs, through `/home/user/code/freshell/.worktrees/dev/src/App.tsx:1224` through `:1226`; hidden terminal panes still send `terminal.create` when they have no `terminalId` at `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:2527` through `:2534`; but the hidden `terminal.created` handler stores the new terminal id and defers attach at `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:2084` through `:2149`. Later `viewport_hydrate` attaches send `sinceSeq: 0` at `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:1629` through `:1632`. - Server replay is ordered and bounded, not full-history. The replay ring evicts by byte budget at `/home/user/code/freshell/.worktrees/dev/server/terminal-stream/replay-ring.ts:43` through `:78`, and the broker emits `replay_window_exceeded` when `sinceSeq` is older than the retained tail at `/home/user/code/freshell/.worktrees/dev/server/terminal-stream/broker.ts:201` through `:235`. This makes late OpenCode viewport hydration unrecoverable without another terminal-state snapshot. @@ -811,8 +817,9 @@ Two focused client lifecycle policies were tested against this failure: | --- | --- | --- | | Immediate hidden attach after `terminal.created` | In `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517`, the red test `attaches hidden restored OpenCode terminals immediately after create` failed before the prototype because no `terminal.attach` was sent. Commit `ddba8fdfc9f03010075203ff4f631de670203251` made the OpenCode startup-probe suite pass by attaching immediately and processing startup probes live. | Preserves the current "prewarm restored panes" behavior, but it is still create-then-attach rather than a formally atomic create-and-attach protocol. | | Defer hidden restored OpenCode create until visible | In `/home/user/code/freshell/.worktrees/opencode-defer-probe`, the red test `defers hidden restored OpenCode process creation` failed before the prototype because hidden restored OpenCode sent `terminal.create`. Commit `01a98383` made the focused lifecycle suite pass by keeping the restore request unconsumed until reveal. | Deterministically removes hidden-output-before-attach. Hidden restored OpenCode panes become queued restores, not live background terminals, until clicked or otherwise made visible. | +| Visible replay-gap replacement for already-broken panes | In `/home/user/code/freshell/.worktrees/opencode-focus-activation-repro-20260517`, the red test `recreates a restored OpenCode pane when visible viewport hydration cannot replay startup output` failed before the implementation because the pane kept the stale `terminalId` after `replay_window_exceeded`. The green path sends `terminal.kill`, waits for `terminal.exit` or invalid-terminal confirmation, clears the stale live handle, and sends a restored `terminal.create` with the same OpenCode `sessionRef`. | Repairs panes that already reached the bad hidden-created state. It is not a replacement for prevention; it deliberately retires the stale OpenCode PTY so the server will not reuse the same canonical running terminal. | -The tested production recommendation is the defer-create policy for hidden restored OpenCode panes, combined with normal immediate visible create/attach. It matches the requirement that old OpenCode sessions remain listed and resumable when clicked, and avoids pretending a hidden OpenCode TUI can be deterministically reconstructed from replay after missing startup terminal state. If Freshell later wants background live OpenCode restores, the next architecture should be an explicit atomic create-and-attach protocol or server-side terminal emulator/snapshot support, not redraw nudges. +The tested production recommendation is the defer-create policy for future hidden restored OpenCode panes, plus visible replay-gap replacement for panes already stuck with a stale live handle. This matches the requirement that old OpenCode sessions remain listed and resumable when clicked, and avoids pretending a hidden OpenCode TUI can be deterministically reconstructed from replay after missing startup terminal state. If Freshell later wants background live OpenCode restores, the next architecture should be an explicit atomic create-and-attach protocol or server-side terminal emulator/snapshot support, not redraw nudges. ### 2026-05-16 dev restart failure classification @@ -861,5 +868,6 @@ Observed title behavior: - `opencode --session ` is necessary for restored launch but insufficient as the whole Freshell restore proof; Freshell must verify that the pane attached to the new terminal and that the activity tracker resolved child statuses to the same root. - Hidden restored OpenCode panes should not start a PTY until visible unless Freshell also creates a live terminal attachment or server-side terminal emulator before OpenCode can emit startup control frames. - A replay gap during OpenCode `viewport_hydrate` is a visible restore failure, not a condition to repair with Ctrl-L, resize, redraw delay, or a larger replay cap. +- If a restored OpenCode pane is visible and hits `replay_window_exceeded` during `viewport_hydrate` from seq 0, the stale PTY must be retired before reissuing a restored create. Otherwise the server can legally reuse the same canonical running terminal and reproduce the blank pane. - OpenCode HTTP can support a native session browser or timeline UI, but it cannot reconstruct the terminal TUI screen in the tested 1.15.3 surface. - Titles are metadata and do not replace session identity. diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index ca25639bb..1e757a964 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -252,6 +252,12 @@ type LaunchAttemptState = { attachReady: boolean } +type PendingDurableReplacement = { + terminalId: string + requestId: string + reason: 'opencode_replay_window_exceeded' +} + type SentViewport = { terminalId: string cols: number @@ -461,6 +467,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) requestId: string terminalId: string } | null>(null) + const pendingDurableReplacementRef = useRef(null) const serverInstanceIdRef = useRef(serverInstanceId) const searchTerminalIdCleanupRef = useRef(terminalContent?.terminalId ?? null) const deferredAttachStateRef = useRef({ @@ -1876,6 +1883,84 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) return true } + const completeDurableReplacement = (pending: PendingDurableReplacement) => { + if (pendingDurableReplacementRef.current?.requestId !== pending.requestId) { + return + } + pendingDurableReplacementRef.current = null + addTerminalRestoreRequestId(pending.requestId) + requestIdRef.current = pending.requestId + terminalIdRef.current = undefined + launchAttemptRef.current = null + currentAttachRef.current = null + deferredAttachStateRef.current = { + mode: 'none', + pendingIntent: null, + pendingSinceSeq: 0, + } + setIsAttaching(false) + setTruncatedHistoryGap(null) + dispatch(clearPaneRuntimeActivity({ paneId: paneIdRef.current })) + applySeqState(createAttachSeqState()) + updateContent({ + terminalId: undefined, + serverInstanceId: undefined, + createRequestId: pending.requestId, + status: 'creating', + restoreError: undefined, + }) + const currentTab = tabRef.current + if (currentTab) { + dispatch(updateTab({ id: currentTab.id, updates: { status: 'creating' } })) + } + } + + const beginOpenCodeReplacementAfterExit = (terminalId: string) => { + const current = contentRef.current + const sessionRef = current?.sessionRef + if ( + current?.mode !== 'opencode' + || sessionRef?.provider !== 'opencode' + || !sessionRef.sessionId + ) { + return false + } + + const existing = pendingDurableReplacementRef.current + if (existing?.terminalId === terminalId) { + return true + } + + const requestId = nanoid() + pendingDurableReplacementRef.current = { + terminalId, + requestId, + reason: 'opencode_replay_window_exceeded', + } + clearRateLimitRetry() + currentAttachRef.current = null + launchAttemptRef.current = null + deferredAttachStateRef.current = { + mode: 'none', + pendingIntent: null, + pendingSinceSeq: 0, + } + setIsAttaching(true) + setTruncatedHistoryGap(null) + dispatch(clearPaneRuntimeActivity({ paneId: paneIdRef.current })) + clearTerminalCursor(terminalId) + forgetSentViewport(terminalId) + lastSentViewportRef.current = null + applySeqState(createAttachSeqState()) + try { + term.writeln('\r\n[Restarting OpenCode session because the saved terminal replay is no longer available]\r\n') + } catch { + // disposed + } + ws.send({ type: 'terminal.kill', terminalId }) + return true + } + async function ensure() { clearRateLimitRetry() // Connection is owned by App.tsx; messages will queue until ready @@ -2021,6 +2106,16 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) // byte-budget truncation (recoverable), not ring overflow (data gone). const isTruncatedReplay = msg.reason === 'replay_budget_exceeded' && seqStateRef.current.pendingReplay + const isUnrecoverableOpenCodeViewportHydrate = msg.reason === 'replay_window_exceeded' + && currentAttachRef.current?.intent === 'viewport_hydrate' + && currentAttachRef.current.sinceSeq === 0 + && !hiddenRef.current + && contentRef.current?.mode === 'opencode' + && contentRef.current.sessionRef?.provider === 'opencode' + if (isUnrecoverableOpenCodeViewportHydrate && beginOpenCodeReplacementAfterExit(tid)) { + return + } + if (isTruncatedReplay) { setTruncatedHistoryGap({ fromSeq: msg.fromSeq, toSeq: msg.toSeq }) } else { @@ -2164,6 +2259,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) } if (msg.type === 'terminal.exit' && msg.terminalId === tid) { + const pendingReplacement = pendingDurableReplacementRef.current + if (pendingReplacement?.terminalId === tid) { + completeDurableReplacement(pendingReplacement) + return + } + const launchAttempt = launchAttemptRef.current const exitedDuringLaunch = launchAttempt?.terminalId === tid && !launchAttempt.attachReady if (exitedDuringLaunch) { @@ -2337,6 +2438,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const currentTerminalId = terminalIdRef.current const current = contentRef.current const launchAttempt = launchAttemptRef.current + const pendingReplacement = pendingDurableReplacementRef.current if (debugRef.current) log.debug('[TRACE resumeSessionId] INVALID_TERMINAL_ID received', { paneId: paneIdRef.current, msgTerminalId: msg.terminalId, @@ -2344,6 +2446,13 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) currentResumeSessionId: current?.resumeSessionId, currentStatus: current?.status, }) + if ( + pendingReplacement + && (!msg.terminalId || msg.terminalId === pendingReplacement.terminalId) + ) { + completeDurableReplacement(pendingReplacement) + return + } if (msg.terminalId && msg.terminalId !== currentTerminalId) { // Show feedback if the terminal already exited (the ID was cleared by // the exit handler, so msg.terminalId no longer matches the ref) diff --git a/test/unit/client/components/TerminalView.lifecycle.test.tsx b/test/unit/client/components/TerminalView.lifecycle.test.tsx index 955e50494..c3f33cd8f 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -3136,6 +3136,7 @@ describe('TerminalView lifecycle updates', () => { requestId?: string ackInitialAttach?: boolean refreshOnMount?: boolean + sessionRef?: TerminalPaneContent['sessionRef'] }) { const tabId = 'tab-v2-stream' const paneId = 'pane-v2-stream' @@ -3151,6 +3152,7 @@ describe('TerminalView lifecycle updates', () => { mode, shell: 'system', ...(terminalId ? { terminalId } : {}), + ...(opts?.sessionRef ? { sessionRef: opts.sessionRef } : {}), } const root: PaneNode = { type: 'leaf', id: paneId, content: paneContent } @@ -3173,6 +3175,7 @@ describe('TerminalView lifecycle updates', () => { titleSetByUser: false, createRequestId: requestId, ...(terminalId ? { terminalId } : {}), + ...(opts?.sessionRef ? { sessionRef: opts.sessionRef } : {}), }], activeTabId: tabId, }, @@ -4066,6 +4069,107 @@ describe('TerminalView lifecycle updates', () => { expect(attach?.attachRequestId).toBeTruthy() }) + it('recreates a restored OpenCode pane when visible viewport hydration cannot replay startup output', async () => { + const sessionRef = { provider: 'opencode', sessionId: 'ses_focus_replay_gap' } as const + const addedRestoreIds = new Set() + restoreMocks.addTerminalRestoreRequestId.mockImplementation((id: string) => { + addedRestoreIds.add(id) + }) + restoreMocks.consumeTerminalRestoreRequestId.mockImplementation((id: string) => { + if (addedRestoreIds.has(id)) { + addedRestoreIds.delete(id) + return true + } + return false + }) + + const { store, tabId, paneId, terminalId, rerender } = await renderTerminalHarness({ + status: 'running', + terminalId: 'term-opencode-focus-gap', + mode: 'opencode', + hidden: true, + clearSends: false, + requestId: 'req-opencode-focus-gap', + sessionRef, + }) + + wsMocks.send.mockClear() + + rerender( + + , + ) + + let attach: any + await waitFor(() => { + attach = wsMocks.send.mock.calls + .map(([msg]) => msg) + .find((msg) => msg?.type === 'terminal.attach' && msg?.terminalId === terminalId) + expect(attach?.attachRequestId).toBeTruthy() + }) + + act(() => { + messageHandler!({ + type: 'terminal.attach.ready', + terminalId, + headSeq: 120, + replayFromSeq: 42, + replayToSeq: 120, + attachRequestId: attach.attachRequestId, + }) + }) + act(() => { + messageHandler!({ + type: 'terminal.output.gap', + terminalId, + fromSeq: 1, + toSeq: 41, + reason: 'replay_window_exceeded', + attachRequestId: attach.attachRequestId, + } as any) + }) + + await waitFor(() => { + expect(wsMocks.send).toHaveBeenCalledWith({ + type: 'terminal.kill', + terminalId, + }) + }) + + act(() => { + messageHandler!({ + type: 'terminal.exit', + terminalId, + exitCode: 0, + }) + }) + + let replacementRequestId: string | undefined + await waitFor(() => { + const layout = store.getState().panes.layouts[tabId] + expect(layout?.type).toBe('leaf') + if (layout?.type !== 'leaf' || layout.content.kind !== 'terminal') { + throw new Error('expected terminal pane') + } + expect(layout.content.terminalId).toBeUndefined() + expect(layout.content.status).toBe('creating') + expect(layout.content.sessionRef).toEqual(sessionRef) + replacementRequestId = layout.content.createRequestId + expect(replacementRequestId).not.toBe('req-opencode-focus-gap') + }) + + await waitFor(() => { + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.create', + requestId: replacementRequestId, + mode: 'opencode', + sessionRef, + restore: true, + })) + }) + }) + it('does not send terminal.resize when an already-live terminal is hidden and revealed with unchanged geometry', async () => { const { rerender, store, tabId, paneId, terminalId } = await renderTerminalHarness({ status: 'running', From 040bcfbe8b9ce69e62e3fe8b4d23519e50c24c4a Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 13:32:55 -0700 Subject: [PATCH 3/3] chore: remove unused restore fallback import --- src/store/panesSlice.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index c7985d4c3..73388714e 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -8,7 +8,6 @@ import { type PaneContentInput, type PaneNode, type PaneRefreshRequest, - type RestoreFallbackAttempt, } from './paneTypes' import { derivePaneTitle } from '@/lib/derivePaneTitle' import { matchesDerivedPaneTitle } from '@/lib/pane-title'