diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md
index d0aed5b17..9a97bed3c 100644
--- a/e2e/AGENTS.md
+++ b/e2e/AGENTS.md
@@ -133,3 +133,32 @@ When handing results to the user, follow the evidence contract in the root
values, not booleans — `expect(list).toContain(x)`, never
`expect(list.includes(x)).toBe(true)` — so failures show the data.
- Keep it deterministic: no sleeps; wait on conditions.
+
+## Developer-session recordings (chat theater + desk)
+
+Some scenarios are meant to be WATCHED — they show the product the way a
+developer actually uses it. Three tiers, pick deliberately:
+
+1. **Chat theater** (`src/clients/chat-theater.ts`): the default for
+ product-flow recordings. The "agent" is a chat renderer in a recorded
+ PTY; every tool spinner brackets a REAL mcporter MCP call (OAuth,
+ execute, approval resume). No inference, no third-party binary.
+ Exemplar: `scenarios/connect-handoff-session.test.ts`. Artifacts:
+ `terminal.cast` (the chat) + `session.mp4` (browser hops); the viewer
+ plays them in story order.
+2. **Replay brain + real client** (`src/clients/replay-brain.ts`): when the
+ third-party CLIENT's behavior is under test (OpenCode/Claude Code
+ protocol handling). A scripted OpenAI-wire server plays the LLM; the
+ real client does everything else. Script by transcript inspection, never
+ turn counting.
+3. **Real-inference evals**: a different axis (performance distributions,
+ not pass/fail). Not in this suite.
+
+**The Desk** (`desk/`): films a scenario on one virtual Linux desktop — the
+chat renderer in a visible xterm, the browser as a real headed window, one
+ffmpeg x11grab. The film replaces session.mp4 in the run dir; the scenario
+file is unchanged (chat-theater switches transports on `E2E_DESK=1`).
+
+```
+e2e/desk/run.sh [scenario] [project] # docker; first run builds + installs
+```
diff --git a/e2e/desk/Dockerfile b/e2e/desk/Dockerfile
new file mode 100644
index 000000000..6b92222e2
--- /dev/null
+++ b/e2e/desk/Dockerfile
@@ -0,0 +1,27 @@
+# The Desk: a virtual Linux desktop that e2e scenarios are FILMED on — one
+# Xvfb display showing a real terminal window (the chat) and a real headed
+# Chromium (the browser hop), captured by a single ffmpeg x11grab into one
+# session.mp4. No splicing: the film is the screen.
+#
+# The repo is bind-mounted at /repo at runtime; node_modules and caches live
+# in named volumes (host artifacts are macOS-native and can't be reused).
+FROM node:22-bookworm
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ xvfb openbox xterm ffmpeg xdotool x11-utils x11-xserver-utils \
+ fonts-dejavu-core fonts-noto-color-emoji \
+ curl unzip ca-certificates git \
+ && rm -rf /var/lib/apt/lists/*
+
+# Bun — the repo's package manager and script runner.
+RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.13" \
+ && ln -s /root/.bun/bin/bun /usr/local/bin/bun \
+ && ln -s /root/.bun/bin/bun /usr/local/bin/bunx
+
+# Chromium + system deps baked into the image, pinned to the repo's
+# playwright version so the runtime install check is a no-op.
+ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+RUN npx -y playwright@1.60.0 install --with-deps chromium && rm -rf /root/.npm
+
+WORKDIR /repo
+ENTRYPOINT ["bash", "e2e/desk/entry.sh"]
diff --git a/e2e/desk/entry.sh b/e2e/desk/entry.sh
new file mode 100755
index 000000000..c1b3fe0c7
--- /dev/null
+++ b/e2e/desk/entry.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Desk entrypoint (runs inside the desk image): boot a display, roll one
+# camera on it, run the scenario with E2E_DESK=1 so its windows land on the
+# display, then drop the film into the scenario's run dir as session.mp4.
+set -euo pipefail
+
+SCENARIO="${DESK_SCENARIO:-scenarios/connect-handoff-session.test.ts}"
+PROJECT="${DESK_PROJECT:-cloud}"
+SIZE="${DESK_SIZE:-1440x900}"
+
+echo "[desk] installing dependencies (cached in a volume after first run)…"
+bun install --frozen-lockfile
+
+echo "[desk] display :99 at ${SIZE}"
+Xvfb :99 -screen 0 "${SIZE}x24" -nolisten tcp &
+export DISPLAY=:99
+sleep 0.5
+openbox &
+xsetroot -solid "#0b0b10" || true
+
+mkdir -p /desk-out
+echo "[desk] camera rolling"
+ffmpeg -loglevel error -f x11grab -framerate 24 -video_size "$SIZE" -i :99 \
+ -c:v libx264 -preset veryfast -crf 24 -pix_fmt yuv420p -y /desk-out/desk.mp4 &
+FFMPEG_PID=$!
+
+set +e
+(cd e2e && E2E_DESK=1 npx vitest run --project "$PROJECT" "$SCENARIO")
+STATUS=$?
+set -e
+
+sleep 1
+kill -INT "$FFMPEG_PID" 2>/dev/null || true
+wait "$FFMPEG_PID" 2>/dev/null || true
+
+# The desk film IS the session recording — it replaces the browser-only
+# video the surface recorded inside the run.
+RUN_DIR=$(ls -dt e2e/runs/"$PROJECT"/*/ 2>/dev/null | head -1)
+if [ -n "$RUN_DIR" ] && [ -f /desk-out/desk.mp4 ]; then
+ cp /desk-out/desk.mp4 "${RUN_DIR}session.mp4"
+ echo "[desk] film → ${RUN_DIR}session.mp4"
+fi
+exit "$STATUS"
diff --git a/e2e/desk/run.sh b/e2e/desk/run.sh
new file mode 100755
index 000000000..1f303b766
--- /dev/null
+++ b/e2e/desk/run.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Film a scenario on the Desk (one virtual desktop, one recording).
+# e2e/desk/run.sh [scenario-path] [project]
+# First run builds the image and fills the node_modules volume; later runs
+# reuse both.
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+
+docker build -t executor-e2e-desk e2e/desk
+
+docker run --rm \
+ -v "$PWD":/repo \
+ -v executor-desk-node-modules:/repo/node_modules \
+ -v executor-desk-bun-cache:/root/.bun/install/cache \
+ -e DESK_SCENARIO="${1:-scenarios/connect-handoff-session.test.ts}" \
+ -e DESK_PROJECT="${2:-cloud}" \
+ executor-e2e-desk
diff --git a/e2e/scenarios/connect-handoff-session.test.ts b/e2e/scenarios/connect-handoff-session.test.ts
new file mode 100644
index 000000000..70b5d0f75
--- /dev/null
+++ b/e2e/scenarios/connect-handoff-session.test.ts
@@ -0,0 +1,189 @@
+// The connect handoff as a DEVELOPER SESSION — the way a human actually
+// tests this: an agent chat in a real terminal where the agent wires up the
+// API over MCP and drops a connect link, a browser hop to paste the key,
+// then back to the chat to prove the connection works with a live send.
+//
+// No inference, no third-party agent binary: the "agent" is the chat
+// theater (src/clients/chat-theater.ts) presenting REAL mcporter MCP calls
+// — OAuth, execute, approval pause/resume all genuine, every tool spinner
+// on screen bracketing the actual call it narrates. The provider on the
+// other side is real too (resend.emulators.dev); its request ledger is the
+// final evidence.
+import { randomBytes } from "node:crypto";
+import { join } from "node:path";
+
+import { expect } from "@effect/vitest";
+import { Effect } from "effect";
+
+import { scenario } from "../src/scenario";
+import { Browser, Cli, Mcp, RunDir, Target } from "../src/services";
+import { withChatTheater } from "../src/clients/chat-theater";
+import type { McpSession } from "../src/surfaces/mcp";
+
+const EMULATOR_BASE = "https://resend.emulators.dev";
+
+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
+
+// The emulator serves its own OpenAPI document (bearer auth, base URL in
+// `servers`) — adding by URL with nothing else is exactly what an agent
+// does, and the platform derives the paste-a-token auth method from the
+// spec's security scheme.
+const EMULATOR_SPEC_URL = `${EMULATOR_BASE}/openapi.json`;
+
+const addSpecCode = (slug: string) => `
+const added = await tools.executor.openapi.addSpec({
+ spec: { kind: "url", url: ${JSON.stringify(EMULATOR_SPEC_URL)} },
+ slug: ${JSON.stringify(slug)},
+});
+return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error };
+`;
+
+const createHandoffCode = (slug: string) => `
+const handoff = await tools.executor.coreTools.connections.createHandoff({
+ integration: ${JSON.stringify(slug)},
+ owner: "org",
+ label: "Resend",
+});
+return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error };
+`;
+
+const sendEmailCode = (slug: string, subject: string) => `
+const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 });
+const path = found.items[0]?.path;
+if (!path) return { ok: false, error: "no send tool found" };
+let t = tools;
+for (const seg of path.split(".")) t = t[seg];
+const sent = await t({
+ body: {
+ from: "onboarding@example.com",
+ to: "dev-session@example.com",
+ subject: ${JSON.stringify(subject)},
+ html: "
connect-handoff developer session
",
+ },
+});
+return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };
+`;
+
+/** Run `execute`, auto-approving a paused execution (policy elicitation)
+ * once, and parse the sandbox's JSON return value. */
+const executeJson = (session: McpSession, code: string) =>
+ Effect.gen(function* () {
+ let result = yield* session.call("execute", { code });
+ if (result.text.includes("executionId:")) {
+ result = yield* session.approvePaused(result.text);
+ }
+ expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
+ return JSON.parse(result.text) as Record;
+ });
+
+const mintEmulatorApiKey = Effect.promise(async () => {
+ const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ type: "api-key" }),
+ });
+ const body = (await response.json()) as { credential?: { token?: string } };
+ const token = body.credential?.token;
+ if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`);
+ return token;
+});
+
+scenario(
+ "Connect · developer session: agent chat → handoff link → paste key → verified send",
+ { timeout: 240_000 },
+ Effect.scoped(
+ Effect.gen(function* () {
+ const target = yield* Target;
+ const mcp = yield* Mcp;
+ const browser = yield* Browser;
+ const cli = yield* Cli;
+ const runDir = yield* RunDir;
+
+ const integration = unique("resendsesh");
+ const emailSubject = unique("dev-session");
+ const apiKey = yield* mintEmulatorApiKey;
+ const identity = yield* target.newIdentity();
+ const session = mcp.session(identity);
+
+ yield* withChatTheater(
+ cli,
+ { title: "executor agent — connect Resend", record: join(runDir, "terminal.cast") },
+ (chat) =>
+ Effect.gen(function* () {
+ // Real MCP OAuth + tool discovery happens behind this call.
+ yield* chat.tool(
+ { name: "executor (mcp)", result: (tools) => `${tools.length} tools available` },
+ session.listTools(),
+ );
+
+ yield* chat.user(
+ "Add the Resend API to my executor and give me a link to connect my account",
+ );
+ yield* chat.assistant("I'll register the Resend API in your Executor now.");
+ const added = yield* chat.tool(
+ { name: "execute", input: addSpecCode(integration) },
+ executeJson(session, addSpecCode(integration)),
+ );
+ expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true);
+
+ yield* chat.assistant("Registered. Creating your connect link…");
+ const handoff = yield* chat.tool(
+ { name: "execute", input: createHandoffCode(integration) },
+ executeJson(session, createHandoffCode(integration)),
+ );
+ expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
+ const handoffUrl = String(handoff.url);
+ expect(new URL(handoffUrl).origin, "handoff targets this deployment").toBe(
+ new URL(target.baseUrl).origin,
+ );
+
+ yield* chat.assistant(
+ `Open this link to connect your Resend account:\n\n${handoffUrl}\n\nTell me once you've pasted your API key.`,
+ );
+
+ // The browser hop — the terminal session stays open while the
+ // "user" pastes the key; the paste is the real add-account UI.
+ yield* chat.status("you, in the browser: opening the link and pasting the API key…");
+ yield* browser.session(identity, async ({ page, step }) => {
+ await step("Open the connect link from the chat", async () => {
+ await page.goto(handoffUrl, { waitUntil: "networkidle" });
+ await page
+ .getByRole("heading", { name: /Add connection/ })
+ .waitFor({ timeout: 15_000 });
+ });
+ await step("Paste the Resend API key and connect", async () => {
+ const credential = page.getByPlaceholder(/paste the value \/ token/i);
+ await credential.waitFor({ timeout: 15_000 });
+ await credential.fill(apiKey);
+ await page.getByRole("button", { name: "Add connection", exact: true }).click();
+ await page
+ .getByRole("heading", { name: /Add connection/ })
+ .waitFor({ state: "hidden", timeout: 20_000 });
+ });
+ });
+
+ yield* chat.user("Connected, now send a test email to prove it works");
+ yield* chat.assistant("Sending a test email through your new connection…");
+ const sent = yield* chat.tool(
+ { name: "execute", input: sendEmailCode(integration, emailSubject) },
+ executeJson(session, sendEmailCode(integration, emailSubject)),
+ );
+ expect(sent.ok, `email sent through the connection: ${JSON.stringify(sent)}`).toBe(
+ true,
+ );
+
+ yield* chat.assistant("Test email sent - your Resend connection works.");
+ }),
+ );
+
+ // Final evidence: the emulator's ledger saw the send from Executor.
+ const ledger = yield* Effect.promise(async () =>
+ (await fetch(`${EMULATOR_BASE}/_emulate/ledger`)).text(),
+ );
+ expect(
+ ledger.includes(emailSubject),
+ "the emulator request ledger recorded the test email",
+ ).toBe(true);
+ }),
+ ),
+);
diff --git a/e2e/scripts/film.ts b/e2e/scripts/film.ts
new file mode 100644
index 000000000..cea0878ac
--- /dev/null
+++ b/e2e/scripts/film.ts
@@ -0,0 +1,193 @@
+// Film a run: turn a chat-theater run's real recordings into ONE mp4 that
+// plays like a screen recording of a developer tabbing between full-screen
+// windows — terminal, browser, terminal. Both segments are the genuine
+// recordings; the only editorial act is the cut, exactly like real tabbing.
+//
+// bun scripts/film.ts runs//
+//
+// The cut list comes from the run's focus timeline (src/timeline.ts):
+// surfaces mark focus as a side effect of acting — a Playwright step
+// focuses the browser, a chat event focuses the terminal — so the
+// operations themselves decide where the film cuts, for any number of
+// hops. Runs without a timeline fall back to the narrator-line / largest-
+// gap heuristic. The cast renders with NO idle compression so cast time
+// equals video time. Output: film.mp4, registered in result.json — the
+// viewer plays it as the session when present.
+import { execFileSync } from "node:child_process";
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+
+import { readTimeline } from "../src/timeline";
+
+const runDir = resolve(process.argv[2] ?? "");
+const castPath = join(runDir, "terminal.cast");
+const browserPath = join(runDir, "session.mp4");
+if (!existsSync(castPath) || !existsSync(browserPath)) {
+ console.error(`film: need terminal.cast + session.mp4 in ${runDir}`);
+ process.exit(1);
+}
+
+const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { stdio: "pipe" });
+const probeSeconds = (file: string): number =>
+ Number(
+ execFileSync("ffprobe", [
+ ...["-v", "quiet", "-show_entries", "format=duration"],
+ ...["-of", "csv=p=0", file],
+ ])
+ .toString()
+ .trim(),
+ );
+
+// ---------------------------------------------------------------------------
+// Build the cut list: acts of { source, from, to } in each source's clock.
+// ---------------------------------------------------------------------------
+
+interface CastEvent {
+ readonly at: number;
+ readonly text: string;
+}
+
+const events: CastEvent[] = readFileSync(castPath, "utf8")
+ .split("\n")
+ .slice(1)
+ .filter(Boolean)
+ .map((line) => JSON.parse(line) as [number, string, string])
+ .filter((event) => event[1] === "o")
+ .map((event) => ({ at: event[0], text: event[2] }));
+
+const castEnd = events.at(-1)?.at ?? 0;
+
+interface Act {
+ readonly source: "terminal" | "browser";
+ readonly from: number;
+ readonly to: number;
+}
+
+/** Acts from the focus timeline: each contiguous focus run becomes a cut of
+ * that window's recording, mapped via the recording-start anchors. */
+const actsFromTimeline = (): Act[] | null => {
+ const timeline = readTimeline(runDir);
+ if (!timeline || timeline.focus.length < 2) return null;
+ const terminalAnchor = timeline.anchors.terminal;
+ const browserAnchor = timeline.anchors.browser;
+ if (terminalAnchor === undefined || browserAnchor === undefined) return null;
+
+ const browserEnd = probeSeconds(browserPath);
+ const toMedia = (window: Act["source"], wallMs: number) =>
+ Math.max(0, (wallMs - (window === "terminal" ? terminalAnchor : browserAnchor)) / 1000);
+
+ const acts: Act[] = [];
+ for (let i = 0; i < timeline.focus.length; i += 1) {
+ const current = timeline.focus[i];
+ if (!current) continue;
+ const nextAt = timeline.focus[i + 1]?.at;
+ const from = i === 0 && current.window === "terminal" ? 0 : toMedia(current.window, current.at);
+ const mediaEnd = current.window === "terminal" ? castEnd : browserEnd;
+ const to =
+ nextAt === undefined ? mediaEnd : Math.min(toMedia(current.window, nextAt), mediaEnd);
+ if (to - from > 0.5) acts.push({ source: current.window, from, to });
+ }
+ return acts.length >= 2 ? acts : null;
+};
+
+/** Fallback for runs without a timeline: one browser hop located by the
+ * narrator line, or the largest output gap in the cast. */
+const actsFromHeuristic = (): Act[] => {
+ const findHop = (): { start: number; end: number } => {
+ const markerIndex = events.findIndex((event) => event.text.includes("in the browser"));
+ if (markerIndex !== -1) {
+ const start = events[markerIndex];
+ const after = events
+ .slice(markerIndex + 1)
+ .find((event) => event.at > (start?.at ?? 0) + 1 && event.text.trim().length > 0);
+ if (start && after) return { start: start.at + 0.8, end: after.at };
+ }
+ let best = { start: 0, end: 0 };
+ for (let i = 1; i < events.length; i += 1) {
+ const previous = events[i - 1];
+ const current = events[i];
+ if (previous && current && current.at - previous.at > best.end - best.start) {
+ best = { start: previous.at, end: current.at };
+ }
+ }
+ return best;
+ };
+ const hop = findHop();
+ if (hop.end - hop.start < 2) {
+ console.error(`film: no browser hop found in the cast (gap ${hop.end - hop.start}s)`);
+ process.exit(1);
+ }
+ return [
+ { source: "terminal", from: 0, to: hop.start },
+ { source: "browser", from: 0, to: probeSeconds(browserPath) },
+ { source: "terminal", from: hop.end, to: castEnd },
+ ];
+};
+
+const acts = actsFromTimeline() ?? actsFromHeuristic();
+
+// ---------------------------------------------------------------------------
+// Render + cut + concatenate onto one canvas — full-screen cuts, like
+// tabbing.
+// ---------------------------------------------------------------------------
+
+const work = mkdtempSync(join(tmpdir(), "e2e-film-"));
+const castGif = join(work, "cast.gif");
+const castVideo = join(work, "cast.mp4");
+
+run("agg", [
+ ...["--idle-time-limit", String(Math.ceil(castEnd) + 60)],
+ ...["--font-size", "16"],
+ castPath,
+ castGif,
+]);
+run("ffmpeg", [
+ ...["-y", "-i", castGif],
+ // agg's gif can have odd dimensions; libx264 requires even.
+ ...["-vf", "scale=ceil(iw/2)*2:ceil(ih/2)*2"],
+ ...["-pix_fmt", "yuv420p", "-r", "24", castVideo],
+]);
+
+const FIT =
+ "scale=1280:800:force_original_aspect_ratio=decrease,pad=1280:800:(ow-iw)/2:(oh-ih)/2:color=0x0b0b10,setsar=1,fps=24,format=yuv420p";
+
+const filmPath = join(runDir, "film.mp4");
+const inputIndex = { terminal: 0, browser: 1 } as const;
+const filters = acts.map(
+ (act, index) =>
+ `[${inputIndex[act.source]}:v]trim=${act.from.toFixed(2)}:${act.to.toFixed(2)},setpts=PTS-STARTPTS,${FIT}[act${index}]`,
+);
+run("ffmpeg", [
+ "-y",
+ ...["-i", castVideo],
+ ...["-i", browserPath],
+ "-filter_complex",
+ [
+ ...filters,
+ `${acts.map((_, index) => `[act${index}]`).join("")}concat=n=${acts.length}:v=1:a=0[out]`,
+ ].join(";"),
+ ...["-map", "[out]"],
+ ...["-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-movflags", "+faststart"],
+ filmPath,
+]);
+rmSync(work, { recursive: true, force: true });
+
+// Register the film in the run's artifact list so the viewer offers it.
+const resultPath = join(runDir, "result.json");
+if (existsSync(resultPath)) {
+ const result = JSON.parse(readFileSync(resultPath, "utf8")) as { artifacts?: string[] };
+ if (Array.isArray(result.artifacts) && !result.artifacts.includes("film.mp4")) {
+ result.artifacts.push("film.mp4");
+ writeFileSync(resultPath, JSON.stringify(result, null, 1));
+ }
+}
+
+console.log(
+ `film: ${filmPath}\n${acts
+ .map(
+ (act, index) =>
+ ` act ${index + 1} ${act.source} ${act.from.toFixed(1)}–${act.to.toFixed(1)}s`,
+ )
+ .join("\n")}`,
+);
diff --git a/e2e/scripts/pr-media.ts b/e2e/scripts/pr-media.ts
index 43e3104cb..972ce015a 100644
--- a/e2e/scripts/pr-media.ts
+++ b/e2e/scripts/pr-media.ts
@@ -8,8 +8,9 @@
// the PR description.
//
// Usage: bun e2e/scripts/pr-media.ts [...more]
-// run dir e2e/runs// — picks session.mp4 or
-// terminal.cast and labels the gif from result.json
+// run dir e2e/runs// — picks film.mp4 (the
+// whole session), else session.mp4, else terminal.cast;
+// labels the gif from result.json
// session.mp4 browser recording (ffmpeg -> gif)
// terminal.cast terminal recording (agg -> gif; brew install agg)
// *.png run screenshot, uploaded as-is
@@ -48,10 +49,13 @@ const resolveArtifact = (input: string): Artifact => {
slug: basename(path).replace(/\.[^.]+$/, ""),
};
}
- const recording = ["session.mp4", "terminal.cast"]
+ // film.mp4 (scripts/film.ts) is the whole session — terminal chat AND the
+ // browser hop, cut in time order; the bare browser session.mp4 is only the
+ // hop, so it must never win when a film exists.
+ const recording = ["film.mp4", "session.mp4", "terminal.cast"]
.map((name) => join(path, name))
.find(existsSync);
- if (!recording) throw new Error(`${input} has no session.mp4 or terminal.cast`);
+ if (!recording) throw new Error(`${input} has no film.mp4, session.mp4, or terminal.cast`);
let label = basename(path);
try {
const result = JSON.parse(readFileSync(join(path, "result.json"), "utf8")) as {
diff --git a/e2e/src/clients/agent-chat-tui.ts b/e2e/src/clients/agent-chat-tui.ts
new file mode 100644
index 000000000..23c1def50
--- /dev/null
+++ b/e2e/src/clients/agent-chat-tui.ts
@@ -0,0 +1,166 @@
+// The chat renderer half of the chat theater (see chat-theater.ts): a tiny
+// agent-chat TUI that runs inside the recorded PTY and paints whatever the
+// scenario's REAL MCP calls are doing. It performs no logic of its own — it
+// reads base64-encoded JSON events on stdin and renders them with the
+// pacing of a chat session (typed user input, streamed agent text, live
+// tool spinners that run exactly as long as the real call did).
+//
+// Run with: bun agent-chat-tui.ts "" [events-fifo]
+//
+// Two transports: events arrive base64-encoded one-per-line either on stdin
+// (PTY mode — the chat theater types them into the recorded terminal) or on
+// a FIFO path (desk mode — the renderer runs inside a visible xterm on the
+// virtual desktop, where stdin belongs to the terminal emulator).
+import { createReadStream, writeFileSync } from "node:fs";
+import { createInterface } from "node:readline";
+
+type TheaterEvent =
+ | { readonly type: "user"; readonly text: string }
+ | { readonly type: "assistant"; readonly text: string }
+ | {
+ readonly type: "tool-start";
+ readonly name: string;
+ /** The call's real input, pre-rendered as preview lines. */
+ readonly input?: readonly string[];
+ }
+ | {
+ readonly type: "tool-end";
+ readonly ok: boolean;
+ /** First line of the call's real result. */
+ readonly result?: string;
+ readonly seconds?: number;
+ }
+ | { readonly type: "status"; readonly text: string }
+ | { readonly type: "done" };
+
+const out = (text: string) => process.stdout.write(text);
+
+// The PTY would otherwise echo every incoming event line into the recording.
+if (process.stdin.isTTY) process.stdin.setRawMode(true);
+
+const DIM = "\x1b[2m";
+const BOLD = "\x1b[1m";
+const RESET = "\x1b[0m";
+const CYAN = "\x1b[36m";
+const GREEN = "\x1b[32m";
+const RED = "\x1b[31m";
+const MAGENTA = "\x1b[35m";
+const CLEAR_LINE = "\x1b[2K\r";
+
+const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+
+const sleep = (ms: number) => new Promise((tick) => setTimeout(tick, ms));
+
+const title = process.argv[2] ?? "executor session";
+const eventsFifo = process.argv[3];
+out(`${BOLD}${MAGENTA}●${RESET} ${BOLD}${title}${RESET}\n`);
+out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`);
+
+let spinnerTimer: ReturnType | undefined;
+let spinnerName = "";
+
+// A tool call renders the way agent TUIs render them: the tool's name, the
+// call's real input as an indented block, a spinner while the real call
+// runs, then the real result's first line with the real duration.
+//
+// ⚙ execute
+// │ const added = await tools.executor.openapi.addSpec({
+// │ spec: { kind: "url", url: "https://…/openapi.json" },
+// ⠼ running…
+// ✓ 2.3s {"ok":true,"slug":"resend","toolCount":9}
+const startTool = (name: string, input?: readonly string[]) => {
+ spinnerName = name;
+ out(`\n ${MAGENTA}⚙${RESET} ${BOLD}${name}${RESET}\n`);
+ for (const line of input ?? []) {
+ out(` ${DIM}│ ${line.slice(0, 92)}${RESET}\n`);
+ }
+ let frame = 0;
+ spinnerTimer = setInterval(() => {
+ out(`${CLEAR_LINE} ${CYAN}${SPINNER[frame % SPINNER.length]}${RESET} ${DIM}running…${RESET}`);
+ frame += 1;
+ }, 80);
+};
+
+const endTool = (ok: boolean, result?: string, seconds?: number) => {
+ if (spinnerTimer) clearInterval(spinnerTimer);
+ spinnerTimer = undefined;
+ const mark = ok ? `${GREEN}✓${RESET}` : `${RED}✗ ${spinnerName}${RESET}`;
+ const took = seconds !== undefined ? `${DIM}${seconds.toFixed(1)}s${RESET} ` : "";
+ out(`${CLEAR_LINE} ${mark} ${took}${DIM}${(result ?? "").slice(0, 88)}${RESET}\n`);
+};
+
+const handle = async (event: TheaterEvent): Promise => {
+ switch (event.type) {
+ case "user": {
+ out(`\n${BOLD}${CYAN}┃ you${RESET} `);
+ for (const ch of event.text) {
+ out(ch);
+ await sleep(18);
+ }
+ out("\n");
+ return true;
+ }
+ case "assistant": {
+ out(`\n${BOLD}${MAGENTA}● agent${RESET} `);
+ for (const piece of event.text.match(/.{1,3}/gs) ?? []) {
+ out(piece);
+ await sleep(12);
+ }
+ out("\n");
+ return true;
+ }
+ case "tool-start": {
+ startTool(event.name, event.input);
+ return true;
+ }
+ case "tool-end": {
+ endTool(event.ok, event.result, event.seconds);
+ return true;
+ }
+ case "status": {
+ out(`\n ${DIM}· ${event.text}${RESET}\n`);
+ return true;
+ }
+ case "done": {
+ out(`\n${DIM}${"─".repeat(40)}${RESET}\n${GREEN}✦ session complete${RESET}\n`);
+ if (eventsFifo) {
+ // Desk mode: ack completion to the driver, then linger so the
+ // closing frame stays on camera before the xterm vanishes.
+ writeFileSync(`${eventsFifo}.done`, "");
+ await sleep(4_000);
+ }
+ return false;
+ }
+ }
+};
+
+// Events arrive faster than they render; process strictly in order so the
+// animations (not arrival time) set the pacing.
+const queue: TheaterEvent[] = [];
+let draining = false;
+const drain = async () => {
+ if (draining) return;
+ draining = true;
+ while (queue.length > 0) {
+ const event = queue.shift();
+ if (!event) break;
+ const keepGoing = await handle(event);
+ if (!keepGoing) {
+ process.exit(0);
+ }
+ }
+ draining = false;
+};
+
+const input = eventsFifo ? createReadStream(eventsFifo) : process.stdin;
+createInterface({ input, terminal: false }).on("line", (line) => {
+ const trimmed = line.trim();
+ if (!trimmed) return;
+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: PTY stdin may echo stray keystrokes that aren't base64 JSON; ignore them
+ try {
+ queue.push(JSON.parse(Buffer.from(trimmed, "base64").toString("utf8")) as TheaterEvent);
+ } catch {
+ return;
+ }
+ void drain();
+});
diff --git a/e2e/src/clients/chat-theater.ts b/e2e/src/clients/chat-theater.ts
new file mode 100644
index 000000000..053306c36
--- /dev/null
+++ b/e2e/src/clients/chat-theater.ts
@@ -0,0 +1,217 @@
+// Chat theater: agent-chat presentation over REAL mcporter MCP calls — no
+// inference, no third-party agent binary. The scenario stays in Effect land
+// making real calls; a chat renderer (agent-chat-tui.ts) paints them, so
+// the recording reads like a developer chatting with an agent while every
+// tool spinner brackets the genuine call it narrates.
+//
+// Two stages for the same play:
+// - PTY mode (default): the renderer runs in a recorded PTY → terminal.cast
+// - Desk mode (E2E_DESK=1): the renderer runs in a visible xterm on the
+// virtual desktop (events over a FIFO) and the desk's single x11grab
+// films it together with the headed browser — one screen, one mp4
+//
+// Division of labor in the e2e stack:
+// - chat theater (this): deterministic PRODUCT-flow recordings
+// - replay brain + real client (replay-brain.ts): CLIENT-behavior tests
+// (OpenCode/Claude Code protocol handling), still no inference
+// - real-inference evals: a separate axis entirely — performance
+// distributions, not pass/fail scenarios
+import { spawn, spawnSync } from "node:child_process";
+import { createWriteStream, existsSync, mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { Effect, Exit, Fiber } from "effect";
+
+import { markFocus, markRecordingStart } from "../timeline";
+import type { CliSurface } from "../surfaces/cli";
+
+const RENDERER = fileURLToPath(new URL("./agent-chat-tui.ts", import.meta.url));
+
+interface TheaterEvent {
+ readonly type: "user" | "assistant" | "tool-start" | "tool-end" | "status" | "done";
+ readonly [key: string]: unknown;
+}
+
+export interface ChatTheater {
+ /** The human's chat line, typed out on screen. */
+ readonly user: (text: string) => Effect.Effect;
+ /** The agent's reply, streamed on screen. */
+ readonly assistant: (text: string) => Effect.Effect;
+ /** Run a REAL effect rendered as a tool call: the tool's name, the call's
+ * real input block, a spinner for exactly as long as the call runs, then
+ * the call's real result line with the real duration. */
+ readonly tool: (
+ call: {
+ /** Tool name as an agent TUI would show it (e.g. "execute"). */
+ readonly name: string;
+ /** The call's real input — rendered as the indented block. */
+ readonly input?: string;
+ /** Render the result's display line (defaults to JSON of the value). */
+ readonly result?: (value: A) => string;
+ },
+ work: Effect.Effect ,
+ ) => Effect.Effect ;
+ /** A dim narrator line (e.g. "waiting for the browser hop"). */
+ readonly status: (text: string) => Effect.Effect;
+}
+
+const sleep = (ms: number) => new Promise((tick) => setTimeout(tick, ms));
+
+const encode = (event: TheaterEvent) =>
+ `${Buffer.from(JSON.stringify(event)).toString("base64")}\n`;
+
+/** Input block, like a TUI's call preview: trimmed lines, capped count. */
+const inputPreview = (input: string | undefined): string[] | undefined => {
+ if (!input) return undefined;
+ const lines = input
+ .split("\n")
+ .map((line) => line.trimEnd())
+ .filter((line) => line.trim().length > 0);
+ return lines.length > 7 ? [...lines.slice(0, 6), `… +${lines.length - 6} lines`] : lines;
+};
+
+const makeTheater = (push: (event: TheaterEvent) => Effect.Effect): ChatTheater => ({
+ user: (text) => push({ type: "user", text }),
+ assistant: (text) => push({ type: "assistant", text }),
+ status: (text) => push({ type: "status", text }),
+ tool: (call, work) =>
+ Effect.gen(function* () {
+ yield* push({ type: "tool-start", name: call.name, input: inputPreview(call.input) });
+ const startedAt = Date.now();
+ const exit = yield* Effect.exit(work);
+ const seconds = (Date.now() - startedAt) / 1000;
+ if (Exit.isSuccess(exit)) {
+ const line = call.result ? call.result(exit.value) : JSON.stringify(exit.value);
+ yield* push({ type: "tool-end", ok: true, result: line, seconds });
+ return exit.value;
+ }
+ yield* push({ type: "tool-end", ok: false, result: "failed", seconds });
+ return yield* exit;
+ }),
+});
+
+export interface TheaterOptions {
+ readonly title: string;
+ readonly record: string;
+ readonly viewport?: { readonly cols: number; readonly rows: number };
+}
+
+/**
+ * Open the chat renderer, hand the scenario a ChatTheater handle, and close
+ * the session (footer + exit) when the body finishes — success or failure,
+ * so the recording always ends cleanly.
+ */
+export const withChatTheater = (
+ cli: CliSurface,
+ options: TheaterOptions,
+ body: (theater: ChatTheater) => Effect.Effect ,
+): Effect.Effect =>
+ process.env.E2E_DESK === "1" ? deskTheater(options, body) : ptyTheater(cli, options, body);
+
+// ---------------------------------------------------------------------------
+// PTY mode: renderer in a recorded PTY; events typed in as base64 lines.
+// ---------------------------------------------------------------------------
+
+const ptyTheater = (
+ cli: CliSurface,
+ options: TheaterOptions,
+ body: (theater: ChatTheater) => Effect.Effect ,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const runDir = dirname(options.record);
+ const queue: TheaterEvent[] = [];
+ let closed = false;
+ const push = (event: TheaterEvent) =>
+ Effect.sync(() => {
+ if (!closed) {
+ // Chatting IS focusing the terminal window.
+ if (event.type !== "done") markFocus(runDir, "terminal");
+ queue.push(event);
+ }
+ });
+
+ const pump = cli.session(
+ ["bun", RENDERER, options.title],
+ async (term) => {
+ // Don't type until the renderer has painted its header — before that
+ // it hasn't set raw mode yet, and the PTY would echo the event line
+ // into the recording.
+ await term.screen.waitForText(options.title, { timeoutMs: 30_000 });
+ // The cast's clock started with the PTY moments ago; anchor it for
+ // the run's focus timeline (scripts/film.ts cuts on these).
+ markRecordingStart(runDir, "terminal");
+ const deadline = Date.now() + 30 * 60 * 1000;
+ for (;;) {
+ const event = queue.shift();
+ if (event) {
+ await term.keyboard.type(encode(event));
+ if (event.type === "done") break;
+ continue;
+ }
+ if (Date.now() > deadline) break;
+ await sleep(40);
+ }
+ await term.screen.waitForText("session complete", { timeoutMs: 60_000 });
+ },
+ {
+ record: options.record,
+ viewport: options.viewport ?? { cols: 100, rows: 32 },
+ },
+ );
+ const pumpFiber = yield* Effect.forkChild(Effect.exit(pump));
+
+ const result = yield* Effect.exit(body(makeTheater(push)));
+ yield* push({ type: "done" });
+ closed = true;
+ yield* Fiber.join(pumpFiber);
+ return yield* result;
+ });
+
+// ---------------------------------------------------------------------------
+// Desk mode: renderer in a visible xterm on the virtual desktop; events over
+// a FIFO. No cast — the desk's x11grab films the screen.
+// ---------------------------------------------------------------------------
+
+const deskTheater = (
+ options: TheaterOptions,
+ body: (theater: ChatTheater) => Effect.Effect ,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const root = mkdtempSync(join(tmpdir(), "desk-theater-"));
+ const fifo = join(root, "events.fifo");
+ spawnSync("mkfifo", [fifo]);
+
+ const xterm = spawn(
+ "xterm",
+ [
+ // -u8 + a UTF-8 locale: the renderer speaks box-drawing and bullets.
+ ...["-u8", "-fa", "DejaVu Sans Mono", "-fs", "13"],
+ ...["-bg", "#0b0b10", "-fg", "#e8e8ea"],
+ ...["-geometry", "112x34+48+40", "-T", options.title],
+ ...["-e", "bun", RENDERER, options.title, fifo],
+ ],
+ { stdio: "ignore", env: { ...process.env, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" } },
+ );
+ // Opening the write end parks until the renderer opens the read end.
+ const writer = createWriteStream(fifo);
+ const push = (event: TheaterEvent) =>
+ Effect.sync(() => {
+ writer.write(encode(event));
+ });
+
+ const result = yield* Effect.exit(body(makeTheater(push)));
+ yield* push({ type: "done" });
+ yield* Effect.promise(async () => {
+ // Renderer acks the footer, lingers for the camera, then exits with
+ // its xterm. Don't hang the run on a wedged terminal.
+ const ackDeadline = Date.now() + 30_000;
+ while (!existsSync(`${fifo}.done`) && Date.now() < ackDeadline) await sleep(200);
+ const exitDeadline = Date.now() + 10_000;
+ while (xterm.exitCode === null && Date.now() < exitDeadline) await sleep(200);
+ if (xterm.exitCode === null) xterm.kill();
+ writer.end();
+ });
+ return yield* result;
+ });
diff --git a/e2e/src/clients/opencode.ts b/e2e/src/clients/opencode.ts
index 6d3c9ead3..f4242063e 100644
--- a/e2e/src/clients/opencode.ts
+++ b/e2e/src/clients/opencode.ts
@@ -24,8 +24,18 @@ export interface OpenCodeHome {
) => { accessToken?: string; refreshToken?: string; expiresAt?: number } | undefined;
}
-/** A throwaway OpenCode installation configured with one remote MCP server. */
-export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHome => {
+/** A throwaway OpenCode installation configured with one remote MCP server.
+ *
+ * With `chatBrainUrl` set, the config also declares a `replay` provider
+ * pointing OpenCode's LLM traffic at a local replay brain
+ * (clients/replay-brain.ts) and selects it as the model — real agent,
+ * scripted conversation. Tool permissions are pre-allowed so the recorded
+ * TUI session flows without approval dialogs. */
+export const makeOpenCodeHome = (
+ serverName: string,
+ mcpUrl: string,
+ options?: { readonly chatBrainUrl?: string },
+): OpenCodeHome => {
const root = mkdtempSync(join(tmpdir(), "e2e-opencode-"));
const projectDir = join(root, "project");
const dataDir = join(root, "data");
@@ -38,6 +48,22 @@ export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHo
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: { [serverName]: { type: "remote", url: mcpUrl } },
+ ...(options?.chatBrainUrl
+ ? {
+ autoupdate: false,
+ share: "disabled",
+ model: "replay/replay-model",
+ permission: { "*": "allow" },
+ provider: {
+ replay: {
+ name: "Replay",
+ npm: "@ai-sdk/openai-compatible",
+ options: { baseURL: options.chatBrainUrl, apiKey: "replay-key" },
+ models: { "replay-model": { name: "Replay Model" } },
+ },
+ },
+ }
+ : {}),
}),
);
// OpenCode launches the OAuth URL via `open`; the shim records it instead.
diff --git a/e2e/src/clients/replay-brain.ts b/e2e/src/clients/replay-brain.ts
new file mode 100644
index 000000000..2d5ac49bb
--- /dev/null
+++ b/e2e/src/clients/replay-brain.ts
@@ -0,0 +1,208 @@
+// Replay brain: a local server speaking the OpenAI chat-completions wire
+// (SSE streaming, tool calls) that a REAL agent (OpenCode) uses as its LLM.
+// The scenario scripts the brain; the agent does everything else for real —
+// TUI rendering, MCP discovery/auth, tool execution against the target,
+// result round-trips. "Replay brain, real hands": deterministic conversation,
+// zero modeled client behavior.
+//
+// The brain is a state machine, not a fixed turn list: each request gets a
+// normalized view of the conversation so far and the scenario's `respond`
+// callback decides what the "model" says next (text, a tool call, or both).
+// That lets a script absorb variable-length detours — approval pauses that
+// need a resume call, retries — without index bookkeeping.
+import { createServer, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+
+import { Effect, Scope } from "effect";
+
+/** What the scenario's script sees per request. */
+export interface BrainContext {
+ /** 0-based count of requests served before this one. */
+ readonly requestIndex: number;
+ /** Role of the final message — "user" means a fresh human turn, "tool"
+ * means the agent is returning a tool result for the brain to continue. */
+ readonly lastRole: string;
+ /** The latest user-role message content (the human's chat input). */
+ readonly lastUser: string;
+ /** The latest tool-role message content (last tool result), if any. */
+ readonly lastToolResult: string | undefined;
+ /** Tool names offered in this request (already namespaced by the agent). */
+ readonly toolNames: ReadonlyArray;
+}
+
+/** What the "model" does next. `tool.name` may be a suffix — the brain
+ * resolves it against the request's offered tool names, so scripts don't
+ * hardcode the agent's MCP namespacing. */
+export interface BrainResponse {
+ readonly text?: string;
+ readonly tool?: { readonly name: string; readonly args: unknown };
+}
+
+export interface ReplayBrain {
+ /** Base URL for the agent's provider config (…/v1). */
+ readonly baseUrl: string;
+ /** Every request body served, in order (for post-hoc assertions). */
+ readonly requests: () => ReadonlyArray;
+ /** Script errors (mismatched expectations, throws) — assert empty. */
+ readonly errors: () => ReadonlyArray;
+}
+
+export interface BrainRequest {
+ readonly messages: ReadonlyArray<{ role: string; content: string }>;
+ readonly toolNames: ReadonlyArray;
+}
+
+interface WireMessage {
+ readonly role: string;
+ readonly content?: unknown;
+ readonly tool_calls?: ReadonlyArray;
+}
+
+interface WireBody {
+ readonly messages?: ReadonlyArray;
+ readonly tools?: ReadonlyArray<{ function?: { name?: string } }>;
+}
+
+const contentText = (content: unknown): string => {
+ if (typeof content === "string") return content;
+ if (Array.isArray(content)) {
+ return content
+ .map((part) =>
+ typeof part === "object" && part !== null && "text" in part
+ ? String((part as { text: unknown }).text)
+ : "",
+ )
+ .join("");
+ }
+ return "";
+};
+
+const sseChunk = (delta: Record, finish: string | null) =>
+ `data: ${JSON.stringify({
+ id: "chatcmpl-replay",
+ object: "chat.completion.chunk",
+ created: 0,
+ model: "replay-model",
+ choices: [{ index: 0, delta, finish_reason: finish }],
+ })}\n\n`;
+
+/**
+ * Serve a scripted brain for the lifetime of the surrounding scope.
+ * `respond` is called once per chat-completions request.
+ */
+export const serveReplayBrain = (
+ respond: (ctx: BrainContext) => BrainResponse,
+): Effect.Effect =>
+ Effect.acquireRelease(
+ Effect.callback<{ server: Server; brain: ReplayBrain }>((resume) => {
+ const served: BrainRequest[] = [];
+ const errors: string[] = [];
+
+ const server = createServer((request, response) => {
+ if (!request.url?.includes("/chat/completions")) {
+ response.writeHead(404).end();
+ return;
+ }
+ let raw = "";
+ request.on("data", (piece: Buffer) => (raw += piece.toString("utf8")));
+ request.on("end", () => {
+ const body = JSON.parse(raw || "{}") as WireBody;
+ const messages = (body.messages ?? []).map((message) => ({
+ role: message.role,
+ content: contentText(message.content),
+ }));
+ const toolNames = (body.tools ?? [])
+ .map((tool) => tool.function?.name ?? "")
+ .filter(Boolean);
+ const requestIndex = served.length;
+ served.push({ messages, toolNames });
+
+ const lastOf = (role: string) =>
+ [...messages].reverse().find((message) => message.role === role)?.content;
+
+ let scripted: BrainResponse;
+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a throwing script must surface as a recorded error + a stop response, not a hung agent
+ try {
+ scripted = respond({
+ requestIndex,
+ lastRole: messages.at(-1)?.role ?? "",
+ lastUser: lastOf("user") ?? "",
+ lastToolResult: lastOf("tool"),
+ toolNames,
+ });
+ } catch (error) {
+ errors.push(`respond() threw on request ${requestIndex}: ${String(error)}`);
+ scripted = { text: "(replay brain script error)" };
+ }
+
+ // Resolve a scripted tool name against the agent's namespaced names.
+ let resolvedTool: { name: string; args: unknown } | undefined;
+ if (scripted.tool) {
+ const wanted = scripted.tool.name;
+ const match =
+ toolNames.find((name) => name === wanted) ??
+ toolNames.find((name) => name.endsWith(wanted));
+ if (match) {
+ resolvedTool = { name: match, args: scripted.tool.args };
+ } else {
+ errors.push(
+ `request ${requestIndex}: no offered tool matches "${wanted}" (offered: ${toolNames.join(", ")})`,
+ );
+ }
+ }
+
+ response.writeHead(200, {
+ "content-type": "text/event-stream",
+ "cache-control": "no-cache",
+ connection: "keep-alive",
+ });
+ response.write(sseChunk({ role: "assistant" }, null));
+ if (scripted.text) {
+ // A few chunks so the TUI streams like a real model reply.
+ for (const piece of scripted.text.match(/.{1,24}/gs) ?? []) {
+ response.write(sseChunk({ content: piece }, null));
+ }
+ }
+ if (resolvedTool) {
+ response.write(
+ sseChunk(
+ {
+ tool_calls: [
+ {
+ index: 0,
+ id: `call_${requestIndex}`,
+ type: "function",
+ function: {
+ name: resolvedTool.name,
+ arguments: JSON.stringify(resolvedTool.args ?? {}),
+ },
+ },
+ ],
+ },
+ null,
+ ),
+ );
+ }
+ response.write(sseChunk({}, resolvedTool ? "tool_calls" : "stop"));
+ response.write("data: [DONE]\n\n");
+ response.end();
+ });
+ });
+
+ server.listen(0, "127.0.0.1", () => {
+ const { port } = server.address() as AddressInfo;
+ resume(
+ Effect.succeed({
+ server,
+ brain: {
+ baseUrl: `http://127.0.0.1:${port}/v1`,
+ requests: () => served,
+ errors: () => errors,
+ },
+ }),
+ );
+ });
+ }),
+ (resource: { server: Server; brain: ReplayBrain }) =>
+ Effect.sync(() => void resource.server.close()),
+ ).pipe(Effect.map(({ brain }) => brain));
diff --git a/e2e/src/scenario.ts b/e2e/src/scenario.ts
index 64c7b7579..b135beecc 100644
--- a/e2e/src/scenario.ts
+++ b/e2e/src/scenario.ts
@@ -9,7 +9,8 @@
// recording layer. What survives per run is a small result.json (for the
// scenario × target matrix) plus whatever artifacts the surfaces produced
// (browser video/trace/screenshots, terminal casts).
-import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -136,6 +137,29 @@ export const scenario = (
1,
),
);
+ // A run with both recordings is ONE developer session — splice them
+ // into film.mp4 (scripts/film.ts cuts on the focus timeline) so the
+ // viewer plays a single recording, not parts. Best-effort: missing
+ // agg/ffmpeg or a film failure never fails the run; the parts stay
+ // and the viewer falls back to cast + video in story order.
+ if (
+ exit._tag === "Success" &&
+ existsSync(join(dir, "terminal.cast")) &&
+ existsSync(join(dir, "session.mp4"))
+ ) {
+ yield* Effect.sync(() => {
+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional post-processing over external tooling (agg, ffmpeg)
+ try {
+ execFileSync(
+ "bun",
+ [fileURLToPath(new URL("../scripts/film.ts", import.meta.url)), dir],
+ { stdio: "pipe", timeout: 120_000 },
+ );
+ } catch {
+ // parts remain the artifacts
+ }
+ });
+ }
buildManifest(RUNS_DIR);
if (exit._tag === "Failure") {
return yield* Effect.failCause(exit.cause);
diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts
index c0d250283..7dd4c6f12 100644
--- a/e2e/src/surfaces/browser.ts
+++ b/e2e/src/surfaces/browser.ts
@@ -11,6 +11,7 @@ import { promisify } from "node:util";
import { Effect } from "effect";
import { chromium, type Page } from "playwright";
+import { markFocus, markRecordingStart } from "../timeline";
import type { Identity, Target } from "../target";
export interface BrowserSession {
@@ -42,7 +43,17 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface
const videoTmp = join(dir, ".video-tmp");
mkdirSync(videoTmp, { recursive: true });
- const browser = await chromium.launch();
+ // On the desk (E2E_DESK), the browser is a real headed window on the
+ // virtual display — the desk's single screen recording films it next
+ // to the chat terminal, exactly like a developer tabbing over.
+ const browser = await chromium.launch(
+ process.env.E2E_DESK === "1"
+ ? {
+ headless: false,
+ args: ["--window-position=300,40", "--window-size=1100,830"],
+ }
+ : {},
+ );
const context = await browser.newContext({
colorScheme: "dark",
viewport: { width: 1280, height: 800 },
@@ -56,11 +67,16 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface
);
}
const page = await context.newPage();
+ // The session video's clock starts with the page; anchor it for the
+ // run's focus timeline (scripts/film.ts cuts on these).
+ markRecordingStart(dir, "browser");
return { browser, context, page, videoTmp, shots: { count: 0 } };
}),
({ page, context, shots }) =>
Effect.promise(async () => {
const step = async (label: string, action: (page: Page) => Promise) => {
+ // Acting on the page IS focusing the browser window.
+ markFocus(dir, "browser");
await context.tracing.group(label);
try {
await action(page);
diff --git a/e2e/src/timeline.ts b/e2e/src/timeline.ts
new file mode 100644
index 000000000..4de414fed
--- /dev/null
+++ b/e2e/src/timeline.ts
@@ -0,0 +1,47 @@
+// The run's focus timeline: which window the scenario was acting on, when.
+//
+// Focus is DERIVED, never declared — driving a Playwright page focuses the
+// browser window; pushing a chat/terminal event focuses the terminal. The
+// surfaces call markFocus as a side effect of normal operations, so any
+// scenario gets a faithful "where was the developer looking" track for
+// free, and scripts/film.ts can cut the session recordings exactly where
+// the action moved. Anchors map wall-clock to each recording's own clock.
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+export type TimelineWindow = "terminal" | "browser";
+
+export interface Timeline {
+ /** Wall-clock ms when each recording's clock started. */
+ readonly anchors: { terminal?: number; browser?: number };
+ /** Focus transitions (first event per contiguous run of a window). */
+ readonly focus: Array<{ at: number; window: TimelineWindow }>;
+}
+
+const fileFor = (runDir: string) => join(runDir, "timeline.json");
+
+const read = (runDir: string): Timeline => {
+ const file = fileFor(runDir);
+ if (!existsSync(file)) return { anchors: {}, focus: [] };
+ return JSON.parse(readFileSync(file, "utf8")) as Timeline;
+};
+
+const write = (runDir: string, timeline: Timeline) =>
+ writeFileSync(fileFor(runDir), JSON.stringify(timeline, null, 1));
+
+/** Record that `window`'s recording clock starts now. */
+export const markRecordingStart = (runDir: string, window: TimelineWindow): void => {
+ const timeline = read(runDir);
+ write(runDir, { ...timeline, anchors: { ...timeline.anchors, [window]: Date.now() } });
+};
+
+/** Record that the scenario is acting on `window` (deduped per run). */
+export const markFocus = (runDir: string, window: TimelineWindow): void => {
+ const timeline = read(runDir);
+ if (timeline.focus.at(-1)?.window === window) return;
+ timeline.focus.push({ at: Date.now(), window });
+ write(runDir, timeline);
+};
+
+export const readTimeline = (runDir: string): Timeline | null =>
+ existsSync(fileFor(runDir)) ? read(runDir) : null;
diff --git a/e2e/viewer/src/App.tsx b/e2e/viewer/src/App.tsx
index c36272626..ef6e940de 100644
--- a/e2e/viewer/src/App.tsx
+++ b/e2e/viewer/src/App.tsx
@@ -155,8 +155,13 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => {
const has = (name: string) => result.artifacts.includes(name);
const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort();
- const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null;
- const cast = has("terminal.cast") ? "terminal.cast" : null;
+ // film.mp4 (scripts/film.ts) is the whole session as one video — terminal,
+ // browser hop, terminal, cut like tabbing between full-screen windows.
+ // When present it IS the session; the parts stay on disk for debugging.
+ const film = has("film.mp4") ? "film.mp4" : null;
+ const video =
+ film ?? (has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null);
+ const cast = film ? null : has("terminal.cast") ? "terminal.cast" : null;
const media = video ?? cast;
const traceUrl = has("trace.zip")
? `https://trace.playwright.dev/?trace=${encodeURIComponent(
@@ -193,7 +198,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => {
className={tab === "media" ? "tab active" : "tab"}
onClick={() => setTab("media")}
>
- ▶ {video ? "video" : "terminal"}
+ ▶ {cast && video ? "session" : video ? "video" : "terminal"}
{
)}
- {cast && !video && tab === "media" && (
+ {/* A run with BOTH recordings is one session in time order: the
+ terminal chat is the spine, the browser video is the hop that
+ happens in the middle of it. Show them in that order. */}
+ {cast && tab === "media" && (
loading recording…}>
+ {video && 1 · the chat, in the terminal }
)}
{video && tab === "media" && (
<>
- {/* muted is required for browsers to honor autoplay */}
+ {cast && 2 · the browser hop, mid-chat }
+ {/* muted is required for browsers to honor autoplay; don't
+ autoplay when it's the second act of a session */}