Skip to content

FE-1321: Converge TUI and web session runtime contracts - #416

Open
lunelson wants to merge 18 commits into
ln/fe-1320-comparison-mission-isolationfrom
ln/fe-1321-shared-session-host-tracer
Open

FE-1321: Converge TUI and web session runtime contracts#416
lunelson wants to merge 18 commits into
ln/fe-1320-comparison-mission-isolationfrom
ln/fe-1321-shared-session-host-tracer

Conversation

@lunelson

@lunelson lunelson commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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

  • A real terminal session, driven exactly as a user would drive it, now streams what's happening to an attached browser companion live — the same conversation, in real time — using only the same safe, filtered messages the browser is normally allowed to see (never raw internal terminal events).
  • When the terminal shows the user a decision to make (a structured question), the browser companion now sees that question appear live too. Only the terminal can answer it — if someone tries answering from the browser instead, that's correctly and visibly rejected rather than silently creating a second, conflicting answer.
  • If a second window or process tries to open that same terminal session while it's already open elsewhere, it is correctly turned away instead of being allowed to create a conflicting second copy of the session.
  • After the terminal is closed normally, the browser-only mode can safely pick up that exact same session and continue it — nothing is lost, duplicated, or rewritten.
  • Along the way, testing surfaced and fixed a real bug: closing the terminal normally could leave the session's internal lock stuck on, which would have silently blocked anyone — terminal or browser — from ever reopening that session again.

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

  1. npm run verify:full passes, including new slow end-to-end proofs that drive a real terminal session and a real browser client together.
  2. No user-facing UI changes ship in this PR — it's the internal proof that unblocks the next (cleanup) step.

lunelson and others added 18 commits August 5, 2026 17:32
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>

lunelson commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@lunelson lunelson changed the title FE-1321: Activate shared session host tracer FE-1321: Converge TUI and web session runtime contracts Aug 7, 2026
@lunelson
lunelson marked this pull request as ready for review August 7, 2026 16:16
Copilot AI review requested due to automatic review settings August 7, 2026 16:16
@cursor

cursor Bot commented Aug 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches live ask discovery vs answering authority and cross-process session writer locks—core session correctness paths—with broad slow-test and spec coverage but limited direct production UI change until cutover.

Overview
FE-1321 advances session runtime contract convergence (D141-L): normal TUI and standalone web stay separate legitimate runtimes while sharing semantic RPC, JSONL truth, and per-target writer authority—not a single shared daemon.

The main runtime change in this diff is opener.announceAsk: when the real TUI collects a structured ask interactively, the ask is listed and streamed to companion observers (ask_opened, session.openAsks) but never joins the answerable pending set, so browser session.answerExchange is refused (no_pending_exchange / ask_closed) and the TUI remains sole authority. Headless openAsk registration is unchanged. ask.ts wraps UI collectors with announcedToObservers for text, single/multi choice, and questionnaire paths.

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. runBrunchTui gains testable agentServices threading; production also relies on process.on('exit') writer release when Pi quits via process.exit(0) (described in topology; fix may land in the same FE-1321 branch beyond this diff snippet).

Raw SessionEventRelay / /rpc/driver removal remains cutover, not this PR.

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`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 runBrunchTui and runBrunchWeb before 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.

Comment on lines +31 to +36
const listeners = new Set<(event: LiveSessionEvent) => void>();
let session: TuiAdaptedSession | null = null;
let unsubscribeSession: (() => void) | undefined;
let unsubscribeAsks: (() => void) | undefined;
let seq = 0;

Comment on lines +45 to +63
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;
},
Comment on lines +64 to +84
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' };
},
Comment thread src/app/brunch-web.ts
Comment on lines +81 to +99
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({});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants