From 39b093ab5048a9c8def2f4b0dd0b28fe2dd6f6f6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:47:19 -0700 Subject: [PATCH 01/10] docs: add implementation plan for rebind-salvage-hardening --- .../2026-07-29-rebind-salvage-hardening.md | 2482 +++++++++++++++++ 1 file changed, 2482 insertions(+) create mode 100644 docs/plans/2026-07-29-rebind-salvage-hardening.md diff --git a/docs/plans/2026-07-29-rebind-salvage-hardening.md b/docs/plans/2026-07-29-rebind-salvage-hardening.md new file mode 100644 index 000000000..473dc010c --- /dev/null +++ b/docs/plans/2026-07-29-rebind-salvage-hardening.md @@ -0,0 +1,2482 @@ +# Terminal Session Rebind Hardening Implementation Plan + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Port 4 vetted salvage pieces from the discarded +`salvage/opencode-rebind-on-drift` branch (tab metadata re-keying on rebind, +stale identity-mismatch bounce suppression, duplicate-rebind idempotence +tests, an opencode rebind browser e2e) plus one new improvement (an opencode +plugin-alive heartbeat), all against main's server-authoritative +`previousSessionId` rebind mechanism. + +**Architecture:** All five deliverables harden ONE mechanism: CLI-emitted +signal files drained by serialized Rust sweeper tasks that run a guarded +rebind lane and broadcast `terminal.session.associated` with +`previousSessionId`, which the client folds via a deterministic supersession +handshake (`src/lib/terminal-session-association.ts`). Deliverable 1 fixes a +real latent client bug (stranded `tab.sessionMetadataByKey` entries). +Deliverable 2 adds the missing Rust input-path identity guard (Node parity) +plus client-side suppression of the self-healing race it creates. +Deliverables 3 and 4 are pure coverage. Deliverable 5 adds log-only +observability for silent plugin death. + +**Tech Stack:** Rust (axum/tokio, `crates/freshell-ws`, +`crates/freshell-protocol`), TypeScript/React/Redux Toolkit client, Vitest +(coordinator-gated), Playwright browser e2e, an opencode TUI plugin in +TypeScript (`extensions/opencode/freshell-rebind-plugin.ts`, embedded into +the Rust binary via `include_str!`). + +## Global Constraints + +- Base branch: `origin/main` @ `62fa0ff1`; work on branch + `feat/rebind-salvage-hardening` in worktree + `/home/dan/code/freshell/.worktrees/rebind-salvage-hardening`. All + commands below run from that worktree root. +- Reference branch `salvage/opencode-rebind-on-drift` is LOCAL-ONLY: never + push it, never merge it wholesale. Read files with + `git show salvage/opencode-rebind-on-drift:`. +- OUT OF SCOPE: the Node/TypeScript server (exists, not production — add NO + Node server functionality), the branch's drift FSM/watchers/SSE mechanism + (do not resurrect any inference-based detection), the branch's + `opencode_roots` resolver, its `try_adopt` CAS, its `test_state.rs` + refactor, and its `allowRebind` client API (main replaced it with + `previousSessionId`; the branch also added a `'rebound'` status — do not + port either). +- Strict red-green-refactor TDD for every task (verify each failing test + fails for the right reason before implementing). +- Vitest: focused runs via `npm run test:vitest -- --run` (never + raw `npx vitest`); broad runs via `FRESHELL_TEST_SUMMARY="..." npm test` + which WAITS on the shared coordinator gate — if another agent holds it, + wait (check `npm run test:status`), never kill a foreign holder. +- Rust gates (all must be clean before the final commit): + `cargo fmt --all --check`, + `cargo clippy --workspace --all-targets -- -D warnings`, + `cargo test -p freshell-ws`, `cargo test -p freshell-protocol --locked`. +- Known pre-existing flake: `crates/freshell-ws` test binary + `auto_resume_e2e` can fail under load — retry once + (`cargo test -p freshell-ws --test auto_resume_e2e`); it is not a + regression from this work. +- NodeNext/ESM: `server/**` and `scripts/**` relative imports need `.js` + extensions. Client code under `src/` uses aliases (`@/` → `src/`, + `@shared/` → `shared/`, `@test/` → `test/`) with no extension. +- `npm run lint` (eslint incl. jsx-a11y over `src/`) and + `npm run typecheck` must pass (CI requirements). No visible UI is added by + this plan. +- NEVER restart the live Rust server on port 3002 (requires the literal + word "APPROVED" from the user; not needed for this work — e2e specs boot + their own throwaway servers on ephemeral ports). +- Do NOT create a PR (handled outside this run). Commit locally per task + with Conventional Commit subjects. +- README.md is the only end-user markdown doc; this plan under `docs/plans/` + is a working/agent doc. Create no other markdown files. +- e2e runs (`npm run test:e2e -- ...`) are separate from `npm test` and not + coordinator-gated; the rust e2e fixture builds/launches the Rust server, + so `cargo` must be on PATH. + +--- + +## Deliverable 2 precondition finding (investigated; drives Task 4) + +The spec mandated verifying whether main's Rust server populates +`actual_session_ref` on the SESSION_IDENTITY_MISMATCH rejection path. +Finding (static analysis of `62fa0ff1`): + +- **Main's Rust server has NO SESSION_IDENTITY_MISMATCH path at all.** + `ErrorCode::SessionIdentityMismatch` is declared + (`crates/freshell-protocol/src/common.rs:79`) and never constructed + anywhere in `crates/`. All 9 `ErrorMsg` constructions hardcode + `actual_session_ref: None`, and none carries that code. +- **Worse: the `ClientMessage::TerminalInput` arm + (`crates/freshell-ws/src/terminal.rs:628-657`) parses + `expected_session_ref` and ignores it** — a stale-ref input frame right + after a rebind is silently delivered to the PTY. The Node reference + implementation rejects it (`server/ws-handler.ts:2902-2925`) and populates + `actualSessionRef` (`server/ws-handler.ts:939-966`). +- **The wire contract already carries the field end-to-end** — TS type + (`shared/ws-protocol.ts:716`), JSON schema + (`port/contract/ws-server-messages.schema.json` `error.actualSessionRef`), + Rust struct (`crates/freshell-protocol/src/server_messages.rs:511`), and a + serde roundtrip fixture already exercising `actualSessionRef` + (`crates/freshell-protocol/tests/roundtrip.rs:214-222`). **Zero + protocol/schema work is needed.** +- The canonical value Rust needs is available synchronously: + `state.identity.session_ref_for(&terminal_id) -> Option` + (`crates/freshell-ws/src/identity.rs:145`, already used at + `terminal.rs:3673`). + +**Resolution (per the spec's "ALSO populate it there" branch):** Deliverable +2 is NOT dropped. Task 4 adds the input-path identity guard to the Rust +server (Node parity, emitting SESSION_IDENTITY_MISMATCH with +`actual_session_ref: Some(..)` — the first non-`None` in the Rust tree), and +Task 5 ports the client suppression helper. One deliberate deviation from +Node: when the client sends an expectation but the identity registry has not +resolved yet (`(Some, None)`), the Rust guard **fails open** (delivers the +input) instead of bouncing — the codex/opencode locators resolve within a +~2s correlation window after spawn, and bouncing keystrokes during pane +startup would be a regression Node does not have an equivalent exposure to. +The suppression helper itself fails toward the visible path on any +absent/unparseable ref, as the spec requires. + +Ordering constraint: the client suppression only fires when the rebind fold +has already synced `contentRef.current` (main does this synchronously on a +`'reconciled'` result, `TerminalView.tsx:~4288`), so Tasks 4–5 land after +the Deliverable-1 tasks. + +## File structure (locked decomposition) + +| File | Responsibility | Tasks | +|---|---|---| +| `src/store/persistControl.ts` | Modify: `buildTerminalDurableSessionRefUpdate` gains `tabSessionMetadataByKey` + re-key via existing `mergeSessionMetadataForPreferredResumeId` | 1 | +| `test/unit/client/store/persistControl.test.ts` | Modify: new re-key describe block | 1 | +| `src/lib/terminal-session-association.ts` | Modify: one-line caller wiring | 2 | +| `test/unit/client/lib/terminal-session-association.test.ts` | Modify: `createState` tab-overrides param, re-key fold test, idempotence regression guards | 2, 3 | +| `crates/freshell-ws/src/terminal.rs` | Modify: input-path identity guard + pure helpers + in-module tests | 4 | +| `crates/freshell-ws/tests/opencode_switch_rebind.rs` | Modify: new Phase 1b (stale-input bounce over the wire) | 4 | +| `crates/freshell-protocol/tests/roundtrip.rs` | Modify: SESSION_IDENTITY_MISMATCH fixture | 4 | +| `src/components/terminal-view-utils.ts` | Modify: add `isStaleSessionIdentityMismatch` | 5 | +| `test/unit/client/components/terminal-view-utils.session-mismatch.test.ts` | Create: pure helper tests | 5 | +| `src/components/TerminalView.tsx` | Modify: 4-line suppression early-return | 5 | +| `test/unit/client/components/TerminalView.codex-identity.test.tsx` | Modify: suppression wiring test | 5 | +| `crates/freshell-ws/src/opencode_signal.rs` | Modify: hello discrimination in the drain, hello tracker, heartbeat WARN + in-module tests | 6 | +| `extensions/opencode/freshell-rebind-plugin.ts` | Modify: `emitHello` at TUI startup | 7 | +| `test/unit/server/opencode-rebind-plugin.test.ts` | Modify: hello emission tests | 7 | +| `test/e2e-browser/specs/opencode-rebind-rust.spec.ts` | Create: signal-driven rebind browser e2e (rust-only) | 8 | +| `test/e2e-browser/playwright.config.ts` | Modify: register the rust-only spec in both lists | 8 | + +Scope check: all five deliverables harden one subsystem (terminal session +rebind) and share test surfaces; one plan is correct. Deliverables 1–3 and 5 +are independent; 4 (e2e) lands last so it exercises the finished stack. + +--- + +### Task 1: Tab session-metadata re-key in `buildTerminalDurableSessionRefUpdate` + +**Files:** +- Modify: `src/store/persistControl.ts:13-67` (`buildTerminalDurableSessionRefUpdate`) +- Test: `test/unit/client/store/persistControl.test.ts` + +**Interfaces:** +- Consumes: `mergeSessionMetadataForPreferredResumeId` (same file, `:121-205`, + unchanged) and `SessionListMetadata` (already imported at the top of + `persistControl.ts` — no import change needed). +- Produces: `buildTerminalDurableSessionRefUpdate` accepts a NEW optional + param `tabSessionMetadataByKey?: Record`; + when re-keying occurs, the returned `tabUpdates` carries + `sessionMetadataByKey: Record`. Task 2's + caller passes `tab.sessionMetadataByKey` into it. All existing params and + the return shape are unchanged; `shouldFlush` semantics are unchanged (a + metadata-only change yields `shouldFlush: false` from this function — the + Task-2 caller force-flushes on non-empty `tabUpdates`). + +Background: the fresh-agent path (`buildFreshAgentPersistedIdentityUpdate`, +same file `:207-284`) already re-keys metadata with exactly this idiom; this +task copies that idiom into the terminal path, as the salvage branch did +(`git diff main..salvage/opencode-rebind-on-drift -- src/store/persistControl.ts`). + +- [ ] **Step 1: Write the failing tests** + +Append a new top-level describe block at the end of +`test/unit/client/store/persistControl.test.ts`, and add +`buildTerminalDurableSessionRefUpdate` to the existing +`@/store/persistControl` import list: + +```ts +import { + buildTerminalDurableSessionRefUpdate, + flushPersistedLayoutNow, + getCanonicalDurableSessionId, + getPreferredResumeSessionId, +} from '@/store/persistControl' +``` + +```ts +describe('buildTerminalDurableSessionRefUpdate metadata re-key', () => { + it('moves sessionMetadataByKey from the old session key to the new one on rebind', () => { + const update = buildTerminalDurableSessionRefUpdate({ + provider: 'opencode', + sessionId: 'ses_new', + paneSessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + tabSessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + paneResumeSessionId: undefined, + tabResumeSessionId: undefined, + tabSessionMetadataByKey: { + 'opencode:ses_old': { sessionType: 'opencode', firstUserMessage: 'hello world' }, + }, + }) + expect(update?.tabUpdates?.sessionMetadataByKey).toBeDefined() + expect(update!.tabUpdates!.sessionMetadataByKey!['opencode:ses_new']).toMatchObject({ firstUserMessage: 'hello world' }) + expect(update!.tabUpdates!.sessionMetadataByKey!['opencode:ses_old']).toBeUndefined() + }) + + it('returns null when nothing needs re-keying (metadata already under the new key)', () => { + const update = buildTerminalDurableSessionRefUpdate({ + provider: 'opencode', + sessionId: 'ses_new', + paneSessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + tabSessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + paneResumeSessionId: undefined, + tabResumeSessionId: undefined, + tabSessionMetadataByKey: { + 'opencode:ses_new': { sessionType: 'opencode' }, + }, + }) + expect(update).toBeNull() + }) + + it('does not add a sessionMetadataByKey update when the metadata map is absent or empty', () => { + for (const tabSessionMetadataByKey of [undefined, {}]) { + const update = buildTerminalDurableSessionRefUpdate({ + provider: 'opencode', + sessionId: 'ses_new', + paneSessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + tabSessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + paneResumeSessionId: undefined, + tabResumeSessionId: undefined, + tabSessionMetadataByKey, + }) + expect(update?.tabUpdates?.sessionRef).toEqual({ provider: 'opencode', sessionId: 'ses_new' }) + expect(update?.tabUpdates !== undefined && 'sessionMetadataByKey' in update.tabUpdates).toBe(false) + } + }) +}) +``` + +(Note: the salvage branch's second test used a weak optional-chain +assertion; the `expect(update).toBeNull()` above is the strengthened form — +with pane and tab already on `ses_new`, no resume ids, and metadata +unchanged, the function's final guard returns `null`.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +npm run test:vitest -- test/unit/client/store/persistControl.test.ts --run +``` +Expected: FAIL — first test fails with `tabSessionMetadataByKey` being an +excess/unknown property (TS) or `update.tabUpdates.sessionMetadataByKey` +undefined at runtime. The other two may pass incidentally; the first MUST +fail. (Typecheck failures on the unknown param count as the right reason.) + +- [ ] **Step 3: Implement the re-key** + +In `src/store/persistControl.ts`, replace the whole +`buildTerminalDurableSessionRefUpdate` function (currently lines 13-67) with: + +```ts +export function buildTerminalDurableSessionRefUpdate({ + provider, + sessionId, + paneSessionRef, + tabSessionRef, + paneResumeSessionId, + tabResumeSessionId, + tabSessionMetadataByKey, +}: { + provider?: CodingCliProviderName + sessionId?: string + paneSessionRef?: SessionRef + tabSessionRef?: SessionRef + paneResumeSessionId?: string + tabResumeSessionId?: string + tabSessionMetadataByKey?: Record +}): { + paneUpdates?: { sessionRef?: SessionRef; resumeSessionId?: undefined } + tabUpdates?: Partial + shouldFlush: boolean +} | null { + const sessionRef = provider && sessionId + ? sanitizeSessionRef({ provider, sessionId }) + : undefined + if (!sessionRef) return null + + const paneNeedsSessionRef = !sessionRefEquals(paneSessionRef, sessionRef) + const tabNeedsSessionRef = !sessionRefEquals(tabSessionRef, sessionRef) + const paneNeedsResumeClear = typeof paneResumeSessionId === 'string' + const tabNeedsResumeClear = typeof tabResumeSessionId === 'string' + + const paneUpdates = paneNeedsSessionRef || paneNeedsResumeClear + ? { + ...(paneNeedsSessionRef ? { sessionRef } : {}), + ...(paneNeedsResumeClear ? { resumeSessionId: undefined } : {}), + } + : undefined + + const nextTabUpdates: Partial = { + ...(tabNeedsSessionRef ? { sessionRef } : {}), + ...(tabNeedsResumeClear ? { resumeSessionId: undefined } : {}), + } + + // Rebind metadata re-key (same idiom as buildFreshAgentPersistedIdentityUpdate + // below): merge whatever the tab knew under the superseded id(s) into the + // `${provider}:${sessionId}` key of the new canonical id, deleting the old + // keys. The merge helper ALWAYS returns a fresh object, so deep-compare + // before writing to avoid churn updates on every broadcast. + const nextSessionMetadataByKey = mergeSessionMetadataForPreferredResumeId({ + localSessionMetadataByKey: tabSessionMetadataByKey, + remoteSessionMetadataByKey: tabSessionMetadataByKey, + existingSessionMetadataByKey: tabSessionMetadataByKey, + provider, + localResumeSessionId: tabSessionRef?.sessionId ?? tabResumeSessionId, + remoteResumeSessionId: paneSessionRef?.sessionId ?? paneResumeSessionId, + preferredResumeSessionId: sessionRef.sessionId, + }) + + if (JSON.stringify(nextSessionMetadataByKey ?? {}) !== JSON.stringify(tabSessionMetadataByKey ?? {})) { + nextTabUpdates.sessionMetadataByKey = nextSessionMetadataByKey + } + + const tabUpdates = Object.keys(nextTabUpdates).length > 0 ? nextTabUpdates : undefined + + const shouldFlush = paneNeedsSessionRef || tabNeedsSessionRef || paneNeedsResumeClear || tabNeedsResumeClear + + if (!paneUpdates && !tabUpdates && !shouldFlush) { + return null + } + + return { + paneUpdates, + tabUpdates, + shouldFlush, + } +} +``` + +No import changes: `SessionListMetadata` is already imported, and +`mergeSessionMetadataForPreferredResumeId` is defined later in the same file +(function hoisting makes the forward reference legal — this matches how the +fresh-agent path already uses it). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +npm run test:vitest -- test/unit/client/store/persistControl.test.ts --run +``` +Expected: PASS (all tests in the file, including the pre-existing ones). + +- [ ] **Step 5: Refactor check** + +Re-read the modified function against +`buildFreshAgentPersistedIdentityUpdate` — the two re-key idioms should now +be visibly parallel. Do not extract a shared helper (the fresh-agent path +carries extra `restoreError`/`codingCliProvider`/`sessionType` concerns; +forcing them together would obscure both). Run: +```bash +npm run typecheck && npm run lint +``` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/store/persistControl.ts test/unit/client/store/persistControl.test.ts +git commit -m "feat(client): re-key tab session metadata to the new id in buildTerminalDurableSessionRefUpdate" +``` + +--- + +### Task 2: Caller wiring — pass `tab.sessionMetadataByKey` through the rebind fold + +**Files:** +- Modify: `src/lib/terminal-session-association.ts:~130` (the + `buildTerminalDurableSessionRefUpdate` call site) +- Test: `test/unit/client/lib/terminal-session-association.test.ts` + +**Interfaces:** +- Consumes: Task 1's `tabSessionMetadataByKey` param. +- Produces: after an accepted rebind broadcast, the dispatched + `updateTab({ id, updates })` payload carries the re-keyed + `sessionMetadataByKey`, and (because the caller already forces + `shouldFlush = true` on any non-empty `tabUpdates`) + `flushPersistedLayoutNow()` is dispatched. Also produces the test + fixture change other tasks rely on: `createState(content, tabOverrides)` + (Task 3 uses `tabOverrides`). + +- [ ] **Step 1: Extend the test fixture and write the failing test** + +In `test/unit/client/lib/terminal-session-association.test.ts`: + +1. Add `updateTab` to the imports: + +```ts +import { describe, expect, it, vi } from 'vitest' +import { reconcileTerminalSessionAssociation } from '@/lib/terminal-session-association' +import { reconcileTerminalSessionRefByTerminalId } from '@/store/panesSlice' +import { flushPersistedLayoutNow } from '@/store/persistControl' +import { updateTab } from '@/store/tabsSlice' +``` + +2. Give `createState` a tab-overrides param (existing callers are + unaffected — the default is `{}`): + +```ts +function createState(content: Record, tabOverrides: Record = {}) { + return { + panes: { + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content, + }, + }, + }, + tabs: { + tabs: [{ + id: 'tab-1', + title: 'Tab 1', + status: 'running', + ...tabOverrides, + }], + }, + } as any +} +``` + +3. Append this test inside the existing + `describe('server-authoritative rebind (previousSessionId)')` block: + +```ts + it('re-keys tab session metadata from the superseded id to the new id on rebind', () => { + const dispatched: any[] = [] + const dispatch = vi.fn((action) => dispatched.push(action)) + const getState = () => createState( + { + kind: 'terminal', + terminalId: 'term-1', + createRequestId: 'req-1', + status: 'running', + mode: 'opencode', + shell: 'system', + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + }, + { + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + sessionMetadataByKey: { + 'opencode:ses_old': { sessionType: 'opencode', firstUserMessage: 'hello world' }, + }, + }, + ) + const result = reconcileTerminalSessionAssociation({ + dispatch, + getState, + terminalId: 'term-1', + sessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + previousSessionId: 'ses_old', + }) + expect(result).toBe('reconciled') + const tabUpdate = dispatched.find((action) => action.type === updateTab.type) + expect(tabUpdate).toBeDefined() + expect(tabUpdate.payload.updates.sessionMetadataByKey).toEqual({ + 'opencode:ses_new': { sessionType: 'opencode', firstUserMessage: 'hello world' }, + }) + expect(dispatched.map((action) => action.type)).toContain(flushPersistedLayoutNow.type) + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +npm run test:vitest -- test/unit/client/lib/terminal-session-association.test.ts --run -t "re-keys tab session metadata" +``` +Expected: FAIL — `tabUpdate.payload.updates.sessionMetadataByKey` is +`undefined` (the caller does not pass the metadata map yet). All +pre-existing tests in the file must still pass: +```bash +npm run test:vitest -- test/unit/client/lib/terminal-session-association.test.ts --run +``` + +- [ ] **Step 3: Wire the caller (one line)** + +In `src/lib/terminal-session-association.ts`, at the +`buildTerminalDurableSessionRefUpdate` call (~line 121-128), add the final +argument line: + +```ts + const durableIdentityUpdate = buildTerminalDurableSessionRefUpdate({ + provider: sessionRef.provider as CodingCliProviderName, + sessionId: sessionRef.sessionId, + paneSessionRef: content.sessionRef, + tabSessionRef: tab.sessionRef, + paneResumeSessionId: content.resumeSessionId, + tabResumeSessionId: tab.resumeSessionId, + tabSessionMetadataByKey: tab.sessionMetadataByKey, + }) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +npm run test:vitest -- test/unit/client/lib/terminal-session-association.test.ts test/unit/client/store/persistControl.test.ts --run +``` +Expected: PASS (all). + +- [ ] **Step 5: Guard against collateral damage in adjacent suites** + +The fold participates in cross-tab sync and App-level handling; run the +neighboring suites: +```bash +npm run test:vitest -- test/unit/client/store/crossTabSync.test.ts --run +npm run typecheck && npm run lint +``` +Expected: PASS / clean. (Do NOT port the salvage branch's +`crossTabSync.test.ts` rewrite — it is a cosmetic rename with zero +behavioral delta.) + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/terminal-session-association.ts test/unit/client/lib/terminal-session-association.test.ts +git commit -m "feat(client): carry tab session metadata through the terminal rebind fold" +``` + +Known accepted limitation (do not "fix" silently): tab-side updates — +including this re-key — only fire for single-pane tabs +(`isSinglePaneTerminalMatch`). That is main's existing invariant for ALL +tab-side rebind state, not a regression introduced here. + +--- + +### Task 3: Duplicate-rebind idempotence regression guards (tests only) + +**Files:** +- Test: `test/unit/client/lib/terminal-session-association.test.ts` +- Modify (ONLY if the second test genuinely fails): + `src/lib/terminal-session-association.ts` + +**Interfaces:** +- Consumes: Task 2's `createState(content, tabOverrides)` fixture and + main's `previousSessionId` API. Do NOT copy the salvage branch's + `allowRebind: true` test API. +- Produces: regression coverage for the repeat-broadcast path. No exported + surface changes. + +Expectation setting (from static analysis of the fold): both tests are +expected to PASS on main as written — they are characterization guards for +a path main never tested. The load-bearing fixture detail is the tab +override: after the first rebind's flush the persisted tab also carries the +new ref, and only a tab whose `sessionRef` already matches suppresses the +tab-side flush (`buildTerminalDurableSessionRefUpdate`'s +`tabNeedsSessionRef`). A fixture without it observes a flush that is a +fixture artifact, not a bug. + +- [ ] **Step 1: Write the two tests** + +Append a new top-level describe block at the end of +`test/unit/client/lib/terminal-session-association.test.ts`: + +```ts +describe('duplicate rebind broadcasts (idempotence regression guards)', () => { + const boundPane = { + kind: 'terminal' as const, + terminalId: 'term-1', + createRequestId: 'req-1', + status: 'running' as const, + mode: 'opencode' as const, + shell: 'system' as const, + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + } + + it('a stale repeat whose previousSessionId no longer matches the pane current ref dispatches nothing', () => { + // The pane has already moved on to ses_new2; an old rebind broadcast + // (ses_old -> ses_new) arrives late. The supersession handshake must + // veto it: previousSessionId (ses_old) is not the pane's current ref. + const dispatched: any[] = [] + const dispatch = vi.fn((action) => dispatched.push(action)) + const result = reconcileTerminalSessionAssociation({ + dispatch, + getState: () => createState( + { ...boundPane, sessionRef: { provider: 'opencode', sessionId: 'ses_new2' } }, + { sessionRef: { provider: 'opencode', sessionId: 'ses_new2' } }, + ), + terminalId: 'term-1', + sessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + previousSessionId: 'ses_old', + }) + expect(result).toBe('conflict') + expect(dispatch).not.toHaveBeenCalled() + }) + + it('an identical repeat neither re-dispatches updateTab nor flushes the persisted layout again', () => { + // Post-first-rebind state: pane AND tab already carry ses_new. The tab + // override is REQUIRED: after the first rebind's flush, the persisted + // tab also carries the new ref -- and only a tab whose sessionRef + // already matches suppresses the tab-side flush + // (buildTerminalDurableSessionRefUpdate's tabNeedsSessionRef). + const dispatched: any[] = [] + const dispatch = vi.fn((action) => dispatched.push(action)) + const result = reconcileTerminalSessionAssociation({ + dispatch, + getState: () => createState( + { ...boundPane, sessionRef: { provider: 'opencode', sessionId: 'ses_new' } }, + { sessionRef: { provider: 'opencode', sessionId: 'ses_new' } }, + ), + terminalId: 'term-1', + sessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + previousSessionId: 'ses_old', + }) + expect(result).toBe('reconciled') + const types = dispatched.map((action) => action.type) + expect(types).not.toContain(flushPersistedLayoutNow.type) + expect(types).not.toContain(updateTab.type) + }) +}) +``` + +- [ ] **Step 2: Run and interpret honestly** + +Run: +```bash +npm run test:vitest -- test/unit/client/lib/terminal-session-association.test.ts --run +``` + +- Expected: PASS (both). These are then committed as regression guards — + the spec explicitly frames this deliverable as "tests only" unless a real + bug surfaces. +- If the SECOND test FAILS with `flushPersistedLayoutNow` (or `updateTab`) + in the dispatched types, that is a real double-flush bug. Diagnose which + gate leaked: log the `updateTab` payload — if it contains + `sessionMetadataByKey`, Task 1's JSON deep-compare guard regressed (fix + there: the compare must run against `tabSessionMetadataByKey ?? {}` on + both sides exactly as written in Task 1 Step 3); if it contains + `sessionRef`, the state fixture is missing the tab override (fixture bug, + fix the test); if `flushPersistedLayoutNow` fires with an EMPTY + dispatched `updateTab` absent, the leak is + `terminalPaneNeedsDurableIdentityUpdate` — fix minimally in + `src/lib/terminal-session-association.ts` by making the leaking predicate + arm value-guarded, and keep the fix to the single predicate. Re-run until + green, and note the fix in the commit message. + +- [ ] **Step 3: Commit** + +```bash +git add test/unit/client/lib/terminal-session-association.test.ts +git commit -m "test(client): duplicate-rebind idempotence regression guards for the association fold" +``` +(If Step 2 required a fold fix, also `git add src/lib/terminal-session-association.ts` +and use subject `fix(client): make the rebind fold idempotent on repeat broadcasts`.) + +--- + +### Task 4: Rust input-path identity guard emitting SESSION_IDENTITY_MISMATCH with `actual_session_ref` + +**Files:** +- Modify: `crates/freshell-ws/src/terminal.rs` — the + `ClientMessage::TerminalInput` arm (currently `:628-657`) + two new pure + helpers + a new in-module test mod +- Modify: `crates/freshell-ws/tests/opencode_switch_rebind.rs` — new + "Phase 1b" after Phase 1 +- Modify: `crates/freshell-protocol/tests/roundtrip.rs` — add a + SESSION_IDENTITY_MISMATCH error fixture + +**Interfaces:** +- Consumes: `state.identity.session_ref_for(&terminal_id) -> Option` + (`crates/freshell-ws/src/identity.rs:145`); `ErrorMsg` / `ErrorCode::SessionIdentityMismatch` + / `SessionLocator` from `freshell-protocol` (all existing); the arm's + existing `send(ws_tx, &msg).await` helper (see the adjacent + `unknown_terminal_input_blocked` bounce in the same arm for the exact + send-and-return shape). +- Produces: on a `terminal.input` frame whose `expected_session_ref` is + `Some` AND the terminal's canonical identity is `Some` AND they differ, + the server sends + `error{code: SESSION_IDENTITY_MISMATCH, terminalId, expectedSessionRef, actualSessionRef: Some(canonical)}` + and does NOT write to the PTY. `(None, _)` and `(Some, None)` deliver as + before (fail-open — see the precondition finding above). Task 5's client + suppression consumes the `actualSessionRef` echo. Wire message string + matches Node: `"Terminal session does not match the expected session."`. + +- [ ] **Step 1: Write the failing unit tests** + +In `crates/freshell-ws/src/terminal.rs`, next to the existing +`mod attach_geometry_tests` (`:4866`), add: + +```rust +#[cfg(test)] +mod input_identity_tests { + use super::{input_session_identity_mismatch_error, input_session_identity_ok}; + use freshell_protocol::SessionLocator; + + fn locator(provider: &str, session_id: &str) -> SessionLocator { + SessionLocator { + provider: provider.to_string(), + session_id: session_id.to_string(), + } + } + + #[test] + fn no_expectation_always_ok() { + assert!(input_session_identity_ok(None, None)); + assert!(input_session_identity_ok(None, Some(&locator("opencode", "ses_a")))); + } + + #[test] + fn unresolved_identity_fails_open() { + // Deliberately laxer than attach_geometry_identity_ok: during the + // codex/opencode locator correlation window (~2s) canonical identity + // is None while a restored client already sends an expectation. + // Never drop keystrokes at pane startup. + assert!(input_session_identity_ok( + Some(&locator("opencode", "ses_a")), + None + )); + } + + #[test] + fn matching_expectation_ok() { + assert!(input_session_identity_ok( + Some(&locator("opencode", "ses_a")), + Some(&locator("opencode", "ses_a")) + )); + } + + #[test] + fn differing_expectation_is_a_mismatch() { + assert!(!input_session_identity_ok( + Some(&locator("opencode", "ses_a")), + Some(&locator("opencode", "ses_b")) + )); + } + + #[test] + fn mismatch_error_serializes_with_actual_session_ref() { + let msg = input_session_identity_mismatch_error( + "term-1", + locator("opencode", "ses_a"), + Some(locator("opencode", "ses_b")), + ); + let value = serde_json::to_value(&msg).expect("serialize"); + assert_eq!(value["type"], "error"); + assert_eq!(value["code"], "SESSION_IDENTITY_MISMATCH"); + assert_eq!(value["terminalId"], "term-1"); + assert_eq!(value["expectedSessionRef"]["sessionId"], "ses_a"); + assert_eq!(value["actualSessionRef"]["sessionId"], "ses_b"); + assert_eq!( + value["message"], + "Terminal session does not match the expected session." + ); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: +```bash +cargo test -p freshell-ws input_identity_tests +``` +Expected: COMPILE FAILURE — `input_session_identity_ok` and +`input_session_identity_mismatch_error` do not exist yet. That is the +correct RED for new pure functions. + +- [ ] **Step 3: Implement the pure helpers** + +In `crates/freshell-ws/src/terminal.rs`, next to +`attach_geometry_identity_ok` (`:3597` region), add: + +```rust +/// Input-path identity guard (Node parity: `server/ws-handler.ts:2902-2925` +/// `inputIfSessionMatches`). Deliberately LAXER than +/// `attach_geometry_identity_ok` in the `(Some, None)` case: during the +/// codex/opencode locator correlation window the client may already hold a +/// restored sessionRef while the identity registry has not resolved yet -- +/// attach's silent resize-skip is harmless there, a dropped keystroke is +/// not, so unresolved identity FAILS OPEN and the input is delivered. +fn input_session_identity_ok( + expected: Option<&SessionLocator>, + canonical: Option<&SessionLocator>, +) -> bool { + match (expected, canonical) { + (None, _) => true, + (Some(_), None) => true, + (Some(e), Some(c)) => e == c, + } +} + +/// The bounce frame for a stale-expectation `terminal.input`: the FIRST +/// `actual_session_ref: Some(..)` emitter in the Rust tree. The client's +/// stale-window suppression (terminal-view-utils.ts +/// isStaleSessionIdentityMismatch) keys on that echo, so it must carry the +/// canonical ref whenever one exists (and the guard only fires when it does). +fn input_session_identity_mismatch_error( + terminal_id: &str, + expected: SessionLocator, + actual: Option, +) -> ServerMessage { + ServerMessage::Error(ErrorMsg { + code: ErrorCode::SessionIdentityMismatch, + message: "Terminal session does not match the expected session.".to_string(), + timestamp: crate::now_iso(), + actual_session_ref: actual, + expected_session_ref: Some(expected), + request_id: None, + retry_after_ms: None, + terminal_exit_code: None, + terminal_id: Some(terminal_id.to_string()), + }) +} +``` + +(Match the surrounding file's existing imports — `ErrorMsg`, `ErrorCode`, +`ServerMessage`, `SessionLocator` are already in scope in this file; if any +is missing at this location, extend the existing `use freshell_protocol::…` +line rather than adding a new one.) + +Run: +```bash +cargo test -p freshell-ws input_identity_tests +``` +Expected: PASS (5 tests). + +- [ ] **Step 4: Wire the guard into the TerminalInput arm and add the wire-level integration proof (RED first)** + +4a. Add "Phase 1b" to +`crates/freshell-ws/tests/opencode_switch_rebind.rs`, immediately after +Phase 1's final assertion (registry resume follows the rebind, ~line 469) +and before Phase 2. Phase 1 has just rebound terminal `tid1` from session +`A` to session `B`, so its canonical identity is `B`: + +```rust + // ────── Phase 1b — stale-expectation input bounce (Node parity, + // server/ws-handler.ts:2902-2925): an in-flight terminal.input still + // carrying the OLD ref A after the A→B rebind is bounced with + // SESSION_IDENTITY_MISMATCH echoing actualSessionRef=B and is NOT + // delivered; a frame carrying the NEW ref B is delivered with no error. + send_json( + &mut ws, + json!({ + "type": "terminal.input", + "terminalId": tid1, + "data": "stale-ref-keystroke", + "expectedSessionRef": { "provider": "opencode", "sessionId": A }, + }), + ) + .await; + let bounce = wait_for_frame(&mut ws, "phase1b/mismatch", |v| { + v["type"] == "error" + && v["code"] == "SESSION_IDENTITY_MISMATCH" + && v["terminalId"] == json!(tid1) + }) + .await; + assert_eq!( + bounce["actualSessionRef"], + json!({ "provider": "opencode", "sessionId": B }), + "bounce must echo the canonical (post-rebind) ref: {bounce}" + ); + assert_eq!( + bounce["expectedSessionRef"], + json!({ "provider": "opencode", "sessionId": A }), + "bounce must echo the stale expectation: {bounce}" + ); + + send_json( + &mut ws, + json!({ + "type": "terminal.input", + "terminalId": tid1, + "data": "fresh-ref-keystroke\r", + "expectedSessionRef": { "provider": "opencode", "sessionId": B }, + }), + ) + .await; + let bounced_again = frame_seen_within(&mut ws, Duration::from_secs(1), |v| { + v["type"] == "error" && v["code"] == "SESSION_IDENTITY_MISMATCH" + }) + .await; + assert!( + !bounced_again, + "a matching expectedSessionRef must be delivered, not bounced" + ); +``` + +Adaptation notes (do these against the file as it exists — its helpers are +private to the file): +- `frame_seen_within` already exists in this file; reuse it. +- For `send_json` and `wait_for_frame`: FIRST check how this file's + existing helpers (`send_create`, `next_associated_frame` at ~`:232-256`) + send/receive frames, and reuse or generalize them. If no generic + raw-send/raw-wait helpers exist, add these two next to + `write_opencode_signal`, matching the ws client type used by + `send_create` (same crate-internal test idiom): + +```rust +async fn send_json(ws: &mut WsClient, value: serde_json::Value) { + // Mirror EXACTLY how send_create transmits its frame (same sink type, + // same Message::Text construction) -- generalized to any payload. +} + +async fn wait_for_frame( + ws: &mut WsClient, + label: &str, + pred: impl Fn(&serde_json::Value) -> bool, +) -> serde_json::Value { + // Mirror next_associated_frame's deadline-poll loop (10s budget), + // generalized to an arbitrary predicate; panic with `label` on timeout. +} +``` + +(`WsClient` here stands for whatever concrete ws type `send_create` takes in +this file — use that exact type.) + +4b. Run to verify RED: +```bash +cargo test -p freshell-ws --test opencode_switch_rebind +``` +Expected: FAIL at `phase1b/mismatch` timeout — the server currently ignores +`expectedSessionRef` on input, so no error frame ever arrives. If instead +Phase 1b fails on the `actualSessionRef` assertion AFTER Step 4c, the +identity registry did not follow the rebind — investigate +`state.identity.session_ref_for(&tid1)` at that point before proceeding +(the rebind lane is expected to update it; Phase 1's registry assertion +covers the meta registry, this covers the identity registry). + +4c. GREEN — wire the guard. In the `ClientMessage::TerminalInput` arm +(`crates/freshell-ws/src/terminal.rs:628`), insert the guard as the FIRST +statement of the arm (before `codex_association::note_possible_submit` — +a bounced frame must not arm the codex first-submit lane either): + +```rust + ClientMessage::TerminalInput(input) => { + // Node parity (server/ws-handler.ts:2902-2925): a terminal.input + // frame whose expectedSessionRef no longer matches the terminal's + // canonical identity is bounced with SESSION_IDENTITY_MISMATCH + // (actualSessionRef populated) instead of being written to a PTY + // the client no longer means. Unresolved identity fails open -- + // see input_session_identity_ok. + if let Some(expected) = input.expected_session_ref.as_ref() { + let canonical = state.identity.session_ref_for(&input.terminal_id); + if !input_session_identity_ok(Some(expected), canonical.as_ref()) { + return send( + ws_tx, + &input_session_identity_mismatch_error( + &input.terminal_id, + expected.clone(), + canonical, + ), + ) + .await; + } + } + // ...existing arm body unchanged (note_possible_submit, + // registry.input, unknown_terminal_input_blocked, opencode + // note_possible_submit, `true`)... + } +``` + +(Follow the arm's existing bounce for the exact `return send(...).await` +shape — the `unknown_terminal_input_blocked` path a few lines below does +exactly this.) + +- [ ] **Step 5: Run the integration test to verify it passes** + +```bash +cargo test -p freshell-ws --test opencode_switch_rebind +cargo test -p freshell-ws input_identity_tests +``` +Expected: PASS. Then guard against collateral regressions in the +suites that exercise input/attach/restore paths: +```bash +cargo test -p freshell-ws +``` +Expected: PASS (retry `--test auto_resume_e2e` once if it flakes — known, +pre-existing). + +- [ ] **Step 6: Extend the protocol roundtrip fixture** + +In `crates/freshell-protocol/tests/roundtrip.rs`, find the error-message +fixture block (~lines 214-222, which already roundtrips an error carrying +`actualSessionRef`) and add a sibling fixture that pins the +SESSION_IDENTITY_MISMATCH code together with a populated `actualSessionRef` +— copy the adjacent entry's exact structure, changing only +`"code": "SESSION_IDENTITY_MISMATCH"` and the two session refs (e.g. +expected `ses_a` / actual `ses_b`). Run: +```bash +cargo test -p freshell-protocol --locked +``` +Expected: PASS. + +- [ ] **Step 7: Rust hygiene + commit** + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +git add crates/freshell-ws/src/terminal.rs crates/freshell-ws/tests/opencode_switch_rebind.rs crates/freshell-protocol/tests/roundtrip.rs +git commit -m "feat(ws): bounce stale-identity terminal.input with SESSION_IDENTITY_MISMATCH carrying actualSessionRef" +``` + +--- + +### Task 5: Client suppression of stale identity-mismatch bounces + +**Files:** +- Modify: `src/components/terminal-view-utils.ts` (add import + private + helper + exported `isStaleSessionIdentityMismatch`) +- Create: `test/unit/client/components/terminal-view-utils.session-mismatch.test.ts` +- Modify: `src/components/TerminalView.tsx` (import + 4-line early return + at `:~4352`) +- Modify: `test/unit/client/components/TerminalView.codex-identity.test.tsx` + (one wiring test) + +**Interfaces:** +- Consumes: `msg.actualSessionRef` on the error frame (already on the TS + type, `shared/ws-protocol.ts:716`; populated by Task 4's server change); + `sanitizeSessionRef` from `@shared/session-contract`; + `contentRef.current?.sessionRef` inside the TerminalView handler. +- Produces: + `export function isStaleSessionIdentityMismatch(currentSessionRef: unknown, actualSessionRef: unknown): boolean` + in `src/components/terminal-view-utils.ts` — returns `true` (suppress) + iff BOTH refs sanitize to full `{provider, sessionId}` refs AND they are + equal; any absent/partial/unparseable ref returns `false` (fail toward + the visible path). + +- [ ] **Step 1: Create the failing pure-helper test file** + +Create +`test/unit/client/components/terminal-view-utils.session-mismatch.test.ts` +(ported verbatim from the salvage branch): + +```ts +import { describe, it, expect } from 'vitest' +import { isStaleSessionIdentityMismatch } from '@/components/terminal-view-utils' + +describe('isStaleSessionIdentityMismatch (rebind swap-window suppression)', () => { + const sesB = { provider: 'opencode', sessionId: 'ses_B' } + + it('suppresses when actualSessionRef matches the pane current ref (fold already applied; stale bounce)', () => { + expect(isStaleSessionIdentityMismatch(sesB, { provider: 'opencode', sessionId: 'ses_B' })).toBe(true) + }) + + it('does not suppress when actualSessionRef differs from the current ref (a REAL mismatch)', () => { + expect(isStaleSessionIdentityMismatch(sesB, { provider: 'opencode', sessionId: 'ses_A' })).toBe(false) + expect(isStaleSessionIdentityMismatch({ provider: 'opencode', sessionId: 'ses_A' }, sesB)).toBe(false) + }) + + it('does not suppress when either ref is absent or unparseable (fail toward the visible path)', () => { + expect(isStaleSessionIdentityMismatch(sesB, undefined)).toBe(false) + expect(isStaleSessionIdentityMismatch(undefined, sesB)).toBe(false) + expect(isStaleSessionIdentityMismatch(sesB, { provider: 'opencode' })).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +npm run test:vitest -- test/unit/client/components/terminal-view-utils.session-mismatch.test.ts --run +``` +Expected: FAIL — `isStaleSessionIdentityMismatch` is not exported. + +- [ ] **Step 3: Implement the helper** + +In `src/components/terminal-view-utils.ts`: + +1. Add as line 2 (after the existing type import): +```ts +import { sanitizeSessionRef } from '@shared/session-contract' +``` + +2. Append at the end of the file: +```ts +function sessionRefsEqual( + left?: { provider?: string; sessionId?: string }, + right?: { provider?: string; sessionId?: string }, +): boolean { + return left?.provider === right?.provider && left?.sessionId === right?.sessionId +} + +/** The rebind swap window (~1 WS RTT) can bounce in-flight input sent with + * the OLD ref. When the server's error echoes an actualSessionRef that + * already equals the pane's CURRENT sessionRef, the rebind fold has + * applied and this is a stale-window bounce: suppress it silently (no + * notice, no repair). Unparseable/absent refs fail toward the visible path. */ +export function isStaleSessionIdentityMismatch( + currentSessionRef: unknown, + actualSessionRef: unknown, +): boolean { + const current = sanitizeSessionRef(currentSessionRef) + const actual = sanitizeSessionRef(actualSessionRef) + if (!current || !actual) return false + return sessionRefsEqual(current, actual) +} +``` + +(Known, ACCEPTED redundancy: `sessionRefsEqual` duplicates a private helper +in `TerminalView.tsx:281`; keeping the util module free of a React-file +import is deliberate — do not DRY it into a circular import.) + +Run the Step-2 command again. Expected: PASS (3 tests). + +- [ ] **Step 4: Write the failing wiring test** + +In `test/unit/client/components/TerminalView.codex-identity.test.tsx`, next +to the existing test +`'repairs stale runtime plumbing on SESSION_IDENTITY_MISMATCH and reissues a restore create'` +(~`:306`), add (reusing that file's `createStore`, `TerminalViewFromStore`, +`wsHarness`, `sentMessages`, `findLeaf` helpers exactly as the existing +test does): + +```tsx + it('suppresses the mismatch repair path when actualSessionRef already equals the pane current ref (stale rebind-window bounce)', async () => { + const store = createStore({ + kind: 'terminal', + createRequestId: 'req-old', + status: 'running', + mode: 'codex', + shell: 'system', + terminalId: 'term-old', + serverInstanceId: 'srv-old', + streamId: 'stream-old', + sessionRef: { + provider: 'codex', + sessionId: 'thread-new', + }, + codexDurability: { + schemaVersion: 1, + state: 'durable', + durableThreadId: 'thread-new', + }, + }) + + render( + + + , + ) + + await waitFor(() => { + expect(sentMessages().some((msg) => msg?.type === 'terminal.attach' && msg.terminalId === 'term-old')).toBe(true) + }) + + wsHarness.send.mockClear() + + act(() => { + wsHarness.emit({ + type: 'error', + code: 'SESSION_IDENTITY_MISMATCH', + terminalId: 'term-old', + expectedSessionRef: { provider: 'codex', sessionId: 'thread-old' }, + // The echoed actual ref equals the pane's CURRENT ref: the rebind + // fold already applied; this bounce is the self-healing race. + actualSessionRef: { provider: 'codex', sessionId: 'thread-new' }, + message: 'stale bounce', + }) + }) + + // Nothing happens: no teardown, no repair create, pane untouched. + const leaf = findLeaf(store.getState().panes.layouts['tab-1'], 'pane-1') + expect(leaf?.content.kind).toBe('terminal') + expect((leaf?.content as TerminalPaneContent).terminalId).toBe('term-old') + expect((leaf?.content as TerminalPaneContent).status).toBe('running') + expect(sentMessages().filter((msg) => msg?.type === 'terminal.create')).toHaveLength(0) + }) +``` + +(The pre-existing repair test doubles as the REAL-mismatch guard: its frame +carries `actualSessionRef: thread-old` ≠ current `thread-new`, so the full +repair path must still run — it must keep passing untouched.) + +Run: +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.codex-identity.test.tsx --run +``` +Expected: the new test FAILS (the handler currently runs the repair/notice +path — `terminalId` gets cleared / a `terminal.create` is reissued). All +pre-existing tests pass. + +- [ ] **Step 5: Wire the early return** + +In `src/components/TerminalView.tsx`: + +1. Add `isStaleSessionIdentityMismatch` to the existing import from + `@/components/terminal-view-utils` (~line 54-57): +```ts +import { + buildTerminalAttachMessage, + buildTerminalInputMessage, + buildTerminalResizeMessage, + getCreateSessionStateFromRef, + isStaleSessionIdentityMismatch, +} from '@/components/terminal-view-utils' +``` +(Keep whatever symbols the import block already lists — only ADD the new +one.) + +2. Insert the early return as the FIRST statements inside the mismatch + handler at `:~4352`: +```tsx + if (msg.type === 'error' && msg.code === 'SESSION_IDENTITY_MISMATCH' && msg.terminalId === tid) { + // Stale-window bounce after a rebind -- the pane already folded + // the new ref; the dropped frame carried the old one. Silent. + if (isStaleSessionIdentityMismatch(contentRef.current?.sessionRef, msg.actualSessionRef)) { + return + } + const staleTerminalId = tid + ... +``` + +Accepted, documented behavior: a suppressed bounce means that ONE input +frame was dropped (the server did not write it to the PTY). Node has the +identical semantics; there is no retry — the user's next keystroke carries +the new ref. + +- [ ] **Step 6: Run tests to verify green** + +```bash +npm run test:vitest -- test/unit/client/components/TerminalView.codex-identity.test.tsx test/unit/client/components/terminal-view-utils.session-mismatch.test.ts --run +npm run typecheck && npm run lint +``` +Expected: PASS / clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/terminal-view-utils.ts src/components/TerminalView.tsx test/unit/client/components/terminal-view-utils.session-mismatch.test.ts test/unit/client/components/TerminalView.codex-identity.test.tsx +git commit -m "feat(client): suppress stale rebind-window SESSION_IDENTITY_MISMATCH bounces" +``` + +--- + +### Task 6: Rust hello consumption + plugin-alive heartbeat WARN + +**Files:** +- Modify: `crates/freshell-ws/src/opencode_signal.rs` (hello discrimination + in the parse/drain, `OpencodeDrainOutcome`, `HelloTracker` on the + watcher, `warn_opencode_panes_without_hello`, in-module tests) + +**Interfaces:** +- Consumes: `TerminalRegistry::identity_probe_rows() -> Vec` + (`crates/freshell-terminal/src/registry.rs:290-305` — fields + `terminal_id, mode, status, created_at, resume_session_id, cwd`, all pub; + mirror `crates/freshell-ws/src/invariants.rs`'s imports for + `IdentityProbeRow`/`TerminalRunStatus`); the `#[cfg(test)]` + `invariants::capture::capture()` tracing harness (`invariants.rs:196-215`, + `pub(crate)` — which is why the WARN tests live in-crate, in this + module); `crate::terminal::now_ms()`. +- Produces: + - Hello signal contract (Task 7's producer writes it): a file named + `__.json` in the SAME opencode signal dir, body + `{"hello":true,"source":"opencode-tui-plugin"}`. Discrimination is by + the payload's `"hello": true` — checked BEFORE `session_id` + validation, so a hello never hits the `opencode_signal_rejected` + warn+delete lane. Hello files are delete-on-read (proof of life, not a + command); rebind files stay act-then-delete. + - `pub struct OpencodeDrainOutcome { pub rebinds: Vec, pub hellos: Vec }` + — `drain()`'s new return type (`hellos` = terminalIds consumed this + pass). + - `pub(crate) const OPENCODE_HELLO_GRACE_MS: i64 = 120_000;` + - One rate-limited (once per terminalId) `tracing::warn!` with message + prefix `opencode_rebind_heartbeat_missing` when a RUNNING + `mode == "opencode"` pane is older than the grace window and its + terminalId never said hello — suppressed entirely when the server + process env shows injection was deliberately skipped. + - `drain_and_rebind_opencode(state, watcher)`'s public signature is + UNCHANGED (the tracker lives inside the watcher behind an + `Arc>`), so the 9 call sites in + `tests/opencode_switch_rebind.rs` and the sweep spawner need no edits. + +Design decisions (documented here so the implementer does not relitigate): +- **Same directory, payload-discriminated** (not a sibling dir): one sweep, + one naming convention, one staleness reap; the cost is updating `drain()`'s + return type and its in-module tests. +- **WARN hosted in the opencode signal sweep** (not the unconditional + invariants sweep): the tracker and the hello source live here, and the + sweep's `default_root().is_some()` boot condition is acceptable because a + server that cannot resolve HOME also cannot receive hellos AND skips + plugin injection (`opencode_rebind_precompute` returns `None`) — no-WARN + is the correct behavior in that degenerate state. +- **Grace = 120_000 ms** (spec allows 60–120s; generous end chosen): + budget = opencode TUI cold start + Bun plugin load + one 1s sweep tick, + with slack for loaded CI machines. Pinned by a test. +- **Env gate covers what the server can cheaply see**: the + `FRESHELL_OPENCODE_REBIND=0|false` kill switch and a user-set + `OPENCODE_TUI_CONFIG` in the server's own process env. Per-pane env + overrides and opencode's `--pure` are NOT visible at warn time — the + WARN text names them as possible causes and is explicitly advisory (the + spec authorizes exactly this). + +- [ ] **Step 1: Write the failing drain-discrimination tests** + +In `crates/freshell-ws/src/opencode_signal.rs`'s existing `#[cfg(test)]` +module (which already has `write_signal` and `remaining` helpers — reuse +them), add: + +```rust + #[test] + fn drain_consumes_hello_files_and_reports_their_terminal_ids() { + let dir = tempfile::tempdir().unwrap(); + write_signal( + dir.path(), + "term-h__00000000000001-000000-1.json", + r#"{"hello":true,"source":"opencode-tui-plugin"}"#, + ); + write_signal( + dir.path(), + "term-h__00000000000002-000001-1.json", + r#"{"session_id":"ses_aaa","source":"opencode-tui-plugin"}"#, + ); + let watcher = OpencodeSignalWatcher::new(dir.path().to_path_buf()); + let outcome = watcher.drain(); + assert_eq!(outcome.hellos, vec!["term-h".to_string()]); + assert_eq!(outcome.rebinds.len(), 1); + assert_eq!(outcome.rebinds[0].session_id, "ses_aaa"); + // Hello is delete-on-read; the rebind file is retained + // (act-then-delete happens in the consumer, not the drain). + assert_eq!( + remaining(dir.path()), + vec!["term-h__00000000000002-000001-1.json".to_string()] + ); + } + + #[test] + fn hello_files_never_hit_the_reject_warn_lane() { + let (events, _guard) = crate::invariants::capture::capture(); + let dir = tempfile::tempdir().unwrap(); + write_signal( + dir.path(), + "term-h__00000000000001-000000-1.json", + r#"{"hello":true}"#, + ); + let watcher = OpencodeSignalWatcher::new(dir.path().to_path_buf()); + let outcome = watcher.drain(); + assert_eq!(outcome.hellos, vec!["term-h".to_string()]); + let events = events.lock().unwrap(); + assert!( + !events.iter().any(|e| e.message.contains("opencode_signal_rejected")), + "a hello must not be warn-logged as a reject" + ); + } +``` + +(Adapt the captured-event field access to `capture`'s actual +`CapturedEvent` shape — see its existing consumer at +`invariants.rs:259-286` for the exact field names.) + +- [ ] **Step 2: Run to verify failure** + +```bash +cargo test -p freshell-ws opencode_signal +``` +Expected: COMPILE FAILURE — `drain()` returns `Vec`, not an +outcome struct. Correct RED for a return-type change. + +- [ ] **Step 3: Implement hello discrimination in the drain** + +In `crates/freshell-ws/src/opencode_signal.rs`: + +1. New types + parse change: + +```rust +/// One drain pass over the signal root, split by payload kind. +#[derive(Debug, Default)] +pub struct OpencodeDrainOutcome { + /// Rebind signals, oldest first. The consumer act-then-deletes these. + pub rebinds: Vec, + /// terminalIds whose plugin-alive hello was consumed this pass + /// (delete-on-read: a hello is proof of life, not a command). + pub hellos: Vec, +} + +enum ParsedSignal { + Rebind(OpencodeSignal), + /// `{"hello":true,...}` -- written once at TUI startup by the rebind + /// plugin as a plugin-alive heartbeat. Carries no session id and can + /// never enter the rebind ladder. + Hello { terminal_id: String }, +} +``` + +2. In `parse_signal_file`, change the return type to +`Option`, and insert the hello branch after the body parses +as JSON but BEFORE the `session_id` extraction/validation: + +```rust +fn parse_signal_file(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + let (terminal_id, _nonce) = stem.rsplit_once("__")?; // LAST "__" — load-bearing + if terminal_id.is_empty() { + return None; + } + let raw = std::fs::read_to_string(path).ok()?; + let body: serde_json::Value = serde_json::from_str(&raw).ok()?; + if body.get("hello").and_then(serde_json::Value::as_bool) == Some(true) { + return Some(ParsedSignal::Hello { + terminal_id: terminal_id.to_string(), + }); + } + let session_id = body.get("session_id")?.as_str()?; + if !is_valid_opencode_session_id(session_id) { + return None; + } + Some(ParsedSignal::Rebind(OpencodeSignal { + path: path.to_path_buf(), + terminal_id: terminal_id.to_string(), + session_id: session_id.to_string(), + source: body.get("source").and_then(|v| v.as_str()).map(str::to_string), + })) +} +``` + +3. In `drain()`, change the return type to `OpencodeDrainOutcome` and the +match to: + +```rust + match parse_signal_file(&path) { + Some(ParsedSignal::Rebind(sig)) => outcome.rebinds.push(sig), + Some(ParsedSignal::Hello { terminal_id }) => { + let _ = std::fs::remove_file(&path); // delete-on-read + outcome.hellos.push(terminal_id); + } + None => { + tracing::warn!(path = %path.display(), + "opencode_signal_rejected: bad terminal id or session_id shape, consuming file"); + let _ = std::fs::remove_file(&path); + } + } +``` + +(with `let mut outcome = OpencodeDrainOutcome::default();` replacing the +`signals` vec, returned at the end; the stale-reap and `.json`-filter logic +is untouched — helloes older than `STALE_SIGNAL_MAX_AGE` are reaped like +any file, which is fine: a stale hello's pane has either long said its +piece or long exited). + +4. Fix the compile fallout: the existing in-module tests that call +`watcher.drain()` (3 sites) destructure the new type +(`let outcome = watcher.drain();` + use `outcome.rebinds` where they used +the vec — the exhaustive `remaining()` assertions are unchanged since those +tests contain no hello files), and `drain_and_rebind_opencode`'s internal +use of the drain result iterates `outcome.rebinds`. + +Run: +```bash +cargo test -p freshell-ws opencode_signal +``` +Expected: PASS (including the two new tests and all pre-existing ones). + +- [ ] **Step 4: Write the failing heartbeat-WARN tests** + +Same test module, add: + +```rust + fn probe_row(terminal_id: &str, mode: &str, created_at: i64) -> IdentityProbeRow { + IdentityProbeRow { + terminal_id: terminal_id.to_string(), + mode: mode.to_string(), + status: TerminalRunStatus::Running, + created_at, + resume_session_id: None, + cwd: None, + } + } + + #[test] + fn warns_once_for_an_opencode_pane_past_grace_with_no_hello() { + let (events, _guard) = crate::invariants::capture::capture(); + let mut tracker = HelloTracker::default(); + let rows = vec![probe_row("term-1", "opencode", 0)]; + let now = OPENCODE_HELLO_GRACE_MS + 1; + warn_opencode_panes_without_hello(&rows, &mut tracker, false, now); + warn_opencode_panes_without_hello(&rows, &mut tracker, false, now + 10_000); + let events = events.lock().unwrap(); + let warns: Vec<_> = events + .iter() + .filter(|e| e.message.contains("opencode_rebind_heartbeat_missing")) + .collect(); + assert_eq!(warns.len(), 1, "once per terminal, ever: {warns:?}"); + } + + #[test] + fn no_warn_when_hello_seen_young_non_opencode_or_injection_disabled() { + let (events, _guard) = crate::invariants::capture::capture(); + let now = OPENCODE_HELLO_GRACE_MS + 1; + + // hello seen + let mut tracker = HelloTracker::default(); + tracker.seen.insert("term-1".to_string()); + warn_opencode_panes_without_hello(&[probe_row("term-1", "opencode", 0)], &mut tracker, false, now); + + // young pane (inside grace) + let mut tracker = HelloTracker::default(); + warn_opencode_panes_without_hello(&[probe_row("term-2", "opencode", now - 1_000)], &mut tracker, false, now); + + // non-opencode pane + let mut tracker = HelloTracker::default(); + warn_opencode_panes_without_hello(&[probe_row("term-3", "codex", 0)], &mut tracker, false, now); + + // injection deliberately skipped (kill switch / user OPENCODE_TUI_CONFIG) + let mut tracker = HelloTracker::default(); + warn_opencode_panes_without_hello(&[probe_row("term-4", "opencode", 0)], &mut tracker, true, now); + + let events = events.lock().unwrap(); + assert!( + !events.iter().any(|e| e.message.contains("opencode_rebind_heartbeat_missing")), + "no warn in any suppressed case" + ); + } + + #[test] + fn hello_grace_stays_generous() { + // TUI cold start + Bun plugin load + one 1s sweep tick, with slack. + assert_eq!(OPENCODE_HELLO_GRACE_MS, 120_000); + } +``` + +Run: +```bash +cargo test -p freshell-ws opencode_signal +``` +Expected: COMPILE FAILURE — `HelloTracker`, `OPENCODE_HELLO_GRACE_MS`, +`warn_opencode_panes_without_hello` do not exist. + +- [ ] **Step 5: Implement the tracker + WARN** + +In `crates/freshell-ws/src/opencode_signal.rs`: + +1. Imports: mirror `invariants.rs` for `IdentityProbeRow` and +`TerminalRunStatus` (same crate-internal source it uses), plus +`std::collections::HashSet`, `std::sync::{Arc, Mutex}`. + +2. Tracker + watcher field: + +```rust +/// Plugin-alive heartbeat state, owned by the watcher so +/// `drain_and_rebind_opencode`'s signature stays unchanged (WsState is an +/// exhaustive struct literal in ~27 test files -- deliberately not touched). +#[derive(Debug, Default)] +pub(crate) struct HelloTracker { + /// terminalIds that have ever said hello (plugin proven alive). + pub(crate) seen: HashSet, + /// terminalIds already warned about -- once per terminal, ever. + pub(crate) warned: HashSet, +} + +#[derive(Clone)] +pub struct OpencodeSignalWatcher { + root: PathBuf, + hello: Arc>, +} + +impl OpencodeSignalWatcher { + pub fn new(root: PathBuf) -> Self { + Self { + root, + hello: Arc::default(), + } + } + // ...default_root() and drain() unchanged apart from Step 3... +} +``` + +3. Const + env gate + pure warn fn: + +```rust +/// How long an opencode pane may run without the rebind plugin's startup +/// hello before the heartbeat alarm fires once. 120s (the generous end of +/// the 60-120s design range): opencode TUI cold start + Bun plugin load + +/// one 1s sweep tick, with slack for loaded CI machines. Pinned by +/// `hello_grace_stays_generous`. +pub(crate) const OPENCODE_HELLO_GRACE_MS: i64 = 120_000; + +/// Injection skips the server CAN see at warn time, re-derived from its own +/// process env with zero plumbing: the FRESHELL_OPENCODE_REBIND kill switch +/// and a user-set OPENCODE_TUI_CONFIG. Per-pane env overrides and opencode's +/// `--pure` are NOT visible here -- the WARN text names them and stays +/// advisory for those. +fn opencode_injection_disabled_by_env() -> bool { + let kill_switch = matches!( + std::env::var("FRESHELL_OPENCODE_REBIND").ok().as_deref(), + Some("0") | Some("false") + ); + kill_switch || std::env::var("OPENCODE_TUI_CONFIG").is_ok() +} + +/// One heartbeat pass (invariants.rs `warn_unresolved_terminal_identities` +/// pattern: pure fn, injected now_ms, once-per-terminal HashSet bound): +/// WARN for every RUNNING opencode pane older than +/// [`OPENCODE_HELLO_GRACE_MS`] whose terminalId never said hello. +pub(crate) fn warn_opencode_panes_without_hello( + rows: &[IdentityProbeRow], + tracker: &mut HelloTracker, + injection_disabled: bool, + now_ms: i64, +) { + if injection_disabled { + return; + } + for row in rows { + if row.mode != "opencode" + || row.status != TerminalRunStatus::Running + || tracker.seen.contains(&row.terminal_id) + || tracker.warned.contains(&row.terminal_id) + { + continue; + } + let age_ms = now_ms - row.created_at; + if age_ms <= OPENCODE_HELLO_GRACE_MS { + continue; + } + tracker.warned.insert(row.terminal_id.clone()); + tracing::warn!( + terminal_id = %row.terminal_id, + age_ms = age_ms, + "opencode_rebind_heartbeat_missing: opencode pane has run past the \ + hello grace window without a plugin-alive signal -- mid-session \ + rebind is likely degraded (plugin not loaded / opencode plugin \ + API drift / plugins disabled via --pure or a per-pane config). \ + Advisory: per-pane injection skips are not visible to this check." + ); + } +} +``` + +4. Wire into `drain_and_rebind_opencode` (public signature unchanged): +after the drain outcome is available and before the rebind loop, add: + +```rust + { + let mut tracker = watcher.hello.lock().expect("hello tracker poisoned"); + tracker.seen.extend(outcome.hellos.iter().cloned()); + let injection_disabled = opencode_injection_disabled_by_env(); + warn_opencode_panes_without_hello( + &state.registry.identity_probe_rows(), + &mut tracker, + injection_disabled, + crate::terminal::now_ms(), + ); + } +``` + +(Adapt placement to the function's actual body: the drain currently runs +under `spawn_blocking` — take the outcome it returns, then do the tracker +block, then the existing per-signal rebind loop over `outcome.rebinds`.) + +- [ ] **Step 6: Run to verify green + full-crate safety** + +```bash +cargo test -p freshell-ws opencode_signal +cargo test -p freshell-ws --test opencode_switch_rebind +cargo test -p freshell-ws +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +``` +Expected: PASS / clean. (The integration test's 9 +`drain_and_rebind_opencode` calls now also run the warn pass — its panes +are far younger than 120s, so no warns fire; its Phase 4 invalid-shape +signal still hits the reject lane because it carries no `"hello":true`.) + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-ws/src/opencode_signal.rs +git commit -m "feat(ws): consume opencode plugin-alive hello signals and warn once when a pane never says hello" +``` + +--- + +### Task 7: opencode plugin hello emission at TUI startup + +**Files:** +- Modify: `extensions/opencode/freshell-rebind-plugin.ts` +- Test: `test/unit/server/opencode-rebind-plugin.test.ts` + +**Interfaces:** +- Consumes: Task 6's hello contract (MUST land after Task 6, or every pane + start warn-logs `opencode_signal_rejected` on servers built from this + branch): filename `__.json` (nonce digits/`-` only — + can never contain the `__` delimiter), body + `{"hello":true,"source":"opencode-tui-plugin"}`, atomic tmp+rename into + `/.freshell/session-signals/opencode/`. +- Produces: `export function emitHello(deps: EmitterDeps): void` and a + shared private `resolveSignalTarget(env)` used by both `emitHello` and + `createEmitter`. `plugin.tui()` writes the hello as its FIRST action — + before the `api` cast — so it proves *plugin-alive*, not + *route-API-alive*, and fires even against a hostile/absent api surface. + The plugin's hard rule stands: never throw into the TUI. +- Note: the plugin source is embedded into the Rust binary via + `include_str!` (`crates/freshell-platform/src/opencode_plugin.rs:14-15`) + and installed write-if-changed — no packaging step, but touching the TS + file means the next `cargo build`/test run recompiles `freshell-platform`. + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/server/opencode-rebind-plugin.test.ts`: + +1. Add `emitHello` to the plugin import (alongside the existing + `createEmitter`/`extractSessionId`/default imports), and add + `import { mkdtempSync, readdirSync, readFileSync } from 'node:fs'`, + `import { join } from 'node:path'`, `import { tmpdir } from 'node:os'` + if not already present. + +2. New describe block, mirroring the existing `createEmitter` DI style: + +```ts +describe('emitHello', () => { + const writes: Array<{ dir: string; name: string; body: string }> = [] + const deps = (env: EmitterDeps['env']): EmitterDeps => ({ + env, + writeFile: (dir, name, body) => writes.push({ dir, name, body }), + now: () => 1_700_000_000_000, + }) + beforeEach(() => writes.splice(0)) + + it('writes a hello signal named __ into the opencode signal dir', () => { + emitHello(deps({ HOME: '/home/u', FRESHELL_TERMINAL_ID: 'term-1' })) + expect(writes).toHaveLength(1) + expect(writes[0].dir).toBe('/home/u/.freshell/session-signals/opencode') + expect(writes[0].name).toMatch(/^term-1__\d{14}-\d{6}-\d+$/) + expect(writes[0].name.split('__')).toHaveLength(2) // nonce never contains the __ delimiter + expect(JSON.parse(writes[0].body)).toEqual({ + hello: true, + source: 'opencode-tui-plugin', + }) + }) + + it('never writes without a home dir or FRESHELL_TERMINAL_ID', () => { + emitHello(deps({ FRESHELL_TERMINAL_ID: 'term-1' })) + emitHello(deps({ HOME: '/home/u' })) + expect(writes).toHaveLength(0) + }) + + it('swallows writer exceptions (never surfaces into the TUI)', () => { + expect(() => emitHello({ + env: { HOME: '/home/u', FRESHELL_TERMINAL_ID: 'term-1' }, + writeFile: () => { throw new Error('disk full') }, + })).not.toThrow() + }) +}) +``` + +3. In the existing `default export (TuiPluginModule)` describe block (which + already uses `vi.useFakeTimers()` + `vi.stubEnv`), add a hello-at-startup + test using a REAL temp home (the default writer is exercised end to end, + including tmp+rename atomicity): + +```ts + it('writes a plugin-alive hello at tui() startup, even against a hostile api surface', () => { + const home = mkdtempSync(join(tmpdir(), 'freshell-hello-')) + vi.stubEnv('HOME', home) + vi.stubEnv('FRESHELL_TERMINAL_ID', 'term-hello') + + const hostileApi = new Proxy({}, { + get() { throw new Error('API drift') }, + }) + expect(() => plugin.tui(hostileApi)).not.toThrow() + + const dir = join(home, '.freshell', 'session-signals', 'opencode') + const files = readdirSync(dir).filter((name) => name.endsWith('.json')) + expect(files).toHaveLength(1) + expect(files[0]).toMatch(/^term-hello__\d{14}-\d{6}-\d+\.json$/) + expect(JSON.parse(readFileSync(join(dir, files[0]), 'utf-8'))).toEqual({ + hello: true, + source: 'opencode-tui-plugin', + }) + }) +``` + +(Adapt the `plugin` identifier to however the file already imports the +default export, and place the test so the block's existing +`beforeEach`/`afterEach` timer + env-stub hygiene applies. If the hostile +`tui()` call starts the 1s poll interval, the block's existing fake-timer +teardown already covers it.) + +- [ ] **Step 2: Run to verify failure** + +```bash +npm run test:vitest -- test/unit/server/opencode-rebind-plugin.test.ts --run +``` +Expected: FAIL — `emitHello` is not exported; the startup test finds no +signal dir. Pre-existing tests still pass. + +- [ ] **Step 3: Implement** + +In `extensions/opencode/freshell-rebind-plugin.ts`: + +1. Extract the home/terminalId/dir resolution out of `createEmitter` into a +shared helper (placed above `createEmitter`), and add `emitHello`: + +```ts +function resolveSignalTarget( + env: EmitterDeps['env'], +): { dir: string; terminalId: string } | null { + // Mirror the Rust consumer's home resolution (OpencodeSignalWatcher:: + // default_root is cfg-gated: USERPROFILE only on Windows, HOME otherwise) + // — otherwise a Windows git-bash/MSYS shell with HOME set would emit + // signals into a directory the server never sweeps. + const home = + process.platform === 'win32' + ? (env.USERPROFILE ?? env.HOME) + : (env.HOME ?? env.USERPROFILE) + const terminalId = env.FRESHELL_TERMINAL_ID + if (!home || !terminalId) return null + return { dir: join(home, '.freshell', 'session-signals', 'opencode'), terminalId } +} + +/** Plugin-alive heartbeat: written ONCE at TUI startup, before any opencode + * API surface is touched, so it proves the plugin LOADED (not that the + * route API still exists). The server's sweeper consumes it silently as + * liveness proof and warns when an opencode pane never says hello. */ +export function emitHello(deps: EmitterDeps): void { + try { + const target = resolveSignalTarget(deps.env) + if (!target) return + const write = deps.writeFile ?? defaultWriteFile + const now = deps.now ?? Date.now + const nonce = `${String(now()).padStart(14, '0')}-000000-${process.pid}` + write( + target.dir, + `${target.terminalId}__${nonce}`, + JSON.stringify({ hello: true, source: 'opencode-tui-plugin' }), + ) + } catch { + // Losing the hello degrades to no-heartbeat. Never surface into the TUI. + } +} +``` + +2. Refactor `createEmitter`'s first lines to use the shared helper (its +behavior is pinned by existing tests — they must stay green): + +```ts +export function createEmitter(deps: EmitterDeps): (candidate: unknown) => void { + const target = resolveSignalTarget(deps.env) + if (!target) return () => {} + const { dir, terminalId } = target + const write = deps.writeFile ?? defaultWriteFile + const now = deps.now ?? Date.now + // ...rest unchanged... +``` + +3. In the default export's `tui(api)`, make the hello the FIRST statement +inside the outer `try`, BEFORE `createEmitter` and before the `api` cast: + +```ts + tui(api: unknown): void { + try { + emitHello({ env: process.env }) + const emit = createEmitter({ env: process.env }) + // ...rest unchanged... +``` + +- [ ] **Step 4: Run tests to verify green** + +```bash +npm run test:vitest -- test/unit/server/opencode-rebind-plugin.test.ts --run +npm run typecheck +cargo test -p freshell-platform +``` +Expected: PASS / clean. (The `freshell-platform` run recompiles the +embedded plugin source and re-runs the installer's pinned tests — the +plugin-only `tui.json` must remain `{"plugin":[…]}` and NOTHING else; this +task does not touch it.) + +- [ ] **Step 5: Commit** + +```bash +git add extensions/opencode/freshell-rebind-plugin.ts test/unit/server/opencode-rebind-plugin.test.ts +git commit -m "feat(opencode-plugin): emit a plugin-alive hello signal at TUI startup" +``` + +--- + +### Task 8: Browser e2e for the signal-driven opencode rebind flow (rust-only) + +**Files:** +- Create: `test/e2e-browser/specs/opencode-rebind-rust.spec.ts` +- Modify: `test/e2e-browser/playwright.config.ts` (register in BOTH + `RUST_ONLY_SPECS` and the `rust-chromium` project's `testMatch`, each + with a one-line justifying comment — house convention) + +**Interfaces:** +- Consumes: main's fake opencode terminal fixture + `test/e2e-browser/fixtures/fake-opencode-terminal.mjs` AS-IS (no fixture + edits: it already logs argv JSONL via `FAKE_OPENCODE_TERMINAL_ARGV_LOG`, + prints `opencode: session started` on first Enter, and + `opencode: resumed session ` under `--session`); helpers + `createE2eServerHandle` (`../helpers/external-target.js`), `TestHarness` + (`../helpers/test-harness.js`), `WsCapture` (`../helpers/ws-capture.js`), + `openPanePicker` (`../helpers/pane-picker.js`); the server's signal dir + `/.freshell/session-signals/opencode` swept every 1s + (`OPENCODE_SIGNAL_SWEEP_INTERVAL`), filename `__.json`, + payload `{"session_id":"ses_","source":"opencode-tui-plugin"}`. +- Produces: the repo's first browser-level proof that the full rebind stack + composes. No production code changes. Do NOT add the spec to + `MATRIX_SPECS` (the Node server has no signal-drain rebind). + +Hard constraints baked into the spec below (violations produce +head-scratcher failures): +- `is_valid_opencode_session_id` requires `ses_` + PURE alnum — the fake's + own generated ids (`ses_e2e__`) contain underscores and are + VALID as prior/bound ids but INVALID inside a signal payload. Every id + this spec writes into a signal must be `ses_` + alnum only. +- Atomic writes: write `.json.tmp`, then rename to `.json` — + the drain filters on the `.json` extension so it can never read a torn + file. +- The 1s sweep cadence: positive waits are generous (30–60s poll budgets); + the never-steal negative window is 3s (≥3 ticks); file-consumption polls + allow ~5s. No fixed sleeps anywhere — bounded polling only. +- Rebind signals are act-then-delete and refused signals are also consumed + — `fs.access` failing on the signal path is a real assertion channel + proving the sweep ran. + +- [ ] **Step 1: Register the spec (rust-only)** + +In `test/e2e-browser/playwright.config.ts`: + +1. Add to `RUST_ONLY_SPECS`: +```ts + // Signal-file rebind lane exists only on the Rust server (opencode_signal.rs). + /opencode-rebind-rust\.spec\.ts$/, +``` +2. Add the same regex (with the same comment) to the `rust-chromium` + project's `testMatch` array. + +- [ ] **Step 2: Write the spec** + +Create `test/e2e-browser/specs/opencode-rebind-rust.spec.ts`. Copy the +scaffolding helpers verbatim from +`test/e2e-browser/specs/opencode-terminal-restore-rust.spec.ts` +(`installFakeOpencodeTerminal`, `selectShellIfPickerShowing`, +`bootAndConnect`, `openOpencodePane`, `collectLeaves`, `findOpencodeLeaves`, +`openOpencodePaneAndGetLeaf`) — that file is the closest precedent and its +helpers are file-private. Then the spec body: + +```ts +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect } from '../helpers/fixtures.js' +import { createE2eServerHandle } from '../helpers/external-target.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' +import { WsCapture, type WsFrame } from '../helpers/ws-capture.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const FAKE_OPENCODE_TERMINAL_SOURCE = path.resolve(__dirname, '../fixtures/fake-opencode-terminal.mjs') +const SESSION_MARKER_RE = /opencode: session (ses_e2e_\S+) started/ + +// >>> paste installFakeOpencodeTerminal, selectShellIfPickerShowing, +// >>> bootAndConnect, openOpencodePane, collectLeaves, findOpencodeLeaves, +// >>> openOpencodePaneAndGetLeaf verbatim from +// >>> opencode-terminal-restore-rust.spec.ts here <<< + +/** + * Write one opencode rebind signal the way the TUI plugin does: + * __.json into the server's signal dir, atomically + * (tmp + rename; the 1s sweep filters on the .json extension so it can + * never read a torn file). Returns the final path so consumption + * (act-then-delete) is assertable. + */ +async function writeOpencodeSignal( + homeDir: string, + terminalId: string, + seq: number, + sessionId: string, +): Promise { + const dir = path.join(homeDir, '.freshell', 'session-signals', 'opencode') + await fs.mkdir(dir, { recursive: true }) + // Timestamp-first, zero-padded: lexicographic order == emission order, + // digits and '-' only so the nonce can never contain the __ delimiter. + const nonce = `${String(Date.now()).padStart(14, '0')}-${String(seq).padStart(6, '0')}-${process.pid}` + const name = `${terminalId}__${nonce}` + const tmpPath = path.join(dir, `${name}.json.tmp`) + const finalPath = path.join(dir, `${name}.json`) + await fs.writeFile(tmpPath, JSON.stringify({ session_id: sessionId, source: 'opencode-tui-plugin' })) + await fs.rename(tmpPath, finalPath) + return finalPath +} + +/** Bounded-wait NEGATIVE helper over WsCapture frames (no fixed sleeps). */ +async function expectNoMatchingFrame( + capture: WsCapture, + pred: (frame: WsFrame) => boolean, + timeoutMs: number, + label: string, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const hit = capture.all.find(pred) + if (hit) { + throw new Error(`Expected NO frame matching ${label}, but got: ${JSON.stringify(hit)}`) + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} + +test.describe('OpenCode signal-driven rebind (Rust only)', () => { + test.setTimeout(240_000) + + test('signal file rebinds the pane end to end: broadcast, fold, persistence, respawn argv, never-steal', async ({ page, e2eServerKind }) => { + // Registered ONLY under rust-chromium; fail loudly on any other kind. + expect(e2eServerKind).toBe('rust') + + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-rebind-')) + const argLogPath = path.join(sharedRoot, 'fake-opencode-terminal-argv.jsonl') + try { + const fakeOpencodePath = await installFakeOpencodeTerminal(path.join(sharedRoot, 'bin')) + + const server = await createE2eServerHandle(process.env, { + kind: e2eServerKind, + construct: { + env: { + OPENCODE_CMD: fakeOpencodePath, + FAKE_OPENCODE_TERMINAL_ARGV_LOG: argLogPath, + }, + setupHome: async (homeDir: string) => { + const freshellDir = path.join(homeDir, '.freshell') + await fs.mkdir(freshellDir, { recursive: true }) + await fs.writeFile(path.join(freshellDir, 'config.json'), JSON.stringify({ + version: 1, + settings: { + codingCli: { enabledProviders: ['opencode'] }, + }, + }, null, 2)) + }, + }, + }) + const info = await server.start() + + type ArgvEntry = { pid: number; t: number; argv: string[] } + async function readArgvEntries(): Promise { + try { + const raw = await fs.readFile(argLogPath, 'utf-8') + return raw.split('\n').filter(Boolean).map((line) => JSON.parse(line) as ArgvEntry) + } catch { + return [] + } + } + function argvValue(argv: string[], flag: string): string | undefined { + const index = argv.indexOf(flag) + return index >= 0 ? argv[index + 1] : undefined + } + + // Server-side raw WS capture, opened BEFORE any opencode pane exists + // so it sees the FIRST association frame too. + const capture = new WsCapture(info.wsUrl, info.token) + await capture.ready() + + const harness = await bootAndConnect(page, info) + const tabId = await harness.getActiveTabId() + expect(tabId).toBeTruthy() + + /** Re-read the (possibly reshuffled) leaf for a given pane id. */ + async function findLeafById(tid: string, paneId: string): Promise { + const layout = await harness.getPaneLayout(tid) + return collectLeaves(layout).find((leaf: any) => leaf.id === paneId) + } + + /** Deterministic typing into a SPECIFIC terminal (CR as its own frame: + * the opencode locator's is_submit_input arms on enter-only input). */ + async function typeLineIntoTerminal(terminalId: string, text: string): Promise { + await page.evaluate(({ tid, data }) => { + const testHarness = (window as any).__FRESHELL_TEST_HARNESS__ + if (!testHarness) throw new Error('Freshell test harness is not installed') + testHarness.sendWsMessage({ type: 'terminal.input', terminalId: tid, data }) + }, { tid: terminalId, data: text }) + await page.evaluate(({ tid }) => { + (window as any).__FRESHELL_TEST_HARNESS__?.sendWsMessage({ type: 'terminal.input', terminalId: tid, data: '\r' }) + }, { tid: terminalId }) + } + + /** Scrape the fake's fresh-session marker out of a terminal buffer. */ + async function scrapeSessionId(terminalId: string): Promise { + let sessionId: string | null = null + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(terminalId) + const unwrapped = typeof buffer === 'string' ? buffer.replace(/\n/g, '') : '' + const match = SESSION_MARKER_RE.exec(unwrapped) + sessionId = match?.[1] ?? null + return sessionId + }, { timeout: 30_000 }).not.toBeNull() + return sessionId! + } + + // ── Setup: one opencode pane, freshly bound to sesA by the locator. + const pane1Leaf = await openOpencodePaneAndGetLeaf(page, harness, tabId!) + const pane1Id: string = pane1Leaf.id + const terminalId1: string = pane1Leaf.content.terminalId + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(terminalId1) + return typeof buffer === 'string' && buffer.includes('opencode> ') + }, { timeout: 15_000 }).toBe(true) + await typeLineIntoTerminal(terminalId1, 'hello from the rebind spec') + const sesA = await scrapeSessionId(terminalId1) + await capture.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === terminalId1 + && frame.sessionRef?.sessionId === sesA, + 60_000, + `fresh association terminal=${terminalId1} session=${sesA}`, + ) + + // ── Leg 1: signal file → rebind broadcast, exact sequence [sesA, sesB]. + // Signal payload ids must be ses_ + PURE alnum + // (is_valid_opencode_session_id rejects underscores). + const sesB = `ses_e2edrift${Date.now()}` + const signalPath = await writeOpencodeSignal(info.homeDir, terminalId1, 1, sesB) + const rebindFrame = await capture.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === terminalId1 + && frame.sessionRef?.sessionId === sesB, + 60_000, + `signal rebind terminal=${terminalId1} session=${sesB}`, + ) + expect(rebindFrame.previousSessionId).toBe(sesA) + + // Acted ⇒ deleted (act-then-delete): consumption proves the sweep ran. + await expect.poll(async () => { + try { + await fs.access(signalPath) + return 'still-present' + } catch { + return 'consumed' + } + }, { timeout: 5_000 }).toBe('consumed') + + // No frame ever reported an unbound/cleared identity between the two: + // every association frame for this terminal carried a truthy session + // id, and the distinct id sequence is exactly [sesA, sesB]. + const associationFrames = capture.all.filter( + (frame) => frame.type === 'terminal.session.associated' && frame.terminalId === terminalId1, + ) + for (const frame of associationFrames) { + expect(frame.sessionRef?.provider).toBe('opencode') + expect(frame.sessionRef?.sessionId).toBeTruthy() + } + const distinctSequence: string[] = [] + for (const frame of associationFrames) { + const id = frame.sessionRef.sessionId as string + if (distinctSequence[distinctSequence.length - 1] !== id) distinctSequence.push(id) + } + expect(distinctSequence).toEqual([sesA, sesB]) + + // ── Leg 2: the browser client folded the rebind into the pane (real Redux). + await expect.poll(async () => { + const leaf = await findLeafById(tabId!, pane1Id) + return leaf?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }).toBe(sesB) + + // ── Leg 3: the pane is still interactive after the rebind. + await page.evaluate(({ tid }) => { + (window as any).__FRESHELL_TEST_HARNESS__?.sendWsMessage({ + type: 'terminal.input', terminalId: tid, data: 'still-interactive-after-rebind', + }) + }, { tid: terminalId1 }) + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(terminalId1) + const unwrapped = typeof buffer === 'string' ? buffer.replace(/\n/g, '') : '' + return unwrapped.includes('still-interactive-after-rebind') + }, { timeout: 15_000 }).toBe(true) + const pane1AfterRebind = await findLeafById(tabId!, pane1Id) + expect(pane1AfterRebind?.content?.status).not.toBe('error') + + // ── Leg 4: reload persistence — freshell.layout.v3 carries the new id. + await page.evaluate(() => { + (window as any).__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'persist/flushNow' }) + }) + await page.reload({ waitUntil: 'domcontentloaded' }) + await harness.waitForHarness() + await harness.waitForConnection() + await expect.poll(async () => { + const leaf = await findLeafById(tabId!, pane1Id) + return leaf?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }).toBe(sesB) + const persistedRaw: string = await page.evaluate(() => { + (window as any).__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'persist/flushNow' }) + const raw = window.localStorage.getItem('freshell.layout.v3') + if (!raw) throw new Error('Missing persisted layout freshell.layout.v3') + return raw + }) + const persisted = JSON.parse(persistedRaw) + const persistedPane1 = collectLeaves(persisted.panes?.layouts?.[tabId!]) + .find((leaf: any) => leaf.id === pane1Id) + expect(persistedPane1?.content?.sessionRef?.sessionId).toBe(sesB) + + // ── Leg 5: server restart → respawn argv carries --session sesB, never sesA. + const terminalIdBeforeRestart: string = (await findLeafById(tabId!, pane1Id))!.content.terminalId + capture.close() + if (!server.restart) { + throw new Error(`${e2eServerKind} E2eServerHandle does not implement restart()`) + } + await server.restart() + await page.reload({ waitUntil: 'domcontentloaded' }) + await harness.waitForHarness() + await harness.waitForConnection() + + const restoredTerminalId1: string = await expect + .poll(async () => { + const leaf = await findLeafById(tabId!, pane1Id) + if (leaf?.content?.status === 'error') return null + const tid = leaf?.content?.terminalId + return tid && tid !== terminalIdBeforeRestart ? tid : null + }, { timeout: 45_000 }) + .not.toBeNull() + .then(async () => (await findLeafById(tabId!, pane1Id))!.content.terminalId) + expect(restoredTerminalId1).not.toBe(terminalIdBeforeRestart) + + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(restoredTerminalId1) + const unwrapped = typeof buffer === 'string' ? buffer.replace(/\n/g, '') : '' + return unwrapped.includes(`opencode: resumed session ${sesB}`) + }, { timeout: 30_000 }).toBe(true) + + const argvEntries: ArgvEntry[] = await expect.poll(async () => { + const entries = await readArgvEntries() + return entries.some((entry) => argvValue(entry.argv, '--session') === sesB) ? entries : null + }, { timeout: 30_000 }).not.toBeNull().then(readArgvEntries) + const newestEntry = argvEntries[argvEntries.length - 1] + expect(argvValue(newestEntry.argv, '--session')).toBe(sesB) + expect(argvEntries.every((entry) => argvValue(entry.argv, '--session') !== sesA)).toBe(true) + + // ── Leg 6: never-steal refusal + liveness control. + // Fresh capture: the pre-restart WS died with the old server process. + const capture2 = new WsCapture(info.wsUrl, info.token) + await capture2.ready() + + // Pane 2, freshly bound, then signal-rebound to the alnum-safe sesC so + // the steal target is a LIVE-OWNED session with a signal-lane history. + const pane2Leaf = await openOpencodePaneAndGetLeaf(page, harness, tabId!) + const pane2Id: string = pane2Leaf.id + const terminalId2: string = pane2Leaf.content.terminalId + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(terminalId2) + return typeof buffer === 'string' && buffer.includes('opencode> ') + }, { timeout: 15_000 }).toBe(true) + await typeLineIntoTerminal(terminalId2, 'hello from pane two') + const sesC0 = await scrapeSessionId(terminalId2) + await capture2.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === terminalId2 + && frame.sessionRef?.sessionId === sesC0, + 60_000, + `pane2 fresh association terminal=${terminalId2} session=${sesC0}`, + ) + const sesC = `ses_e2eowned${Date.now()}` + await writeOpencodeSignal(info.homeDir, terminalId2, 2, sesC) + await capture2.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === terminalId2 + && frame.sessionRef?.sessionId === sesC, + 60_000, + `pane2 signal rebind terminal=${terminalId2} session=${sesC}`, + ) + + // The steal attempt: a signal for PANE 1 naming pane-2-owned sesC. + const stealPath = await writeOpencodeSignal(info.homeDir, restoredTerminalId1, 3, sesC) + await expectNoMatchingFrame( + capture2, + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === restoredTerminalId1 + && frame.sessionRef?.sessionId === sesC, + 3_000, + `steal rebind terminal=${restoredTerminalId1} -> ${sesC}`, + ) + // The refusal is act-then-delete: consumption proves the sweep SAW and + // REFUSED the signal (not that it never ran). + await expect.poll(async () => { + try { + await fs.access(stealPath) + return 'still-present' + } catch { + return 'consumed' + } + }, { timeout: 5_000 }).toBe('consumed') + + // Both panes kept their identities across a reload. + await page.evaluate(() => { + (window as any).__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'persist/flushNow' }) + }) + await page.reload({ waitUntil: 'domcontentloaded' }) + await harness.waitForHarness() + await harness.waitForConnection() + await expect.poll(async () => { + const leaf = await findLeafById(tabId!, pane1Id) + return leaf?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }).toBe(sesB) + await expect.poll(async () => { + const leaf = await findLeafById(tabId!, pane2Id) + return leaf?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }).toBe(sesC) + + // POSITIVE LIVENESS CONTROL: prove the same channel (same dir, same + // sweep, same pane) was alive by rebinding pane 1 to an UNOWNED + // session on it and observing success. Deliberately LAST: it moves + // pane 1 off sesB after every sesB persistence assertion completed. + const sesD = `ses_e2eliveness${Date.now()}` + await writeOpencodeSignal(info.homeDir, restoredTerminalId1, 4, sesD) + await capture2.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === restoredTerminalId1 + && frame.sessionRef?.sessionId === sesD, + 60_000, + `post-refusal liveness control rebind terminal=${restoredTerminalId1} session=${sesD}`, + ) + // Re-assert the negative over the FULL frame history: even while the + // channel was provably consuming signals, no frame ever rebound pane 1 + // to the pane-2-owned sesC. + const stealFrame = capture2.all.find( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === restoredTerminalId1 + && frame.sessionRef?.sessionId === sesC, + ) + expect(stealFrame).toBeUndefined() + + capture2.close() + await server.stop() + } finally { + await fs.rm(sharedRoot, { recursive: true, force: true }) + } + }) +}) +``` + +Adaptation notes: +- The `>>> paste ... <<<` marker is the ONLY intentional indirection: those + seven helpers must be copied character-for-character from + `opencode-terminal-restore-rust.spec.ts` (they are file-private there and + battle-tested; do not "improve" them). +- If `WsCapture`'s frame type is exported under a different name than + `WsFrame`, match `helpers/ws-capture.ts`'s actual export. +- `bootAndConnect`'s `info` parameter type in the restore spec is + `{ baseUrl: string; token: string }` — the started handle's `info` + satisfies it. + +- [ ] **Step 3: Run the spec and iterate to green** + +This is coverage of behavior that already exists (server rebind lane + the +D1/D2 client hardening from earlier tasks), so the honest cycle is +run-and-fix rather than red-green. Run: +```bash +npm run test:e2e -- --project=rust-chromium opencode-rebind-rust +``` +Expected: PASS (budget several minutes: global-setup builds the client and +the Rust server; `cargo` must be on PATH). Debugging channels when it does +not pass first try: the server log at `info.debugLogPath` (grep +`opencode_signal_rejected` for payload-shape mistakes and +`opencode_rebind_refused` for guard refusals), and signal-file +presence/absence in the signal dir. + +- [ ] **Step 4: Sanity-check the negative is not vacuous** + +Deliberately break the liveness control once (change `sesD`'s write to use +terminalId `'term-bogus'`), re-run, and confirm the spec FAILS at the +liveness `waitFor` — proving the never-steal negative is guarded by a live +channel. Revert the breakage and re-run to green. + +- [ ] **Step 5: Commit** + +```bash +git add test/e2e-browser/specs/opencode-rebind-rust.spec.ts test/e2e-browser/playwright.config.ts +git commit -m "test(e2e): signal-driven opencode rebind browser spec (rust-only)" +``` + +--- + +### Task 9: Full verification sweep + +**Files:** none created; fixes only if gates fail. + +**Interfaces:** +- Consumes: everything above. +- Produces: a branch where every repo gate is green. + +- [ ] **Step 1: TypeScript gates** + +```bash +npm run typecheck +npm run lint +FRESHELL_TEST_SUMMARY="rebind-salvage-hardening: full suite" npm test +``` +Expected: all green. `npm test` waits on the shared coordinator gate — if +another agent holds it, wait (monitor `npm run test:status`); never kill a +foreign holder. + +- [ ] **Step 2: Rust gates** + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test -p freshell-ws +cargo test -p freshell-protocol --locked +``` +Expected: all green. If `auto_resume_e2e` fails, retry once +(`cargo test -p freshell-ws --test auto_resume_e2e`) — known pre-existing +flake under load, not a regression; anything else that fails gets fixed, +not retried. + +- [ ] **Step 3: e2e re-run** + +```bash +npm run test:e2e -- --project=rust-chromium opencode-rebind-rust +``` +Expected: PASS. + +- [ ] **Step 4: Final tidy commit (only if Steps 1–3 required fixes)** + +```bash +git add -A ':!docs/plans' +git commit -m "fix: address full-suite verification fallout for rebind salvage hardening" +``` +(Skip if nothing changed. Do NOT push, do NOT create a PR — handled outside +this run. Never push the local `salvage/opencode-rebind-on-drift` branch.) + +--- + +## Self-review record + +**1. Spec coverage.** +- Deliverable 1 (metadata re-key): Tasks 1–2. Acceptance mapped: old→new + key move (Task 1 test 1, Task 2 fold test), covered by unit tests, no-op + on absent/empty map (Task 1 tests 2–3). +- Deliverable 2 (bounce suppression + Rust field): precondition + investigation done and documented above (path did not exist; spec's + "ALSO populate it there" branch taken); Task 4 (Rust guard + emission + + wire-level integration + roundtrip), Task 5 (helper + fail-toward-visible + + wiring + both suppress/real-mismatch unit coverage). Not dropped; not + dead code — Task 4 creates the emitter the suppression consumes. +- Deliverable 3 (idempotence tests): Task 3, written against + `previousSessionId`, with the honest expected-PASS framing and a bounded + contingency if (b) exposes a real double-flush. +- Deliverable 4 (browser e2e): Task 8 — signal-file driven, rust-only (both + registration lists, not MATRIX_SPECS), all six legs kept incl. the + liveness control, control-plane fake machinery never introduced (main's + fixture used as-is), bounded polling keyed to the 1s sweep cadence. +- Deliverable 5 (heartbeat): Tasks 6–7 — hello via the same signal-file + transport with a distinguishable payload, atomic tmp+rename, never-throw, + silent consumption, single once-per-terminal WARN at a documented 120s + threshold, env-visible skips gated, non-visible skips documented as + advisory; plugin tests + Rust hello/WARN/no-WARN tests. + +**1b. No silent deferrals.** No stubs or fake providers stand in for +production behavior anywhere: the e2e fake opencode CLI is main's existing +e2e fixture (the sanctioned harness for this stack, already used by shipped +specs), and every client/server behavior added here is exercised at unit + +integration level, with Task 8 as the composed end-to-end proof. The one +scope-bounded behavior note (tab-side re-key fires only for single-pane +tabs) is main's pre-existing invariant for ALL tab-side rebind state, not a +deferral introduced by this plan — it is flagged in Task 2 rather than +hidden. + +**2. Placeholder scan.** Two intentional adapt-in-place points remain, both +concrete and source-anchored rather than "TBD": Task 8's +copy-these-seven-helpers-verbatim marker (exact source file named) and Task +4's `send_json`/`wait_for_frame` mirror-the-file's-own-helper instruction +(exact reference helpers and line regions named, with skeletons). Everything +else carries complete code, exact commands, and expected outputs. + +**3. Type consistency.** Verified: `tabSessionMetadataByKey?: Record` (Tasks 1→2), `isStaleSessionIdentityMismatch(currentSessionRef: +unknown, actualSessionRef: unknown): boolean` (Task 5 helper/tests/wiring), +`OpencodeDrainOutcome { rebinds, hellos }` + `HelloTracker { seen, warned }` ++ `warn_opencode_panes_without_hello(rows, tracker, injection_disabled, +now_ms)` (Task 6 impl/tests), `emitHello(deps: EmitterDeps)` (Task 7), +`input_session_identity_ok(expected, canonical)` / +`input_session_identity_mismatch_error(terminal_id, expected, actual)` +(Task 4 impl/tests/arm), signal payload `{"session_id", "source"}` vs hello +`{"hello": true, "source"}` consistent across Tasks 6, 7, and 8. From 5ceab3845fdd46741fee7041e30cb3a4beea3293 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:43:34 -0700 Subject: [PATCH 02/10] docs(plan): harden rebind-salvage plan per load-bearing validation Load-bearing assumption pass (6 verified, 4 falsified) drove: - new Task 0: mandatory merge of origin/main @ e1f4d4c5 (#571) before implementation; anchors refreshed to the post-merge tree - Task 4 identity guard scoped to opencode (claude/codex have identical swap windows; all-provider guard would bounce legitimate input) - fail-open coverage claim rewritten (REST/fresh-agent panes are permanently unresolved, not a ~2s spawn window) - Task 5 wiring test re-keyed on the '[Resume blocked]' xterm write (expected!=current takes the notice-only branch; old RED was invalid) - documented accepted residual: pre-fold bounces are visibly refused (no cross-channel ordering guarantee exists) - Task 8 never-steal negative gains a WsCapture liveness sentinel (bus lag closes captures with 4008, making negatives vacuous) --- .../2026-07-29-rebind-salvage-hardening.md | 436 +++++++++++++++--- 1 file changed, 365 insertions(+), 71 deletions(-) diff --git a/docs/plans/2026-07-29-rebind-salvage-hardening.md b/docs/plans/2026-07-29-rebind-salvage-hardening.md index 473dc010c..3977a3eaa 100644 --- a/docs/plans/2026-07-29-rebind-salvage-hardening.md +++ b/docs/plans/2026-07-29-rebind-salvage-hardening.md @@ -18,8 +18,10 @@ rebind lane and broadcast `terminal.session.associated` with `previousSessionId`, which the client folds via a deterministic supersession handshake (`src/lib/terminal-session-association.ts`). Deliverable 1 fixes a real latent client bug (stranded `tab.sessionMetadataByKey` entries). -Deliverable 2 adds the missing Rust input-path identity guard (Node parity) -plus client-side suppression of the self-healing race it creates. +Deliverable 2 adds the missing Rust input-path identity guard (Node parity +in frame shape, deliberately scoped to the opencode lane) plus client-side +suppression of the spurious visible notice the self-healing race would +otherwise produce. Deliverables 3 and 4 are pure coverage. Deliverable 5 adds log-only observability for silent plugin death. @@ -31,8 +33,14 @@ the Rust binary via `include_str!`). ## Global Constraints -- Base branch: `origin/main` @ `62fa0ff1`; work on branch - `feat/rebind-salvage-hardening` in worktree +- Base branch: the branch was cut from `origin/main` @ `62fa0ff1`, but + **Task 0 (mandatory, FIRST implementation action) merges current + `origin/main` @ `e1f4d4c5` (PR #571 `fix/silent-input-loss`) into the + branch** — this plan's Task 4/5 edit points cite code (e.g. + `unknown_terminal_input_blocked`) that exists only post-#571. All code + anchors below describe the POST-MERGE tree; where a line number is + uncertain, the named function/handler is the authoritative anchor. Work + on branch `feat/rebind-salvage-hardening` in worktree `/home/dan/code/freshell/.worktrees/rebind-salvage-hardening`. All commands below run from that worktree root. - Reference branch `salvage/opencode-rebind-on-drift` is LOCAL-ONLY: never @@ -90,11 +98,13 @@ Finding (static analysis of `62fa0ff1`): anywhere in `crates/`. All 9 `ErrorMsg` constructions hardcode `actual_session_ref: None`, and none carries that code. - **Worse: the `ClientMessage::TerminalInput` arm - (`crates/freshell-ws/src/terminal.rs:628-657`) parses + (post-merge ~`:628-660`; anchor by the arm, not the line) parses `expected_session_ref` and ignores it** — a stale-ref input frame right - after a rebind is silently delivered to the PTY. The Node reference - implementation rejects it (`server/ws-handler.ts:2902-2925`) and populates - `actualSessionRef` (`server/ws-handler.ts:939-966`). + after a rebind is silently delivered to the PTY. This holds at + `e1f4d4c5` too: #571 added the `unknown_terminal_input_blocked` bounce + to the arm but still never reads `expected_session_ref`. The Node + reference implementation rejects it (`server/ws-handler.ts:2902-2925`) + and populates `actualSessionRef` (`server/ws-handler.ts:939-966`). - **The wire contract already carries the field end-to-end** — TS type (`shared/ws-protocol.ts:716`), JSON schema (`port/contract/ws-server-messages.schema.json` `error.actualSessionRef`), @@ -109,21 +119,49 @@ Finding (static analysis of `62fa0ff1`): **Resolution (per the spec's "ALSO populate it there" branch):** Deliverable 2 is NOT dropped. Task 4 adds the input-path identity guard to the Rust -server (Node parity, emitting SESSION_IDENTITY_MISMATCH with -`actual_session_ref: Some(..)` — the first non-`None` in the Rust tree), and -Task 5 ports the client suppression helper. One deliberate deviation from -Node: when the client sends an expectation but the identity registry has not -resolved yet (`(Some, None)`), the Rust guard **fails open** (delivers the -input) instead of bouncing — the codex/opencode locators resolve within a -~2s correlation window after spawn, and bouncing keystrokes during pane -startup would be a regression Node does not have an equivalent exposure to. +server (Node parity in frame shape and message, emitting +SESSION_IDENTITY_MISMATCH with `actual_session_ref: Some(..)` — the first +non-`None` in the Rust tree), and Task 5 ports the client suppression +helper. Two deliberate deviations from Node: + +1. **The guard is scoped to the opencode lane** (both refs' provider == + `"opencode"`; see Task 4 for the rationale, from the load-bearing audit + LB3): claude has an identical legitimate rebind swap window on every + in-TUI /clear-resume (`claude_signal.rs:192`), codex has one via fork + rebind (`codex_association.rs:207-220` → `codex_identity.rs:195-197`), + and the client fold's conflict-veto + (`terminal-session-association.ts:98-105`) can pin a pane on a stale + ref indefinitely. An all-provider guard would bounce legitimate + keystrokes across three providers; only the opencode signal-rebind lane + this plan hardens gets the guard. +2. **`(Some, None)` fails open** (delivers the input) instead of bouncing. + Honest coverage claim (load-bearing audit LB4): unresolved identity is + NOT just a short spawn window. REST/fresh-agent-created resume panes + never reach the WS identity registry at all + (`terminal_tabs.rs:1396-1409`, `registry.rs:297-301`) yet carry a + client sessionRef — they are permanently `(Some, None)`. The guard + therefore protects WS-created/registry-seeded opencode panes; + permanently-`None` pane classes are a documented known limitation, and + fail-open is the only correct policy for them (a strict guard would + drop ALL their input forever). Out-of-scope observation surfaced by the + audit: REST-resumed opencode/claude panes' rebind lanes are dead + (their identity-row/never-bound preconditions can never be met) — a + latent pre-existing bug, not addressed here. + The suppression helper itself fails toward the visible path on any absent/unparseable ref, as the spec requires. Ordering constraint: the client suppression only fires when the rebind fold has already synced `contentRef.current` (main does this synchronously on a -`'reconciled'` result, `TerminalView.tsx:~4288`), so Tasks 4–5 land after -the Deliverable-1 tasks. +`'reconciled'` result — the contentRef sync in `TerminalView.tsx`, +post-merge ~`:4507`), so Tasks 4–5 land after the Deliverable-1 tasks. +Documented residual (load-bearing audit LB2, adjudicated accepted): there is +NO delivery-order guarantee between the `associated` broadcast and an +inline bounce on the same connection, so a keystroke typed INSIDE the swap +window can still produce one truthful visible notice — see Task 5 for the +full residual note. What Task 5 suppresses is the spurious visible notice +for the already-self-healed (post-fold) race, not a repair storm — the +mismatch handler's repair path never runs for the expected≠current shape. ## File structure (locked decomposition) @@ -133,13 +171,13 @@ the Deliverable-1 tasks. | `test/unit/client/store/persistControl.test.ts` | Modify: new re-key describe block | 1 | | `src/lib/terminal-session-association.ts` | Modify: one-line caller wiring | 2 | | `test/unit/client/lib/terminal-session-association.test.ts` | Modify: `createState` tab-overrides param, re-key fold test, idempotence regression guards | 2, 3 | -| `crates/freshell-ws/src/terminal.rs` | Modify: input-path identity guard + pure helpers + in-module tests | 4 | +| `crates/freshell-ws/src/terminal.rs` | Modify: opencode-scoped input-path identity guard + pure helpers + in-module tests (incl. non-opencode passthrough) | 4 | | `crates/freshell-ws/tests/opencode_switch_rebind.rs` | Modify: new Phase 1b (stale-input bounce over the wire) | 4 | | `crates/freshell-protocol/tests/roundtrip.rs` | Modify: SESSION_IDENTITY_MISMATCH fixture | 4 | | `src/components/terminal-view-utils.ts` | Modify: add `isStaleSessionIdentityMismatch` | 5 | | `test/unit/client/components/terminal-view-utils.session-mismatch.test.ts` | Create: pure helper tests | 5 | | `src/components/TerminalView.tsx` | Modify: 4-line suppression early-return | 5 | -| `test/unit/client/components/TerminalView.codex-identity.test.tsx` | Modify: suppression wiring test | 5 | +| `test/unit/client/components/TerminalView.codex-identity.test.tsx` | Modify: suppression wiring test (RED keyed on the `[Resume blocked]` xterm write spy) | 5 | | `crates/freshell-ws/src/opencode_signal.rs` | Modify: hello discrimination in the drain, hello tracker, heartbeat WARN + in-module tests | 6 | | `extensions/opencode/freshell-rebind-plugin.ts` | Modify: `emitHello` at TUI startup | 7 | | `test/unit/server/opencode-rebind-plugin.test.ts` | Modify: hello emission tests | 7 | @@ -152,6 +190,70 @@ are independent; 4 (e2e) lands last so it exercises the finished stack. --- +### Task 0: Merge current `origin/main` (`e1f4d4c5`, PR #571 fix/silent-input-loss) into the branch + +**Files:** none authored — a merge commit only. + +Why this is mandatory and FIRST (load-bearing validation, LB11): the branch +was cut at `62fa0ff1`, but Tasks 4–5 cite post-#571 code — most concretely +the `unknown_terminal_input_blocked` bounce in the `TerminalInput` arm, +which does not exist at the old base. `git diff --stat 62fa0ff1..e1f4d4c5` +shows the delta touches exactly Tasks 4–5's files +(`crates/freshell-ws/src/terminal.rs` +84, `src/components/TerminalView.tsx` ++289, plus protocol/registry adjacencies) and does NOT touch any Task 1–3 +file (`src/store/persistControl.ts`, `src/lib/terminal-session-association.ts`, +their tests) — so merging up front is conflict-free for the early tasks and +makes every later anchor real. The delta EXTENDED (did not rewrite) the two +edit regions; no design change follows from the merge. + +Post-merge anchor map (use the named function/handler when a line drifts): +- `TerminalInput` arm: `crates/freshell-ws/src/terminal.rs` ~`:628-660` + (now: codex `note_possible_submit` → `registry.input` returning + `InputOutcome` → `unknown_terminal_input_blocked` bounce on + `!outcome.found` → opencode `note_possible_submit`). +- SESSION_IDENTITY_MISMATCH handler: `TerminalView.tsx` ~`:4575` (was + ~`:4352`); its repair path now also calls + `discardPendingInput('terminal_gone')`. +- `'reconciled'` contentRef sync: `TerminalView.tsx` ~`:4507` (was ~`:4288`). +- `terminal.input.blocked` handler: `TerminalView.tsx` ~`:4552-4575` — a + SEPARATE lane; Tasks 4–5 do not touch it. + +Design-tension note (record it, do not "fix" it): #571's charter is "no +silent input loss," and Task 5 deliberately makes the stale-window bounce +silent (one dropped frame, no notice). This is NOT a charter violation — +the dropped frame was addressed to an identity the pane no longer holds, +and Node has identical semantics. A future kata must not "repair" Task 5's +suppression back into a visible notice. + +- [ ] **Step 1: Merge** + +```bash +git fetch origin +git merge e1f4d4c5 -m "chore: merge origin/main @ e1f4d4c5 (PR #571 fix/silent-input-loss) before implementation" +``` +Expected: clean merge (only `docs/plans/` exists on the branch). If git +reports conflicts, resolve mechanically and STOP to re-verify the anchor +map above before proceeding. + +- [ ] **Step 2: Verify the post-merge anchors exist** + +```bash +grep -n "unknown_terminal_input_blocked" crates/freshell-ws/src/terminal.rs | head -3 +grep -n "SESSION_IDENTITY_MISMATCH" src/components/TerminalView.tsx | head -3 +``` +Expected: both non-empty (the first was absent at `62fa0ff1`). + +- [ ] **Step 3: Baseline gates on the merged tree** + +```bash +cargo test -p freshell-ws +npm run typecheck +``` +Expected: green before any plan work starts (retry `auto_resume_e2e` once +if it flakes — known, pre-existing). + +--- + ### Task 1: Tab session-metadata re-key in `buildTerminalDurableSessionRefUpdate` **Files:** @@ -540,6 +642,13 @@ including this re-key — only fire for single-pane tabs (`isSinglePaneTerminalMatch`). That is main's existing invariant for ALL tab-side rebind state, not a regression introduced here. +Minor note (LB6, verified): a browser tab that is disconnected during the +rebind broadcast stays on the old key until reload (the reconnect +`terminal.inventory` reconcile carries no `previousSessionId`) — accepted +eventual consistency; metadata is never stranded, since the cross-tab +merge narrows to the preferred key (verified by executing the real +`hydrateTabs` reducer in both recency orders). + --- ### Task 3: Duplicate-rebind idempotence regression guards (tests only) @@ -663,12 +772,15 @@ and use subject `fix(client): make the rebind fold idempotent on repeat broadcas --- -### Task 4: Rust input-path identity guard emitting SESSION_IDENTITY_MISMATCH with `actual_session_ref` +### Task 4: Rust input-path identity guard (opencode-scoped) emitting SESSION_IDENTITY_MISMATCH with `actual_session_ref` + +Requires Task 0's merge (`e1f4d4c5`): this task's edit region and the +`unknown_terminal_input_blocked` shape template exist only post-#571. **Files:** - Modify: `crates/freshell-ws/src/terminal.rs` — the - `ClientMessage::TerminalInput` arm (currently `:628-657`) + two new pure - helpers + a new in-module test mod + `ClientMessage::TerminalInput` arm (post-merge ~`:628-660`; anchor by the + arm) + two new pure helpers + a new in-module test mod - Modify: `crates/freshell-ws/tests/opencode_switch_rebind.rs` — new "Phase 1b" after Phase 1 - Modify: `crates/freshell-protocol/tests/roundtrip.rs` — add a @@ -679,16 +791,36 @@ and use subject `fix(client): make the rebind fold idempotent on repeat broadcas (`crates/freshell-ws/src/identity.rs:145`); `ErrorMsg` / `ErrorCode::SessionIdentityMismatch` / `SessionLocator` from `freshell-protocol` (all existing); the arm's existing `send(ws_tx, &msg).await` helper (see the adjacent - `unknown_terminal_input_blocked` bounce in the same arm for the exact - send-and-return shape). + `unknown_terminal_input_blocked` bounce in the same arm — added by #571, + present only after Task 0's merge — for the exact send-and-return shape). - Produces: on a `terminal.input` frame whose `expected_session_ref` is - `Some` AND the terminal's canonical identity is `Some` AND they differ, - the server sends + `Some` AND the terminal's canonical identity is `Some` AND **both refs' + provider is `"opencode"`** AND they differ, the server sends `error{code: SESSION_IDENTITY_MISMATCH, terminalId, expectedSessionRef, actualSessionRef: Some(canonical)}` and does NOT write to the PTY. `(None, _)` and `(Some, None)` deliver as - before (fail-open — see the precondition finding above). Task 5's client - suppression consumes the `actualSessionRef` echo. Wire message string - matches Node: `"Terminal session does not match the expected session."`. + before (fail-open — see the precondition finding above), and ANY + non-opencode `(Some, Some)` divergence also delivers (passthrough). + Task 5's client suppression consumes the `actualSessionRef` echo. Wire + message string matches Node: + `"Terminal session does not match the expected session."`. + +Why the guard is opencode-scoped (LB3, do not widen without a new audit): +the identity-registry write-site audit found legitimate (Some, Some) +divergence windows in ALL three providers — claude upserts a NEW session id +on every in-TUI /clear-resume (`claude_signal.rs:192`), codex on fork +rebind (`codex_association.rs:207-220` → `codex_identity.rs:195-197`) — +and the client fold's conflict-veto +(`terminal-session-association.ts:98-105`) can pin a pane on a stale ref +indefinitely (chained rebinds/refused folds), during which today's +keystrokes are delivered correctly to the live PTY. An all-provider guard +would convert all of those into bounced (lost) keystrokes. Scoping to the +opencode lane — the one this plan's signal-rebind hardening actually +changes — keeps Node's frame shape while bounding the blast radius. The +discriminator is the refs themselves (`provider == "opencode"` on BOTH +expected and canonical): the canonical `SessionLocator` already carries the +provider, so no extra registry lookup is needed, and a cross-provider +disagreement (a shape this plan never produces) fails open rather than +bouncing. - [ ] **Step 1: Write the failing unit tests** @@ -716,16 +848,39 @@ mod input_identity_tests { #[test] fn unresolved_identity_fails_open() { - // Deliberately laxer than attach_geometry_identity_ok: during the - // codex/opencode locator correlation window (~2s) canonical identity - // is None while a restored client already sends an expectation. - // Never drop keystrokes at pane startup. + // Deliberately laxer than attach_geometry_identity_ok: canonical + // identity is None not only during the opencode locator correlation + // window after spawn but PERMANENTLY for REST/fresh-agent-created + // resume panes (they never reach the WS identity registry) -- while + // the client already sends an expectation. Never drop their + // keystrokes: (Some, None) fails open, for life if need be. assert!(input_session_identity_ok( Some(&locator("opencode", "ses_a")), None )); } + #[test] + fn non_opencode_divergence_passes_through() { + // Guard scope (LB3): claude (/clear-resume) and codex (fork rebind) + // have LEGITIMATE identity swap windows -- a divergent (Some, Some) + // pair outside the opencode lane must deliver, not bounce. + assert!(input_session_identity_ok( + Some(&locator("claude", "ses_a")), + Some(&locator("claude", "ses_b")) + )); + assert!(input_session_identity_ok( + Some(&locator("codex", "thread_a")), + Some(&locator("codex", "thread_b")) + )); + // Cross-provider disagreement (never produced by this plan's lanes) + // also fails open. + assert!(input_session_identity_ok( + Some(&locator("codex", "ses_a")), + Some(&locator("opencode", "ses_b")) + )); + } + #[test] fn matching_expectation_ok() { assert!(input_session_identity_ok( @@ -779,13 +934,23 @@ In `crates/freshell-ws/src/terminal.rs`, next to `attach_geometry_identity_ok` (`:3597` region), add: ```rust -/// Input-path identity guard (Node parity: `server/ws-handler.ts:2902-2925` -/// `inputIfSessionMatches`). Deliberately LAXER than -/// `attach_geometry_identity_ok` in the `(Some, None)` case: during the -/// codex/opencode locator correlation window the client may already hold a -/// restored sessionRef while the identity registry has not resolved yet -- -/// attach's silent resize-skip is harmless there, a dropped keystroke is -/// not, so unresolved identity FAILS OPEN and the input is delivered. +/// Input-path identity guard (Node-parity FRAME, `server/ws-handler.ts:2902-2925` +/// `inputIfSessionMatches`, but deliberately NARROWER in scope). Two +/// deviations, both load-bearing: +/// 1. SCOPE: the guard bounces only when BOTH refs are `provider == +/// "opencode"`. Claude (`claude_signal.rs:192`, every in-TUI +/// /clear-resume) and codex (`codex_association.rs:207-220` fork rebind) +/// have legitimate identity swap windows, and the client fold's +/// conflict-veto can pin a pane on a stale ref indefinitely -- an +/// all-provider guard would bounce keystrokes that are delivered +/// correctly today. Only the opencode signal-rebind lane this plan +/// hardens is guarded; everything else passes through. +/// 2. Deliberately LAXER than `attach_geometry_identity_ok` in the +/// `(Some, None)` case: canonical identity is None during the opencode +/// locator correlation window AND permanently for REST/fresh-agent-created +/// resume panes (they never reach the WS identity registry) -- attach's +/// silent resize-skip is harmless there, a dropped keystroke is not, so +/// unresolved identity FAILS OPEN and the input is delivered. fn input_session_identity_ok( expected: Option<&SessionLocator>, canonical: Option<&SessionLocator>, @@ -793,7 +958,12 @@ fn input_session_identity_ok( match (expected, canonical) { (None, _) => true, (Some(_), None) => true, - (Some(e), Some(c)) => e == c, + (Some(e), Some(c)) => { + if e.provider != "opencode" || c.provider != "opencode" { + return true; + } + e == c + } } } @@ -830,7 +1000,7 @@ Run: ```bash cargo test -p freshell-ws input_identity_tests ``` -Expected: PASS (5 tests). +Expected: PASS (6 tests). - [ ] **Step 4: Wire the guard into the TerminalInput arm and add the wire-level integration proof (RED first)** @@ -841,11 +1011,14 @@ and before Phase 2. Phase 1 has just rebound terminal `tid1` from session `A` to session `B`, so its canonical identity is `B`: ```rust - // ────── Phase 1b — stale-expectation input bounce (Node parity, - // server/ws-handler.ts:2902-2925): an in-flight terminal.input still + // ────── Phase 1b — stale-expectation input bounce (Node-parity frame, + // server/ws-handler.ts:2902-2925; guard SCOPED to the opencode lane, + // which this rebind is): an in-flight terminal.input still // carrying the OLD ref A after the A→B rebind is bounced with // SESSION_IDENTITY_MISMATCH echoing actualSessionRef=B and is NOT // delivered; a frame carrying the NEW ref B is delivered with no error. + // (Non-opencode divergence never bounces — pinned by the in-module + // test non_opencode_divergence_passes_through.) send_json( &mut ws, json!({ @@ -935,18 +1108,20 @@ identity registry did not follow the rebind — investigate covers the meta registry, this covers the identity registry). 4c. GREEN — wire the guard. In the `ClientMessage::TerminalInput` arm -(`crates/freshell-ws/src/terminal.rs:628`), insert the guard as the FIRST +(post-merge ~`:628-660`; anchor by the arm), insert the guard as the FIRST statement of the arm (before `codex_association::note_possible_submit` — a bounced frame must not arm the codex first-submit lane either): ```rust ClientMessage::TerminalInput(input) => { - // Node parity (server/ws-handler.ts:2902-2925): a terminal.input - // frame whose expectedSessionRef no longer matches the terminal's - // canonical identity is bounced with SESSION_IDENTITY_MISMATCH - // (actualSessionRef populated) instead of being written to a PTY - // the client no longer means. Unresolved identity fails open -- - // see input_session_identity_ok. + // Node-parity frame (server/ws-handler.ts:2902-2925), scoped to + // the opencode lane (see input_session_identity_ok): a + // terminal.input frame whose opencode expectedSessionRef no + // longer matches the terminal's canonical opencode identity is + // bounced with SESSION_IDENTITY_MISMATCH (actualSessionRef + // populated) instead of being written to a PTY the client no + // longer means. Unresolved identity and non-opencode lanes fail + // open -- see input_session_identity_ok. if let Some(expected) = input.expected_session_ref.as_ref() { let canonical = state.identity.session_ref_for(&input.terminal_id); if !input_session_identity_ok(Some(expected), canonical.as_ref()) { @@ -961,15 +1136,18 @@ a bounced frame must not arm the codex first-submit lane either): .await; } } - // ...existing arm body unchanged (note_possible_submit, - // registry.input, unknown_terminal_input_blocked, opencode - // note_possible_submit, `true`)... + // ...existing arm body unchanged (codex note_possible_submit, + // registry.input -> InputOutcome, the #571 + // unknown_terminal_input_blocked bounce on !outcome.found, + // opencode note_possible_submit, `true`)... } ``` (Follow the arm's existing bounce for the exact `return send(...).await` shape — the `unknown_terminal_input_blocked` path a few lines below does -exactly this.) +exactly this. Ordering note: the guard early-returns BEFORE +`registry.input`, so a bounced input produces exactly ONE refusal frame — +it can never also trip the unknown-terminal bounce.) - [ ] **Step 5: Run the integration test to verify it passes** @@ -1012,14 +1190,26 @@ git commit -m "feat(ws): bounce stale-identity terminal.input with SESSION_IDENT ### Task 5: Client suppression of stale identity-mismatch bounces +Requires Task 0's merge (`e1f4d4c5`): #571 changed 289 lines in +`TerminalView.tsx`, including inside the mismatch handler. + +What this task actually suppresses (corrected by the load-bearing audit, +LB1): for the stale-bounce shape (frame `expectedSessionRef` ≠ pane current +ref), the existing handler takes a NOTICE-ONLY branch — it writes +`[Resume blocked] …` to the xterm and returns. No teardown, no +`terminal.create`; the repair path runs only when expected == current. So +the deliverable is suppressing a **spurious visible notice for the +already-self-healed race**, not preventing a repair storm. + **Files:** - Modify: `src/components/terminal-view-utils.ts` (add import + private helper + exported `isStaleSessionIdentityMismatch`) - Create: `test/unit/client/components/terminal-view-utils.session-mismatch.test.ts` - Modify: `src/components/TerminalView.tsx` (import + 4-line early return - at `:~4352`) + in the SESSION_IDENTITY_MISMATCH handler, post-merge ~`:4575`; anchor by + the handler, not the line) - Modify: `test/unit/client/components/TerminalView.codex-identity.test.tsx` - (one wiring test) + (one wiring test keyed on the `[Resume blocked]` xterm write) **Interfaces:** - Consumes: `msg.actualSessionRef` on the error frame (already on the TS @@ -1088,11 +1278,14 @@ function sessionRefsEqual( return left?.provider === right?.provider && left?.sessionId === right?.sessionId } -/** The rebind swap window (~1 WS RTT) can bounce in-flight input sent with - * the OLD ref. When the server's error echoes an actualSessionRef that - * already equals the pane's CURRENT sessionRef, the rebind fold has - * applied and this is a stale-window bounce: suppress it silently (no - * notice, no repair). Unparseable/absent refs fail toward the visible path. */ +/** The rebind swap window (sub-second: identity upsert -> ledger fsync -> + * broadcast -> client fold) can bounce in-flight input sent with the OLD + * ref. When the server's error echoes an actualSessionRef that already + * equals the pane's CURRENT sessionRef, the rebind fold has applied and + * this is a stale POST-fold bounce: suppress it silently (no notice, no + * repair). A bounce that OUTRUNS the fold stays visible by design (LB2 + * residual, see the task notes). Unparseable/absent refs fail toward the + * visible path. */ export function isStaleSessionIdentityMismatch( currentSessionRef: unknown, actualSessionRef: unknown, @@ -1112,12 +1305,24 @@ Run the Step-2 command again. Expected: PASS (3 tests). - [ ] **Step 4: Write the failing wiring test** +RED-signal design (corrected by the load-bearing audit, LB1): on unmodified +code the expected≠current frame takes the NOTICE-ONLY branch — pane state +is untouched and no `terminal.create` is sent — so assertions on those +alone would be born GREEN. The only observable that flips with Task 5's +early return is the `[Resume blocked]` xterm write. The RED/GREEN pivot is +therefore its PRESENCE on unmodified code vs its ABSENCE afterwards, +observed via the file's mocked `@xterm/xterm` `Terminal.write` spy (the +mock at ~`:110-121` records `write = vi.fn((data, cb) => ...)` and pushes +every instance onto `runtimeHarness.terminals` — verify the exact shape in +the file). The pane-untouched/no-create assertions are KEPT as regression +guards, not as the RED signal. + In `test/unit/client/components/TerminalView.codex-identity.test.tsx`, next to the existing test `'repairs stale runtime plumbing on SESSION_IDENTITY_MISMATCH and reissues a restore create'` (~`:306`), add (reusing that file's `createStore`, `TerminalViewFromStore`, -`wsHarness`, `sentMessages`, `findLeaf` helpers exactly as the existing -test does): +`wsHarness`, `sentMessages`, `findLeaf`, `runtimeHarness` helpers exactly +as the existing tests do): ```tsx it('suppresses the mismatch repair path when actualSessionRef already equals the pane current ref (stale rebind-window bounce)', async () => { @@ -1166,7 +1371,20 @@ test does): }) }) - // Nothing happens: no teardown, no repair create, pane untouched. + // RED/GREEN pivot: on unmodified code the handler's notice-only branch + // writes '[Resume blocked] ...' to the xterm; after the suppression + // early-return it writes NOTHING. The notice may travel the async write + // queue (TerminalView.tsx writeQueueRef, ~:996-999 in + // writeLocalXtermNotice), so flush before asserting absence. + const wroteResumeBlocked = () => + runtimeHarness.terminals.some((term) => + term.write.mock.calls.some(([data]: [string]) => String(data).includes('[Resume blocked]'))) + await act(async () => { await Promise.resolve() }) + expect(wroteResumeBlocked()).toBe(false) + + // Regression guards (these pass BOTH before and after implementation — + // they are NOT the RED signal): no teardown, no repair create, pane + // untouched. const leaf = findLeaf(store.getState().panes.layouts['tab-1'], 'pane-1') expect(leaf?.content.kind).toBe('terminal') expect((leaf?.content as TerminalPaneContent).terminalId).toBe('term-old') @@ -1183,9 +1401,19 @@ Run: ```bash npm run test:vitest -- test/unit/client/components/TerminalView.codex-identity.test.tsx --run ``` -Expected: the new test FAILS (the handler currently runs the repair/notice -path — `terminalId` gets cleared / a `terminal.create` is reissued). All -pre-existing tests pass. +Expected: the new test FAILS on the `wroteResumeBlocked()` assertion — the +unmodified handler takes its notice-only branch for this expected≠current +frame and writes `[Resume blocked] stale bounce` to the mocked terminal. +(It does NOT clear `terminalId` or reissue a `terminal.create` — that is +why the write spy, not pane state, is the RED signal.) All pre-existing +tests pass. + +CALIBRATE the RED before trusting it: if the assertion unexpectedly passes +on unmodified code, first invert it (`.toBe(true)`) and confirm the notice +IS present — if presence is only observable after a longer flush, wrap the +absence check in `waitFor`/additional microtask flushes until the inverted +(presence) form passes deterministically, then restore `.toBe(false)`. A +RED that never saw the notice is vacuous. - [ ] **Step 5: Wire the early return** @@ -1206,7 +1434,8 @@ import { one.) 2. Insert the early return as the FIRST statements inside the mismatch - handler at `:~4352`: + handler (post-merge ~`:4575`; anchor by the + `msg.code === 'SESSION_IDENTITY_MISMATCH'` condition): ```tsx if (msg.type === 'error' && msg.code === 'SESSION_IDENTITY_MISMATCH' && msg.terminalId === tid) { // Stale-window bounce after a rebind -- the pane already folded @@ -1218,10 +1447,35 @@ one.) ... ``` +Composition note (post-#571): the handler's repair path now also calls +`discardPendingInput('terminal_gone')`. The suppression early-return fires +BEFORE it — correct: a stale-window bounce is not an identity abandonment, +and buffered input must survive. Real mismatches still discard + repair. + Accepted, documented behavior: a suppressed bounce means that ONE input frame was dropped (the server did not write it to the PTY). Node has the identical semantics; there is no retry — the user's next keystroke carries -the new ref. +the new ref. This deliberate silence is NOT a violation of #571's +no-silent-input-loss charter (see Task 0's design-tension note): the +dropped frame was addressed to an identity the pane no longer holds. + +Documented residual (LB2, adjudicated ACCEPTED — do not redesign): there is +NO delivery-order guarantee between the `associated` broadcast and an +inline input bounce on the same connection. The connection loop's +`tokio::select!` is unbiased (`terminal.rs:236`), and the rebind tail is +identity upsert → awaited ledger fsync → broadcast +(`opencode_signal.rs:246-268`) — so an input processed inside that window +bounces before the broadcast even exists on the bus. Consequence: a +keystroke typed inside the sub-second swap window can produce ONE truthful +visible `[Resume blocked]` notice and is dropped; the broadcast folds +moments later and subsequent input flows. This task's suppression covers +only POST-fold stale bounces (current ref already equals the echoed +`actualSessionRef`). Alternatives were considered and rejected: a second +grace-window suppression arm (false-suppression risk for REAL mismatches), +routing the bounce through the ordered broadcast channel (disproportionate +server redesign for a sub-second window), and a client self-heal fold of +the echoed `actualSessionRef` (enlarges the anti-hijack trust surface the +fold's conflict-veto exists to protect). Document; do not "fix". - [ ] **Step 6: Run tests to verify green** @@ -1921,6 +2175,15 @@ head-scratcher failures): - Rebind signals are act-then-delete and refused signals are also consumed — `fs.access` failing on the signal path is a real assertion channel proving the sweep ran. +- WsCapture liveness (LB9): the broadcast bus (capacity 1024) CLOSES a + lagging connection with code 4008 (`terminal.rs:402-418`), and `WsCapture` + has no close detection — a silently-dead capture makes + `expectNoMatchingFrame` pass vacuously. The never-steal negative is + therefore only conclusive together with a POSITIVE liveness proof on the + SAME capture connection AFTER the negative window (the sesD control + below, which observes a later frame on `capture2` and fails loudly if the + socket died). Do not remove, reorder, or re-home that control onto a + fresh capture. - [ ] **Step 1: Register the spec (rust-only)** @@ -2308,6 +2571,11 @@ test.describe('OpenCode signal-driven rebind (Rust only)', () => { // sweep, same pane) was alive by rebinding pane 1 to an UNOWNED // session on it and observing success. Deliberately LAST: it moves // pane 1 off sesB after every sesB persistence assertion completed. + // Double duty (LB9): this waitFor runs on the SAME capture2 socket as + // the negative window above — a bus-lag close (4008, no close + // detection in WsCapture) would have silenced capture2 and made the + // negative vacuous, but then THIS positive proof times out and fails + // the spec. The negative is only concluded after this succeeds. const sesD = `ses_e2eliveness${Date.now()}` await writeOpencodeSignal(info.homeDir, restoredTerminalId1, 4, sesD) await capture2.waitFor( @@ -2480,3 +2748,29 @@ now_ms)` (Task 6 impl/tests), `emitHello(deps: EmitterDeps)` (Task 7), `input_session_identity_mismatch_error(terminal_id, expected, actual)` (Task 4 impl/tests/arm), signal payload `{"session_id", "source"}` vs hello `{"hello": true, "source"}` consistent across Tasks 6, 7, and 8. + +**4. Load-bearing validation amendments (post-plan review).** The +load-bearing-assumption pass (`.worktrees/.the-usual-logs/ +rebind-salvage-hardening/load-bearing-ledger.md`) drove these changes, +already folded into the tasks above: +- LB11 (verified, mandatory change): Task 0 merge of `origin/main @ + e1f4d4c5` added as the first implementation action; client/server + anchors refreshed to the post-merge tree; #571 design-tension note + recorded in Task 0 and Task 5. +- LB3 (falsified): Task 4's guard scoped to the opencode lane (both refs' + provider == `"opencode"`), with a non-opencode passthrough unit test. +- LB4 (falsified): fail-open coverage re-worded — permanently-`(Some,None)` + pane classes (REST/fresh-agent resumes) are a documented known + limitation, not a ~2s startup window; fail-open policy itself vindicated. +- LB1 (falsified): Task 5's wiring test re-keyed to the `[Resume blocked]` + xterm write spy (presence = RED on unmodified code, absence = GREEN); + pane-untouched/no-create assertions demoted to regression guards; + Deliverable-2 framing corrected (spurious notice, not repair storm). +- LB2 (falsified, accepted): pre-fold bounce residual documented in Task 5 + (one truthful visible notice + one dropped keystroke inside the swap + window); no redesign. +- LB9 (verified, hardened): Task 8's never-steal negative explicitly tied + to the same-connection sesD liveness sentinel (bus lag-close 4008 makes + an unsentineled negative vacuous). +- LB6 (verified): disconnected-tab eventual-consistency note added to + Task 2. From 00eddb5fb0580aaadb71d8e77db69345918246ba Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:08:03 -0700 Subject: [PATCH 03/10] feat(client): re-key tab session metadata to the new id in buildTerminalDurableSessionRefUpdate --- src/store/persistControl.ts | 33 +++++++++--- test/unit/client/store/persistControl.test.ts | 51 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/store/persistControl.ts b/src/store/persistControl.ts index c61222c61..f431a351c 100644 --- a/src/store/persistControl.ts +++ b/src/store/persistControl.ts @@ -17,6 +17,7 @@ export function buildTerminalDurableSessionRefUpdate({ tabSessionRef, paneResumeSessionId, tabResumeSessionId, + tabSessionMetadataByKey, }: { provider?: CodingCliProviderName sessionId?: string @@ -24,6 +25,7 @@ export function buildTerminalDurableSessionRefUpdate({ tabSessionRef?: SessionRef paneResumeSessionId?: string tabResumeSessionId?: string + tabSessionMetadataByKey?: Record }): { paneUpdates?: { sessionRef?: SessionRef; resumeSessionId?: undefined } tabUpdates?: Partial @@ -46,12 +48,31 @@ export function buildTerminalDurableSessionRefUpdate({ } : undefined - const tabUpdates = tabNeedsSessionRef || tabNeedsResumeClear - ? { - ...(tabNeedsSessionRef ? { sessionRef } : {}), - ...(tabNeedsResumeClear ? { resumeSessionId: undefined } : {}), - } - : undefined + const nextTabUpdates: Partial = { + ...(tabNeedsSessionRef ? { sessionRef } : {}), + ...(tabNeedsResumeClear ? { resumeSessionId: undefined } : {}), + } + + // Rebind metadata re-key (same idiom as buildFreshAgentPersistedIdentityUpdate + // below): merge whatever the tab knew under the superseded id(s) into the + // `${provider}:${sessionId}` key of the new canonical id, deleting the old + // keys. The merge helper ALWAYS returns a fresh object, so deep-compare + // before writing to avoid churn updates on every broadcast. + const nextSessionMetadataByKey = mergeSessionMetadataForPreferredResumeId({ + localSessionMetadataByKey: tabSessionMetadataByKey, + remoteSessionMetadataByKey: tabSessionMetadataByKey, + existingSessionMetadataByKey: tabSessionMetadataByKey, + provider, + localResumeSessionId: tabSessionRef?.sessionId ?? tabResumeSessionId, + remoteResumeSessionId: paneSessionRef?.sessionId ?? paneResumeSessionId, + preferredResumeSessionId: sessionRef.sessionId, + }) + + if (JSON.stringify(nextSessionMetadataByKey ?? {}) !== JSON.stringify(tabSessionMetadataByKey ?? {})) { + nextTabUpdates.sessionMetadataByKey = nextSessionMetadataByKey + } + + const tabUpdates = Object.keys(nextTabUpdates).length > 0 ? nextTabUpdates : undefined const shouldFlush = paneNeedsSessionRef || tabNeedsSessionRef || paneNeedsResumeClear || tabNeedsResumeClear diff --git a/test/unit/client/store/persistControl.test.ts b/test/unit/client/store/persistControl.test.ts index 9e5cc396d..3363b1a67 100644 --- a/test/unit/client/store/persistControl.test.ts +++ b/test/unit/client/store/persistControl.test.ts @@ -10,6 +10,7 @@ import { resetPersistedPanesCacheForTests, } from '@/store/persistMiddleware' import { + buildTerminalDurableSessionRefUpdate, flushPersistedLayoutNow, getCanonicalDurableSessionId, getPreferredResumeSessionId, @@ -90,3 +91,53 @@ describe('persistControl', () => { expect(getCanonicalDurableSessionId(session)).toBe('00000000-0000-4000-8000-000000000321') }) }) + +describe('buildTerminalDurableSessionRefUpdate metadata re-key', () => { + it('moves sessionMetadataByKey from the old session key to the new one on rebind', () => { + const update = buildTerminalDurableSessionRefUpdate({ + provider: 'opencode', + sessionId: 'ses_new', + paneSessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + tabSessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + paneResumeSessionId: undefined, + tabResumeSessionId: undefined, + tabSessionMetadataByKey: { + 'opencode:ses_old': { sessionType: 'opencode', firstUserMessage: 'hello world' }, + }, + }) + expect(update?.tabUpdates?.sessionMetadataByKey).toBeDefined() + expect(update!.tabUpdates!.sessionMetadataByKey!['opencode:ses_new']).toMatchObject({ firstUserMessage: 'hello world' }) + expect(update!.tabUpdates!.sessionMetadataByKey!['opencode:ses_old']).toBeUndefined() + }) + + it('returns null when nothing needs re-keying (metadata already under the new key)', () => { + const update = buildTerminalDurableSessionRefUpdate({ + provider: 'opencode', + sessionId: 'ses_new', + paneSessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + tabSessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + paneResumeSessionId: undefined, + tabResumeSessionId: undefined, + tabSessionMetadataByKey: { + 'opencode:ses_new': { sessionType: 'opencode' }, + }, + }) + expect(update).toBeNull() + }) + + it('does not add a sessionMetadataByKey update when the metadata map is absent or empty', () => { + for (const tabSessionMetadataByKey of [undefined, {}]) { + const update = buildTerminalDurableSessionRefUpdate({ + provider: 'opencode', + sessionId: 'ses_new', + paneSessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + tabSessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + paneResumeSessionId: undefined, + tabResumeSessionId: undefined, + tabSessionMetadataByKey, + }) + expect(update?.tabUpdates?.sessionRef).toEqual({ provider: 'opencode', sessionId: 'ses_new' }) + expect(update?.tabUpdates !== undefined && 'sessionMetadataByKey' in update.tabUpdates).toBe(false) + } + }) +}) From f2bf08ff8a4e21259e1cb4429fd985eafcb7559b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:14:13 -0700 Subject: [PATCH 04/10] feat(client): carry tab session metadata through the terminal rebind fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend createState test fixture to accept tabOverrides for metadata setup - Add updateTab to imports for asserting on tab update actions - Wire caller in reconcileTerminalSessionAssociation to pass tab.sessionMetadataByKey to buildTerminalDurableSessionRefUpdate - Verify metadata is re-keyed (ses_old → ses_new) on server-authoritative rebind - Test verifies: result='reconciled', updateTab dispatch, re-keyed metadata, and flushPersistedLayoutNow dispatch Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- src/lib/terminal-session-association.ts | 1 + .../lib/terminal-session-association.test.ts | 40 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/lib/terminal-session-association.ts b/src/lib/terminal-session-association.ts index 853e91d99..884b09e6f 100644 --- a/src/lib/terminal-session-association.ts +++ b/src/lib/terminal-session-association.ts @@ -127,6 +127,7 @@ export function reconcileTerminalSessionAssociation({ tabSessionRef: tab.sessionRef, paneResumeSessionId: content.resumeSessionId, tabResumeSessionId: tab.resumeSessionId, + tabSessionMetadataByKey: tab.sessionMetadataByKey, }) const nextTabCodexDurability = sessionRef.provider === 'codex' && tab.codexDurability?.state === 'durable' diff --git a/test/unit/client/lib/terminal-session-association.test.ts b/test/unit/client/lib/terminal-session-association.test.ts index 8e971e83f..eb851ec59 100644 --- a/test/unit/client/lib/terminal-session-association.test.ts +++ b/test/unit/client/lib/terminal-session-association.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { reconcileTerminalSessionAssociation } from '@/lib/terminal-session-association' import { reconcileTerminalSessionRefByTerminalId } from '@/store/panesSlice' import { flushPersistedLayoutNow } from '@/store/persistControl' +import { updateTab } from '@/store/tabsSlice' -function createState(content: Record) { +function createState(content: Record, tabOverrides: Record = {}) { return { panes: { layouts: { @@ -19,6 +20,7 @@ function createState(content: Record) { id: 'tab-1', title: 'Tab 1', status: 'running', + ...tabOverrides, }], }, } as any @@ -168,4 +170,40 @@ describe('server-authoritative rebind (previousSessionId)', () => { expect(result).toBe('conflict') expect(dispatch).not.toHaveBeenCalled() }) + + it('re-keys tab session metadata from the superseded id to the new id on rebind', () => { + const dispatched: any[] = [] + const dispatch = vi.fn((action) => dispatched.push(action)) + const getState = () => createState( + { + kind: 'terminal', + terminalId: 'term-1', + createRequestId: 'req-1', + status: 'running', + mode: 'opencode', + shell: 'system', + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + }, + { + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + sessionMetadataByKey: { + 'opencode:ses_old': { sessionType: 'opencode', firstUserMessage: 'hello world' }, + }, + }, + ) + const result = reconcileTerminalSessionAssociation({ + dispatch, + getState, + terminalId: 'term-1', + sessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + previousSessionId: 'ses_old', + }) + expect(result).toBe('reconciled') + const tabUpdate = dispatched.find((action) => action.type === updateTab.type) + expect(tabUpdate).toBeDefined() + expect(tabUpdate.payload.updates.sessionMetadataByKey).toEqual({ + 'opencode:ses_new': { sessionType: 'opencode', firstUserMessage: 'hello world' }, + }) + expect(dispatched.map((action) => action.type)).toContain(flushPersistedLayoutNow.type) + }) }) From 23e0a7590e6aa6fb03b2e3ce24881542c90a4960 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:18:01 -0700 Subject: [PATCH 05/10] test(client): duplicate-rebind idempotence regression guards for the association fold Add two characterization guards for the duplicate rebind broadcast path: 1. Stale repeat whose previousSessionId no longer matches the pane current ref 2. Identical repeat that neither re-dispatches updateTab nor flushes layout again Both tests verify idempotent behavior on repeat broadcasts when the pane and tab have already been updated to the new session reference. Generated with Amplifier (https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../lib/terminal-session-association.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/unit/client/lib/terminal-session-association.test.ts b/test/unit/client/lib/terminal-session-association.test.ts index eb851ec59..6145f117c 100644 --- a/test/unit/client/lib/terminal-session-association.test.ts +++ b/test/unit/client/lib/terminal-session-association.test.ts @@ -207,3 +207,59 @@ describe('server-authoritative rebind (previousSessionId)', () => { expect(dispatched.map((action) => action.type)).toContain(flushPersistedLayoutNow.type) }) }) + +describe('duplicate rebind broadcasts (idempotence regression guards)', () => { + const boundPane = { + kind: 'terminal' as const, + terminalId: 'term-1', + createRequestId: 'req-1', + status: 'running' as const, + mode: 'opencode' as const, + shell: 'system' as const, + sessionRef: { provider: 'opencode', sessionId: 'ses_old' }, + } + + it('a stale repeat whose previousSessionId no longer matches the pane current ref dispatches nothing', () => { + // The pane has already moved on to ses_new2; an old rebind broadcast + // (ses_old -> ses_new) arrives late. The supersession handshake must + // veto it: previousSessionId (ses_old) is not the pane's current ref. + const dispatched: any[] = [] + const dispatch = vi.fn((action) => dispatched.push(action)) + const result = reconcileTerminalSessionAssociation({ + dispatch, + getState: () => createState( + { ...boundPane, sessionRef: { provider: 'opencode', sessionId: 'ses_new2' } }, + { sessionRef: { provider: 'opencode', sessionId: 'ses_new2' } }, + ), + terminalId: 'term-1', + sessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + previousSessionId: 'ses_old', + }) + expect(result).toBe('conflict') + expect(dispatch).not.toHaveBeenCalled() + }) + + it('an identical repeat neither re-dispatches updateTab nor flushes the persisted layout again', () => { + // Post-first-rebind state: pane AND tab already carry ses_new. The tab + // override is REQUIRED: after the first rebind's flush, the persisted + // tab also carries the new ref -- and only a tab whose sessionRef + // already matches suppresses the tab-side flush + // (buildTerminalDurableSessionRefUpdate's tabNeedsSessionRef). + const dispatched: any[] = [] + const dispatch = vi.fn((action) => dispatched.push(action)) + const result = reconcileTerminalSessionAssociation({ + dispatch, + getState: () => createState( + { ...boundPane, sessionRef: { provider: 'opencode', sessionId: 'ses_new' } }, + { sessionRef: { provider: 'opencode', sessionId: 'ses_new' } }, + ), + terminalId: 'term-1', + sessionRef: { provider: 'opencode', sessionId: 'ses_new' }, + previousSessionId: 'ses_old', + }) + expect(result).toBe('reconciled') + const types = dispatched.map((action) => action.type) + expect(types).not.toContain(flushPersistedLayoutNow.type) + expect(types).not.toContain(updateTab.type) + }) +}) From 60281e251e529262e6fe991b84474a7b0813ad1e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:32:25 -0700 Subject: [PATCH 06/10] feat(ws): bounce stale-identity terminal.input with SESSION_IDENTITY_MISMATCH carrying actualSessionRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-protocol/tests/roundtrip.rs | 8 + crates/freshell-ws/src/terminal.rs | 170 ++++++++++++++++++ .../tests/opencode_switch_rebind.rs | 90 ++++++++++ 3 files changed, 268 insertions(+) diff --git a/crates/freshell-protocol/tests/roundtrip.rs b/crates/freshell-protocol/tests/roundtrip.rs index dadffc1e9..d25e4f5fb 100644 --- a/crates/freshell-protocol/tests/roundtrip.rs +++ b/crates/freshell-protocol/tests/roundtrip.rs @@ -220,6 +220,14 @@ fn rich_server_messages() { other => panic!("expected Error, got {other:?}"), } + // error — SESSION_IDENTITY_MISMATCH with a populated actualSessionRef + // (the stale-expectation terminal.input bounce). + let wire = r#"{"type":"error","code":"SESSION_IDENTITY_MISMATCH","message":"no such terminal","timestamp":"2026-07-05T00:00:00.000Z","requestId":"req-1","terminalId":"t1","terminalExitCode":1,"expectedSessionRef":{"provider":"opencode","sessionId":"ses_a"},"actualSessionRef":{"provider":"opencode","sessionId":"ses_b"}}"#; + match server_roundtrip(wire, "error") { + ServerMessage::Error(e) => assert_eq!(e.code, ErrorCode::SessionIdentityMismatch), + other => panic!("expected Error, got {other:?}"), + } + // terminal.output.batch — segments[] with a barrier enum. let wire = r#"{"type":"terminal.output.batch","attachRequestId":"a1","data":"aGVsbG8=","segments":[{"seqStart":1,"seqEnd":3,"endOffset":5,"rawFrameCount":2,"barrier":"turn_complete","data":"aGk="}],"seqStart":1,"seqEnd":3,"serializedBytes":42,"source":"live","streamId":"s1","terminalId":"t1"}"#; match server_roundtrip(wire, "terminal.output.batch") { diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index b35dedcbf..380083df1 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -626,6 +626,28 @@ async fn handle_client_text( } } ClientMessage::TerminalInput(input) => { + // Node-parity frame (server/ws-handler.ts:2902-2925), scoped to + // the opencode lane (see input_session_identity_ok): a + // terminal.input frame whose opencode expectedSessionRef no + // longer matches the terminal's canonical opencode identity is + // bounced with SESSION_IDENTITY_MISMATCH (actualSessionRef + // populated) instead of being written to a PTY the client no + // longer means. Unresolved identity and non-opencode lanes fail + // open -- see input_session_identity_ok. + if let Some(expected) = input.expected_session_ref.as_ref() { + let canonical = state.identity.session_ref_for(&input.terminal_id); + if !input_session_identity_ok(Some(expected), canonical.as_ref()) { + return send( + ws_tx, + &input_session_identity_mismatch_error( + &input.terminal_id, + expected.clone(), + canonical, + ), + ) + .await; + } + } // Lane B2: MUST complete before the Enter reaches the PTY — the // codex locator's FIRST-submit re-snapshot has to finish before // codex can materialize the rollout this very Enter triggers @@ -3611,6 +3633,62 @@ fn attach_geometry_identity_ok( } } +/// Input-path identity guard (Node-parity FRAME, `server/ws-handler.ts:2902-2925` +/// `inputIfSessionMatches`, but deliberately NARROWER in scope). Two +/// deviations, both load-bearing: +/// 1. SCOPE: the guard bounces only when BOTH refs are `provider == +/// "opencode"`. Claude (`claude_signal.rs:192`, every in-TUI +/// /clear-resume) and codex (`codex_association.rs:207-220` fork rebind) +/// have legitimate identity swap windows, and the client fold's +/// conflict-veto can pin a pane on a stale ref indefinitely -- an +/// all-provider guard would bounce keystrokes that are delivered +/// correctly today. Only the opencode signal-rebind lane this plan +/// hardens is guarded; everything else passes through. +/// 2. Deliberately LAXER than `attach_geometry_identity_ok` in the +/// `(Some, None)` case: canonical identity is None during the opencode +/// locator correlation window AND permanently for REST/fresh-agent-created +/// resume panes (they never reach the WS identity registry) -- attach's +/// silent resize-skip is harmless there, a dropped keystroke is not, so +/// unresolved identity FAILS OPEN and the input is delivered. +fn input_session_identity_ok( + expected: Option<&SessionLocator>, + canonical: Option<&SessionLocator>, +) -> bool { + match (expected, canonical) { + (None, _) => true, + (Some(_), None) => true, + (Some(e), Some(c)) => { + if e.provider != "opencode" || c.provider != "opencode" { + return true; + } + e == c + } + } +} + +/// The bounce frame for a stale-expectation `terminal.input`: the FIRST +/// `actual_session_ref: Some(..)` emitter in the Rust tree. The client's +/// stale-window suppression (terminal-view-utils.ts +/// isStaleSessionIdentityMismatch) keys on that echo, so it must carry the +/// canonical ref whenever one exists (and the guard only fires when it does). +fn input_session_identity_mismatch_error( + terminal_id: &str, + expected: SessionLocator, + actual: Option, +) -> ServerMessage { + ServerMessage::Error(ErrorMsg { + code: ErrorCode::SessionIdentityMismatch, + message: "Terminal session does not match the expected session.".to_string(), + timestamp: crate::now_iso(), + actual_session_ref: actual, + expected_session_ref: Some(expected), + request_id: None, + retry_after_ms: None, + terminal_exit_code: None, + terminal_id: Some(terminal_id.to_string()), + }) +} + /// Node-parity geometry bounds for `terminal.attach` / `terminal.resize` /// (`shared/ws-protocol.ts:344-345, 364-365`): `cols` must be an integer in /// `[2, 1000]` and `rows` in `[2, 500]`. Node enforces this at the Zod @@ -4957,6 +5035,98 @@ mod attach_geometry_tests { } } +#[cfg(test)] +mod input_identity_tests { + use super::{input_session_identity_mismatch_error, input_session_identity_ok}; + use freshell_protocol::SessionLocator; + + fn locator(provider: &str, session_id: &str) -> SessionLocator { + SessionLocator { + provider: provider.to_string(), + session_id: session_id.to_string(), + } + } + + #[test] + fn no_expectation_always_ok() { + assert!(input_session_identity_ok(None, None)); + assert!(input_session_identity_ok( + None, + Some(&locator("opencode", "ses_a")) + )); + } + + #[test] + fn unresolved_identity_fails_open() { + // Deliberately laxer than attach_geometry_identity_ok: canonical + // identity is None not only during the opencode locator correlation + // window after spawn but PERMANENTLY for REST/fresh-agent-created + // resume panes (they never reach the WS identity registry) -- while + // the client already sends an expectation. Never drop their + // keystrokes: (Some, None) fails open, for life if need be. + assert!(input_session_identity_ok( + Some(&locator("opencode", "ses_a")), + None + )); + } + + #[test] + fn non_opencode_divergence_passes_through() { + // Guard scope (LB3): claude (/clear-resume) and codex (fork rebind) + // have LEGITIMATE identity swap windows -- a divergent (Some, Some) + // pair outside the opencode lane must deliver, not bounce. + assert!(input_session_identity_ok( + Some(&locator("claude", "ses_a")), + Some(&locator("claude", "ses_b")) + )); + assert!(input_session_identity_ok( + Some(&locator("codex", "thread_a")), + Some(&locator("codex", "thread_b")) + )); + // Cross-provider disagreement (never produced by this plan's lanes) + // also fails open. + assert!(input_session_identity_ok( + Some(&locator("codex", "ses_a")), + Some(&locator("opencode", "ses_b")) + )); + } + + #[test] + fn matching_expectation_ok() { + assert!(input_session_identity_ok( + Some(&locator("opencode", "ses_a")), + Some(&locator("opencode", "ses_a")) + )); + } + + #[test] + fn differing_expectation_is_a_mismatch() { + assert!(!input_session_identity_ok( + Some(&locator("opencode", "ses_a")), + Some(&locator("opencode", "ses_b")) + )); + } + + #[test] + fn mismatch_error_serializes_with_actual_session_ref() { + let msg = input_session_identity_mismatch_error( + "term-1", + locator("opencode", "ses_a"), + Some(locator("opencode", "ses_b")), + ); + let value = serde_json::to_value(&msg).expect("serialize"); + assert_eq!(value["type"], "error"); + assert_eq!(value["code"], "SESSION_IDENTITY_MISMATCH"); + assert_eq!(value["terminalId"], "term-1"); + assert_eq!(value["expectedSessionRef"]["sessionId"], "ses_a"); + assert_eq!(value["actualSessionRef"]["sessionId"], "ses_b"); + assert_eq!( + value["message"], + "Terminal session does not match the expected session." + ); + } +} + #[cfg(test)] mod terminal_dims_range_tests { use super::{invalid_dims_error, terminal_dims_in_range, unknown_terminal_input_blocked}; diff --git a/crates/freshell-ws/tests/opencode_switch_rebind.rs b/crates/freshell-ws/tests/opencode_switch_rebind.rs index 77051c37b..c9b7aa3cf 100644 --- a/crates/freshell-ws/tests/opencode_switch_rebind.rs +++ b/crates/freshell-ws/tests/opencode_switch_rebind.rs @@ -147,6 +147,42 @@ fn write_opencode_signal(root: &std::path::Path, terminal_id: &str, seq: u64, se .unwrap(); } +/// Send one raw client frame (the [`send_create`] transmit idiom, generalized +/// to any payload). +#[cfg(unix)] +async fn send_json(ws: &mut common::TestWs, value: serde_json::Value) { + ws.send(WsMessage::Text(value.to_string())) + .await + .expect("send raw client frame"); +} + +/// Scan WS text frames until `pred` matches (10 s budget, the +/// [`next_associated_frame`] deadline-poll loop generalized to an arbitrary +/// predicate), returning the matching frame; panic with `label` on timeout. +#[cfg(unix)] +async fn wait_for_frame( + ws: &mut common::TestWs, + label: &str, + pred: impl Fn(&serde_json::Value) -> bool, +) -> serde_json::Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match tokio::time::timeout(remaining.max(Duration::from_millis(1)), ws.next()).await { + Ok(Some(Ok(WsMessage::Text(text)))) => { + if let Ok(value) = serde_json::from_str::(&text) { + if pred(&value) { + return value; + } + } + } + Ok(Some(Ok(_))) => {} + other => panic!("[{label}] ws ended/errored/timed out awaiting frame: {other:?}"), + } + } + panic!("[{label}] no matching frame within 10s"); +} + /// [`common::spawn_server_with_specs`], but ALSO returning the `WsState` /// handle so the test can drive `drain_and_rebind_opencode` directly /// (deterministic — no sweep-timer race), and with an ENABLED pane ledger @@ -476,6 +512,60 @@ async fn tui_switch_signal_rebinds_and_restart_resumes_the_new_id() { "the acted-on signal file must be deleted (act-then-delete)" ); + // ────── Phase 1b — stale-expectation input bounce (Node-parity frame, + // server/ws-handler.ts:2902-2925; guard SCOPED to the opencode lane, + // which this rebind is): an in-flight terminal.input still + // carrying the OLD ref A after the A→B rebind is bounced with + // SESSION_IDENTITY_MISMATCH echoing actualSessionRef=B and is NOT + // delivered; a frame carrying the NEW ref B is delivered with no error. + // (Non-opencode divergence never bounces — pinned by the in-module + // test non_opencode_divergence_passes_through.) + send_json( + &mut ws, + json!({ + "type": "terminal.input", + "terminalId": tid1, + "data": "stale-ref-keystroke", + "expectedSessionRef": { "provider": "opencode", "sessionId": A }, + }), + ) + .await; + let bounce = wait_for_frame(&mut ws, "phase1b/mismatch", |v| { + v["type"] == "error" + && v["code"] == "SESSION_IDENTITY_MISMATCH" + && v["terminalId"] == json!(tid1) + }) + .await; + assert_eq!( + bounce["actualSessionRef"], + json!({ "provider": "opencode", "sessionId": B }), + "bounce must echo the canonical (post-rebind) ref: {bounce}" + ); + assert_eq!( + bounce["expectedSessionRef"], + json!({ "provider": "opencode", "sessionId": A }), + "bounce must echo the stale expectation: {bounce}" + ); + + send_json( + &mut ws, + json!({ + "type": "terminal.input", + "terminalId": tid1, + "data": "fresh-ref-keystroke\r", + "expectedSessionRef": { "provider": "opencode", "sessionId": B }, + }), + ) + .await; + let bounced_again = frame_seen_within(&mut ws, Duration::from_secs(1), |v| { + v["type"] == "error" && v["code"] == "SESSION_IDENTITY_MISMATCH" + }) + .await; + assert!( + !bounced_again, + "a matching expectedSessionRef must be delivered, not bounced" + ); + // ── Phase 2 — the restart story: kill, then replay EXACTLY what a // client that accepted the rebind persists (sessionRef {opencode, B} + // restore:true). The respawned opencode must launch `--session B`. From 3de72e587cab92c54c390606a53e7e1e3a54522e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:40:50 -0700 Subject: [PATCH 07/10] feat(client): suppress stale rebind-window SESSION_IDENTITY_MISMATCH bounces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the server bounces an in-flight input frame carrying the OLD sessionRef (Task 4's guard) but the pane has already folded the new ref, the echoed actualSessionRef equals the pane's current ref -- the race is already self-healed and the '[Resume blocked]' notice is spurious. Add an exported pure helper isStaleSessionIdentityMismatch (suppress iff BOTH refs sanitize to full refs AND are equal; absent/partial refs fail toward the visible path) and an early return at the top of TerminalView's SESSION_IDENTITY_MISMATCH handler. Real mismatches still notice/repair. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- src/components/TerminalView.tsx | 6 ++ src/components/terminal-view-utils.ts | 26 +++++++ .../TerminalView.codex-identity.test.tsx | 67 +++++++++++++++++++ ...rminal-view-utils.session-mismatch.test.ts | 21 ++++++ 4 files changed, 120 insertions(+) create mode 100644 test/unit/client/components/terminal-view-utils.session-mismatch.test.ts diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 0cabcc5cf..436ef74b0 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -54,6 +54,7 @@ import { buildTerminalInputMessage, buildTerminalResizeMessage, getCreateSessionStateFromRef, + isStaleSessionIdentityMismatch, } from '@/components/terminal-view-utils' import { reconcileTerminalSessionAssociation } from '@/lib/terminal-session-association' import { copyText, readText } from '@/lib/clipboard' @@ -4573,6 +4574,11 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) } if (msg.type === 'error' && msg.code === 'SESSION_IDENTITY_MISMATCH' && msg.terminalId === tid) { + // Stale-window bounce after a rebind -- the pane already folded + // the new ref; the dropped frame carried the old one. Silent. + if (isStaleSessionIdentityMismatch(contentRef.current?.sessionRef, msg.actualSessionRef)) { + return + } const staleTerminalId = tid const current = contentRef.current const expectedSessionRef = sanitizeSessionRef(msg.expectedSessionRef) diff --git a/src/components/terminal-view-utils.ts b/src/components/terminal-view-utils.ts index 229150c77..190792d07 100644 --- a/src/components/terminal-view-utils.ts +++ b/src/components/terminal-view-utils.ts @@ -1,4 +1,5 @@ import type { SessionLocator, TerminalPaneContent } from '@/store/paneTypes' +import { sanitizeSessionRef } from '@shared/session-contract' export type TerminalContentRef = { current: TerminalPaneContent | null } @@ -142,3 +143,28 @@ export function buildCodexIdentityMismatchRepairContent( codexDurability: matchingDurableCodexIdentity, } } + +function sessionRefsEqual( + left?: { provider?: string; sessionId?: string }, + right?: { provider?: string; sessionId?: string }, +): boolean { + return left?.provider === right?.provider && left?.sessionId === right?.sessionId +} + +/** The rebind swap window (sub-second: identity upsert -> ledger fsync -> + * broadcast -> client fold) can bounce in-flight input sent with the OLD + * ref. When the server's error echoes an actualSessionRef that already + * equals the pane's CURRENT sessionRef, the rebind fold has applied and + * this is a stale POST-fold bounce: suppress it silently (no notice, no + * repair). A bounce that OUTRUNS the fold stays visible by design (LB2 + * residual, see the task notes). Unparseable/absent refs fail toward the + * visible path. */ +export function isStaleSessionIdentityMismatch( + currentSessionRef: unknown, + actualSessionRef: unknown, +): boolean { + const current = sanitizeSessionRef(currentSessionRef) + const actual = sanitizeSessionRef(actualSessionRef) + if (!current || !actual) return false + return sessionRefsEqual(current, actual) +} diff --git a/test/unit/client/components/TerminalView.codex-identity.test.tsx b/test/unit/client/components/TerminalView.codex-identity.test.tsx index 1432187e0..fbc9e0dbb 100644 --- a/test/unit/client/components/TerminalView.codex-identity.test.tsx +++ b/test/unit/client/components/TerminalView.codex-identity.test.tsx @@ -378,6 +378,73 @@ describe('TerminalView codex identity', () => { expect(sentMessages().filter((msg) => msg?.type === 'terminal.create')).toHaveLength(1) }) + it('suppresses the mismatch repair path when actualSessionRef already equals the pane current ref (stale rebind-window bounce)', async () => { + const store = createStore({ + kind: 'terminal', + createRequestId: 'req-old', + status: 'running', + mode: 'codex', + shell: 'system', + terminalId: 'term-old', + serverInstanceId: 'srv-old', + streamId: 'stream-old', + sessionRef: { + provider: 'codex', + sessionId: 'thread-new', + }, + codexDurability: { + schemaVersion: 1, + state: 'durable', + durableThreadId: 'thread-new', + }, + }) + + render( + + + , + ) + + await waitFor(() => { + expect(sentMessages().some((msg) => msg?.type === 'terminal.attach' && msg.terminalId === 'term-old')).toBe(true) + }) + + wsHarness.send.mockClear() + + act(() => { + wsHarness.emit({ + type: 'error', + code: 'SESSION_IDENTITY_MISMATCH', + terminalId: 'term-old', + expectedSessionRef: { provider: 'codex', sessionId: 'thread-old' }, + // The echoed actual ref equals the pane's CURRENT ref: the rebind + // fold already applied; this bounce is the self-healing race. + actualSessionRef: { provider: 'codex', sessionId: 'thread-new' }, + message: 'stale bounce', + }) + }) + + // RED/GREEN pivot: on unmodified code the handler's notice-only branch + // writes '[Resume blocked] ...' to the xterm; after the suppression + // early-return it writes NOTHING. The notice may travel the async write + // queue (TerminalView.tsx writeQueueRef, ~:996-999 in + // writeLocalXtermNotice), so flush before asserting absence. + const wroteResumeBlocked = () => + runtimeHarness.terminals.some((term) => + term.write.mock.calls.some(([data]: [string]) => String(data).includes('[Resume blocked]'))) + await act(async () => { await Promise.resolve() }) + expect(wroteResumeBlocked()).toBe(false) + + // Regression guards (these pass BOTH before and after implementation — + // they are NOT the RED signal): no teardown, no repair create, pane + // untouched. + const leaf = findLeaf(store.getState().panes.layouts['tab-1'], 'pane-1') + expect(leaf?.content.kind).toBe('terminal') + expect((leaf?.content as TerminalPaneContent).terminalId).toBe('term-old') + expect((leaf?.content as TerminalPaneContent).status).toBe('running') + expect(sentMessages().filter((msg) => msg?.type === 'terminal.create')).toHaveLength(0) + }) + it('preserves a durable-identity breadcrumb instead of silently wiping restoreError when a codex pane has no resumable identity on INVALID_TERMINAL_ID', async () => { // Regression test: previously this branch persisted a totally clean slate // (restoreError: undefined) the instant the live terminal died with no diff --git a/test/unit/client/components/terminal-view-utils.session-mismatch.test.ts b/test/unit/client/components/terminal-view-utils.session-mismatch.test.ts new file mode 100644 index 000000000..0a79c9a49 --- /dev/null +++ b/test/unit/client/components/terminal-view-utils.session-mismatch.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { isStaleSessionIdentityMismatch } from '@/components/terminal-view-utils' + +describe('isStaleSessionIdentityMismatch (rebind swap-window suppression)', () => { + const sesB = { provider: 'opencode', sessionId: 'ses_B' } + + it('suppresses when actualSessionRef matches the pane current ref (fold already applied; stale bounce)', () => { + expect(isStaleSessionIdentityMismatch(sesB, { provider: 'opencode', sessionId: 'ses_B' })).toBe(true) + }) + + it('does not suppress when actualSessionRef differs from the current ref (a REAL mismatch)', () => { + expect(isStaleSessionIdentityMismatch(sesB, { provider: 'opencode', sessionId: 'ses_A' })).toBe(false) + expect(isStaleSessionIdentityMismatch({ provider: 'opencode', sessionId: 'ses_A' }, sesB)).toBe(false) + }) + + it('does not suppress when either ref is absent or unparseable (fail toward the visible path)', () => { + expect(isStaleSessionIdentityMismatch(sesB, undefined)).toBe(false) + expect(isStaleSessionIdentityMismatch(undefined, sesB)).toBe(false) + expect(isStaleSessionIdentityMismatch(sesB, { provider: 'opencode' })).toBe(false) + }) +}) From 5d2ba0398ee520c80383f08a6d6d418551833f11 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:53:44 -0700 Subject: [PATCH 08/10] feat(ws): consume opencode plugin-alive hello signals and warn once when a pane never says hello MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teach the opencode signal drain to discriminate {"hello":true,...} plugin-alive heartbeats (delete-on-read) from rebind signals (act-then-delete, unchanged), returning both via the new OpencodeDrainOutcome. A HelloTracker behind Arc> inside the watcher keeps drain_and_rebind_opencode's public signature unchanged, and a once-per-terminal opencode_rebind_heartbeat_missing WARN fires for RUNNING opencode panes older than OPENCODE_HELLO_GRACE_MS (120s) that never said hello, suppressed when the server's own env shows injection was deliberately skipped. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- crates/freshell-ws/src/opencode_signal.rs | 320 ++++++++++++++++++++-- 1 file changed, 294 insertions(+), 26 deletions(-) diff --git a/crates/freshell-ws/src/opencode_signal.rs b/crates/freshell-ws/src/opencode_signal.rs index e7868433c..0ce1f793e 100644 --- a/crates/freshell-ws/src/opencode_signal.rs +++ b/crates/freshell-ws/src/opencode_signal.rs @@ -20,7 +20,12 @@ //! (claude_signal.rs:12-14 — WsState is an exhaustive struct literal in //! ~27 test files). +use std::collections::HashSet; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use freshell_protocol::TerminalRunStatus; +use freshell_terminal::registry::IdentityProbeRow; use crate::terminal::now_ms; use crate::WsState; @@ -29,9 +34,21 @@ use crate::WsState; /// (re)appears is reaped after this age instead of living forever. pub(crate) const STALE_SIGNAL_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(600); +/// Plugin-alive heartbeat state, owned by the watcher so +/// `drain_and_rebind_opencode`'s signature stays unchanged (WsState is an +/// exhaustive struct literal in ~27 test files -- deliberately not touched). +#[derive(Debug, Default)] +pub(crate) struct HelloTracker { + /// terminalIds that have ever said hello (plugin proven alive). + pub(crate) seen: HashSet, + /// terminalIds already warned about -- once per terminal, ever. + pub(crate) warned: HashSet, +} + #[derive(Clone)] pub struct OpencodeSignalWatcher { root: PathBuf, + hello: Arc>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -50,9 +67,32 @@ pub(crate) fn is_valid_opencode_session_id(id: &str) -> bool { .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric())) } +/// One drain pass over the signal root, split by payload kind. +#[derive(Debug, Default)] +pub struct OpencodeDrainOutcome { + /// Rebind signals, oldest first. The consumer act-then-deletes these. + pub rebinds: Vec, + /// terminalIds whose plugin-alive hello was consumed this pass + /// (delete-on-read: a hello is proof of life, not a command). + pub hellos: Vec, +} + +enum ParsedSignal { + Rebind(OpencodeSignal), + /// `{"hello":true,...}` -- written once at TUI startup by the rebind + /// plugin as a plugin-alive heartbeat. Carries no session id and can + /// never enter the rebind ladder. + Hello { + terminal_id: String, + }, +} + impl OpencodeSignalWatcher { pub fn new(root: PathBuf) -> Self { - Self { root } + Self { + root, + hello: Arc::default(), + } } /// `$HOME` (unix) / `%USERPROFILE%` (windows) + `.freshell/session-signals/opencode`. @@ -76,17 +116,21 @@ impl OpencodeSignalWatcher { ) } - /// Read + parse every `*.json`, sorted by filename. Valid signals are - /// returned WITH their file paths and RETAINED on disk — act-then-delete - /// is the consumer's job (D1.1: a fire-and-forget drain permanently lost - /// signals when a pane died within seconds of a switch, V6). Malformed - /// and invalid-shape files are warn-logged (`opencode_signal_rejected`) - /// and deleted (single-shot semantics — junk must not re-fail every - /// sweep). Files older than STALE_SIGNAL_MAX_AGE are reaped without - /// emitting. `*.tmp` staging files are ignored. - pub fn drain(&self) -> Vec { + /// Read + parse every `*.json`, sorted by filename. Valid rebind signals + /// are returned WITH their file paths and RETAINED on disk — + /// act-then-delete is the consumer's job (D1.1: a fire-and-forget drain + /// permanently lost signals when a pane died within seconds of a switch, + /// V6). `{"hello":true,...}` plugin-alive heartbeats are delete-on-read + /// (proof of life, not a command) and reported via + /// [`OpencodeDrainOutcome::hellos`]. Malformed and invalid-shape files + /// are warn-logged (`opencode_signal_rejected`) and deleted (single-shot + /// semantics — junk must not re-fail every sweep). Files older than + /// STALE_SIGNAL_MAX_AGE are reaped without emitting. `*.tmp` staging + /// files are ignored. + pub fn drain(&self) -> OpencodeDrainOutcome { let Ok(entries) = std::fs::read_dir(&self.root) else { - return Vec::new(); // no dir yet: no opencode pane has ever signaled + // no dir yet: no opencode pane has ever signaled + return OpencodeDrainOutcome::default(); }; let mut paths: Vec = entries .flatten() @@ -94,7 +138,7 @@ impl OpencodeSignalWatcher { .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json")) .collect(); paths.sort(); - let mut signals = Vec::new(); + let mut outcome = OpencodeDrainOutcome::default(); for path in paths { let stale = std::fs::metadata(&path) .and_then(|m| m.modified()) @@ -106,7 +150,12 @@ impl OpencodeSignalWatcher { continue; } match parse_signal_file(&path) { - Some(sig) => signals.push(sig), // retained: consumer act-then-deletes + // Retained: consumer act-then-deletes. + Some(ParsedSignal::Rebind(sig)) => outcome.rebinds.push(sig), + Some(ParsedSignal::Hello { terminal_id }) => { + let _ = std::fs::remove_file(&path); // delete-on-read + outcome.hellos.push(terminal_id); + } None => { // A silently-never-firing lane is the failure mode to // avoid (A8 detectability): log rejects before consuming. @@ -116,11 +165,11 @@ impl OpencodeSignalWatcher { } } } - signals + outcome } } -fn parse_signal_file(path: &Path) -> Option { +fn parse_signal_file(path: &Path) -> Option { let stem = path.file_stem()?.to_str()?; let (terminal_id, _nonce) = stem.rsplit_once("__")?; // LAST "__" — load-bearing if terminal_id.is_empty() { @@ -128,11 +177,18 @@ fn parse_signal_file(path: &Path) -> Option { } let raw = std::fs::read_to_string(path).ok()?; let body: serde_json::Value = serde_json::from_str(&raw).ok()?; + // Hello discrimination comes BEFORE session_id validation: a hello + // carries no session id and must never hit the reject warn+delete lane. + if body.get("hello").and_then(serde_json::Value::as_bool) == Some(true) { + return Some(ParsedSignal::Hello { + terminal_id: terminal_id.to_string(), + }); + } let session_id = body.get("session_id")?.as_str()?; if !is_valid_opencode_session_id(session_id) { return None; } - Some(OpencodeSignal { + Some(ParsedSignal::Rebind(OpencodeSignal { path: path.to_path_buf(), terminal_id: terminal_id.to_string(), session_id: session_id.to_string(), @@ -140,7 +196,65 @@ fn parse_signal_file(path: &Path) -> Option { .get("source") .and_then(|v| v.as_str()) .map(str::to_string), - }) + })) +} + +/// How long an opencode pane may run without the rebind plugin's startup +/// hello before the heartbeat alarm fires once. 120s (the generous end of +/// the 60-120s design range): opencode TUI cold start + Bun plugin load + +/// one 1s sweep tick, with slack for loaded CI machines. Pinned by +/// `hello_grace_stays_generous`. +pub(crate) const OPENCODE_HELLO_GRACE_MS: i64 = 120_000; + +/// Injection skips the server CAN see at warn time, re-derived from its own +/// process env with zero plumbing: the FRESHELL_OPENCODE_REBIND kill switch +/// and a user-set OPENCODE_TUI_CONFIG. Per-pane env overrides and opencode's +/// `--pure` are NOT visible here -- the WARN text names them and stays +/// advisory for those. +fn opencode_injection_disabled_by_env() -> bool { + let kill_switch = matches!( + std::env::var("FRESHELL_OPENCODE_REBIND").ok().as_deref(), + Some("0") | Some("false") + ); + kill_switch || std::env::var("OPENCODE_TUI_CONFIG").is_ok() +} + +/// One heartbeat pass (invariants.rs `warn_unresolved_terminal_identities` +/// pattern: pure fn, injected now_ms, once-per-terminal HashSet bound): +/// WARN for every RUNNING opencode pane older than +/// [`OPENCODE_HELLO_GRACE_MS`] whose terminalId never said hello. +pub(crate) fn warn_opencode_panes_without_hello( + rows: &[IdentityProbeRow], + tracker: &mut HelloTracker, + injection_disabled: bool, + now_ms: i64, +) { + if injection_disabled { + return; + } + for row in rows { + if row.mode != "opencode" + || row.status != TerminalRunStatus::Running + || tracker.seen.contains(&row.terminal_id) + || tracker.warned.contains(&row.terminal_id) + { + continue; + } + let age_ms = now_ms - row.created_at; + if age_ms <= OPENCODE_HELLO_GRACE_MS { + continue; + } + tracker.warned.insert(row.terminal_id.clone()); + tracing::warn!( + terminal_id = %row.terminal_id, + age_ms = age_ms, + "opencode_rebind_heartbeat_missing: opencode pane has run past the \ + hello grace window without a plugin-alive signal -- mid-session \ + rebind is likely degraded (plugin not loaded / opencode plugin \ + API drift / plugins disabled via --pure or a per-pane config). \ + Advisory: per-pane injection skips are not visible to this check." + ); + } } /// Drain opencode switch signals and rebind panes through the guarded lane. @@ -182,8 +296,8 @@ fn parse_signal_file(path: &Path) -> Option { pub async fn drain_and_rebind_opencode(state: &WsState, watcher: &OpencodeSignalWatcher) { // drain() is sync fs I/O -> blocking pool (claude_signal.rs pattern). let drain_watcher = watcher.clone(); - let signals = match tokio::task::spawn_blocking(move || drain_watcher.drain()).await { - Ok(signals) => signals, + let outcome = match tokio::task::spawn_blocking(move || drain_watcher.drain()).await { + Ok(outcome) => outcome, Err(join_error) => { tracing::warn!( error = %join_error, @@ -192,7 +306,22 @@ pub async fn drain_and_rebind_opencode(state: &WsState, watcher: &OpencodeSignal return; } }; - for sig in signals { + // Plugin-alive heartbeat pass: fold consumed hellos into the tracker, + // then WARN once per RUNNING opencode pane that outlived the grace + // window without ever saying hello (suppressed when the server's own + // env shows injection was deliberately skipped). + { + let mut tracker = watcher.hello.lock().expect("hello tracker poisoned"); + tracker.seen.extend(outcome.hellos.iter().cloned()); + let injection_disabled = opencode_injection_disabled_by_env(); + warn_opencode_panes_without_hello( + &state.registry.identity_probe_rows(), + &mut tracker, + injection_disabled, + crate::terminal::now_ms(), + ); + } + for sig in outcome.rebinds { let acted = apply_opencode_signal(state, &sig).await; if acted { let _ = std::fs::remove_file(&sig.path); // act-then-delete (D1.1) @@ -433,17 +562,21 @@ mod tests { ); let watcher = OpencodeSignalWatcher::new(dir.path().to_path_buf()); - let signals = watcher.drain(); - let ids: Vec<(&str, &str)> = signals + let outcome = watcher.drain(); + let ids: Vec<(&str, &str)> = outcome + .rebinds .iter() .map(|s| (s.terminal_id.as_str(), s.session_id.as_str())) .collect(); assert_eq!(ids, vec![("term-1", "ses_aaa"), ("term-1", "ses_bbb")]); - assert_eq!(signals[0].source.as_deref(), Some("opencode-tui-plugin")); + assert_eq!( + outcome.rebinds[0].source.as_deref(), + Some("opencode-tui-plugin") + ); // Valid signals carry their file paths and are RETAINED on disk — // the Task 5 consumer deletes each file only after ACTING on it // (act-then-delete, D1.1). - assert!(signals.iter().all(|s| s.path.exists())); + assert!(outcome.rebinds.iter().all(|s| s.path.exists())); // Rejected .json files are consumed (single-shot — junk must not // re-fail every sweep); the .tmp staging file is untouched. assert_eq!( @@ -456,6 +589,53 @@ mod tests { ); } + #[test] + fn drain_consumes_hello_files_and_reports_their_terminal_ids() { + let dir = tempfile::tempdir().unwrap(); + write_signal( + dir.path(), + "term-h__00000000000001-000000-1.json", + r#"{"hello":true,"source":"opencode-tui-plugin"}"#, + ); + write_signal( + dir.path(), + "term-h__00000000000002-000001-1.json", + r#"{"session_id":"ses_aaa","source":"opencode-tui-plugin"}"#, + ); + let watcher = OpencodeSignalWatcher::new(dir.path().to_path_buf()); + let outcome = watcher.drain(); + assert_eq!(outcome.hellos, vec!["term-h".to_string()]); + assert_eq!(outcome.rebinds.len(), 1); + assert_eq!(outcome.rebinds[0].session_id, "ses_aaa"); + // Hello is delete-on-read; the rebind file is retained + // (act-then-delete happens in the consumer, not the drain). + assert_eq!( + remaining(dir.path()), + vec!["term-h__00000000000002-000001-1.json".to_string()] + ); + } + + #[test] + fn hello_files_never_hit_the_reject_warn_lane() { + let (events, _guard) = crate::invariants::capture::capture(); + let dir = tempfile::tempdir().unwrap(); + write_signal( + dir.path(), + "term-h__00000000000001-000000-1.json", + r#"{"hello":true}"#, + ); + let watcher = OpencodeSignalWatcher::new(dir.path().to_path_buf()); + let outcome = watcher.drain(); + assert_eq!(outcome.hellos, vec!["term-h".to_string()]); + let events = events.lock().unwrap(); + assert!( + !events + .iter() + .any(|e| e.message.contains("opencode_signal_rejected")), + "a hello must not be warn-logged as a reject" + ); + } + #[test] fn drain_reaps_stale_files_without_emitting() { let dir = tempfile::tempdir().unwrap(); @@ -476,15 +656,103 @@ mod tests { .set_modified(stale) .unwrap(); let watcher = OpencodeSignalWatcher::new(dir.path().to_path_buf()); - assert!(watcher.drain().is_empty()); + let outcome = watcher.drain(); + assert!(outcome.rebinds.is_empty()); + assert!(outcome.hellos.is_empty()); assert!(!path.exists()); } + fn probe_row(terminal_id: &str, mode: &str, created_at: i64) -> IdentityProbeRow { + IdentityProbeRow { + terminal_id: terminal_id.to_string(), + mode: mode.to_string(), + status: TerminalRunStatus::Running, + created_at, + resume_session_id: None, + cwd: None, + } + } + + #[test] + fn warns_once_for_an_opencode_pane_past_grace_with_no_hello() { + let (events, _guard) = crate::invariants::capture::capture(); + let mut tracker = HelloTracker::default(); + let rows = vec![probe_row("term-1", "opencode", 0)]; + let now = OPENCODE_HELLO_GRACE_MS + 1; + warn_opencode_panes_without_hello(&rows, &mut tracker, false, now); + warn_opencode_panes_without_hello(&rows, &mut tracker, false, now + 10_000); + let events = events.lock().unwrap(); + let warns: Vec<_> = events + .iter() + .filter(|e| e.message.contains("opencode_rebind_heartbeat_missing")) + .collect(); + assert_eq!(warns.len(), 1, "once per terminal, ever: {warns:?}"); + } + + #[test] + fn no_warn_when_hello_seen_young_non_opencode_or_injection_disabled() { + let (events, _guard) = crate::invariants::capture::capture(); + let now = OPENCODE_HELLO_GRACE_MS + 1; + + // hello seen + let mut tracker = HelloTracker::default(); + tracker.seen.insert("term-1".to_string()); + warn_opencode_panes_without_hello( + &[probe_row("term-1", "opencode", 0)], + &mut tracker, + false, + now, + ); + + // young pane (inside grace) + let mut tracker = HelloTracker::default(); + warn_opencode_panes_without_hello( + &[probe_row("term-2", "opencode", now - 1_000)], + &mut tracker, + false, + now, + ); + + // non-opencode pane + let mut tracker = HelloTracker::default(); + warn_opencode_panes_without_hello( + &[probe_row("term-3", "codex", 0)], + &mut tracker, + false, + now, + ); + + // injection deliberately skipped (kill switch / user OPENCODE_TUI_CONFIG) + let mut tracker = HelloTracker::default(); + warn_opencode_panes_without_hello( + &[probe_row("term-4", "opencode", 0)], + &mut tracker, + true, + now, + ); + + let events = events.lock().unwrap(); + assert!( + !events + .iter() + .any(|e| e.message.contains("opencode_rebind_heartbeat_missing")), + "no warn in any suppressed case" + ); + } + + #[test] + fn hello_grace_stays_generous() { + // TUI cold start + Bun plugin load + one 1s sweep tick, with slack. + assert_eq!(OPENCODE_HELLO_GRACE_MS, 120_000); + } + #[test] fn drain_on_missing_directory_is_empty() { let watcher = OpencodeSignalWatcher::new(std::path::PathBuf::from( "/nonexistent/freshell-opencode-signals", )); - assert!(watcher.drain().is_empty()); + let outcome = watcher.drain(); + assert!(outcome.rebinds.is_empty()); + assert!(outcome.hellos.is_empty()); } } From 17d9b836f096e551fdcaad462bab59e9e4d30e4d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:59:30 -0700 Subject: [PATCH 09/10] feat(opencode-plugin): emit a plugin-alive hello signal at TUI startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resolveSignalTarget() helper to extract home/terminalId resolution - Implement emitHello() that writes a one-time hello signal at TUI startup - Refactor createEmitter() to use the shared resolveSignalTarget() - Hello signal: filename __.json (14-digit ts, 6-digit seq, pid) - Body: {"hello":true,"source":"opencode-tui-plugin"} - Never throws into TUI (writer exceptions swallowed) - Add unit tests for emitHello() with DI pattern - Add integration test with real temp home and hostile API surface 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- extensions/opencode/freshell-rebind-plugin.ts | 42 ++++++++++-- .../server/opencode-rebind-plugin.test.ts | 65 +++++++++++++++++++ 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/extensions/opencode/freshell-rebind-plugin.ts b/extensions/opencode/freshell-rebind-plugin.ts index 8c035d38c..ea0551a79 100644 --- a/extensions/opencode/freshell-rebind-plugin.ts +++ b/extensions/opencode/freshell-rebind-plugin.ts @@ -51,18 +51,47 @@ function defaultWriteFile(dir: string, name: string, body: string): void { renameSync(tmp, join(dir, `${name}.json`)) } -export function createEmitter(deps: EmitterDeps): (candidate: unknown) => void { +function resolveSignalTarget( + env: EmitterDeps['env'], +): { dir: string; terminalId: string } | null { // Mirror the Rust consumer's home resolution (OpencodeSignalWatcher:: // default_root is cfg-gated: USERPROFILE only on Windows, HOME otherwise) // — otherwise a Windows git-bash/MSYS shell with HOME set would emit // signals into a directory the server never sweeps. const home = process.platform === 'win32' - ? (deps.env.USERPROFILE ?? deps.env.HOME) - : (deps.env.HOME ?? deps.env.USERPROFILE) - const terminalId = deps.env.FRESHELL_TERMINAL_ID - if (!home || !terminalId) return () => {} - const dir = join(home, '.freshell', 'session-signals', 'opencode') + ? (env.USERPROFILE ?? env.HOME) + : (env.HOME ?? env.USERPROFILE) + const terminalId = env.FRESHELL_TERMINAL_ID + if (!home || !terminalId) return null + return { dir: join(home, '.freshell', 'session-signals', 'opencode'), terminalId } +} + +/** Plugin-alive heartbeat: written ONCE at TUI startup, before any opencode + * API surface is touched, so it proves the plugin LOADED (not that the + * route API still exists). The server's sweeper consumes it silently as + * liveness proof and warns when an opencode pane never says hello. */ +export function emitHello(deps: EmitterDeps): void { + try { + const target = resolveSignalTarget(deps.env) + if (!target) return + const write = deps.writeFile ?? defaultWriteFile + const now = deps.now ?? Date.now + const nonce = `${String(now()).padStart(14, '0')}-000000-${process.pid}` + write( + target.dir, + `${target.terminalId}__${nonce}`, + JSON.stringify({ hello: true, source: 'opencode-tui-plugin' }), + ) + } catch { + // Losing the hello degrades to no-heartbeat. Never surface into the TUI. + } +} + +export function createEmitter(deps: EmitterDeps): (candidate: unknown) => void { + const target = resolveSignalTarget(deps.env) + if (!target) return () => {} + const { dir, terminalId } = target const write = deps.writeFile ?? defaultWriteFile const now = deps.now ?? Date.now let lastEmitted: string | null = null @@ -93,6 +122,7 @@ export default { id: 'freshell-rebind', tui(api: unknown): void { try { + emitHello({ env: process.env }) const emit = createEmitter({ env: process.env }) const a = api as { slots?: { register?: (name: string, fn: (ctx: unknown) => unknown) => unknown } diff --git a/test/unit/server/opencode-rebind-plugin.test.ts b/test/unit/server/opencode-rebind-plugin.test.ts index a0d200d91..426efc7c4 100644 --- a/test/unit/server/opencode-rebind-plugin.test.ts +++ b/test/unit/server/opencode-rebind-plugin.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import plugin, { createEmitter, + emitHello, extractSessionId, type EmitterDeps, } from '../../../extensions/opencode/freshell-rebind-plugin' @@ -29,6 +33,45 @@ describe('extractSessionId', () => { }) }) +describe('emitHello', () => { + const writes: Array<{ dir: string; name: string; body: string }> = [] + const deps = (env: EmitterDeps['env']): EmitterDeps => ({ + env, + writeFile: (dir, name, body) => writes.push({ dir, name, body }), + now: () => 1_700_000_000_000, + }) + beforeEach(() => writes.splice(0)) + + it('writes a hello signal named __ into the opencode signal dir', () => { + emitHello(deps({ HOME: '/home/u', FRESHELL_TERMINAL_ID: 'term-1' })) + expect(writes).toHaveLength(1) + expect(writes[0].dir).toBe('/home/u/.freshell/session-signals/opencode') + expect(writes[0].name).toMatch(/^term-1__\d{14}-\d{6}-\d+$/) + expect(writes[0].name.split('__')).toHaveLength(2) // nonce never contains the __ delimiter + expect(JSON.parse(writes[0].body)).toEqual({ + hello: true, + source: 'opencode-tui-plugin', + }) + }) + + it('never writes without a home dir or FRESHELL_TERMINAL_ID', () => { + emitHello(deps({ FRESHELL_TERMINAL_ID: 'term-1' })) + emitHello(deps({ HOME: '/home/u' })) + expect(writes).toHaveLength(0) + }) + + it('swallows writer exceptions (never surfaces into the TUI)', () => { + expect(() => + emitHello({ + env: { HOME: '/home/u', FRESHELL_TERMINAL_ID: 'term-1' }, + writeFile: () => { + throw new Error('disk full') + }, + }), + ).not.toThrow() + }) +}) + describe('createEmitter', () => { const writes: Array<{ dir: string; name: string; body: string }> = [] const deps = (env: EmitterDeps['env']): EmitterDeps => ({ @@ -149,4 +192,26 @@ describe('default export (TuiPluginModule)', () => { ).not.toThrow() vi.advanceTimersByTime(10_000) }) + + it('writes a plugin-alive hello at tui() startup, even against a hostile api surface', () => { + const home = mkdtempSync(join(tmpdir(), 'freshell-hello-')) + vi.stubEnv('HOME', home) + vi.stubEnv('FRESHELL_TERMINAL_ID', 'term-hello') + + const hostileApi = new Proxy({}, { + get() { + throw new Error('API drift') + }, + }) + expect(() => plugin.tui(hostileApi)).not.toThrow() + + const dir = join(home, '.freshell', 'session-signals', 'opencode') + const files = readdirSync(dir).filter((name) => name.endsWith('.json')) + expect(files).toHaveLength(1) + expect(files[0]).toMatch(/^term-hello__\d{14}-\d{6}-\d+\.json$/) + expect(JSON.parse(readFileSync(join(dir, files[0]), 'utf-8'))).toEqual({ + hello: true, + source: 'opencode-tui-plugin', + }) + }) }) From 9c795c21a351ae8271fbbd04247f4182f05a72c4 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:29:45 -0700 Subject: [PATCH 10/10] test(e2e): signal-driven opencode rebind browser spec (rust-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First browser-level proof that the full signal-driven rebind stack composes: a Playwright spec drives a real browser against a real Rust server, writing atomic signal files into the swept opencode signal dir. Legs: rebind broadcast with exact [sesA, sesB] sequence, client Redux fold, reload persistence (freshell.layout.v3), post-restart respawn argv (--session sesB, never sesA), never-steal refusal of a pane-2-owned id, and a same-capture positive liveness control (LB9) that keeps the negative window non-vacuous. Deviation from the task brief (spec-realism fix, no production change): signal-minted ids are seeded as session rows into the fake's opencode.db first. The reconcile existence probe correctly adjudicates a diskless id dead_session/session_not_on_disk on the post-restart respawn path; in production the TUI plugin only signals ids opencode has already persisted, so the seed models the real drifted-session shape. Registered rust-only (RUST_ONLY_SPECS + rust-chromium testMatch): the signal-file rebind lane exists only on the Rust server (opencode_signal.rs). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- test/e2e-browser/playwright.config.ts | 4 + .../specs/opencode-rebind-rust.spec.ts | 544 ++++++++++++++++++ 2 files changed, 548 insertions(+) create mode 100644 test/e2e-browser/specs/opencode-rebind-rust.spec.ts diff --git a/test/e2e-browser/playwright.config.ts b/test/e2e-browser/playwright.config.ts index 75e75d8ba..801b883cc 100644 --- a/test/e2e-browser/playwright.config.ts +++ b/test/e2e-browser/playwright.config.ts @@ -158,6 +158,8 @@ const RUST_ONLY_SPECS = [ // P0.2 lane D4: freshclaude durable identity across reload + SIGKILL + // stale-sessionRef dead_session guard. Imports RustServer for restartAbrupt(). /freshclaude-identity-persistence-rust\.spec\.ts$/, + // Signal-file rebind lane exists only on the Rust server (opencode_signal.rs). + /opencode-rebind-rust\.spec\.ts$/, ] export default defineConfig({ @@ -347,6 +349,8 @@ export default defineConfig({ // P0.2 lane D4: freshclaude durable identity across reload + SIGKILL + // stale-sessionRef dead_session guard. Imports RustServer for restartAbrupt(). /freshclaude-identity-persistence-rust\.spec\.ts$/, + // Signal-file rebind lane exists only on the Rust server (opencode_signal.rs). + /opencode-rebind-rust\.spec\.ts$/, ], }, // CONTINUITY SMOKE (pre-deploy gate): REAL freshell-server binary + REAL diff --git a/test/e2e-browser/specs/opencode-rebind-rust.spec.ts b/test/e2e-browser/specs/opencode-rebind-rust.spec.ts new file mode 100644 index 000000000..89ca97d3f --- /dev/null +++ b/test/e2e-browser/specs/opencode-rebind-rust.spec.ts @@ -0,0 +1,544 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { fileURLToPath } from 'node:url' +import { test, expect } from '../helpers/fixtures.js' +import { createE2eServerHandle } from '../helpers/external-target.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' +import { WsCapture, type WsFrame } from '../helpers/ws-capture.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const FAKE_OPENCODE_TERMINAL_SOURCE = path.resolve(__dirname, '../fixtures/fake-opencode-terminal.mjs') +const SESSION_MARKER_RE = /opencode: session (ses_e2e_\S+) started/ + +/** + * Install the fake opencode CLI as an executable named `opencode` in a + * throwaway bin dir, then point `OPENCODE_CMD` at it -- same copy-then-chmod + * pattern `amplifier-restore-rust.spec.ts`'s `installFakeAmplifierCli` uses. + */ +async function installFakeOpencodeTerminal(binDir: string): Promise { + await fs.mkdir(binDir, { recursive: true }) + const target = path.join(binDir, 'opencode') + await fs.copyFile(FAKE_OPENCODE_TERMINAL_SOURCE, target) + await fs.chmod(target, 0o755) + return target +} + +async function selectShellIfPickerShowing(page: import('@playwright/test').Page): Promise { + await page.waitForTimeout(500) + const xtermVisible = await page.locator('.xterm').first().isVisible().catch(() => false) + if (xtermVisible) return + const shellNames = ['Shell', 'WSL', 'CMD', 'PowerShell', 'Bash'] + for (const name of shellNames) { + try { + await page.getByRole('button', { name: new RegExp(`^${name}$`, 'i') }).click({ timeout: 5_000 }) + await page.locator('.xterm').first().waitFor({ state: 'visible', timeout: 15_000 }) + return + } catch { + continue + } + } +} + +async function bootAndConnect( + page: import('@playwright/test').Page, + info: { baseUrl: string; token: string }, +): Promise { + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await selectShellIfPickerShowing(page) + return harness +} + +/** + * Open a NEW pane via the picker and select the "OpenCode" provider option. + * Selecting a coding-CLI provider opens a follow-up "Starting directory for + * OpenCode" combobox (`src/components/panes/DirectoryPicker.tsx`), + * pre-filled with the CURRENT directory and already focused. Pressing Enter + * submits the combobox's own pre-filled value directly, accepting the + * current directory as-is (mirrors `amplifier-restore-rust.spec.ts`'s + * `openAmplifierPane`). + */ +async function openOpencodePane(page: import('@playwright/test').Page): Promise { + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^OpenCode$/i }).click({ force: true }) + await page.getByRole('combobox', { name: /Starting directory for OpenCode/i }).press('Enter') +} + +/** Flatten a pane layout tree into its leaf nodes. */ +function collectLeaves(node: any): any[] { + if (!node) return [] + if (node.type === 'leaf') return [node] + if (node.type === 'split') return (node.children ?? []).flatMap(collectLeaves) + return [] +} + +/** Every opencode-mode terminal leaf currently in a tab's layout. */ +function findOpencodeLeaves(layout: any): any[] { + return collectLeaves(layout).filter((leaf) => leaf?.content?.mode === 'opencode') +} + +/** + * Open a new opencode pane (splitting the current terminal) and return the + * NEWLY-added opencode leaf -- identified by diffing the leaf set before vs + * after, since a fresh opencode pane's `content.terminalId` isn't known + * until the create round-trip completes. + */ +async function openOpencodePaneAndGetLeaf( + page: import('@playwright/test').Page, + harness: TestHarness, + tabId: string, +): Promise { + const before = findOpencodeLeaves(await harness.getPaneLayout(tabId)) + const beforeIds = new Set(before.map((leaf) => leaf.id)) + await openOpencodePane(page) + await expect(page.locator('.xterm').last()).toBeVisible({ timeout: 15_000 }) + return expect.poll(async () => { + const layout = await harness.getPaneLayout(tabId) + const newLeaf = findOpencodeLeaves(layout).find((leaf) => !beforeIds.has(leaf.id)) + return newLeaf?.content?.terminalId ? newLeaf : null + }, { timeout: 15_000 }).not.toBeNull().then(async () => { + const layout = await harness.getPaneLayout(tabId) + return findOpencodeLeaves(layout).find((leaf) => !beforeIds.has(leaf.id)) + }) +} + +/** + * Write one opencode rebind signal the way the TUI plugin does: + * __.json into the server's signal dir, atomically + * (tmp + rename; the 1s sweep filters on the .json extension so it can + * never read a torn file). Returns the final path so consumption + * (act-then-delete) is assertable. + */ +async function writeOpencodeSignal( + homeDir: string, + terminalId: string, + seq: number, + sessionId: string, +): Promise { + const dir = path.join(homeDir, '.freshell', 'session-signals', 'opencode') + await fs.mkdir(dir, { recursive: true }) + // Timestamp-first, zero-padded: lexicographic order == emission order, + // digits and '-' only so the nonce can never contain the __ delimiter. + const nonce = `${String(Date.now()).padStart(14, '0')}-${String(seq).padStart(6, '0')}-${process.pid}` + const name = `${terminalId}__${nonce}` + const tmpPath = path.join(dir, `${name}.json.tmp`) + const finalPath = path.join(dir, `${name}.json`) + await fs.writeFile(tmpPath, JSON.stringify({ session_id: sessionId, source: 'opencode-tui-plugin' })) + await fs.rename(tmpPath, finalPath) + return finalPath +} + +/** + * Seed a real root `session` row for `sessionId` into the fake's + * `/.local/share/opencode/opencode.db` (the server's + * `applyIsolatedHomeEnvironment` pins `XDG_DATA_HOME` to + * `/.local/share`, so this is the SAME db the fake CLI writes and + * the server's `OpencodeSource` index scans). Schema mirrors + * `fake-opencode-terminal.mjs`'s `writeSessionRow` exactly. + * + * Why the spec needs this: in production the TUI plugin only ever signals + * session ids opencode has ALREADY persisted to disk. A signal-minted id + * with no disk row is unrealistic — and the reconcile existence probe + * (`crates/freshell-server/src/existence.rs`) correctly adjudicates such an + * id `dead_session`/`session_not_on_disk` on the post-restart respawn path, + * parking the pane in the Dead-sessions dialog instead of resuming it. So + * every id this spec writes into a signal is seeded on disk first, modeling + * the real drifted-session shape end to end. + */ +async function seedOpencodeSessionRow(homeDir: string, sessionId: string, directory: string): Promise { + const dataHome = path.join(homeDir, '.local', 'share', 'opencode') + await fs.mkdir(dataHome, { recursive: true }) + const db = new DatabaseSync(path.join(dataHome, 'opencode.db')) + try { + db.exec('PRAGMA busy_timeout = 5000') + db.exec(` + CREATE TABLE IF NOT EXISTS project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_archived INTEGER + ); + `) + const now = Date.now() + db.prepare('INSERT OR REPLACE INTO project (id, worktree) VALUES (?, ?)').run( + `proj-${sessionId}`, + directory, + ) + db.prepare( + `INSERT OR REPLACE INTO session + (id, project_id, parent_id, slug, directory, title, version, + time_created, time_updated, time_archived) + VALUES (?, ?, NULL, ?, ?, ?, 'opencode-rebind-e2e-seed', ?, ?, NULL)`, + ).run(sessionId, `proj-${sessionId}`, sessionId, directory, sessionId, now, now) + } finally { + db.close() + } +} + +/** Bounded-wait NEGATIVE helper over WsCapture frames (no fixed sleeps). */ +async function expectNoMatchingFrame( + capture: WsCapture, + pred: (frame: WsFrame) => boolean, + timeoutMs: number, + label: string, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const hit = capture.all.find(pred) + if (hit) { + throw new Error(`Expected NO frame matching ${label}, but got: ${JSON.stringify(hit)}`) + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} + +test.describe('OpenCode signal-driven rebind (Rust only)', () => { + test.setTimeout(240_000) + + test('signal file rebinds the pane end to end: broadcast, fold, persistence, respawn argv, never-steal', async ({ page, e2eServerKind }) => { + // Registered ONLY under rust-chromium; fail loudly on any other kind. + expect(e2eServerKind).toBe('rust') + + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-rebind-')) + const argLogPath = path.join(sharedRoot, 'fake-opencode-terminal-argv.jsonl') + try { + const fakeOpencodePath = await installFakeOpencodeTerminal(path.join(sharedRoot, 'bin')) + + const server = await createE2eServerHandle(process.env, { + kind: e2eServerKind, + construct: { + env: { + OPENCODE_CMD: fakeOpencodePath, + FAKE_OPENCODE_TERMINAL_ARGV_LOG: argLogPath, + }, + setupHome: async (homeDir: string) => { + const freshellDir = path.join(homeDir, '.freshell') + await fs.mkdir(freshellDir, { recursive: true }) + await fs.writeFile(path.join(freshellDir, 'config.json'), JSON.stringify({ + version: 1, + settings: { + codingCli: { enabledProviders: ['opencode'] }, + }, + }, null, 2)) + }, + }, + }) + const info = await server.start() + + type ArgvEntry = { pid: number; t: number; argv: string[] } + async function readArgvEntries(): Promise { + try { + const raw = await fs.readFile(argLogPath, 'utf-8') + return raw.split('\n').filter(Boolean).map((line) => JSON.parse(line) as ArgvEntry) + } catch { + return [] + } + } + function argvValue(argv: string[], flag: string): string | undefined { + const index = argv.indexOf(flag) + return index >= 0 ? argv[index + 1] : undefined + } + + // Server-side raw WS capture, opened BEFORE any opencode pane exists + // so it sees the FIRST association frame too. + const capture = new WsCapture(info.wsUrl, info.token) + await capture.ready() + + const harness = await bootAndConnect(page, info) + const tabId = await harness.getActiveTabId() + expect(tabId).toBeTruthy() + + /** Re-read the (possibly reshuffled) leaf for a given pane id. */ + async function findLeafById(tid: string, paneId: string): Promise { + const layout = await harness.getPaneLayout(tid) + return collectLeaves(layout).find((leaf: any) => leaf.id === paneId) + } + + /** Deterministic typing into a SPECIFIC terminal (CR as its own frame: + * the opencode locator's is_submit_input arms on enter-only input). */ + async function typeLineIntoTerminal(terminalId: string, text: string): Promise { + await page.evaluate(({ tid, data }) => { + const testHarness = (window as any).__FRESHELL_TEST_HARNESS__ + if (!testHarness) throw new Error('Freshell test harness is not installed') + testHarness.sendWsMessage({ type: 'terminal.input', terminalId: tid, data }) + }, { tid: terminalId, data: text }) + await page.evaluate(({ tid }) => { + (window as any).__FRESHELL_TEST_HARNESS__?.sendWsMessage({ type: 'terminal.input', terminalId: tid, data: '\r' }) + }, { tid: terminalId }) + } + + /** Scrape the fake's fresh-session marker out of a terminal buffer. */ + async function scrapeSessionId(terminalId: string): Promise { + let sessionId: string | null = null + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(terminalId) + const unwrapped = typeof buffer === 'string' ? buffer.replace(/\n/g, '') : '' + const match = SESSION_MARKER_RE.exec(unwrapped) + sessionId = match?.[1] ?? null + return sessionId + }, { timeout: 30_000 }).not.toBeNull() + return sessionId! + } + + // ── Setup: one opencode pane, freshly bound to sesA by the locator. + const pane1Leaf = await openOpencodePaneAndGetLeaf(page, harness, tabId!) + const pane1Id: string = pane1Leaf.id + const terminalId1: string = pane1Leaf.content.terminalId + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(terminalId1) + return typeof buffer === 'string' && buffer.includes('opencode> ') + }, { timeout: 15_000 }).toBe(true) + await typeLineIntoTerminal(terminalId1, 'hello from the rebind spec') + const sesA = await scrapeSessionId(terminalId1) + await capture.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === terminalId1 + && frame.sessionRef?.sessionId === sesA, + 60_000, + `fresh association terminal=${terminalId1} session=${sesA}`, + ) + + // ── Leg 1: signal file → rebind broadcast, exact sequence [sesA, sesB]. + // Signal payload ids must be ses_ + PURE alnum + // (is_valid_opencode_session_id rejects underscores). Seeded on disk + // first: the plugin only signals sessions opencode has persisted, and + // the post-restart respawn path (Leg 5) adjudicates a diskless id + // dead_session/session_not_on_disk instead of resuming it. + const sesB = `ses_e2edrift${Date.now()}` + await seedOpencodeSessionRow(info.homeDir, sesB, info.homeDir) + const signalPath = await writeOpencodeSignal(info.homeDir, terminalId1, 1, sesB) + const rebindFrame = await capture.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === terminalId1 + && frame.sessionRef?.sessionId === sesB, + 60_000, + `signal rebind terminal=${terminalId1} session=${sesB}`, + ) + expect(rebindFrame.previousSessionId).toBe(sesA) + + // Acted ⇒ deleted (act-then-delete): consumption proves the sweep ran. + await expect.poll(async () => { + try { + await fs.access(signalPath) + return 'still-present' + } catch { + return 'consumed' + } + }, { timeout: 5_000 }).toBe('consumed') + + // No frame ever reported an unbound/cleared identity between the two: + // every association frame for this terminal carried a truthy session + // id, and the distinct id sequence is exactly [sesA, sesB]. + const associationFrames = capture.all.filter( + (frame) => frame.type === 'terminal.session.associated' && frame.terminalId === terminalId1, + ) + for (const frame of associationFrames) { + expect(frame.sessionRef?.provider).toBe('opencode') + expect(frame.sessionRef?.sessionId).toBeTruthy() + } + const distinctSequence: string[] = [] + for (const frame of associationFrames) { + const id = frame.sessionRef.sessionId as string + if (distinctSequence[distinctSequence.length - 1] !== id) distinctSequence.push(id) + } + expect(distinctSequence).toEqual([sesA, sesB]) + + // ── Leg 2: the browser client folded the rebind into the pane (real Redux). + await expect.poll(async () => { + const leaf = await findLeafById(tabId!, pane1Id) + return leaf?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }).toBe(sesB) + + // ── Leg 3: the pane is still interactive after the rebind. + await page.evaluate(({ tid }) => { + (window as any).__FRESHELL_TEST_HARNESS__?.sendWsMessage({ + type: 'terminal.input', terminalId: tid, data: 'still-interactive-after-rebind', + }) + }, { tid: terminalId1 }) + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(terminalId1) + const unwrapped = typeof buffer === 'string' ? buffer.replace(/\n/g, '') : '' + return unwrapped.includes('still-interactive-after-rebind') + }, { timeout: 15_000 }).toBe(true) + const pane1AfterRebind = await findLeafById(tabId!, pane1Id) + expect(pane1AfterRebind?.content?.status).not.toBe('error') + + // ── Leg 4: reload persistence — freshell.layout.v3 carries the new id. + await page.evaluate(() => { + (window as any).__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'persist/flushNow' }) + }) + await page.reload({ waitUntil: 'domcontentloaded' }) + await harness.waitForHarness() + await harness.waitForConnection() + await expect.poll(async () => { + const leaf = await findLeafById(tabId!, pane1Id) + return leaf?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }).toBe(sesB) + const persistedRaw: string = await page.evaluate(() => { + (window as any).__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'persist/flushNow' }) + const raw = window.localStorage.getItem('freshell.layout.v3') + if (!raw) throw new Error('Missing persisted layout freshell.layout.v3') + return raw + }) + const persisted = JSON.parse(persistedRaw) + const persistedPane1 = collectLeaves(persisted.panes?.layouts?.[tabId!]) + .find((leaf: any) => leaf.id === pane1Id) + expect(persistedPane1?.content?.sessionRef?.sessionId).toBe(sesB) + + // ── Leg 5: server restart → respawn argv carries --session sesB, never sesA. + const terminalIdBeforeRestart: string = (await findLeafById(tabId!, pane1Id))!.content.terminalId + capture.close() + if (!server.restart) { + throw new Error(`${e2eServerKind} E2eServerHandle does not implement restart()`) + } + await server.restart() + await page.reload({ waitUntil: 'domcontentloaded' }) + await harness.waitForHarness() + await harness.waitForConnection() + + const restoredTerminalId1: string = await expect + .poll(async () => { + const leaf = await findLeafById(tabId!, pane1Id) + if (leaf?.content?.status === 'error') return null + const tid = leaf?.content?.terminalId + return tid && tid !== terminalIdBeforeRestart ? tid : null + }, { timeout: 45_000 }) + .not.toBeNull() + .then(async () => (await findLeafById(tabId!, pane1Id))!.content.terminalId) + expect(restoredTerminalId1).not.toBe(terminalIdBeforeRestart) + + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(restoredTerminalId1) + const unwrapped = typeof buffer === 'string' ? buffer.replace(/\n/g, '') : '' + return unwrapped.includes(`opencode: resumed session ${sesB}`) + }, { timeout: 30_000 }).toBe(true) + + const argvEntries: ArgvEntry[] = await expect.poll(async () => { + const entries = await readArgvEntries() + return entries.some((entry) => argvValue(entry.argv, '--session') === sesB) ? entries : null + }, { timeout: 30_000 }).not.toBeNull().then(readArgvEntries) + const newestEntry = argvEntries[argvEntries.length - 1] + expect(argvValue(newestEntry.argv, '--session')).toBe(sesB) + expect(argvEntries.every((entry) => argvValue(entry.argv, '--session') !== sesA)).toBe(true) + + // ── Leg 6: never-steal refusal + liveness control. + // Fresh capture: the pre-restart WS died with the old server process. + const capture2 = new WsCapture(info.wsUrl, info.token) + await capture2.ready() + + // Pane 2, freshly bound, then signal-rebound to the alnum-safe sesC so + // the steal target is a LIVE-OWNED session with a signal-lane history. + const pane2Leaf = await openOpencodePaneAndGetLeaf(page, harness, tabId!) + const pane2Id: string = pane2Leaf.id + const terminalId2: string = pane2Leaf.content.terminalId + await expect.poll(async () => { + const buffer = await harness.getTerminalBuffer(terminalId2) + return typeof buffer === 'string' && buffer.includes('opencode> ') + }, { timeout: 15_000 }).toBe(true) + await typeLineIntoTerminal(terminalId2, 'hello from pane two') + const sesC0 = await scrapeSessionId(terminalId2) + await capture2.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === terminalId2 + && frame.sessionRef?.sessionId === sesC0, + 60_000, + `pane2 fresh association terminal=${terminalId2} session=${sesC0}`, + ) + const sesC = `ses_e2eowned${Date.now()}` + await seedOpencodeSessionRow(info.homeDir, sesC, info.homeDir) + await writeOpencodeSignal(info.homeDir, terminalId2, 2, sesC) + await capture2.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === terminalId2 + && frame.sessionRef?.sessionId === sesC, + 60_000, + `pane2 signal rebind terminal=${terminalId2} session=${sesC}`, + ) + + // The steal attempt: a signal for PANE 1 naming pane-2-owned sesC. + const stealPath = await writeOpencodeSignal(info.homeDir, restoredTerminalId1, 3, sesC) + await expectNoMatchingFrame( + capture2, + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === restoredTerminalId1 + && frame.sessionRef?.sessionId === sesC, + 3_000, + `steal rebind terminal=${restoredTerminalId1} -> ${sesC}`, + ) + // The refusal is act-then-delete: consumption proves the sweep SAW and + // REFUSED the signal (not that it never ran). + await expect.poll(async () => { + try { + await fs.access(stealPath) + return 'still-present' + } catch { + return 'consumed' + } + }, { timeout: 5_000 }).toBe('consumed') + + // Both panes kept their identities across a reload. + await page.evaluate(() => { + (window as any).__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'persist/flushNow' }) + }) + await page.reload({ waitUntil: 'domcontentloaded' }) + await harness.waitForHarness() + await harness.waitForConnection() + await expect.poll(async () => { + const leaf = await findLeafById(tabId!, pane1Id) + return leaf?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }).toBe(sesB) + await expect.poll(async () => { + const leaf = await findLeafById(tabId!, pane2Id) + return leaf?.content?.sessionRef?.sessionId ?? null + }, { timeout: 30_000 }).toBe(sesC) + + // POSITIVE LIVENESS CONTROL: prove the same channel (same dir, same + // sweep, same pane) was alive by rebinding pane 1 to an UNOWNED + // session on it and observing success. Deliberately LAST: it moves + // pane 1 off sesB after every sesB persistence assertion completed. + // Double duty (LB9): this waitFor runs on the SAME capture2 socket as + // the negative window above — a bus-lag close (4008, no close + // detection in WsCapture) would have silenced capture2 and made the + // negative vacuous, but then THIS positive proof times out and fails + // the spec. The negative is only concluded after this succeeds. + const sesD = `ses_e2eliveness${Date.now()}` + await seedOpencodeSessionRow(info.homeDir, sesD, info.homeDir) + await writeOpencodeSignal(info.homeDir, restoredTerminalId1, 4, sesD) + await capture2.waitFor( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === restoredTerminalId1 + && frame.sessionRef?.sessionId === sesD, + 60_000, + `post-refusal liveness control rebind terminal=${restoredTerminalId1} session=${sesD}`, + ) + // Re-assert the negative over the FULL frame history: even while the + // channel was provably consuming signals, no frame ever rebound pane 1 + // to the pane-2-owned sesC. + const stealFrame = capture2.all.find( + (frame) => frame.type === 'terminal.session.associated' + && frame.terminalId === restoredTerminalId1 + && frame.sessionRef?.sessionId === sesC, + ) + expect(stealFrame).toBeUndefined() + + capture2.close() + await server.stop() + } finally { + await fs.rm(sharedRoot, { recursive: true, force: true }) + } + }) +})