Skip to content
Merged
1,271 changes: 1,271 additions & 0 deletions docs/superpowers/plans/2026-06-01-opencode-browser-refresh-restore.md

Large diffs are not rendered by default.

33 changes: 25 additions & 8 deletions server/terminal-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { EventEmitter } from 'events'
import { logger } from './logger.js'
import { getPerfConfig, logPerfEvent, shouldLog, startPerfTimer } from './perf-logger.js'
import type { ServerSettings } from '../shared/settings.js'
import type { SessionLocator } from '../shared/ws-protocol.js'
import {
CODEX_DURABILITY_SCHEMA_VERSION,
type CodexCandidateSource,
Expand Down Expand Up @@ -132,6 +133,29 @@ export function modeSupportsResume(mode: TerminalMode): boolean {
return !!codingCliCommands.get(mode)?.resumeArgs
}

type TerminalSessionRefSource = Pick<TerminalRecord, 'mode' | 'resumeSessionId'> & {
codexDurability?: CodexDurabilityRef
}

export function buildTerminalSessionRef(record: TerminalSessionRefSource): SessionLocator | undefined {
if (!modeSupportsResume(record.mode as TerminalMode)) return undefined
if (!record.resumeSessionId) return undefined
if (
record.mode === 'codex'
&& (
record.codexDurability?.state !== 'durable'
|| record.codexDurability.durableThreadId !== record.resumeSessionId
)
) {
return undefined
}

return {
provider: record.mode as CodingCliProviderName,
sessionId: record.resumeSessionId,
}
}

type ProviderTarget = 'unix' | 'windows'

function providerNotificationArgs(
Expand Down Expand Up @@ -3295,14 +3319,7 @@ export class TerminalRegistry extends EventEmitter {
description: t.description,
mode: t.mode,
resumeSessionId: t.resumeSessionId,
sessionRef: modeSupportsResume(t.mode)
&& t.resumeSessionId
&& (t.mode !== 'codex' || (
t.codexDurability?.state === 'durable'
&& t.codexDurability.durableThreadId === t.resumeSessionId
))
? { provider: t.mode as CodingCliProviderName, sessionId: t.resumeSessionId }
: undefined,
sessionRef: buildTerminalSessionRef(t),
createdAt: t.createdAt,
lastActivityAt: t.lastActivityAt,
status: t.status,
Expand Down
4 changes: 3 additions & 1 deletion server/terminal-stream/broker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import WebSocket from 'ws'
import type { LiveWebSocket } from '../ws-handler.js'
import type { TerminalRegistry } from '../terminal-registry.js'
import { buildTerminalSessionRef, type TerminalRegistry } from '../terminal-registry.js'
import { logger } from '../logger.js'
import { logTerminalStreamPerfEvent, type TerminalStreamPerfEvent } from '../perf-logger.js'
import type { TerminalOutputRawEvent } from './registry-events.js'
Expand Down Expand Up @@ -187,13 +187,15 @@ export class TerminalStreamBroker {
})
}

const sessionRef = buildTerminalSessionRef(record)
if (!this.safeSend(ws, {
type: 'terminal.attach.ready',
terminalId,
headSeq,
replayFromSeq,
replayToSeq,
...(attachment.activeAttachRequestId ? { attachRequestId: attachment.activeAttachRequestId } : {}),
...(sessionRef ? { sessionRef } : {}),
})) {
return
}
Expand Down
49 changes: 22 additions & 27 deletions server/ws-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { logger } from './logger.js'
import { recordSessionLifecycleEvent } from './session-observability.js'
import { getPerfConfig, logPerfEvent, shouldLog, startPerfTimer } from './perf-logger.js'
import { getRequiredAuthToken, isLoopbackAddress, isOriginAllowed, timingSafeCompare } from './auth.js'
import { modeSupportsResume, terminalIdFromCreateError } from './terminal-registry.js'
import { buildTerminalSessionRef, modeSupportsResume, terminalIdFromCreateError } from './terminal-registry.js'
import type { TerminalRecord, TerminalRegistry, TerminalMode } from './terminal-registry.js'
import { configStore, type ConfigReadError, type UserConfig } from './config-store.js'
import type { CodingCliSessionManager } from './coding-cli/session-manager.js'
Expand Down Expand Up @@ -2417,57 +2417,54 @@ export class WsHandler {
const sendCreateResult = async (opts: {
ws: LiveWebSocket
requestId: string
terminalId: string
createdAt: number
effectiveResumeSessionId?: string
record: TerminalRecord
clearCodexDurability?: boolean
restoreError?: RestoreError
}): Promise<boolean> => {
if (opts.ws.readyState !== WebSocket.OPEN) {
return false
}

const sessionRef = buildTerminalSessionRef(opts.record)
this.send(opts.ws, {
type: 'terminal.created',
requestId: opts.requestId,
terminalId: opts.terminalId,
createdAt: opts.createdAt,
terminalId: opts.record.terminalId,
createdAt: opts.record.createdAt,
...(sessionRef ? { sessionRef } : {}),
...(opts.clearCodexDurability ? { clearCodexDurability: true } : {}),
...(opts.restoreError ? { restoreError: opts.restoreError } : {}),
})
return true
}

const attachReusedTerminal = async (
reusedTerminalId: string,
createdAt: number,
resumeSessionId?: string,
record: TerminalRecord,
): Promise<boolean> => {
const sent = await sendCreateResult({
ws,
requestId: m.requestId,
terminalId: reusedTerminalId,
createdAt,
effectiveResumeSessionId: resumeSessionId,
record,
})
if (!sent) {
return false
}
state.createdByRequestId.set(m.requestId, reusedTerminalId)
this.rememberCreatedRequestId(m.requestId, reusedTerminalId)
terminalId = reusedTerminalId
state.createdByRequestId.set(m.requestId, record.terminalId)
this.rememberCreatedRequestId(m.requestId, record.terminalId)
terminalId = record.terminalId
reused = true
const sessionRef = buildTerminalSessionRef(record)
recordSessionLifecycleEvent({
kind: 'terminal_created',
requestId: m.requestId,
connectionId: ws.connectionId || 'unknown',
terminalId: reusedTerminalId,
terminalId: record.terminalId,
...(m.tabId ? { tabId: m.tabId } : {}),
...(m.paneId ? { paneId: m.paneId } : {}),
...(m.cwd ? { cwd: m.cwd } : {}),
mode: m.mode as TerminalMode,
reused: true,
hasSessionRef: !!resumeSessionId,
hasSessionRef: !!sessionRef,
})
this.broadcastTerminalsChanged()
return true
Expand Down Expand Up @@ -2526,7 +2523,7 @@ export class WsHandler {
}
const existing = this.registry.get(existingId)
if (existing) {
await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId)
await attachReusedTerminal(existing)
return
}
// If it no longer exists, fall through and create a new one.
Expand Down Expand Up @@ -2602,7 +2599,7 @@ export class WsHandler {
state: 'durable',
durableThreadId: decision.sessionId,
})
await attachReusedTerminal(live.terminalId, live.createdAt, decision.sessionId)
await attachReusedTerminal(live)
broadcastCodexSessionAssociated(live.terminalId, decision.sessionId)
return
}
Expand Down Expand Up @@ -2637,7 +2634,7 @@ export class WsHandler {
'restore_proof_failed_attached_live',
)
}
await attachReusedTerminal(live.terminalId, live.createdAt, live.resumeSessionId)
await attachReusedTerminal(live)
return
} else if (decision.kind === 'proof_failed_fresh_create') {
const { candidate, proof } = decision
Expand All @@ -2661,7 +2658,7 @@ export class WsHandler {
if (!codexDurabilityForDecision?.candidate) {
const live = requestedLiveTerminal()
if (live) {
await attachReusedTerminal(live.terminalId, live.createdAt, live.resumeSessionId)
await attachReusedTerminal(live)
return
}
}
Expand All @@ -2686,7 +2683,7 @@ export class WsHandler {
codexDurabilityStoreRecordToDeleteOnSuccessfulUse,
'restore_proof_succeeded_attached_existing',
)
await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId)
await attachReusedTerminal(existing)
return
}
}
Expand All @@ -2706,7 +2703,7 @@ export class WsHandler {
}
const existing = this.registry.get(existingAfterConfigId)
if (existing) {
await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId)
await attachReusedTerminal(existing)
return
}
state.createdByRequestId.delete(m.requestId)
Expand Down Expand Up @@ -2750,7 +2747,7 @@ export class WsHandler {
codexDurabilityStoreRecordToDeleteOnSuccessfulUse,
'restore_proof_succeeded_attached_existing',
)
await attachReusedTerminal(existing.terminalId, existing.createdAt, existing.resumeSessionId)
await attachReusedTerminal(existing)
return
}
}
Expand Down Expand Up @@ -2913,9 +2910,7 @@ export class WsHandler {
const sent = await sendCreateResult({
ws,
requestId: m.requestId,
terminalId: record.terminalId,
createdAt: record.createdAt,
effectiveResumeSessionId,
record,
clearCodexDurability: clearCodexDurabilityOnCreate,
restoreError: restoreErrorOnCreate,
})
Expand Down
2 changes: 2 additions & 0 deletions shared/ws-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ export type TerminalCreatedMessage = {
requestId: string
terminalId: string
createdAt: number
sessionRef?: SessionLocator
clearCodexDurability?: boolean
restoreError?: RestoreError
}
Expand All @@ -644,6 +645,7 @@ export type TerminalAttachReadyMessage = {
replayFromSeq: number
replayToSeq: number
attachRequestId?: string
sessionRef?: SessionLocator
}

export type TerminalDetachedMessage = {
Expand Down
25 changes: 25 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { updateSettingsLocal } from '@/store/settingsSlice'
import { setTerminalMetaSnapshot, upsertTerminalMeta, removeTerminalMeta } from '@/store/terminalMetaSlice'
import { clearDeadTerminals } from '@/store/panesSlice'
import { addTerminalFreshRecoveryRequestId, addTerminalRestoreRequestId } from '@/lib/terminal-restore'
import { reconcileTerminalSessionAssociation } from '@/lib/terminal-session-association'
import { setCodexActivitySnapshot, upsertCodexActivity, removeCodexActivity, resetCodexActivity } from '@/store/codexActivitySlice'
import { setClaudeActivitySnapshot, upsertClaudeActivity, removeClaudeActivity, resetClaudeActivity } from '@/store/claudeActivitySlice'
import { setOpencodeActivitySnapshot, upsertOpencodeActivity, removeOpencodeActivity, resetOpencodeActivity } from '@/store/opencodeActivitySlice'
Expand Down Expand Up @@ -855,6 +856,20 @@ export default function App() {
if (terminalInvalidationHandler.handle(msg as any)) {
return
}
if (
(msg.type === 'terminal.session.associated'
|| msg.type === 'terminal.created'
|| msg.type === 'terminal.attach.ready')
&& typeof (msg as any).terminalId === 'string'
&& (msg as any).sessionRef
) {
reconcileTerminalSessionAssociation({
dispatch,
getState: appStore.getState,
terminalId: (msg as any).terminalId,
sessionRef: (msg as any).sessionRef,
})
}
if (msg.type === 'terminal.inventory') {
const terminals = Array.isArray(msg.terminals) ? msg.terminals : []
const terminalMeta = Array.isArray(msg.terminalMeta) ? msg.terminalMeta : []
Expand All @@ -871,6 +886,16 @@ export default function App() {
&& !(typeof record?.updatedAt === 'number' && record.updatedAt > terminalMetaRequestedAt)
))
.map(([terminalId]) => terminalId)
for (const terminal of terminals) {
if (terminal?.terminalId && terminal?.sessionRef) {
reconcileTerminalSessionAssociation({
dispatch,
getState: appStore.getState,
terminalId: terminal.terminalId,
sessionRef: terminal.sessionRef,
})
}
}
const liveIds = terminals
.filter((t: any) => t.status === 'running')
.map((t: any) => t.terminalId as string)
Expand Down
Loading
Loading