From 07de81678576936b80c58d343aaf504a0437da5b Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 23:50:57 -0700 Subject: [PATCH 1/7] docs: record opencode hidden restore investigation --- .../2026-04-20-coding-cli-session-contract.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/lab-notes/2026-04-20-coding-cli-session-contract.md b/docs/lab-notes/2026-04-20-coding-cli-session-contract.md index 34123ed2b..c935c7439 100644 --- a/docs/lab-notes/2026-04-20-coding-cli-session-contract.md +++ b/docs/lab-notes/2026-04-20-coding-cli-session-contract.md @@ -102,6 +102,16 @@ The implementation plan file is dated `2026-04-19` because the design work was w "canonicalIdentity": "session-id", "runEventSessionIdMatchesDbId": true, "busyStatusUsesAuthoritativeSessionId": 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", @@ -316,6 +326,30 @@ Observed control behavior: - `/session/status` returned `{}` while idle. - During an attached `opencode run ... --attach http://127.0.0.1:`, `/session/status` returned the same authoritative `sessionID` with `{ "type": "busy" }`. +### 2026-05-17 TUI visual restore addendum + +The 2026-05-17 source pass showed that OpenCode's visible UI is terminal-rendered state, not an HTTP-rendered state Freshell can query after the fact. + +- Freshell launches OpenCode as a PTY process, appends `--hostname 127.0.0.1 --port `, and for restored panes passes `--session ` from the canonical `sessionRef`. +- OpenCode's HTTP API exposes session metadata, messages, status, events, and TUI control routes, but no canonical framebuffer, screen snapshot, or render-state endpoint was found in the tested OpenCode 1.15.3 surface. +- Therefore Freshell cannot reconstruct an OpenCode TUI from HTTP after terminal startup frames are missed. The terminal pane model must either preserve terminal state through live attachment and replay from startup, or add a server-side terminal emulator or snapshot owner. + +The 2026-05-17 restart failure was a terminal viewport hydration failure, not proof that the OpenCode sessions failed to resume. + +- Restored OpenCode creates were requested, bound, created, and still running as `opencode --hostname 127.0.0.1 --port --session ` processes. +- Later visible `viewport_hydrate` attaches requested `sinceSeq: 0` after the replay-ring prefix had already been evicted, producing `terminal_stream_replay_miss` and `terminal_stream_gap` with `reason: "replay_window_exceeded"`. +- Hidden restored panes with no persisted `terminalId` had started PTYs, stored the new terminal id on `terminal.created`, and deferred `terminal.attach`; that ordering allowed OpenCode startup control frames and first paint output to be missed. +- Ctrl-L, resize nudges, delayed redraws, and larger replay budgets are not restore contracts. A replay gap during OpenCode viewport hydration is a visible restore failure unless Freshell has another authoritative terminal-state snapshot. + +Two focused client lifecycle policies were tested against this failure: + +| Policy | Test proof | Product tradeoff | +| --- | --- | --- | +| Immediate hidden attach after `terminal.created` | A focused lifecycle test failed before the prototype because hidden restored OpenCode sent no `terminal.attach`, then passed when the client attached immediately after `terminal.created`. | Preserves prewarmed restored panes, but still depends on create-then-attach rather than a formally atomic create-and-attach protocol. | +| Defer hidden restored OpenCode create until visible | A focused lifecycle test failed before the prototype because hidden restored OpenCode sent `terminal.create`, then passed when the restore request remained 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 production recommendation from the addendum is the defer-create policy for hidden restored OpenCode panes, combined with normal immediate visible create and attach. If Freshell later needs background live OpenCode restores, the next architecture should be an explicit atomic create-and-attach protocol or server-side terminal emulator/snapshot support. + Title semantics were probed with: ```bash @@ -334,4 +368,7 @@ Allowed Freshell behavior: - Canonical OpenCode identity is the authoritative `sessionID`. - Busy or restore state may only be promoted from the control surface or the canonical DB/session events. +- 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 af45323f52bc564472b9ff16ea932b43758c02a6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sun, 17 May 2026 23:53:19 -0700 Subject: [PATCH 2/7] fix: recover opencode hydrate replay gaps --- .../2026-04-20-coding-cli-session-contract.md | 8 +- src/components/TerminalView.tsx | 109 +++++++++++++++++ .../TerminalView.lifecycle.test.tsx | 110 +++++++++++++++++- 3 files changed, 224 insertions(+), 3 deletions(-) diff --git a/docs/lab-notes/2026-04-20-coding-cli-session-contract.md b/docs/lab-notes/2026-04-20-coding-cli-session-contract.md index c935c7439..91daed8cd 100644 --- a/docs/lab-notes/2026-04-20-coding-cli-session-contract.md +++ b/docs/lab-notes/2026-04-20-coding-cli-session-contract.md @@ -107,6 +107,9 @@ The implementation plan file is dated `2026-04-19` because the design work was w "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", @@ -340,6 +343,7 @@ The 2026-05-17 restart failure was a terminal viewport hydration failure, not pr - Later visible `viewport_hydrate` attaches requested `sinceSeq: 0` after the replay-ring prefix had already been evicted, producing `terminal_stream_replay_miss` and `terminal_stream_gap` with `reason: "replay_window_exceeded"`. - Hidden restored panes with no persisted `terminalId` had started PTYs, stored the new terminal id on `terminal.created`, and deferred `terminal.attach`; that ordering allowed OpenCode startup control frames and first paint output to be missed. - Ctrl-L, resize nudges, delayed redraws, and larger replay budgets are not restore contracts. A replay gap during OpenCode viewport hydration is a visible restore failure unless Freshell has another authoritative terminal-state snapshot. +- For OpenCode panes already in this bad state, focus or activation can make the pane visible and still fail to reappear. The deterministic repair is to retire the stale PTY, wait for `terminal.exit` or invalid-terminal confirmation, then issue a restored `terminal.create` from the canonical OpenCode `sessionRef`. Two focused client lifecycle policies were tested against this failure: @@ -347,8 +351,9 @@ Two focused client lifecycle policies were tested against this failure: | --- | --- | --- | | Immediate hidden attach after `terminal.created` | A focused lifecycle test failed before the prototype because hidden restored OpenCode sent no `terminal.attach`, then passed when the client attached immediately after `terminal.created`. | Preserves prewarmed restored panes, but still depends on create-then-attach rather than a formally atomic create-and-attach protocol. | | Defer hidden restored OpenCode create until visible | A focused lifecycle test failed before the prototype because hidden restored OpenCode sent `terminal.create`, then passed when the restore request remained 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 | A focused lifecycle test failed before the implementation because the pane kept the stale `terminalId` after `replay_window_exceeded`, then passed when the client killed the stale terminal, cleared the live handle, and sent a restored `terminal.create` with the same OpenCode `sessionRef`. | Repairs panes that already reached the hidden-created bad state. It is not a substitute for preventing hidden output before attach. | -The production recommendation from the addendum is the defer-create policy for hidden restored OpenCode panes, combined with normal immediate visible create and attach. If Freshell later needs background live OpenCode restores, the next architecture should be an explicit atomic create-and-attach protocol or server-side terminal emulator/snapshot support. +The production recommendation from the addendum 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. If Freshell later needs background live OpenCode restores, the next architecture should be an explicit atomic create-and-attach protocol or server-side terminal emulator/snapshot support. Title semantics were probed with: @@ -370,5 +375,6 @@ Allowed Freshell behavior: - Busy or restore state may only be promoted from the control surface or the canonical DB/session events. - 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 def853dc9..9acab1683 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -213,6 +213,12 @@ type LaunchAttemptState = { attachReady: boolean } +type PendingDurableReplacement = { + terminalId: string + requestId: string + reason: 'opencode_replay_window_exceeded' +} + type SentViewport = { terminalId: string cols: number @@ -416,6 +422,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) requestId: string terminalId: string } | null>(null) + const pendingDurableReplacementRef = useRef(null) const searchTerminalIdCleanupRef = useRef(terminalContent?.terminalId ?? null) const deferredAttachStateRef = useRef({ mode: 'none', @@ -1795,6 +1802,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 @@ -1940,6 +2025,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 { @@ -2068,6 +2163,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) { @@ -2181,6 +2282,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, @@ -2188,6 +2290,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 572373112..3997d8d20 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -3096,20 +3096,24 @@ describe('TerminalView lifecycle updates', () => { requestId?: string ackInitialAttach?: boolean refreshOnMount?: boolean + mode?: TerminalPaneContent['mode'] + sessionRef?: TerminalPaneContent['sessionRef'] }) { const tabId = 'tab-v2-stream' const paneId = 'pane-v2-stream' const requestId = opts?.requestId ?? 'req-v2-stream' const initialStatus = opts?.status ?? 'running' const terminalId = opts?.terminalId + const mode = opts?.mode ?? 'shell' const paneContent: TerminalPaneContent = { kind: 'terminal', createRequestId: requestId, status: initialStatus, - mode: 'shell', + mode, shell: 'system', ...(terminalId ? { terminalId } : {}), + ...(opts?.sessionRef ? { sessionRef: opts.sessionRef } : {}), } const root: PaneNode = { type: 'leaf', id: paneId, content: paneContent } @@ -3126,12 +3130,13 @@ describe('TerminalView lifecycle updates', () => { tabs: { tabs: [{ id: tabId, - mode: 'shell', + mode, status: initialStatus, title: 'Shell', titleSetByUser: false, createRequestId: requestId, ...(terminalId ? { terminalId } : {}), + ...(opts?.sessionRef ? { sessionRef: opts.sessionRef } : {}), }], activeTabId: tabId, }, @@ -4004,6 +4009,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 e6c2cf33dfa1dfbc2eac79995765189ee0064dad Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 16 May 2026 11:32:06 -0700 Subject: [PATCH 3/7] test: use canonical session ids in focus flows (cherry picked from commit 035a694989189f95e521a2cab297e9a52eae746b) --- test/e2e/sidebar-click-opens-pane.test.tsx | 2 +- test/unit/server/ws-sdk-session-history-cache.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/e2e/sidebar-click-opens-pane.test.tsx b/test/e2e/sidebar-click-opens-pane.test.tsx index b246f31da..abf8dc8f7 100644 --- a/test/e2e/sidebar-click-opens-pane.test.tsx +++ b/test/e2e/sidebar-click-opens-pane.test.tsx @@ -62,7 +62,7 @@ vi.mock('@/lib/api', async () => { const sessionId = (label: string) => { const chars = Array.from(label).map((ch, idx) => ((ch.charCodeAt(0) + idx) % 16).toString(16)) const hex = chars.join('').padEnd(32, '0').slice(0, 32) - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}` } function createStore(options: { diff --git a/test/unit/server/ws-sdk-session-history-cache.test.ts b/test/unit/server/ws-sdk-session-history-cache.test.ts index 9d6e46c1d..b7fe32eb9 100644 --- a/test/unit/server/ws-sdk-session-history-cache.test.ts +++ b/test/unit/server/ws-sdk-session-history-cache.test.ts @@ -377,7 +377,7 @@ describe('WsHandler agent history source DI', () => { messages: [], model: 'claude-sonnet-4-20250514', cwd: '/tmp', - resumeSessionId: '01234567-89ab-cdef-0123-456789abcdef', + resumeSessionId: '01234567-89ab-4def-8123-456789abcdef', streamingActive: false, streamingText: '', pendingPermissions: new Map(), @@ -394,7 +394,7 @@ describe('WsHandler agent history source DI', () => { messages: [], model: 'claude-sonnet-4-20250514', cwd: '/tmp', - resumeSessionId: '01234567-89ab-cdef-0123-456789abcdef', + resumeSessionId: '01234567-89ab-4def-8123-456789abcdef', streamingActive: false, streamingText: '', })), @@ -423,12 +423,12 @@ describe('WsHandler agent history source DI', () => { type: 'sdk.create', requestId: 'req-module', cwd: '/tmp', - resumeSessionId: '01234567-89ab-cdef-0123-456789abcdef', + resumeSessionId: '01234567-89ab-4def-8123-456789abcdef', })) await waitForMessage(ws, (d) => d.type === 'sdk.session.snapshot') - expect(moduleLoadSessionHistoryMock).toHaveBeenCalledWith('01234567-89ab-cdef-0123-456789abcdef') + expect(moduleLoadSessionHistoryMock).toHaveBeenCalledWith('01234567-89ab-4def-8123-456789abcdef') ws.close() }) From c57a9c33630984bad62ef200b74e953cdb70927e Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 16 May 2026 21:41:20 -0700 Subject: [PATCH 4/7] fix: preserve opencode resume model identity (cherry picked from commit ec0217804584a87214836464fa03a707f8db124c) --- scripts/run-standard-tests.ts | 2 ++ server/terminal-registry.ts | 4 ++- .../real-session-contract-harness.ts | 18 +++++++++-- .../real/coding-cli-session-contract.test.ts | 4 +-- test/unit/server/run-standard-tests.test.ts | 15 +++++++++ test/unit/server/terminal-registry.test.ts | 32 +++++++++++++++++++ test/unit/vite-config.test.ts | 12 +++++-- vitest.config.ts | 1 + vitest.server.config.ts | 1 + 9 files changed, 82 insertions(+), 7 deletions(-) diff --git a/scripts/run-standard-tests.ts b/scripts/run-standard-tests.ts index db3b31969..fd8af348a 100644 --- a/scripts/run-standard-tests.ts +++ b/scripts/run-standard-tests.ts @@ -108,9 +108,11 @@ function classifySuitePath(token: string): SuiteName | null { normalizedToken.startsWith('test/server/') || normalizedToken.startsWith('test/unit/server/') || normalizedToken.startsWith('test/integration/server/') + || normalizedToken.startsWith('test/integration/real/') || normalizedToken.includes('/test/server/') || normalizedToken.includes('/test/unit/server/') || normalizedToken.includes('/test/integration/server/') + || normalizedToken.includes('/test/integration/real/') || normalizedToken.endsWith('/test/integration/session-repair.test.ts') || normalizedToken.endsWith('/test/integration/session-search-e2e.test.ts') || normalizedToken.endsWith('/test/integration/extension-system.test.ts') diff --git a/server/terminal-registry.ts b/server/terminal-registry.ts index 8e0395bda..e9cee9e63 100644 --- a/server/terminal-registry.ts +++ b/server/terminal-registry.ts @@ -258,7 +258,9 @@ function resolveCodingCliCommand( ) } const effectiveModel = mode === 'opencode' - ? resolveOpencodeLaunchModel(providerSettings?.model, { ...process.env, ...commandEnv }) + ? (resumeSessionId + ? undefined + : resolveOpencodeLaunchModel(providerSettings?.model, { ...process.env, ...commandEnv })) : providerSettings?.model if (effectiveModel && spec.modelArgs) { settingsArgs.push(...spec.modelArgs(effectiveModel)) diff --git a/test/helpers/coding-cli/real-session-contract-harness.ts b/test/helpers/coding-cli/real-session-contract-harness.ts index 1e5644896..0fe9d087d 100644 --- a/test/helpers/coding-cli/real-session-contract-harness.ts +++ b/test/helpers/coding-cli/real-session-contract-harness.ts @@ -213,7 +213,7 @@ async function listFilesRecursive(rootDir: string): Promise { async function waitForHttpJson(url: string, timeoutMs = 30_000): Promise { return waitFor(`HTTP JSON at ${url}`, async () => { try { - const response = await fetch(url) + const response = await fetchWithTimeout(url) if (!response.ok) { return undefined } @@ -224,6 +224,16 @@ async function waitForHttpJson(url: string, timeoutMs = 30_000): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { signal: controller.signal }) + } finally { + clearTimeout(timeout) + } +} + function parseJsonLines(text: string): unknown[] { return text .split(/\r?\n/) @@ -1268,13 +1278,17 @@ export async function waitForFileSizeIncrease(filePath: string, previousSize: nu } export async function fetchJson(url: string): Promise { - const response = await fetch(url) + const response = await fetchWithTimeout(url) if (!response.ok) { throw new Error(`Expected a successful response from ${url}, received ${response.status}.`) } return response.json() } +export async function waitForJsonResponse(url: string): Promise { + return waitForHttpJson(url) +} + export async function waitForHttpBusyStatus(url: string, sessionId: string): Promise> { return waitFor(`OpenCode busy status for ${sessionId}`, async () => { const payload = await fetchJson(url).catch(() => undefined) diff --git a/test/integration/real/coding-cli-session-contract.test.ts b/test/integration/real/coding-cli-session-contract.test.ts index 699242022..2caed76ba 100644 --- a/test/integration/real/coding-cli-session-contract.test.ts +++ b/test/integration/real/coding-cli-session-contract.test.ts @@ -10,7 +10,6 @@ import { captureCodexBootstrapEvents, captureCodexResumeBootstrapEvents, extractCodexResumeId, - fetchJson, findClaudeTranscript, findCodexSessionArtifacts, loadCodingCliSessionContractNote, @@ -27,6 +26,7 @@ import { waitForFileSizeIncrease, waitForAnyHttpBusyStatus, waitForHttpHealthy, + waitForJsonResponse, waitForJsonLine, waitForOpencodeDbSession, } from '../../helpers/coding-cli/real-session-contract-harness.js' @@ -451,7 +451,7 @@ describe.sequential('coding cli real provider session contract', () => { healthy: true, version: note.providers.opencode.version, }) - expect(await fetchJson(statusUrl)).toEqual({}) + expect(await waitForJsonResponse(statusUrl)).toEqual({}) const attachedRun = await workspace.spawnProcess( opencodePath, diff --git a/test/unit/server/run-standard-tests.test.ts b/test/unit/server/run-standard-tests.test.ts index 971cdb670..bb9574b25 100644 --- a/test/unit/server/run-standard-tests.test.ts +++ b/test/unit/server/run-standard-tests.test.ts @@ -123,6 +123,21 @@ describe('run-standard-tests', () => { }) }) + it('routes real provider integration paths to the server suite only', () => { + expect(createStandardTestPlan({ + availableParallelism: 32, + ci: false, + forwardedArgs: ['test/integration/real/coding-cli-session-contract.test.ts'], + })).toEqual({ + mode: 'desktop', + stages: [ + [ + { name: 'server', configPath: 'vitest.server.config.ts', maxWorkers: '3', priority: 'background' }, + ], + ], + }) + }) + it('routes electron-targeted paths to the electron suite only', () => { expect(createStandardTestPlan({ availableParallelism: 32, diff --git a/test/unit/server/terminal-registry.test.ts b/test/unit/server/terminal-registry.test.ts index ba2b915df..28b60e41d 100644 --- a/test/unit/server/terminal-registry.test.ts +++ b/test/unit/server/terminal-registry.test.ts @@ -990,6 +990,38 @@ describe('buildSpawnSpec Unix paths', () => { expect(spec.args).toContain('openai/gpt-5-mini') }) + it('does not pass a default OpenCode model when resuming a session', () => { + delete process.env.OPENCODE_CMD + + const spec = buildSpawnSpec('opencode', '/Users/john/project', 'system', 'ses_existing', { + model: 'abc', + opencodeServer: TEST_OPENCODE_SERVER, + }) + + expect(spec.args).toContain('--session') + expect(spec.args).toContain('ses_existing') + expect(spec.args).not.toContain('--model') + expect(spec.args).not.toContain('abc') + }) + + it('does not pass an inferred OpenCode model when resuming a session', () => { + delete process.env.OPENCODE_CMD + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY + delete process.env.GOOGLE_API_KEY + delete process.env.OPENAI_API_KEY + delete process.env.ANTHROPIC_API_KEY + process.env.GEMINI_API_KEY = 'gemini-key' + + const spec = buildSpawnSpec('opencode', '/Users/john/project', 'system', 'ses_existing', { + opencodeServer: TEST_OPENCODE_SERVER, + }) + + expect(spec.args).toContain('--session') + expect(spec.args).toContain('ses_existing') + expect(spec.args).not.toContain('--model') + expect(spec.args).not.toContain('google/gemini-3-pro-preview') + }) + it('defaults OpenCode to a usable Google model and alias env when only GEMINI_API_KEY is set', () => { delete process.env.OPENCODE_CMD delete process.env.GOOGLE_GENERATIVE_AI_API_KEY diff --git a/test/unit/vite-config.test.ts b/test/unit/vite-config.test.ts index ec5c800af..6080e3362 100644 --- a/test/unit/vite-config.test.ts +++ b/test/unit/vite-config.test.ts @@ -150,11 +150,19 @@ describe('vitest config', () => { } }) - it('does not exclude real-provider integration contracts from the default suite', async () => { + it('excludes real-provider integration contracts from the default jsdom suite', async () => { const configModule = await import('../../vitest.config.ts') const config = configModule.default const excluded = config.test?.exclude ?? [] - expect(excluded).not.toContain('test/integration/real/**') + expect(excluded).toContain('test/integration/real/**') + }) + + it('runs real-provider integration contracts in the node server suite', async () => { + const configModule = await import('../../vitest.server.config.ts') + const config = configModule.default + const included = config.test?.include ?? [] + + expect(included).toContain('test/integration/real/**/*.test.ts') }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 6c9a546a4..cf4e92982 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ 'test/integration/session-repair.test.ts', 'test/integration/session-search-e2e.test.ts', 'test/e2e-browser/**', + 'test/integration/real/**', // Electron tests run under vitest.electron.config.ts (node environment) 'test/unit/electron/**', // Electron E2E tests run under Playwright, not Vitest diff --git a/vitest.server.config.ts b/vitest.server.config.ts index 8f065e954..8ca3d23be 100644 --- a/vitest.server.config.ts +++ b/vitest.server.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ 'test/unit/server/**/*.test.ts', 'test/unit/visible-first/**/*.test.ts', 'test/integration/server/**/*.test.ts', + 'test/integration/real/**/*.test.ts', 'test/integration/session-repair.test.ts', 'test/integration/session-search-e2e.test.ts', 'test/integration/extension-system.test.ts', From 15c3f4dc89399d6cb214ea57ac7023062500e42c Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 16 May 2026 23:26:59 -0700 Subject: [PATCH 5/7] fix: hydrate restored opencode tui without replay cap (cherry picked from commit fdcc0bfdd2726d3406fa1e8bc5f03f13d93d5103) --- src/components/TerminalView.tsx | 19 ++++++++++-- .../TerminalView.lifecycle.test.tsx | 29 +++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index def853dc9..0af23dc0e 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -112,6 +112,12 @@ const DEFAULT_MIN_CONTRAST_RATIO = 1 const MAX_LAST_SENT_VIEWPORT_CACHE_ENTRIES = 200 const TRUNCATED_REPLAY_BYTES = 128 * 1024 +function viewportHydrateReplayOptions(content?: TerminalPaneContent | null): { maxReplayBytes: number } | undefined { + return content?.mode === 'opencode' + ? undefined + : { maxReplayBytes: TRUNCATED_REPLAY_BYTES } +} + type StartupProbeReplayDiscardState = { remainder: string | null buffered: string @@ -1640,7 +1646,10 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) } setIsAttaching(false) } else { - attachTerminal(tid, 'viewport_hydrate', { clearViewportFirst: true, maxReplayBytes: TRUNCATED_REPLAY_BYTES }) + attachTerminal(tid, 'viewport_hydrate', { + clearViewportFirst: true, + ...viewportHydrateReplayOptions(currentContent), + }) } dispatch(consumePaneRefreshRequest({ tabId, paneId, requestId: request.requestId })) @@ -1689,7 +1698,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) clearViewportFirst: deferred.pendingIntent === 'viewport_hydrate', suppressNextMatchingResize: true, skipPreAttachFit: true, - ...(deferred.pendingIntent === 'viewport_hydrate' ? { maxReplayBytes: TRUNCATED_REPLAY_BYTES } : {}), + ...(deferred.pendingIntent === 'viewport_hydrate' + ? viewportHydrateReplayOptions(contentRef.current) + : undefined), }) return } @@ -2342,7 +2353,9 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const intent: AttachIntent = deferredAttachStateRef.current.mode === 'live' ? 'keepalive_delta' : 'viewport_hydrate' - attachTerminal(currentTerminalId, intent, intent === 'viewport_hydrate' ? { maxReplayBytes: TRUNCATED_REPLAY_BYTES } : undefined) + attachTerminal(currentTerminalId, intent, intent === 'viewport_hydrate' + ? viewportHydrateReplayOptions(contentRef.current) + : undefined) } } else { deferredAttachStateRef.current = { diff --git a/test/unit/client/components/TerminalView.lifecycle.test.tsx b/test/unit/client/components/TerminalView.lifecycle.test.tsx index 572373112..d6cd81fa8 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -3091,6 +3091,7 @@ describe('TerminalView lifecycle updates', () => { async function renderTerminalHarness(opts?: { status?: 'creating' | 'running' terminalId?: string + mode?: TerminalPaneContent['mode'] hidden?: boolean clearSends?: boolean requestId?: string @@ -3102,12 +3103,13 @@ describe('TerminalView lifecycle updates', () => { const requestId = opts?.requestId ?? 'req-v2-stream' const initialStatus = opts?.status ?? 'running' const terminalId = opts?.terminalId + const mode = opts?.mode ?? 'shell' const paneContent: TerminalPaneContent = { kind: 'terminal', createRequestId: requestId, status: initialStatus, - mode: 'shell', + mode, shell: 'system', ...(terminalId ? { terminalId } : {}), } @@ -3126,9 +3128,9 @@ describe('TerminalView lifecycle updates', () => { tabs: { tabs: [{ id: tabId, - mode: 'shell', + mode, status: initialStatus, - title: 'Shell', + title: mode === 'opencode' ? 'OpenCode' : 'Shell', titleSetByUser: false, createRequestId: requestId, ...(terminalId ? { terminalId } : {}), @@ -3977,6 +3979,27 @@ describe('TerminalView lifecycle updates', () => { })) }) + it('does not cap OpenCode viewport hydration replay for restored running terminals', async () => { + const { terminalId } = await renderTerminalHarness({ + status: 'running', + terminalId: 'term-opencode-restored', + mode: 'opencode', + clearSends: false, + }) + + const attach = wsMocks.send.mock.calls + .map(([msg]) => msg) + .find((msg) => msg?.type === 'terminal.attach' && msg?.terminalId === terminalId) + + expect(attach).toMatchObject({ + type: 'terminal.attach', + terminalId, + intent: 'viewport_hydrate', + sinceSeq: 0, + }) + expect(attach).not.toHaveProperty('maxReplayBytes') + }) + it('revealing a hidden running pane sends a viewport attach with sinceSeq=0', async () => { const { store, tabId, paneId, terminalId, rerender } = await renderTerminalHarness({ status: 'running', From 49af5d4577f3f396d008c8fa650fe19166e1796b Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Mon, 18 May 2026 04:18:39 -0700 Subject: [PATCH 6/7] docs: add visible-first OpenCode restore plan --- ...26-05-18-visible-first-opencode-restore.md | 1724 +++++++++++++++++ 1 file changed, 1724 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-visible-first-opencode-restore.md diff --git a/docs/superpowers/plans/2026-05-18-visible-first-opencode-restore.md b/docs/superpowers/plans/2026-05-18-visible-first-opencode-restore.md new file mode 100644 index 000000000..ef7d1cefe --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-visible-first-opencode-restore.md @@ -0,0 +1,1724 @@ +# Visible-First OpenCode Restore Implementation Plan + +> **For Claude:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make restored OpenCode terminal panes deterministic by representing stale/dead OpenCode restores as visibility-gated restore intents, launching them only when a mounted `TerminalView` is visible, while preserving a constrained replay-gap repair path for same-server restored panes. + +**Architecture:** Add an explicit queued-restore state to terminal pane content instead of treating `status: "creating"` as both "create now" and "restore later." `TerminalView` becomes a small restore lifecycle state machine: queued OpenCode panes do not send `terminal.create` while hidden; the same mounted component transitions the queued pane once to a restored create as soon as it is visible; visible-started OpenCode restores carry an immediate attach obligation so `terminal.created` cannot become a hidden detached PTY if the user switches tabs mid-launch. + +**Fundamental Invariant:** Durable session identity never grants runtime ownership or destructive authority. `sessionRef` identifies the OpenCode session to restore; `terminalId` identifies a current PTY; only a current restore-attempt lease tied to that specific `{ sessionRef, createRequestId, terminalId, serverInstanceId }` may kill, replace, or recreate the PTY. The initial implementation represents this lease with `restoreRuntime`. The lease is created only for a restored OpenCode create, may survive a browser refresh only while the same server instance and live terminal handle are preserved, and is retired after the first successful `viewport_hydrate` attach for that terminal. After the lease is retired, replay gaps are non-destructive live-terminal events. + +**Tech Stack:** React 18, Redux Toolkit, TypeScript, Vitest, Playwright browser e2e, Freshell WebSocket terminal protocol. + +--- + +## Chunk 1: Restore State Model + +### File Structure + +- Verify/no change: `/home/user/code/freshell/.worktrees/dev/src/store/types.ts` + - Leave the shared tab/background terminal status unchanged. +- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/paneTypes.ts` + - Add a pane-only queued terminal status type. + - Add explicit queued OpenCode restore metadata on `TerminalPaneContent`. +- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/panesSlice.ts` + - Use shared restore-state normalization for reducer actions. + - Convert restored hidden OpenCode panes to queued state when stale runtime ids are stripped. +- Add/modify: `/home/user/code/freshell/.worktrees/dev/src/store/paneRestoreState.ts` + - Own the shared terminal pane restore-state sanitizers used by both `panesSlice.ts` and `persistMiddleware.ts`. +- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/persistMiddleware.ts` + - Apply shared boot-load sanitization for queued/error/lease fields without discarding same-server live terminal handles. + - Persist only valid same-server restore-attempt leases, and strip them when the server instance or terminal handle is no longer live. +- Modify: `/home/user/code/freshell/.worktrees/dev/src/lib/terminal-status-indicator.ts` + - Render queued restore as pending/neutral, not running/error. +- Modify as needed: `/home/user/code/freshell/.worktrees/dev/src/components/panes/Pane.tsx`, `/home/user/code/freshell/.worktrees/dev/src/components/panes/PaneHeader.tsx`, `/home/user/code/freshell/.worktrees/dev/src/components/TabItem.tsx`, `/home/user/code/freshell/.worktrees/dev/src/components/TabSwitcher.tsx` + - Accept pane-local `TerminalPaneStatus` only where status is derived from pane content. +- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/store/panesSlice.test.ts` +- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/store/panesPersistence.test.ts` + +### Task 1: Add Explicit Queued Restore Types + +- [ ] **Step 1: Write the failing type/model test** + +Add a test proving restored OpenCode pane hydration becomes queued instead of creating: + +```ts +it('queues restored OpenCode panes until visible when stale runtime ids are stripped', () => { + const layout: PaneNode = { + type: 'leaf', + id: 'pane-opencode', + content: { + kind: 'terminal', + mode: 'opencode', + shell: 'system', + status: 'running', + terminalId: 'old-term', + createRequestId: 'old-request', + sessionRef: { provider: 'opencode', sessionId: 'ses_root_1' }, + }, + } + + const result = panesReducer( + initialState, + restoreLayout({ tabId: 'tab-opencode', layout, paneTitles: {} }), + ) + const restored = result.layouts['tab-opencode'] + expect(restored.type).toBe('leaf') + if (restored.type !== 'leaf' || restored.content.kind !== 'terminal') { + throw new Error('expected terminal leaf') + } + + expect(restored.content).toMatchObject({ + kind: 'terminal', + mode: 'opencode', + status: 'queued', + sessionRef: { provider: 'opencode', sessionId: 'ses_root_1' }, + queuedRestore: { + kind: 'until_visible', + provider: 'opencode', + reason: 'visible_owner_required', + }, + }) + expect(restored.content.terminalId).toBeUndefined() + expect(restored.content.createRequestId).not.toBe('old-request') +}) +``` + +Add a companion malformed-state test so queued never degrades into a valid create state. This intentionally uses `initLayout` with an `as any` corrupt payload to exercise `normalizePaneContent` directly; the normal `restoreLayout` path should repair OpenCode restores into a valid queued shape via `stripStaleIds`. This store test is necessary but not sufficient: Chunk 2 adds the mounted `TerminalView` no-create proof so an `error` terminal without a `terminalId` cannot still launch a PTY. + +```ts +it('marks malformed queued terminal state as an error instead of creating', () => { + const result = panesReducer( + initialState, + initLayout({ + tabId: 'tab-bad-queue', + paneId: 'pane-bad-queue', + content: { + kind: 'terminal', + mode: 'opencode', + shell: 'system', + status: 'queued' as any, + createRequestId: 'req-bad-queue', + terminalId: 'stale-term', + sessionRef: { provider: 'opencode', sessionId: 'ses_root_bad' }, + }, + }), + ) + const restored = result.layouts['tab-bad-queue'] + expect(restored.type).toBe('leaf') + if (restored.type !== 'leaf' || restored.content.kind !== 'terminal') { + throw new Error('expected terminal leaf') + } + expect(restored.content.status).toBe('error') + expect(restored.content.terminalId).toBeUndefined() + expect(restored.content.queuedRestore).toBeUndefined() + expect(restored.content.restoreError?.reason).toBe('provider_runtime_failed') +}) +``` + +- [ ] **Step 2: Run the failing test** + +Run: + +```bash +npm run test:vitest -- test/unit/client/store/panesSlice.test.ts --run -t "queued" +``` + +Expected: FAIL because `TerminalPaneContent.status` currently cannot represent `queued` and `stripStaleIds` currently returns a normal creating terminal input. + +- [ ] **Step 3: Add the model** + +Do not widen `/home/user/code/freshell/.worktrees/dev/src/store/types.ts`'s `TerminalStatus`. `TerminalStatus` is also used by `Tab.status`, `BackgroundTerminal`, `tabsSlice.normalizePersistedTerminalStatus`, `TabSwitcher`, and `TabItem`; tabs must never enter `queued`. + +In `/home/user/code/freshell/.worktrees/dev/src/store/paneTypes.ts`: + +```ts +export type TerminalPaneStatus = TerminalStatus | 'queued' + +export type TerminalQueuedRestore = { + kind: 'until_visible' + provider: 'opencode' + /** Restore needs a visible terminal owner before Freshell may start the OpenCode PTY. */ + reason: 'visible_owner_required' +} +``` + +Change `TerminalPaneContent.status` and `TerminalPaneInput.status` from `TerminalStatus` to `TerminalPaneStatus`, then add `queuedRestore` to `TerminalPaneContent`: + +```ts + /** Current pane-local terminal status. `queued` is not valid for Tab.status. */ + status: TerminalPaneStatus + /** Restore launch is intentionally delayed until this pane is visible. */ + queuedRestore?: TerminalQueuedRestore +``` + +Update the input alias explicitly: + +```ts +export type TerminalPaneInput = Omit & { + createRequestId?: string + status?: TerminalPaneStatus +} +``` + +Keep the type narrow. Do not make this a generic provider framework until a second provider needs it. + +Update pane-only UI and helpers that consume `TerminalPaneContent.status` (`Pane`, `PaneHeader`, `TabItem`'s pane-derived status path, and `/home/user/code/freshell/.worktrees/dev/src/lib/terminal-status-indicator.ts`) to accept `TerminalPaneStatus`. Leave `/home/user/code/freshell/.worktrees/dev/src/store/tabsSlice.ts`, `Tab.status`, and `normalizePersistedTerminalStatus` on `TerminalStatus`; persisted tab status of `'queued'` should continue normalizing to `'creating'` because it is invalid tab state. + +Add an explicit tab-status mapping rule: a tab containing a queued OpenCode restore must present `Tab.status === 'creating'`, never stale `'running'` and never pane-only `'queued'`. Because `panesSlice` cannot mutate `tabsSlice`, apply this in the dispatching orchestration around any transition that can queue the primary terminal pane (`restoreLayout`, `clearDeadTerminals`, and startup repair paths). Existing direct `Tab.status` consumers such as `/home/user/code/freshell/.worktrees/dev/src/components/TabSwitcher.tsx` and the no-pane-icons path in `/home/user/code/freshell/.worktrees/dev/src/components/TabItem.tsx` may continue reading `tab.status`, but tests must prove queued panes do not leave those surfaces showing `running`. + +Add focused UI/store tests: + +- In the flow that dispatches `clearDeadTerminals` after receiving the server live-terminal list, prove any tab whose active/primary OpenCode pane was changed to queued also receives `updateTab({ status: 'creating' })`. `panesSlice` itself cannot update `tabsSlice`, so this belongs in the App/startup orchestration test or a small thunk/listener test, not in a pure `panesReducer` assertion. +- In the restored-tab/open-session path, prove adding a tab for a queued OpenCode pane initializes `Tab.status` as `creating`. +- In the tab component tests closest to `TabSwitcher`/`TabItem`, render a tab with a queued OpenCode pane and `tab.status: 'creating'`; assert the status label/dot is pending/creating, not running. If no such focused test exists, add one in `/home/user/code/freshell/.worktrees/dev/test/unit/client/components/`. + +- [ ] **Step 4: Normalize queued restore metadata** + +In `/home/user/code/freshell/.worktrees/dev/src/store/panesSlice.ts`, add a small sanitizer near `normalizePaneContent`: + +```ts +function sanitizeTerminalQueuedRestore(input: unknown): TerminalQueuedRestore | undefined { + const value = input as Partial | undefined + if ( + value?.kind === 'until_visible' + && value.provider === 'opencode' + && value.reason === 'visible_owner_required' + ) { + return { + kind: 'until_visible', + provider: 'opencode', + reason: 'visible_owner_required', + } + } + return undefined +} +``` + +Use it from `normalizePaneContent`. Keep the existing explicit terminal return object; do not switch to an implicit spread of unknown input. Import `type TerminalQueuedRestore` from `./paneTypes`, then make the terminal branch compute `sessionRef`, `queuedRestore`, and `canQueue` before the return: + +```ts +const sessionRef = sanitizeSessionRef(input.sessionRef) +const queuedRestore = sanitizeTerminalQueuedRestore((input as { queuedRestore?: unknown }).queuedRestore) +const canQueue = mode === 'opencode' && sessionRef?.provider === 'opencode' +const invalidQueuedRestore = input.status === 'queued' && !(queuedRestore && canQueue) +const status = normalizeTerminalPaneStatus(input.status, Boolean(queuedRestore && canQueue)) +const stripRuntimeForQueued = status === 'queued' || invalidQueuedRestore +``` + +Then keep the full explicit return shape and add `queuedRestore` as one conditional field: + +```ts +return { + kind: 'terminal', + terminalId: !stripRuntimeForQueued && typeof input.terminalId === 'string' ? input.terminalId : undefined, + createRequestId: typeof input.createRequestId === 'string' && input.createRequestId + ? input.createRequestId + : nanoid(), + status, + mode, + shell: typeof input.shell === 'string' ? input.shell : 'system', + resumeSessionId, + ...(sessionRef ? { sessionRef } : {}), + ...(codexDurability ? { codexDurability } : {}), + serverInstanceId: !stripRuntimeForQueued && typeof input.serverInstanceId === 'string' ? input.serverInstanceId : undefined, + ...(invalidQueuedRestore + ? { restoreError: buildRestoreError('provider_runtime_failed') } + : (restoreError.success ? { restoreError: restoreError.data } : {})), + ...(queuedRestore && canQueue ? { queuedRestore } : {}), + initialCwd: typeof input.initialCwd === 'string' ? input.initialCwd : undefined, +} +``` + +Add `normalizeTerminalPaneStatus` in `panesSlice.ts` so arbitrary persisted strings cannot become runtime status: + +```ts +function normalizeTerminalPaneStatus(status: unknown, allowQueued: boolean): TerminalPaneStatus { + if (status === 'queued' && allowQueued) return 'queued' + if (status === 'queued') return 'error' + if ( + status === 'creating' + || status === 'running' + || status === 'recovering' + || status === 'exited' + || status === 'error' + ) return status + return 'creating' +} +``` + +Import `buildRestoreError` from `@shared/session-contract` if it is not already available in this file. This prevents malformed persisted `status: 'queued'` from silently falling back to `creating`; the runtime launch prevention is pinned separately in Chunk 2. + +- [ ] **Step 5: Share restore-state normalization with persisted load** + +Do not maintain separate reducer and localStorage boot paths. Move the restore-state content helpers needed by both paths into `/home/user/code/freshell/.worktrees/dev/src/store/paneRestoreState.ts`: + +- `normalizePaneContent` +- `stripStaleIds` +- `normalizeRestoredTree` +- `normalizePersistedPaneTreeForBoot` +- `normalizePersistedTerminalContentForBoot` +- queued-restore/session-ref/status sanitizers + +`paneRestoreState.ts` must not import `panesSlice.ts` or `persistMiddleware.ts`; both of those modules may import the shared helpers. This avoids a circular dependency and keeps reducer and localStorage boot sanitization consistent without pretending that same-server reload and known-dead restore are the same transition. + +Update `panesSlice.ts` to import and use the shared helpers instead of keeping local-only copies. + +Update both sanitized layout loops in `persistMiddleware.ts` so `loadPersistedPanes()` applies `normalizePersistedPaneTreeForBoot` before returning data to `panesSlice.ts` or `terminal-restore.ts`: + +```ts +const sanitizedNode = stripEditorContentFromNode(normalizePersistedPaneTreeForBoot(migrateNode(node))) +``` + +For the post-migration loop that already operates on `layouts`, apply the same `normalizePersistedPaneTreeForBoot` call before `stripEditorContentFromNode`. This helper sanitizes queued/error/lease fields, but it must preserve `terminalId` and `serverInstanceId` for normal same-server browser refresh. Do not call `normalizeRestoredTree` directly from persisted boot loading; that helper is for explicit restore/dead-handle transitions where stale runtime ids are already known. + +Add failing persisted-load tests in `/home/user/code/freshell/.worktrees/dev/test/unit/client/store/panesPersistence.test.ts`: + +```ts +it('preserves a valid same-server OpenCode restore lease candidate with its live handle', () => { + localStorage.setItem('freshell.layout.v3', JSON.stringify({ + version: 3, + tabs: { tabs: [{ id: 'tab-opencode', title: 'OpenCode' }], activeTabId: 'tab-opencode' }, + panes: { + version: PANES_SCHEMA_VERSION, + layouts: { + 'tab-opencode': { + type: 'leaf', + id: 'pane-opencode', + content: { + kind: 'terminal', + mode: 'opencode', + shell: 'system', + status: 'running', + terminalId: 'term-live-refresh', + createRequestId: 'req-live-refresh', + serverInstanceId: 'server-same', + sessionRef: { provider: 'opencode', sessionId: 'ses_root_1' }, + restoreRuntime: { + replaceOnViewportReplayGap: true, + createRequestId: 'req-live-refresh', + terminalId: 'term-live-refresh', + serverInstanceId: 'server-same', + }, + }, + }, + }, + activePane: { 'tab-opencode': 'pane-opencode' }, + paneTitles: {}, + paneTitleSetByUser: {}, + }, + tombstones: [], + })) + + const loaded = loadPersistedPanes() + const layout = loaded!.layouts['tab-opencode'] + expect(layout.type).toBe('leaf') + expect(layout.content).toMatchObject({ + kind: 'terminal', + mode: 'opencode', + status: 'running', + terminalId: 'term-live-refresh', + createRequestId: 'req-live-refresh', + serverInstanceId: 'server-same', + sessionRef: { provider: 'opencode', sessionId: 'ses_root_1' }, + restoreRuntime: { + replaceOnViewportReplayGap: true, + createRequestId: 'req-live-refresh', + terminalId: 'term-live-refresh', + serverInstanceId: 'server-same', + }, + }) +}) +``` + +```ts +it('queues dead OpenCode handles after terminal liveness is known', () => { + const state = panesReducer(initialState, initLayout({ + tabId: 'tab-opencode', + paneId: 'pane-opencode', + content: { + kind: 'terminal', + mode: 'opencode', + shell: 'system', + status: 'running', + terminalId: 'term-dead-opencode', + createRequestId: 'req-dead-opencode', + sessionRef: { provider: 'opencode', sessionId: 'ses_root_dead' }, + }, + })) + + const next = panesReducer(state, clearDeadTerminals({ liveTerminalIds: [] })) + const layout = next.layouts['tab-opencode'] + expect(layout.type).toBe('leaf') + expect(layout.content).toMatchObject({ + kind: 'terminal', + mode: 'opencode', + status: 'queued', + sessionRef: { provider: 'opencode', sessionId: 'ses_root_dead' }, + queuedRestore: { + kind: 'until_visible', + provider: 'opencode', + reason: 'visible_owner_required', + }, + }) + expect(layout.content.terminalId).toBeUndefined() + expect(layout.content.createRequestId).not.toBe('req-dead-opencode') + expect(layout.content.restoreRuntime).toBeUndefined() +}) +``` + +Run: + +```bash +npm run test:vitest -- test/unit/client/store/panesPersistence.test.ts --run -t "OpenCode|restore-attempt lease" +npm run test:vitest -- test/unit/client/store/panesSlice.test.ts --run -t "queues dead OpenCode handles" +``` + +Expected: FAIL before persisted-load sanitization and dead-handle queuing exist. + +- [ ] **Step 6: Queue OpenCode restores when stripping stale ids** + +In `stripStaleIds`, replace the terminal branch with this shape: + +```ts +if (content.kind === 'terminal') { + const { + terminalId: _terminalId, + createRequestId: _createRequestId, + status: _status, + queuedRestore: _queuedRestore, + ...rest + } = content + const sessionRef = sanitizeSessionRef(content.sessionRef) + if (content.mode === 'opencode' && sessionRef?.provider === 'opencode') { + const { serverInstanceId: _serverInstanceId, ...queuedRest } = rest + return { + ...queuedRest, + status: 'queued', + queuedRestore: { + kind: 'until_visible', + provider: 'opencode', + reason: 'visible_owner_required', + }, + } + } + return rest +} +``` + +This keeps shell/Claude/Codex behavior unchanged, including the existing preservation of `serverInstanceId` for non-OpenCode restores. `serverInstanceId` is stripped only for the OpenCode queued branch because a queued pane has no same-server live terminal to match. + +Use this same `stripStaleIds` + `normalizePaneContent` path from `clearDeadTerminals` when a terminal pane's `terminalId` is absent from the server's live terminal list. For dead OpenCode handles with canonical `sessionRef.provider === 'opencode'`, the reducer must transition to a visibility-gated restore intent: `status: 'queued'`, `queuedRestore.reason: 'visible_owner_required'`, and a fresh `createRequestId` instead of `status: 'creating'`. + +Do not make `clearDeadTerminals` branch on visibility; it does not know which panes are visible. The queued state means "do not start this OpenCode PTY until a mounted visible `TerminalView` owns it", not "this pane was definitely hidden." A currently visible pane may pass through queued for one reducer tick and then launch immediately through `TerminalView`'s visible transition. For non-OpenCode providers, keep the existing dead-handle behavior unless a focused test proves it must change. + +- [ ] **Step 7: Update status UI** + +In `/home/user/code/freshell/.worktrees/dev/src/lib/terminal-status-indicator.ts`, handle `queued` the same as `creating`: + +```ts +case 'queued': +case 'creating': +default: + return 'text-muted-foreground' +``` + +and: + +```ts +case 'queued': +case 'creating': +default: + return 'fill-muted-foreground text-muted-foreground' +``` + +- [ ] **Step 7: Run the model test green** + +Run: + +```bash +npm run test:vitest -- test/unit/client/store/panesSlice.test.ts --run -t "queued" +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/store/paneTypes.ts src/store/panesSlice.ts src/store/paneRestoreState.ts src/store/persistMiddleware.ts src/lib/terminal-status-indicator.ts src/components/panes/Pane.tsx src/components/panes/PaneHeader.tsx src/components/TabItem.tsx src/components/TabSwitcher.tsx test/unit/client/store/panesSlice.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components +git commit -m "feat: model queued opencode restores" +``` + +--- + +## Chunk 2: TerminalView Visible-First State Machine + +### File Structure + +- Modify: `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx` + - Do not create hidden queued OpenCode restores. + - On reveal, mark request id as a restore request and send exactly one restored create. + - Attach immediately after `terminal.created` for visible-started OpenCode restores, even if the pane became hidden before create completed. +- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/components/TerminalView.lifecycle.test.tsx` + +### Task 2: Prevent Hidden Queued Creates + +- [ ] **Step 1: Write the failing hidden-queued test** + +Add to the existing `v2 stream lifecycle` describe block: + +```ts +it('does not create hidden queued OpenCode restores', async () => { + const sessionRef = { provider: 'opencode', sessionId: 'ses_queued_hidden' } as const + await renderTerminalHarness({ + status: 'queued', + mode: 'opencode', + hidden: true, + requestId: 'req-opencode-queued-hidden', + sessionRef, + queuedRestore: { + kind: 'until_visible', + provider: 'opencode', + reason: 'visible_owner_required', + }, + clearSends: false, + }) + + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.create', + })) + expect(restoreMocks.consumeTerminalRestoreRequestId).not.toHaveBeenCalledWith('req-opencode-queued-hidden') +}) +``` + +Add a mounted malformed-state guard test. This is the runtime proof that complements the reducer test from Chunk 1: + +```ts +it('does not create a terminal for malformed queued state normalized to error', async () => { + await renderTerminalHarness({ + status: 'error', + mode: 'opencode', + requestId: 'req-opencode-bad-queue', + terminalId: undefined, + sessionRef: { provider: 'opencode', sessionId: 'ses_bad_queue' }, + clearSends: false, + }) + + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.create', + })) + expect(restoreMocks.consumeTerminalRestoreRequestId).not.toHaveBeenCalledWith('req-opencode-bad-queue') +}) +``` + +Extend `renderTerminalHarness` options with: + +```ts +status?: TerminalPaneContent['status'] +queuedRestore?: TerminalPaneContent['queuedRestore'] +renderFromStore?: boolean +``` + +When `renderFromStore` is true, render `TerminalViewFromStore` on the initial render as well as later rerenders: + +```tsx +const view = render( + + {opts?.renderFromStore + ? , +) +``` + +Reveal tests must use the same component type before and after `rerender`. Do not render `TerminalView` first and then `TerminalViewFromStore`; React treats that as an unmount/remount and hides the production tab-activation bug. + +- [ ] **Step 2: Run the failing test** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "does not create hidden queued OpenCode restores" +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "does not create a terminal for malformed queued state normalized to error" +``` + +Expected: FAIL because current code sends `terminal.create` for any terminal content with no `terminalId`, including invalid `error` content. + +- [ ] **Step 3: Extract create launch into a reusable state-machine transition** + +In `TerminalView.tsx`, add helpers near the other lifecycle helpers: + +```ts +function isQueuedVisibleFirstOpenCodeRestore(content: TerminalPaneContent | null | undefined): boolean { + return content?.mode === 'opencode' + && content.status === 'queued' + && !content.terminalId + && content.queuedRestore?.kind === 'until_visible' + && content.queuedRestore.provider === 'opencode' + && content.sessionRef?.provider === 'opencode' + && typeof content.sessionRef.sessionId === 'string' +} +``` + +Treat `queued` plus an existing `terminalId` as invalid state. The normal restore path strips runtime ids before queuing; if a malformed persisted pane has both, normalization must drop the stale `terminalId` or surface an error instead of launching or attaching ambiguously. + +Add a create eligibility guard that is independent from the queued reveal transition: + +```ts +function shouldCreateTerminalImmediately(content: TerminalPaneContent | null | undefined): boolean { + if (!content) return false + if (isQueuedVisibleFirstOpenCodeRestore(content)) return false + return content.status === 'creating' || content.status === 'recovering' +} +``` + +Use this guard in the no-`terminalId` branch before sending a create: + +```ts +} else { + deferredAttachStateRef.current = { + mode: 'none', + pendingIntent: null, + pendingSinceSeq: 0, + } + if (!shouldCreateTerminalImmediately(contentRef.current)) { + setIsAttaching(false) + return + } + sendCreateForCurrentContent(createRequestId) +} +``` + +This is not a fallback. It is the terminal lifecycle boundary: only `creating`/`recovering` content may launch immediately, while valid queued OpenCode restores launch through the visibility transition and invalid/error/exited states do not launch. + +Extract the existing create sender into a `useCallback` helper outside the create/attach effect so it can be called from both the initial lifecycle effect and the visibility effect: + +```ts +const sendCreateForCurrentContent = useCallback((requestId: string) => { + const content = contentRef.current + if (!content) return + const recoveryIntent = getFreshRecoveryIntentForRequest(requestId) + const restore = recoveryIntent ? false : getRestoreFlagForRequest(requestId) + const createSessionState = getCreateSessionStateFromRef(contentRef) + launchAttemptRef.current = { + requestId, + restore, + ...(recoveryIntent ? { recoveryIntent } : {}), + attachReady: false, + attachOnCreatedEvenIfHidden: content.mode === 'opencode' && restore && !hiddenRef.current, + } + ws.send({ + type: 'terminal.create', + requestId, + mode: content.mode, + shell: content.shell || 'system', + cwd: content.initialCwd, + ...(!recoveryIntent && createSessionState.sessionRef ? { sessionRef: createSessionState.sessionRef } : {}), + ...(!recoveryIntent && createSessionState.codexDurability ? { codexDurability: createSessionState.codexDurability } : {}), + ...(!recoveryIntent && createSessionState.liveTerminal ? { liveTerminal: createSessionState.liveTerminal } : {}), + tabId, + paneId: paneIdRef.current, + ...(restore ? { restore: true } : {}), + ...(recoveryIntent ? { recoveryIntent } : {}), + }) +}, [getFreshRecoveryIntentForRequest, getRestoreFlagForRequest, tabId, ws]) +``` + +Move the current `getRestoreFlag` and `getFreshRecoveryIntent` side-channel reads into helpers outside the effect, so rate-limit retry and queued-reveal launch use the same sender: + +```ts +const getRestoreFlagForRequest = useCallback((requestId: string) => { + if (restoreRequestIdRef.current !== requestId) { + restoreRequestIdRef.current = requestId + restoreFlagRef.current = consumeTerminalRestoreRequestId(requestId) + } + return restoreFlagRef.current +}, []) + +const getFreshRecoveryIntentForRequest = useCallback((requestId: string) => { + if (freshRecoveryRequestIdRef.current !== requestId) { + freshRecoveryRequestIdRef.current = requestId + freshRecoveryIntentRef.current = consumeTerminalFreshRecoveryRequest(requestId) + } + return freshRecoveryIntentRef.current +}, []) +``` + +Define these helper callbacks before `sendCreateForCurrentContent` in the file. This preserves the existing "consume when create is actually sent" behavior; hidden queued panes must not consume restore request ids while they are still queued. Rate-limit retry should reuse `sendCreateForCurrentContent(requestId)` so it does not create a second restore-consumption path. If `pendingDurableReplacementRef` or other recovery branches add a restore request id, they should continue doing so before updating `requestIdRef.current`, and the new sender should consume it on the subsequent create. + +Add a second helper: + +```ts +const launchedQueuedRestoreRequestIdsRef = useRef>(new Set()) + +const launchQueuedOpenCodeRestoreIfVisible = useCallback(() => { + const content = contentRef.current + if (!isQueuedVisibleFirstOpenCodeRestore(content)) return false + if (hiddenRef.current) return false + const createRequestId = content.createRequestId + if (launchedQueuedRestoreRequestIdsRef.current.has(createRequestId)) return true + launchedQueuedRestoreRequestIdsRef.current.add(createRequestId) + addTerminalRestoreRequestId(createRequestId) + updateContent({ + status: 'creating', + queuedRestore: undefined, + restoreError: undefined, + }) + const currentTab = tabRef.current + if (currentTab) { + dispatch(updateTab({ id: currentTab.id, updates: { status: 'creating' } })) + } + sendCreateForCurrentContent(createRequestId) + return true +}, [dispatch, sendCreateForCurrentContent, updateContent]) +``` + +Inside the create/attach effect before the `currentTerminalId` decision, add: + +```ts +if (isQueuedVisibleFirstOpenCodeRestore(contentRef.current)) { + if (!launchQueuedOpenCodeRestoreIfVisible()) { + deferredAttachStateRef.current = { + mode: 'none', + pendingIntent: null, + pendingSinceSeq: 0, + } + setIsAttaching(false) + } + return +} +``` + +Do not consume the restore request id while hidden. + +In the existing `hidden`-dependent "When becoming visible" effect, call the same transition before existing attach/layout logic: + +```ts +if (!hidden && launchQueuedOpenCodeRestoreIfVisible()) { + return +} +``` + +This `hidden`-dependent call is the production path for tab activation, because `/home/user/code/freshell/.worktrees/dev/src/App.tsx` mounts each `TabContent` once and toggles `hidden` instead of remounting tabs. + +- [ ] **Step 4: Run the hidden-queued test green** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "does not create hidden queued OpenCode restores" +``` + +Expected: PASS. + +### Task 3: Reveal Queued Restores Exactly Once + +- [ ] **Step 1: Write the failing reveal test** + +```ts +it('revealing a queued OpenCode restore sends one restored create with the canonical sessionRef', async () => { + const sessionRef = { provider: 'opencode', sessionId: 'ses_queued_visible' } as const + const { store, tabId, paneId, rerender, requestId } = await renderTerminalHarness({ + status: 'queued', + mode: 'opencode', + hidden: true, + requestId: 'req-opencode-queued-visible', + sessionRef, + renderFromStore: true, + queuedRestore: { + kind: 'until_visible', + provider: 'opencode', + reason: 'visible_owner_required', + }, + clearSends: false, + }) + + wsMocks.send.mockClear() + + const addedRestoreIds = new Set() + restoreMocks.addTerminalRestoreRequestId.mockImplementation((id: string) => { + addedRestoreIds.add(id) + }) + restoreMocks.consumeTerminalRestoreRequestId.mockImplementation((id: string) => { + if (!addedRestoreIds.has(id)) return false + addedRestoreIds.delete(id) + return true + }) + + // Keep the component type identical across rerenders. This simulates the + // production tab path where App.tsx toggles hidden without remounting. + rerender( + + , + ) + + await waitFor(() => { + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.create', + requestId, + mode: 'opencode', + sessionRef, + restore: true, + })) + }) + + const creates = wsMocks.send.mock.calls + .map(([msg]) => msg) + .filter((msg) => msg?.type === 'terminal.create' && msg?.requestId === requestId) + expect(creates).toHaveLength(1) + const layout = store.getState().panes.layouts[tabId] + expect(layout?.type).toBe('leaf') + if (layout?.type === 'leaf' && layout.content.kind === 'terminal') { + expect(layout.content.status).toBe('creating') + expect(layout.content.queuedRestore).toBeUndefined() + } + expect(store.getState().tabs.tabs.find((tab) => tab.id === tabId)?.status).toBe('creating') +}) +``` + +- [ ] **Step 2: Run the reveal test** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "revealing a queued OpenCode restore" +``` + +Expected: FAIL until the `hidden`-dependent reveal effect launches queued restores without relying on remount. + +- [ ] **Step 3: Fix duplicate create risk** + +If the test sends twice, use the ref added in Task 2: + +```ts +const launchedQueuedRestoreRequestIdsRef = useRef>(new Set()) +``` + +Guard the reveal branch: + +```ts +if (launchedQueuedRestoreRequestIdsRef.current.has(createRequestId)) return +launchedQueuedRestoreRequestIdsRef.current.add(createRequestId) +``` + +Clear this set only on unmount by letting the component instance go away; do not persist it. Do not clear the old id when a recovery path mints a new `createRequestId`: the old id should remain suppressed, and the new id is intentionally allowed to launch once. The guard is same-pane dedupe, not same-session dedupe across multiple panes. + +- [ ] **Step 4: Run the reveal test green** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "revealing a queued OpenCode restore" +``` + +Expected: PASS. + +### Task 4: Close the Visible-Create-Then-Hidden Race + +- [ ] **Step 1: Write the failing race test** + +```ts +it('attaches an OpenCode restore created while visible even if hidden before terminal.created', async () => { + const sessionRef = { provider: 'opencode', sessionId: 'ses_visible_then_hidden' } as const + const { store, tabId, paneId, rerender, requestId } = await renderTerminalHarness({ + status: 'queued', + mode: 'opencode', + hidden: false, + requestId: 'req-opencode-visible-race', + sessionRef, + renderFromStore: true, + queuedRestore: { + kind: 'until_visible', + provider: 'opencode', + reason: 'visible_owner_required', + }, + clearSends: false, + }) + + await waitFor(() => { + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.create', + requestId, + restore: true, + })) + }) + + wsMocks.send.mockClear() + + rerender( + + , + ) + + act(() => { + messageHandler!({ + type: 'terminal.created', + requestId, + terminalId: 'term-visible-started-opencode', + createdAt: Date.now(), + }) + }) + + await waitFor(() => { + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.attach', + terminalId: 'term-visible-started-opencode', + intent: 'viewport_hydrate', + sinceSeq: 0, + })) + }) + + const attach = wsMocks.send.mock.calls + .map(([msg]) => msg) + .find((msg) => msg?.type === 'terminal.attach' && msg?.terminalId === 'term-visible-started-opencode') + wsMocks.send.mockClear() + + act(() => { + messageHandler!({ + type: 'terminal.attach.ready', + terminalId: 'term-visible-started-opencode', + headSeq: 0, + replayFromSeq: 1, + replayToSeq: 0, + attachRequestId: attach.attachRequestId, + }) + }) + + // Seed the cached viewport to the same geometry the fit will produce. The + // reveal resize must still send because hidden attach used fallback geometry. + seedLastSentViewportForTest('term-visible-started-opencode', { cols: 80, rows: 24 }) + + rerender( + + , + ) + + await waitFor(() => { + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.resize', + terminalId: 'term-visible-started-opencode', + })) + }) +}) +``` + +Add the `seedLastSentViewportForTest` hook through `renderTerminalHarness` or another test-only harness seam; do not expose it in production UI props. + +- [ ] **Step 2: Run the race test red** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "attaches an OpenCode restore created while visible" +``` + +Expected: FAIL because `terminal.created` currently branches on `hiddenRef.current` and defers attach. + +- [ ] **Step 3: Add attach obligation to launch attempts** + +Extend `LaunchAttemptState` in `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx`: + +```ts + attachOnCreatedEvenIfHidden?: boolean +``` + +If this was not already done while extracting `sendCreateForCurrentContent` in Task 2, set the attach obligation after computing `restore`: + +```ts +const attachOnCreatedEvenIfHidden = mode === 'opencode' + && restore + && !hiddenRef.current +``` + +Store it in `launchAttemptRef.current`. + +In `terminal.created`, compute the attach obligation from the pre-overwrite launch snapshot before assigning a fresh `launchAttemptRef.current`: + +```ts +const mustAttachNow = pendingLaunch?.requestId === reqId + && pendingLaunch.attachOnCreatedEvenIfHidden + +launchAttemptRef.current = { + requestId: reqId, + restore: pendingLaunch?.restore ?? false, + attachReady: false, + ...(mustAttachNow ? { attachOnCreatedEvenIfHidden: true } : {}), +} +``` + +Then replace: + +```ts +if (hiddenRef.current) { +``` + +with: + +```ts +if (hiddenRef.current && !mustAttachNow) { +``` + +For `mustAttachNow`, call: + +```ts +attachTerminal(newId, 'viewport_hydrate', { + clearViewportFirst: true, + skipPreAttachFit: true, +}) +``` + +Because this attach happens while the pane may be hidden, record that the terminal needs a real geometry resize on reveal: + +```ts +const resizeAfterHiddenOpenCodeRestoreAttachRef = useRef>(new Set()) + +if (mustAttachNow && hiddenRef.current) { + resizeAfterHiddenOpenCodeRestoreAttachRef.current.add(newId) +} +``` + +Extend `requestTerminalLayout`/`pendingLayoutWorkRef` with a one-shot `forceResize?: boolean` flag. When `forceResize` is set, `flushScheduledLayout` must send `terminal.resize` after `runtime.fit()` even if the new geometry matches `lastSentViewportRef`; clear the flag in the same flush. This is the only place in this plan that may bypass the cached viewport suppression. + +In the `hidden`-dependent visibility effect, after queued-launch handling and before the generic layout return, send a forced fit+resize when the pane becomes visible: + +```ts +if (!hidden) { + const tid = terminalIdRef.current + if (tid && resizeAfterHiddenOpenCodeRestoreAttachRef.current.delete(tid)) { + requestTerminalLayout({ fit: true, resize: true, forceResize: true }) + return + } +} +``` + +This keeps the immediate attach obligation deterministic without freezing OpenCode at the fallback hidden geometry. A test must assert an actual `terminal.resize` message is sent on reveal even when `lastSentViewportRef` already matches the fitted geometry. + +Clean up `resizeAfterHiddenOpenCodeRestoreAttachRef` whenever the terminal id is replaced, killed/exited, or the component unmounts: + +```ts +resizeAfterHiddenOpenCodeRestoreAttachRef.current.delete(oldTerminalId) +resizeAfterHiddenOpenCodeRestoreAttachRef.current.clear() // unmount cleanup +``` + +Only visible-started OpenCode restores get `attachOnCreatedEvenIfHidden`. Recovery-driven creates sent while already hidden should keep the existing deferred-attach behavior unless a separate test proves they need the same obligation. + +- [ ] **Step 4: Run race test green** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "attaches an OpenCode restore created while visible" +``` + +Expected: PASS. + +- [ ] **Step 5: Run full lifecycle test file** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/TerminalView.tsx test/unit/client/components/TerminalView.lifecycle.test.tsx +git commit -m "fix: launch opencode restores only when visible" +``` + +--- + +## Chunk 3: Constrain Replay-Gap Safety Net + +### File Structure + +- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/paneTypes.ts` + - Add a narrow restore-attempt lease for OpenCode restored PTYs that may be replaced if their initial viewport hydrate is unrecoverable. +- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/paneRestoreState.ts` + - Sanitize restore-attempt leases without widening the durable pane contract. +- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/persistMiddleware.ts` + - Persist only validated same-server restore-attempt lease candidates; dead-handle cleanup strips them before queued repair. +- Modify: `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx` + - Set the lease only for restored OpenCode creates that can still be safely replaced. + - Require the lease before the kill/recreate safety net runs. +- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/components/TerminalView.lifecycle.test.tsx` +- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/store/panesPersistence.test.ts` + +### Task 5: Mark Replaceable Restored OpenCode PTYs + +- [ ] **Step 1: Write failing non-kill test** + +```ts +it('does not kill an unowned OpenCode terminal on viewport replay gap', async () => { + const sessionRef = { provider: 'opencode', sessionId: 'ses_live_busy' } as const + const { store, tabId, paneId, terminalId } = await renderTerminalHarness({ + status: 'running', + terminalId: 'term-opencode-live-gap', + mode: 'opencode', + sessionRef, + clearSends: false, + }) + + const attach = wsMocks.send.mock.calls + .map(([msg]) => msg) + .find((msg) => msg?.type === 'terminal.attach' && msg?.terminalId === terminalId) + + wsMocks.send.mockClear() + act(() => { + messageHandler!({ + type: 'terminal.output.gap', + terminalId, + fromSeq: 1, + toSeq: 100, + reason: 'replay_window_exceeded', + attachRequestId: attach.attachRequestId, + } as any) + }) + + expect(wsMocks.send).not.toHaveBeenCalledWith({ + type: 'terminal.kill', + terminalId, + }) + const layout = store.getState().panes.layouts[tabId] + expect(layout?.type).toBe('leaf') + if (layout?.type === 'leaf') { + expect(layout.id).toBe(paneId) + expect(layout.content.kind).toBe('terminal') + if (layout.content.kind === 'terminal') { + expect(layout.content.status).toBe('running') + expect(layout.content.restoreRuntime).toBeUndefined() + } + } +}) +``` + +Expected red today if the current PR #346 guard kills any OpenCode pane with `sessionRef`. + +- [ ] **Step 2: Add restore-attempt lease type** + +In `/home/user/code/freshell/.worktrees/dev/src/store/paneTypes.ts`: + +```ts +export type TerminalRestoreRuntime = { + replaceOnViewportReplayGap: true + createRequestId: string + terminalId: string + serverInstanceId: string +} +``` + +Add to `TerminalPaneContent`: + +```ts + /** Restore-attempt lease for a restored PTY that is still safe to replace if initial replay is unrecoverable. */ + restoreRuntime?: TerminalRestoreRuntime +``` + +Keep this lease narrow. It does not need a `provider` field because it is only valid on `mode: 'opencode'` pane content with `sessionRef.provider === 'opencode'`. It does need `serverInstanceId` so a browser refresh can preserve an in-flight same-server lease without letting that lease survive a server restart. Do not treat `sessionRef` or `terminalId` alone as proof of ownership. + +- [ ] **Step 3: Normalize restore-attempt lease** + +In `panesSlice.ts`, add a sanitizer near the queued-restore sanitizer and call it from `normalizePaneContent`: + +```ts +function sanitizeTerminalRestoreRuntime(input: unknown): TerminalRestoreRuntime | undefined { + const value = input as Partial | undefined + if ( + value?.replaceOnViewportReplayGap === true + && typeof value.createRequestId === 'string' + && value.createRequestId.length > 0 + && typeof value.terminalId === 'string' + && value.terminalId.length > 0 + && typeof value.serverInstanceId === 'string' + && value.serverInstanceId.length > 0 + ) { + return { + replaceOnViewportReplayGap: true, + createRequestId: value.createRequestId, + terminalId: value.terminalId, + serverInstanceId: value.serverInstanceId, + } + } + return undefined +} +``` + +Only preserve `restoreRuntime` when: + +```ts +mode === 'opencode' +&& sessionRef?.provider === 'opencode' +&& restoreRuntime.replaceOnViewportReplayGap === true +&& typeof restoreRuntime.createRequestId === 'string' +&& typeof restoreRuntime.terminalId === 'string' +&& typeof restoreRuntime.serverInstanceId === 'string' +&& restoreRuntime.createRequestId === createRequestId +&& restoreRuntime.terminalId === terminalId +&& restoreRuntime.serverInstanceId === serverInstanceId +``` + +Add it explicitly to `normalizePaneContent`'s terminal return object, just like `queuedRestore`: + +```ts +const restoreRuntime = sanitizeTerminalRestoreRuntime((input as { restoreRuntime?: unknown }).restoreRuntime) +const canKeepRestoreRuntime = mode === 'opencode' + && sessionRef?.provider === 'opencode' + && status !== 'queued' + && !invalidQueuedRestore + && Boolean(restoreRuntime) + && restoreRuntime?.createRequestId === createRequestId + && restoreRuntime?.terminalId === terminalId + && restoreRuntime?.serverInstanceId === serverInstanceId + +return { + // existing explicit fields... + ...(queuedRestore && canQueue ? { queuedRestore } : {}), + ...(canKeepRestoreRuntime ? { restoreRuntime } : {}), + initialCwd: typeof input.initialCwd === 'string' ? input.initialCwd : undefined, +} +``` + +Do not let a restore-attempt lease survive a server restart or dead terminal handle. Do allow it to survive a same-server browser refresh while the restored PTY is still live and before the first successful `viewport_hydrate`; otherwise a refresh during initial OpenCode startup would remove the only ownership proof allowed to repair an unrecoverable replay gap. + +In this chunk, update the shared terminal restore-state helper introduced in Chunk 1 so every stale-runtime stripping path drops `restoreRuntime` before constructing either return path: + +```ts +const { + terminalId: _terminalId, + createRequestId: _createRequestId, + status: _status, + queuedRestore: _queuedRestore, + restoreRuntime: _restoreRuntime, + ...rest +} = content +``` + +Keep the Chunk 1 behavior that preserves `serverInstanceId` for non-OpenCode restores; only the OpenCode queued branch should strip `serverInstanceId`. A fresh `restoreRuntime` lease is authored in `terminal.created` for the new restored create. + +Update `/home/user/code/freshell/.worktrees/dev/src/store/persistMiddleware.ts` so `stripTransientSessionFields` preserves only a structurally valid same-server lease candidate and strips malformed lease payloads: + +```ts +if (content.kind === 'terminal') { + const normalized = normalizePersistedTerminalContentForBoot(content) + const sessionRef = sanitizeSessionRef(normalized.sessionRef) + const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, sessionId: _sessionId, ...rest } = normalized + return { + ...rest, + ...(sessionRef ? { sessionRef } : {}), + } +} +``` + +The read path must be sanitized too, because users may already have persisted layouts containing malformed or stale-looking `restoreRuntime`. The `panesPersistence.test.ts` lease test from Chunk 1 proves that a valid candidate with matching `createRequestId`, `terminalId`, and `serverInstanceId` is preserved on boot. Add companion tests proving malformed candidates are stripped and `clearDeadTerminals` strips even valid candidates when the terminal is not live. + +- [ ] **Step 4: Set lease on restored OpenCode create** + +Do not rely only on Redux propagation for the server instance id. On cold boot, `WsClient` can receive `ready`, populate its own `serverInstanceId`, flush queued `terminal.create`, and receive a fast `terminal.created` before `App.tsx` has pushed `connection.serverInstanceId` through React into `TerminalView`. Add a local helper that reads the synchronous WebSocket client value first: + +```ts +const getCurrentServerInstanceId = useCallback(() => { + const fromWs = typeof ws.serverInstanceId === 'string' && ws.serverInstanceId.trim() + ? ws.serverInstanceId + : undefined + return fromWs ?? serverInstanceIdRef.current +}, [ws]) +``` + +In `terminal.created`, when `pendingLaunch?.restore === true`, `mode === 'opencode'`, and `getCurrentServerInstanceId()` returns a non-empty string, include: + +```ts +const restoreServerInstanceId = getCurrentServerInstanceId() +restoreRuntime: { + replaceOnViewportReplayGap: true, + createRequestId: reqId, + terminalId: newId, + serverInstanceId: restoreServerInstanceId, +}, +``` + +Add a lifecycle test that simulates `ws.serverInstanceId` being set while Redux `connection.serverInstanceId` is still undefined and proves the resulting `terminal.created` handler writes `restoreRuntime.serverInstanceId`. This test should fail if the implementation reads only `serverInstanceIdRef.current`. + +Also store the create request id by terminal id in a ref so attach-completion code never compares create ids to attach ids: + +```ts +const replaceableRestoreByTerminalIdRef = useRef>(new Map()) +replaceableRestoreByTerminalIdRef.current.set(newId, reqId) +``` + +Clear the lease only after the first successful `viewport_hydrate` attach completes for the same terminal and the same create request: + +```ts +const currentAttach = currentAttachRef.current +const restoreRuntime = contentRef.current?.restoreRuntime +if ( + currentAttach?.intent === 'viewport_hydrate' + && restoreRuntime?.replaceOnViewportReplayGap === true + && restoreRuntime.terminalId === currentAttach.terminalId + && restoreRuntime.serverInstanceId === getCurrentServerInstanceId() + && replaceableRestoreByTerminalIdRef.current.get(currentAttach.terminalId) === restoreRuntime.createRequestId +) { + replaceableRestoreByTerminalIdRef.current.delete(currentAttach.terminalId) + updateContent({ restoreRuntime: undefined }) +} +``` + +Do not clear the lease on `terminal.output.gap`; the lease must remain available for the replacement decision. + +- [ ] **Step 5: Require restore-attempt ownership before replay-gap replacement** + +Change `beginOpenCodeReplacementAfterExit` or its caller so the kill/recreate path only runs when: + +```ts +contentRef.current?.restoreRuntime?.replaceOnViewportReplayGap === true +&& contentRef.current.restoreRuntime.terminalId === terminalId +&& contentRef.current.restoreRuntime.createRequestId === contentRef.current.createRequestId +&& contentRef.current.restoreRuntime.serverInstanceId === contentRef.current.serverInstanceId +&& contentRef.current.restoreRuntime.serverInstanceId === getCurrentServerInstanceId() +``` + +If the lease is absent, do not kill and do not mark the pane errored. Leave live OpenCode terminals in the existing soft gap behavior: write the ordinary replay-gap notice, apply sequence state, and let fresh frames continue. This is important because an unowned OpenCode terminal may still be live and usable. + +```ts +if (!canReplaceForReplayGap) { + // Existing non-destructive output-gap path continues here. + term.writeln('\r\n[Terminal output gap detected; some earlier output is no longer available]\r\n') + applySeqState(/* existing gap handling state */) + return +} +``` + +Do not silently start a fresh terminal for an unowned gap. Also do not set `status: 'error'` unless the terminal actually exits or another existing unrecoverable-terminal path proves the PTY is gone. + +- [ ] **Step 6: Preserve migration repair for the current bad release window** + +Do not keep PR #346's broad `lastSeq === 0 && !terminalFirstOutputMarkedRef.current` compatibility guard. That runtime state is indistinguishable from a live user-owned OpenCode terminal that simply missed replay, so using it would either kill real work or make the non-kill test impossible to satisfy. + +Preserve migration repair only through explicit state: + +```ts +const canReplaceForReplayGap = contentRef.current?.mode === 'opencode' + && contentRef.current?.sessionRef?.provider === 'opencode' + && contentRef.current?.restoreRuntime?.replaceOnViewportReplayGap === true + && contentRef.current.restoreRuntime.terminalId === terminalId + && contentRef.current.restoreRuntime.createRequestId === contentRef.current.createRequestId + && contentRef.current.restoreRuntime.serverInstanceId === contentRef.current.serverInstanceId + && contentRef.current.restoreRuntime.serverInstanceId === getCurrentServerInstanceId() +``` + +The deterministic migration is: Chunk 1 turns restored hidden OpenCode panes into queued restore intents, and Chunk 3 authors `restoreRuntime` at the point the restored `terminal.create` succeeds. Persisted pre-lease panes therefore do not need a stored lease; their next visible restore create gets one before any replay-gap replacement can run. Already-live, unowned PTYs from the current release window are treated conservatively as live terminals: they receive the soft output-gap notice and keep running, with no kill/recreate. + +Do not invent alternate ownership proofs from `seqStateRef`, `lastSeq`, first-output flags, or the mere presence of `sessionRef`. + +Update the existing repair test `recreates a restored OpenCode pane when visible viewport hydration cannot replay startup output` so its harness state includes: + +```ts +restoreRuntime: { + replaceOnViewportReplayGap: true, + createRequestId: requestId, + terminalId, + serverInstanceId: 'server-test', +} +``` + +Extend `renderTerminalHarness` with `restoreRuntime?: TerminalPaneContent['restoreRuntime']`. This makes the repair test and the new non-kill test intentionally different: the repair case has the restore-attempt lease; the live case does not. + +- [ ] **Step 7: Run safety-net tests** + +Run: + +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "viewport replay gap" +``` + +Expected: both the existing leased repair test and the new unowned non-kill test pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/store/paneTypes.ts src/store/paneRestoreState.ts src/store/persistMiddleware.ts src/components/TerminalView.tsx test/unit/client/components/TerminalView.lifecycle.test.tsx test/unit/client/store/panesPersistence.test.ts +git commit -m "fix: constrain opencode replay-gap replacement" +``` + +--- + +## Chunk 4: Restart And Sidebar Contract + +### File Structure + +- Modify: `/home/user/code/freshell/.worktrees/dev/test/e2e-browser/specs/opencode-restart-recovery.spec.ts` + - Change restart expectations: active visible OpenCode restores immediately; hidden OpenCode panes remain queued until clicked. + - Assert left/sidebar history still lists old OpenCode sessions. +- Possibly modify: `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx` + - User-visible copy for queued visible-first restore, if a visible queued pane renders before transition. +- Possibly modify: `/home/user/code/freshell/.worktrees/dev/src/components/Sidebar.tsx` or selectors under `/home/user/code/freshell/.worktrees/dev/src/store/selectors/` + - Only if tests prove queued no-PTY sessions disappear from the left pane. + +### Task 6: Update Browser Restart Expectations + +- [ ] **Step 1: Write failing browser e2e for queued hidden restart** + +In `/home/user/code/freshell/.worktrees/dev/test/e2e-browser/specs/opencode-restart-recovery.spec.ts`, split the current assertion after restart: + +```ts +const activeTabId = await harness.getActiveTabId() +const hiddenOpenCodeTabIds = survivingOpenCodeTabIds.filter((tabId) => tabId !== activeTabId) + +const activeAfterRestart = await waitForRunningTerminals(input.page, [activeTabId], previousTerminalIdsByTab) +expect(activeAfterRestart[0].mode).toBe('opencode') +expect(activeAfterRestart[0].terminalId).toBeTruthy() + +const hiddenSnapshots = await getPaneSnapshots(input.page, hiddenOpenCodeTabIds) +for (const snapshot of hiddenSnapshots) { + expect(snapshot.status).toBe('queued') + expect(snapshot.terminalId).toBeFalsy() + expect(snapshot.sessionRef?.sessionId).toMatch(/^ses_root_/) +} +``` + +Then click each hidden OpenCode tab and assert it restores: + +```ts +for (const tabId of hiddenOpenCodeTabIds) { + await selectTab(input.page, tabId) + const [snapshot] = await waitForOpenCodeSessions(input.page, [tabId]) + expect(snapshot.terminalId).toBeTruthy() + expect(snapshot.sessionRef).toEqual(beforeByTab.get(tabId)?.sessionRef) +} +``` + +Use existing helper names where available; add `harness.getActiveTabId` or equivalent only if no helper exists. + +- [ ] **Step 2: Run browser e2e red** + +Run: + +```bash +npm run test:e2e:chromium -- test/e2e-browser/specs/opencode-restart-recovery.spec.ts +``` + +Expected: FAIL before visible-first implementation is complete because hidden OpenCode panes still restore immediately or because helpers do not yet expose queued state. + +- [ ] **Step 3: Fix helper/state exposure if needed** + +If `getPaneSnapshots` omits `status` or `queuedRestore`, extend the browser harness snapshot shape in `/home/user/code/freshell/.worktrees/dev/test/e2e-browser/helpers/test-harness.ts` only for test visibility. + +- [ ] **Step 4: Assert sidebar/history rows remain present** + +Add an assertion after restart and before clicking hidden tabs: + +```ts +const expectedHistoryRows = survivingOpenCodeTabIds.map((tabId) => ({ + tabId, + sessionId: beforeByTab.get(tabId)?.sessionRef?.sessionId, +})) + +for (const row of expectedHistoryRows) { + expect(row.sessionId, `missing sessionRef for ${row.tabId}`).toBeTruthy() + const fakeOpenCodeHistoryTitle = `Root ${row.sessionId}` + await expect(input.page.getByRole('button', { + name: new RegExp(`^${escapeRegExp(fakeOpenCodeHistoryTitle)}\\b`, 'i'), + })).toBeVisible() +} +``` + +Use the visible title rendered by the fake OpenCode session metadata (`/home/user/code/freshell/.worktrees/dev/test/e2e-browser/fixtures/fake-opencode.cjs` currently seeds `Root ${rootSessionId}`), not the tab title. Add a local `escapeRegExp` helper if the spec does not already have one. If the fixture title shape changes, compute the title from that fixture metadata and keep the assertion role-based and session-specific. Do not weaken this to checking Redux state only; the requirement is left-pane visibility. + +- [ ] **Step 5: Run browser e2e green** + +Run: + +```bash +npm run test:e2e:chromium -- test/e2e-browser/specs/opencode-restart-recovery.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add test/e2e-browser/specs/opencode-restart-recovery.spec.ts test/e2e-browser/helpers/test-harness.ts src/components/TerminalView.tsx src/components/Sidebar.tsx src/store/selectors +git commit -m "test: cover visible-first opencode restart restores" +``` + +Only add `src/components/Sidebar.tsx` or selector files if the implementation actually required them. + +--- + +## Chunk 5: Multi-Client And Server Reuse Proofs + +### File Structure + +- Modify: `/home/user/code/freshell/.worktrees/dev/test/server/ws-terminal-create-session-repair.test.ts` + - Prove killed OpenCode terminal binding is released before restored create. + - Prove the existing canonical-session reuse path handles both sequential and simultaneous duplicate restored creates for the same OpenCode `sessionRef`. +- Add or modify: `/home/user/code/freshell/.worktrees/dev/test/e2e/opencode-visible-first-restore-flow.test.tsx` + - Mock-WebSocket client test for same-pane duplicate create prevention. +- Possibly modify: `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx` + - Add same-pane dedupe guard if tests show duplicate creates. +- Possibly modify: `/home/user/code/freshell/.worktrees/dev/server/ws-handler.ts` and `/home/user/code/freshell/.worktrees/dev/server/terminal-registry.ts` + - Extend the existing canonical-session reuse/binding path if the simultaneous-create characterization proves a real race; do not add a second session ownership table. + +### Task 7: Prove Replacement Cannot Reuse Killed Terminal + +- [ ] **Step 1: Write server test** + +Add a server-side test that: + +1. Creates an OpenCode terminal with `sessionRef`. +2. Kills it with `terminal.kill`. +3. Sends restored `terminal.create` for the same `sessionRef`. +4. Asserts the new `terminal.created.terminalId` differs from the killed id. + +Use existing fake registry/test harness in `ws-terminal-create-session-repair.test.ts`. + +- [ ] **Step 2: Run server test** + +Run: + +```bash +npm run test:vitest -- test/server/ws-terminal-create-session-repair.test.ts --run -t "opencode" +``` + +Expected: PASS if current `TerminalRegistry.kill()` release binding behavior is correct; FAIL if the create reuses stale binding. + +### Task 8: Prove Restore Launches Are Idempotent At The Right Layer + +- [ ] **Step 1: Write same-pane client flow test** + +Create `/home/user/code/freshell/.worktrees/dev/test/e2e/opencode-visible-first-restore-flow.test.tsx` or extend an existing terminal lifecycle flow test. Simulate one queued OpenCode pane that becomes visible, rerenders, hides, and becomes visible again before `terminal.created`. + +Expected: + +```ts +const restoreCreatesForPaneRequestId = wsMocks.send.mock.calls + .map(([msg]) => msg) + .filter((msg) => msg?.type === 'terminal.create' + && msg.requestId === requestId + && msg.restore === true) +expect(restoreCreatesForPaneRequestId).toHaveLength(1) +``` + +This test pins the `launchedQueuedRestoreRequestIdsRef` contract: a single pane instance sends at most one restored create for its current persisted `createRequestId`. Do not filter by a fresh `nanoid`; the matcher must compare against the pane's `requestId` returned by the harness. + +- [ ] **Step 2: Write server same-session reuse and race tests** + +First add a characterization for the existing sequential reuse path in `/home/user/code/freshell/.worktrees/dev/test/server/ws-terminal-create-session-repair.test.ts`. This may already pass because `/home/user/code/freshell/.worktrees/dev/server/ws-handler.ts` checks `getCanonicalRunningTerminalBySession` before and after async config loading: + +```ts +const sessionRef = { provider: 'opencode', sessionId: 'ses_same_session' } as const +sendCreate({ requestId: 'req-a', mode: 'opencode', restore: true, sessionRef }) +const first = await waitForCreated('req-a') +sendCreate({ requestId: 'req-b', mode: 'opencode', restore: true, sessionRef }) +const second = await waitForCreated('req-b') +expect(second.terminalId).toBe(first.terminalId) +expect(fakePtySpawnCountForSession(sessionRef.sessionId)).toBe(1) +``` + +Then add the actual race characterization: hold the fake spawn/config path open, send `req-a` and `req-b` for the same canonical OpenCode `sessionRef` before either request can emit `terminal.created`, release the held create, and assert both request ids receive the same terminal id with one PTY spawn. + +```ts +const sessionRef = { provider: 'opencode', sessionId: 'ses_same_session_race' } as const +const hold = holdNextPtyCreateForSession(sessionRef.sessionId) +sendCreate({ requestId: 'req-a', mode: 'opencode', restore: true, sessionRef }) +sendCreate({ requestId: 'req-b', mode: 'opencode', restore: true, sessionRef }) +hold.release() +const first = await waitForCreated('req-a') +const second = await waitForCreated('req-b') +expect(second.terminalId).toBe(first.terminalId) +expect(fakePtySpawnCountForSession(sessionRef.sessionId)).toBe(1) +``` + +Use the existing fake registry/test harness equivalent for the spawn-count assertion; do not add production-only counters. If the sequential characterization passes but the simultaneous race fails, extend the existing canonical-session binding/reuse mechanism already used by `getCanonicalRunningTerminalBySession`, `repairLegacySessionOwners`, and `SessionBindingAuthority`. The fix should reserve an in-flight canonical create for `{ mode: 'opencode', sessionRef.sessionId }` inside the existing registry/binding architecture, then resolve all waiters to the created terminal id. Do not create a parallel OpenCode-only ownership table with separate kill/exit semantics. + +Server contract if the race is real: + +- For `terminal.create` with `restore: true`, `mode: 'opencode'`, and canonical `sessionRef.provider === 'opencode'`, canonicalize the session id through the existing session-binding validation before considering reuse. +- If `getCanonicalRunningTerminalBySession` returns a live non-exited terminal, return that terminal id and do not spawn. +- If an in-flight canonical create already exists in the existing binding/reuse mechanism, await it and return that terminal id for the later request id. This closes simultaneous-create races. +- If the in-flight create rejects, clear the reservation and surface the original create error to all waiters; do not leave a permanently poisoned reservation. +- If the prior PTY exited or was killed, existing binding release must happen before a later restored create can reuse anything, so Task 7's kill-then-restore path creates a new terminal id. +- If the live PTY exists but has no attached client yet, still reuse it; each client/pane will attach after receiving `terminal.created`. +- Existing ws-handler repair paths that recover or migrate an OpenCode terminal must either flow through the same canonical binding mechanism or deliberately bypass it with a test explaining why. The Task 8 server race test should cover the main restored-create path; add a second assertion if implementation discovery shows a separate repair path can create an equivalent terminal. + +The WebSocket handler remains responsible for emitting one `terminal.created` message per incoming request id, even when the terminal id is reused: + +```ts +ws.send({ + type: 'terminal.created', + requestId: incoming.requestId, + terminalId: reusedOrCreatedTerminalId, + createdAt, +}) +``` + +- [ ] **Step 3: Run flow tests red/green** + +Run: + +```bash +npm run test:vitest -- test/e2e/opencode-visible-first-restore-flow.test.tsx --run +npm run test:vitest -- test/server/ws-terminal-create-session-repair.test.ts --run -t "same OpenCode session" +``` + +Expected: the same-pane client test fails before the `TerminalView` guard and passes after it. The sequential server characterization may already pass; the simultaneous server race test is the proof that determines whether server code needs to change. If both server tests are already green, record them as characterization coverage and do not change server code. + +- [ ] **Step 4: Commit** + +```bash +git add test/server/ws-terminal-create-session-repair.test.ts test/e2e/opencode-visible-first-restore-flow.test.tsx src/components/TerminalView.tsx server/ws-handler.ts server/terminal-registry.ts +git commit -m "test: prove opencode restore reuse and replacement binding" +``` + +--- + +## Chunk 6: Documentation, Verification, And Landing + +### File Structure + +- Modify: `/home/user/code/freshell/.worktrees/dev/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md` + - Record visible-first restore as the chosen architecture and note the race/safety-net constraints. +- Possibly modify: `/home/user/code/freshell/.worktrees/dev/docs/index.html` + - Only if visible queued restore becomes a user-facing UI mode worth documenting in the mock docs. + +### Task 9: Update Research Note + +- [ ] **Step 1: Add the decision record** + +In the OpenCode section, add: + +```md +### 2026-05-18 visible-first restore decision + +Freshell now treats stale/dead OpenCode restores as visibility-gated restore intents, not background PTYs. This is required because OpenCode's TUI screen is terminal-rendered and cannot be reconstructed from HTTP metadata if startup terminal frames are missed. A visible-started OpenCode restore carries an immediate attach obligation: if the user switches away before `terminal.created`, Freshell still attaches once so the PTY has a terminal owner from startup. + +The core invariant is that durable session identity does not grant runtime ownership. `sessionRef` identifies what to restore; `terminalId` identifies a current PTY; only a current restore-attempt lease tied to `{ sessionRef, createRequestId, terminalId, serverInstanceId }` authorizes replacement. The lease may survive a browser refresh only if the same server instance and live terminal handle are preserved; it is invalidated by server restart or dead-handle repair. The replay-gap replacement path remains only as a constrained stale-restore repair while that lease is active. Any in-memory `replaceableRestoreByTerminalIdRef` map is only an implementation aid for clearing `restoreRuntime`; it is not a separate durable contract. +``` + +- [ ] **Step 2: Update machine-readable contract** + +Add fields under `providers.opencode`: + +```json +"hiddenRestorePolicy": "queue_until_visible", +"visibleStartedRestoreAttachObligation": true, +"queuedRestoreStatus": "queued", +"queuedRestoreReason": "visible_owner_required", +"durableSessionIdentityGrantsRuntimeOwnership": false, +"runtimeReplacementAuthority": "restore_attempt_lease", +"replayGapReplacementRequiresRestoreAttemptLease": true, +"restoreAttemptLeaseFields": ["replaceOnViewportReplayGap", "createRequestId", "terminalId", "serverInstanceId"], +"restoreAttemptLeaseSurvivesSameServerRefresh": true, +"restoreAttemptLeaseInvalidatedByServerRestart": true, +"restoredCreateReuseKey": "opencode.sessionRef.sessionId" +``` + +- [ ] **Step 3: Validate JSON block** + +Run: + +```bash +node - <<'NODE' +const fs = require('fs') +const path = 'docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md' +const text = fs.readFileSync(path, 'utf8') +const match = text.match(/## Machine-readable contract\n```json\n([\s\S]*?)\n```/) +if (!match) throw new Error('machine-readable contract block not found') +JSON.parse(match[1]) +console.log('machine-readable contract JSON OK') +NODE +``` + +Expected: `machine-readable contract JSON OK`. + +- [ ] **Step 4: Run focused verification** + +Run: + +```bash +npm run typecheck +npm run lint +npm run test:vitest -- test/unit/client/store/panesSlice.test.ts --run +npm run test:vitest -- test/unit/client/store/panesPersistence.test.ts --run +npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run +npm run test:vitest -- test/unit/client/components --run -t "queued OpenCode|TabSwitcher|TabItem" +npm run test:vitest -- test/e2e/opencode-startup-probes.test.tsx --run +npm run test:vitest -- test/server/ws-terminal-create-session-repair.test.ts --run -t "opencode" +npm run test:e2e:chromium -- test/e2e-browser/specs/opencode-restart-recovery.spec.ts +``` + +Expected: + +- `npm run typecheck`: exit 0. +- `npm run lint`: exit 0; existing warnings are acceptable only if already present on dev before this branch. +- All focused tests pass. + +- [ ] **Step 5: Check broad suite status** + +Run: + +```bash +npm run test:status +``` + +If the coordinator is idle and no reusable green baseline is available, run the repo's coordinated check gate: + +```bash +FRESHELL_TEST_SUMMARY="visible-first opencode restore" npm run check +``` + +Expected: typecheck and tests pass, or document pre-existing failures with proof. + +- [ ] **Step 6: Commit docs** + +```bash +git add docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md docs/index.html +git commit -m "docs: record visible-first opencode restore contract" +``` + +Only include `docs/index.html` if it changed. + +- [ ] **Step 7: Open PR** + +Follow the repository branch model. Authored behavior changes start from `origin/main` and the PR targets `origin/main`; do not branch from `/home/user/code/freshell/.worktrees/dev` and do not open a PR with `--base dev`. If this plan depends on an OpenCode resilience prerequisite that is not yet on `origin/main`, pause and either land that prerequisite first or create an explicitly stacked PR against the prerequisite branch with user approval. + +```bash +git push -u origin +gh pr create --repo danshapiro/freshell --base main --head --title "Implement visible-first OpenCode restores" --body "" +``` + +- [ ] **Step 8: Land on dev without restarting self-hosted server** + +After the PR branch is pushed and verified, apply the PR head to `/home/user/code/freshell/.worktrees/dev` as integration-only consumption of the reviewed branch. Do not hide behavior changes in local-only `dev` commits. If applying the PR head needs semantic conflict resolution, stop and fix the PR branch or create a replacement PR. + +```bash +cd /home/user/code/freshell/.worktrees/dev +git status --short +git fetch origin +# Apply the fetched PR head using the repo's current dev integration practice. +``` + +Do not restart the self-hosted dev server unless the user explicitly says `APPROVED`. + +--- + +## Implementation Notes And Guardrails + +- Do not implement this as a local `if (hidden) return` hack in `TerminalView`. The queued state must be visible in the pane model. +- Do not start visibility-gated OpenCode restore PTYs unless an immediate visible terminal owner exists. +- Do not generalize this to Codex or Claude Code. +- Do not use redraw nudges, Ctrl-L, delayed resize, or larger replay buffers as restore contracts. +- Preserve the runtime ownership invariant: `sessionRef` is durable identity, not authority to kill or replace a PTY. +- Preserve a restore-attempt lease across browser refresh only when `createRequestId`, `terminalId`, and `serverInstanceId` all still match; strip it on dead-handle repair or server-instance change. +- Do not remove or broaden the replay-gap safety net; it is valid only while the current restore-attempt lease is active. +- Do not test reveal by remounting `TerminalView`; production toggles `hidden` on an already-mounted component. +- Do not confuse server-side same-session reuse with destructive authority; reuse uses canonical `sessionRef`, replacement uses the restore-attempt lease. +- When an OpenCode restore attaches while hidden because it was launched visible, send a real fit+resize on reveal. +- Be explicit in tests about three states: queued restore, creating restore, running attached restore. +- Preserve old sessions in the left pane even when no live terminal exists. +- If a queued pane lacks canonical `sessionRef.provider === "opencode"`, it must not silently start a fresh terminal. Surface a restore-unavailable error path. From 49bb75b28f8a4b7cf83ed49eea1fc40af5fa016f Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Mon, 18 May 2026 04:32:36 -0700 Subject: [PATCH 7/7] test: avoid duplicate OpenCode lifecycle mode option --- test/unit/client/components/TerminalView.lifecycle.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/client/components/TerminalView.lifecycle.test.tsx b/test/unit/client/components/TerminalView.lifecycle.test.tsx index 63af4c18c..4eaac29e9 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -3097,7 +3097,6 @@ describe('TerminalView lifecycle updates', () => { requestId?: string ackInitialAttach?: boolean refreshOnMount?: boolean - mode?: TerminalPaneContent['mode'] sessionRef?: TerminalPaneContent['sessionRef'] }) { const tabId = 'tab-v2-stream'