FE-1321: Converge TUI and web session runtime contracts - #416
Conversation
Drive a deterministic PTY journey through the production normal-TUI entry: the child calls the real runBrunchTui with no launchInteractive override, so launchPiInteractive builds the sealed runtime and a real Pi InteractiveMode. The witness observes Brunch/Pi startup chrome and an editable prompt, types one ordinary turn, reads that exact user/assistant exchange back from the sole canonical JSONL, and quits with Ctrl-D. Provider substitution now rides a runBrunchTui option mirroring runBrunchWeb's, so a deterministic backend reaches the sealed runtime factory without replacing the production launcher. The journey also exposed a live defect: Pi's InteractiveMode ends every interactive quit with process.exit(0), so runBrunchTui's finally never ran and the fail-closed per-target writer lock survived a normal Ctrl-D, permanently stranding the target for later TUI and standalone-web processes (I64-L). Released synchronously from a process exit hook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test support modules under src/**/__tests__/ that are not themselves *.test.ts were compiled into dist, shipping harness scaffolding in the published tarball — including a file importing dist/probes, which the package deliberately excludes. Exclude the directories from the build project instead of relocating the files: the PTY witness spawns its child from source, and nothing outside __tests__ imports these modules, so the exclude also retires the pre-existing dist/executor/__tests__ and dist/graph/__tests__ emissions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reconcile memory/SPEC.md and the four D141-L topology homes with the
landed PTY witness and writer-lock exit-release: I64-L coverage records
the process.on('exit') release and the leaves still owed, the session
runtime convergence oracle now describes only the remaining companion/
rival/reopen proofs, I42-L extends build exclusion to test substrate,
and app/session/rpc/web TOPOLOGY notes name the agentServices seam and
the discharged PTY proof.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Attach the production React app to a real TUI's web sidecar over a real
WebSocket and prove the companion converges with canonical JSONL for a
turn typed into the real Pi editor: semantic-only intake, no browser
driving, settled equality with a parent-computed fresh presentation
projection, inert detach, and writer-lock release on Ctrl-D.
The PTY choreography the landed tracer owned privately moves to a shared
module so both witnesses boot the same child entry.
The DOM is installed at runtime rather than by declaring jsdom as the
file's vitest environment: that declaration switches Vite to its client
transform, which rewrites the tui-driver's `new URL('./driver.exp',
import.meta.url)` into a served asset URL and breaks the PTY spawn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
PR SummaryMedium Risk Overview The main runtime change in this diff is Docs and plan retire the independent-host premise (A47-L), record automated witnesses (PTY journey, companion React, structured ask, rival refusal, post–Ctrl-D standalone reopen), and defer companion UX questions (SA1/SA2) to the A51-L walkthrough. Raw Reviewed by Cursor Bugbot for commit 79afabc. Bugbot is set up for automated code reviews on this repo. Configure here. |
| // frames below are the same ones a browser would receive. | ||
| const notifications: WebSocketRpcNotification[] = []; | ||
| rpcClient = createWebSocketRpcClient({ | ||
| url: `ws://${new URL(report.webSidecarUrl).host}/rpc`, |
There was a problem hiding this comment.
Semgrep identified an issue in your code:
Insecure WebSocket Detected. WebSocket Secure (wss) should be used for all WebSocket connections.
To resolve this comment:
🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.
💬 Ignore this finding
Reply with Semgrep commands to ignore this finding.
/fp <comment>for false positive/ar <comment>for acceptable risk/other <comment>for all other reasons
Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by detect-insecure-websocket.
You can view more details about this finding in the Semgrep AppSec Platform.
There was a problem hiding this comment.
Pull request overview
This PR converges the normal-TUI and standalone-web session runtime contracts so the browser companion can attach to a real TUI-owned session via the same target-addressed hosted-session RPC + brunch.liveSessionEvent stream, while enforcing single-writer authority across processes.
Changes:
- Add a fail-closed, per-target filesystem writer lock and wire it into both
runBrunchTuiandrunBrunchWebbefore runtime construction. - Introduce a TUI live-session adapter that projects InteractiveMode session events into the canonical semantic event contract and surfaces live asks (including new observe-only ask announcements).
- Add/extend slow production PTY witnesses that boot the real TUI, attach the real React client over WebSocket, prove semantic-only intake, structured ask observation/refusal, contention refusal, and post-shutdown reopen.
Reviewed changes
Copilot reviewed 38 out of 38 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.build.json | Exclude src/**/__tests__/** from build output. |
| TESTING_FINDINGS.md | Record structured-ask companion UX/design questions (SA1/SA2). |
| src/web/TOPOLOGY.md | Update web client migration state + add companion PTY witness reference. |
| src/session/tui-live-session-adapter.ts | New adapter exposing a TUI-owned session through LiveSessionHost. |
| src/session/TOPOLOGY.md | Document D141-L/D125-L changes: adapter + writer-guard + announce-only asks. |
| src/session/session-writer-guard.ts | New fail-closed per-target writer lock (atomic mkdir + owner record). |
| src/session/live-ask-registry.ts | Add announceAsk for observe-only asks (discoverable but not answerable). |
| src/session/tests/tui-live-session-adapter.test.ts | Unit tests for TUI adapter projection + ask announcement behavior. |
| src/session/tests/session-writer-guard.test.ts | Unit tests for writer lock conflict/release/fail-closed behavior. |
| src/session/tests/live-ask-registry.test.ts | Tests for announced asks (listed open, refused answers, teardown clears). |
| src/rpc/web-host.ts | Introduce legacySidecar + legacySessionEvents wiring for transitional /rpc/driver. |
| src/rpc/TOPOLOGY.md | Update canonical /rpc vs transitional /rpc/driver contract documentation. |
| src/rpc/tests/standalone-web-session-host.contract.test.ts | Assert canonical hosted-session surface rejects raw/targetless dialect. |
| src/dev/tests/web-driver-streaming.relay.test.ts | Switch relay assertions to canonical brunch.liveSessionEvent frames. |
| src/dev/tests/web-driver-streaming.reconnect.test.ts | Reconnect test: validate semantic live frames and absence of raw frames. |
| src/dev/tests/web-driver-streaming.fan-out.test.ts | Fan-out test: validate semantic live frames and absence of raw frames. |
| src/dev/tests/web-driver-streaming.exchange-convergence.test.ts | Exchange convergence: observers watch semantic ask_opened + text deltas. |
| src/dev/tests/web-driver-streaming.command-intake.test.ts | Command intake: split driver (raw) vs observers (semantic) assertions. |
| src/dev/tests/web-driver-streaming-support.ts | Add helper to assemble assistant text from semantic live-session frames. |
| src/app/TOPOLOGY.md | Document D141-L composition split + writer-lock exit-hook + PTY witnesses. |
| src/app/brunch-web.ts | Acquire writer authority before runtime creation; release on dispose. |
| src/app/brunch-tui.ts | Acquire writer authority + expose hosted-session boundary + semantic event stream; exit-hook release. |
| src/app/tests/session-runtime-contract-tracer.slow.test.ts | Add production PTY tracer (boot/turn/cleanup) + semantic sidecar assertions. |
| src/app/tests/session-runtime-contract-tracer-support.ts | Shared constants + cross-process report shape for PTY witnesses. |
| src/app/tests/session-runtime-contract-tracer-child.ts | PTY child entry that boots real runBrunchTui with deterministic provider. |
| src/app/tests/session-runtime-contract-structured-ask.slow.test.ts | PTY + React witness for structured ask observation/refusal + TUI-only interaction. |
| src/app/tests/session-runtime-contract-pty-journey.ts | Shared PTY choreography helpers (boot, echo submit, quit, lock checks). |
| src/app/tests/session-runtime-contract-companion.slow.test.ts | PTY + React witness for semantic-only companion convergence + inert detach. |
| src/app/tests/session-runtime-contract-authority.slow.test.ts | PTY + standalone-web witness for contention refusal + post-quit takeover/reopen. |
| src/app/tests/brunch-tui.test.ts | Add test proving agentServices override threads into sealed TUI runtime factory. |
| src/.pi/extensions/exchanges/ask.ts | Announce UI-owned asks for observers while keeping answering authority local. |
| src/.pi/extensions/tests/exchanges-present-request.test.ts | Update expectations for announce vs headless broker paths. |
| src/.pi/extensions/tests/ask-headless-discovery.test.ts | Add coverage for announced UI-owned asks (listed open, refused remote answer). |
| memory/SPEC.md | Update decisions/invariants (D141-L, A51-L, D125-L widening, I64/I65 updates). |
| memory/PLAN.md | Reshape the convergence arc status/definition around D141-L + PTY witnesses. |
| docs/praxis/manual-testing.md | Update manual evidence checklist for contract convergence (not one-process host). |
| docs/design/WEB_UI_ARCHITECTURE.md | Record supersession: converge contracts vs enforcing one independent host. |
| .changeset/announce-tui-owned-asks.md | Patch changeset for observe-only TUI-owned asks in companion. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const listeners = new Set<(event: LiveSessionEvent) => void>(); | ||
| let session: TuiAdaptedSession | null = null; | ||
| let unsubscribeSession: (() => void) | undefined; | ||
| let unsubscribeAsks: (() => void) | undefined; | ||
| let seq = 0; | ||
|
|
| attachSession(next) { | ||
| unsubscribeSession?.(); | ||
| unsubscribeAsks?.(); | ||
| session = next; | ||
| seq = 0; | ||
| const project = createLiveSessionEventProjection(); | ||
| unsubscribeSession = next.subscribe((event) => { | ||
| const delta = project(event); | ||
| if (delta) emit(delta); | ||
| }); | ||
| unsubscribeAsks = input.asks.subscribe((ask) => emit({ type: 'ask_opened', ask })); | ||
| }, | ||
| detachSession() { | ||
| unsubscribeSession?.(); | ||
| unsubscribeAsks?.(); | ||
| unsubscribeSession = undefined; | ||
| unsubscribeAsks = undefined; | ||
| session = null; | ||
| }, |
| async open(target) { | ||
| return { status: matches(target) && session ? 'attached' : 'not_open' }; | ||
| }, | ||
| async close(target) { | ||
| return { status: matches(target) && session ? 'closed' : 'not_open' }; | ||
| }, | ||
| async driveTurn(target, _driverId, prompt) { | ||
| if (!matches(target) || !session) return { status: 'not_open' }; | ||
| if (session.isStreaming) return { status: 'busy' }; | ||
| await session.prompt(prompt, { expandPromptTemplates: false, source: 'rpc' }); | ||
| return { status: 'completed' }; | ||
| }, | ||
| openAsks(target) { | ||
| return matches(target) && session ? input.asks.reader.openAsks() : undefined; | ||
| }, | ||
| answerExchange(target, _driverId, exchangeId, answer) { | ||
| if (!matches(target) || !session) return { status: 'not_open' }; | ||
| const outcome = input.asks.answerer.submitAnswer({ exchangeId, answer }); | ||
| if (outcome.submitted) return { status: 'completed' }; | ||
| return { status: outcome.reason === 'invalid_answer' ? 'invalid_answer' : 'ask_closed' }; | ||
| }, |
| const liveExchange = createLiveAskRegistry(); | ||
| const liveAgentSession = { current: null }; | ||
| const runtime = await createAgentSessionRuntime( | ||
| createBrunchAgentSessionRuntimeFactory({ | ||
| workspace, | ||
| coordinator: { | ||
| inspectWorkspace: () => coordinator.inspectWorkspace(), | ||
| activateWorkspace: (decision) => coordinator.activateWorkspace(decision), | ||
| bindCurrentSpecToReplacementSession: (manager) => | ||
| coordinator.bindTargetSpecToReplacementSession(target, manager), | ||
| }, | ||
| liveExchange, | ||
| liveAgentSession, | ||
| allowSubagents: true, | ||
| ...(agentServices ? { agentServices } : {}), | ||
| }), | ||
| { cwd, agentDir: getAgentDir(), sessionManager: workspace.session.manager }, | ||
| ); | ||
| await runtime.session.bindExtensions({}); |

Why
Brunch can be used two ways: directly in a terminal, or through a browser companion view. We want someone to be able to watch and interact with their terminal session from a browser live, without standing up a separate, more fragile "shared session" server just to make that possible. This PR is the proof step: does the simpler design — one terminal session, one browser view attached to it — actually hold up against the real product, not just in theory?
What
This is proving groundwork behind existing internal/test surfaces, not a new user-facing feature yet. It does not remove the older, more fragile browser-connection mechanism — that cleanup is the next planned step, once a short colleague walkthrough confirms this new approach feels right to actually use day to day.
How to test
npm run verify:fullpasses, including new slow end-to-end proofs that drive a real terminal session and a real browser client together.