chore(runner): restore Phase 6 package baseline

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-10 02:21:39 +00:00
1 parent 6a4e2e1b8c
commit 361f9c8725
348 files changed
+94328

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
/dist-browser/
/dist-phase5/
/playwright-report/
/test-results/
+154
View File
@@ -0,0 +1,154 @@
# Paperclip Native Runner
This workspace is the standalone development boundary for Paperclip's native
runner protocol, harness drivers, and normalized session backends. The
production runner direction is Rust; `runner/` is its Cargo workspace. The
TypeScript surface remains a control-plane/client reference implementation.
Phase 0 runs both implementations against one language-neutral fixture. Phase 1
adds executable PRP schemas, a cross-language conformance corpus, deterministic
static replay, and a standalone browser reference page. Phase 2 adds the Rust
runner process, process-group supervision, a scripted fake harness over stdio
JSONL, a TypeScript mock core, and validated live browser replay. Phase 3 adds
the Rust outbound WebSocket client, private durable state, replay and restart
recovery, and safe CLI/browser diagnostics. Phase 4 adds a direct, skillless
Codex app-server driver, semantic completion tools, canonical event tracing,
and a real-model example against the mock core. These phases do not import or
start Paperclip's server, UI, CLI, or production database. The Phase 4b lower
layer adds browser-resolved provider requests, goal capability detection,
subagent lineage, control-race semantics, and a package-local demo server that
keeps Codex authentication server-side. The Phase 4b live console adds the
browser tracer over that boundary: a live transcript and composer, same-turn
steering, three distinct interrupt races, inline request cards,
capability-gated goal controls, parent/child lineage, reconnect, refresh,
replay, and a redacted protocol inspector. Phase 5 extracts that accepted
transport, reducer projection, hook, components, styles, and reference console
into versioned public subpaths, then proves the surface with a second minimal
consumer.
Phase 6 adds a default-off Paperclip adapter at the public control-plane/session
boundary, durable PRP replay and result finalization, and a kill switch that
keeps the legacy adapter path as the default.
Its audit-only attention path also terminates through the public port/finalizer
call graph: duplicate and stale requests commit exact interaction receipts with
no status decision, wake, notification, or issue status/version change, and a
committed coordinator makes replay a no-op.
## Phase 0–5 quick start
From the repository root:
```sh
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev
pnpm --filter @paperclipai/paperclip-runner verify
```
The verification command requires a stable Rust toolchain with `cargo` on
`PATH`, in addition to Node.js 20+ and pnpm 9+.
Minimal Debian/Ubuntu hosts without root access can extract the required
Playwright browser libraries into a user-owned cache and run the same acceptance
sequence with:
```sh
pnpm --filter @paperclipai/paperclip-runner verify:rootless
```
The tracer's final line is stable:
```json
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
```
Run only the tracer with:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase0
```
Replay the Phase 1 happy path, run a Phase 2 local session, or open the browser
devtool:
```sh
pnpm --filter @paperclipai/paperclip-runner replay:phase1
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario happy-path
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lost-ack
pnpm --filter @paperclipai/paperclip-runner trace:phase4
pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- --host 127.0.0.1 --port 4174
# Phase 4b: chat with a live session in the browser.
pnpm --filter @paperclipai/paperclip-runner console:phase4b
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
# Phase 5: open the public-SDK reference console and mini consumer.
pnpm --filter @paperclipai/paperclip-runner console:phase5
# Phase 6: prove the mock-side integration contract.
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --target mock --scenario happy-path
```
Phase 4b provider-backed routes are loopback-only and reject wildcard/LAN
binds. Browser mutations require same-origin Fetch Metadata, matching Origin,
and JSON content; see the protocol-server tutorial for direct `curl` examples.
## Package-owned commands
| Command | Purpose |
|---|---|
| `build` | Compile the TypeScript public surface, Rust workspace, and browser devtool. |
| `typecheck` | Check TypeScript, Rust, generated schema sources, and browser types. |
| `test` | Run Rust/TypeScript fixture, supervisor, fake-driver, live/replay, and boundary tests. |
| `check:forbidden-imports` | Reject TypeScript imports and Cargo path dependencies that cross into Paperclip core. |
| `check:phase0-parity` | Require byte-for-byte equivalent Rust and TypeScript tracer output. |
| `check:phase1-goldens` | Require all reducer snapshots and cross-language summaries to match checked goldens. |
| `check:phase1-parity` | Run TypeScript and Rust against the same Phase 1 fixture summaries. |
| `check:browser-tokens` | Reject component-local visual literals and require the standalone token layer. |
| `docs:validate` | Validate local documentation links and the OKF v0.2 bundle. |
| `trace:phase0` | Run the Rust mock-core tracer, print the stable result, and exit. |
| `trace:phase0:typescript` | Run the TypeScript reference tracer directly. |
| `replay:phase1` | Validate and reduce a fixture to a final snapshot. |
| `trace:phase2` | Run one native local session through the Rust runner and fake harness. |
| `record:phase2` | Capture a validated happy-path live trace as a replay fixture. |
| `trace:phase3` | Run the Rust runner against the mock core with a selected recovery fault. |
| `record:phase3` | Regenerate the complete Phase 3 fault matrix and exact per-fault traces. |
| `trace:phase4` | Run the mock core with a real, local skillless Codex app-server session. |
| `record:phase4` | Run the safe Codex task and record its validated, normalized trace. |
| `demo:phase4b` | Start the package-local HTTP/SSE server with server-only Codex authentication. |
| `console:phase4b` | Start the standalone browser devtool with the Phase 4b live console on `127.0.0.1:4180`. |
| `record:phase4b` | Run a safe real Codex task through the demo server and record reconnect/replay evidence. |
| `console:phase5` | Start the public-SDK reference console and mini consumer on `127.0.0.1:4181`. |
| `test:phase5` | Run targeted browser-client, reducer-projection, and React component contract tests. |
| `test:browser:phase5` | Exercise both consumers with the fake driver, keyboard/a11y checks, reconnect/replay, measurements, and screenshots. |
| `record:phase5:codex` | Run both public consumers against a safe real Codex session and capture live screenshots. |
| `browser:dev` | Start the standalone live/replay/recovery browser devtool. |
| `test:browser` | Exercise static replay and live scenarios, then capture temporary screenshots under ignored test output. |
| `verify` | Run the complete deterministic Phase 0 through Phase 5 acceptance sequence. |
| `verify:rootless` | Extract Debian/Ubuntu browser libraries without root, then run `verify`. |
## Navigate
- [Architecture and dependency boundary](docs/architecture.md)
- [Tutorial index](docs/index.md)
- [Phase 0 hand-run tutorial](docs/tutorials/phase-00-standalone-tracer.md)
- [Phase 1 hand-run tutorial](docs/tutorials/phase-01-static-replay.md)
- [Phase 2 hand-run tutorial](docs/tutorials/phase-02-local-runner.md)
- [Phase 2 local protocol reference](docs/phase-02-local-protocol.md)
- [Phase 3 break-it-on-purpose tutorial](docs/tutorials/phase-03-break-recovery.md)
- [Phase 3 durable transport reference](docs/phase-03-durable-transport.md)
- [Phase 4 skillless Codex tutorial](docs/tutorials/phase-04-skillless-codex.md)
- [Phase 4 skillless Codex driver reference](docs/phase-04-skillless-codex-driver.md)
- [Phase 4b protocol/server tutorial](docs/tutorials/phase-04b-protocol-server.md)
- [Phase 4b protocol/server reference](docs/phase-04b-protocol-server.md)
- [Phase 4b live console tutorial](docs/tutorials/phase-04b-live-console.md)
- [Phase 4b live console reference](docs/phase-04b-live-console.md)
- [Phase 5 SDK console tutorial](docs/tutorials/phase-05-sdk-console.md)
- [Phase 5 browser SDK reference](docs/phase-05-sdk.md)
- [Phase 5 component decision record](docs/design/phase-5-component-decisions.md)
- [PRP compatibility/versioning policy](docs/protocol-compatibility.md)
- [Cumulative end-to-end tutorial](docs/tutorials/end-to-end.md)
- [Journal guide](docs/journal.md)
- [OKF knowledge bundle](knowledge/)
- [Implementation plan](spec/paperclip-native-runner-implementation-plan.md)
- [Normative spike specification](spec/paperclip-native-runner-spike-spec.md)
Phase 4 adds the package-local real-model reference driver, Phase 4b adds the
package-local browser console, and Phase 5 extracts a reusable public SDK plus
two standalone consumers. Production Paperclip integration remains deferred.
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Paperclip Runner · PRP Static Replay</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,405 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
const evidenceRoot = resolve(
fileURLToPath(new URL("../../knowledge/evidence/phase-04b", import.meta.url)),
);
const DESKTOP = { width: 1440, height: 900 };
const MOBILE = { width: 390, height: 844 };
function shot(page: Page, name: string) {
return page.screenshot({ path: resolve(evidenceRoot, `${name}.png`), fullPage: true });
}
async function openConsole(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Live console" }).click();
await expect(page.getByRole("heading", { name: "Live Codex protocol console" })).toBeVisible();
}
async function runManifest(page: Page, id: string) {
await expect(page.getByTestId("manifest-list")).toBeVisible();
await page.getByTestId(`manifest-${id}`).click();
await page.getByTestId("run-manifest").click();
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 15_000 });
}
test.describe("Phase 4b live console", () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(DESKTOP);
});
test("shows the empty state and runs a completed turn", async ({ page }) => {
await openConsole(page);
await expect(page.getByTestId("empty-transcript")).toBeVisible();
await expect(page.getByTestId("composer-send")).toBeDisabled();
await shot(page, "desktop-01-idle-empty");
await runManifest(page, "completion");
await expect(page.getByTestId("reasoning-item")).toBeVisible();
await expect(page.getByTestId("tool-item")).toBeVisible();
await shot(page, "desktop-02-streaming-turn");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("live-transcript")).toContainText("finished the task");
await expect(page.getByTestId("live-transcript")).toContainText(
"Summarize what the Phase 4b demo server proves.",
);
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "completed");
await shot(page, "desktop-03-turn-completed");
// The browser must never receive a provider credential.
const body = await page.locator("body").innerText();
expect(body).not.toContain("Bearer ");
expect(body).not.toContain("auth.json");
});
test("acknowledges steering and keeps rejected text recoverable", async ({ page }) => {
await openConsole(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
await page.getByTestId("composer-input").fill("Keep it to one sentence.");
await page.getByTestId("composer-send").click();
const chip = page.getByTestId("steering-chip").first();
await expect(chip).toContainText("acknowledged", { timeout: 15_000 });
await shot(page, "desktop-04-steering-acknowledged");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 });
// The composer state machine now offers Send again; force a stale steer.
await page.evaluate(() => window.history.replaceState({}, "", "/"));
});
test("rejects a stale steer and offers to resend it", async ({ page }) => {
await openConsole(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
await page.getByTestId("composer-input").fill("Typed while the turn was running.");
// Let the turn finish before the steer is sent: the classic stale race.
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 });
await expect(page.getByTestId("composer-send")).toHaveText("Send");
});
test("cancels a turn interrupted before it starts", async ({ page }) => {
await openConsole(page);
await runManifest(page, "interrupt-before-start");
await expect(page.getByTestId("composer-stop")).toBeEnabled();
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-cancelled")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("turn-cancelled")).toContainText("Cancelled before start");
await shot(page, "desktop-05-interrupt-before-start");
});
test("keeps partial text when a streaming turn is stopped", async ({ page }) => {
await openConsole(page);
await runManifest(page, "interrupt-during-generation");
await expect(page.getByTestId("live-transcript")).toContainText("The tracer keeps", {
timeout: 15_000,
});
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("interrupted-divider").first()).toBeVisible();
await expect(page.getByTestId("live-transcript")).not.toContainText("And this final one.");
await shot(page, "desktop-06-interrupt-during-generation");
});
test("keeps an interrupted tool item without a fabricated result", async ({ page }) => {
await openConsole(page);
await runManifest(page, "interrupt-during-tool");
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "running", {
timeout: 15_000,
});
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "interrupted", {
timeout: 15_000,
});
await expect(page.getByTestId("turn-interrupted")).toBeVisible();
await shot(page, "desktop-07-interrupt-during-tool");
});
test("resolves approvals exactly once and collapses the resolved card", async ({ page }) => {
await openConsole(page);
await runManifest(page, "approvals");
const card = page.getByTestId("request-card").first();
await expect(card).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await expect(page.getByTestId("pending-request-banner")).toBeVisible();
await expect(page.getByTestId("request-action-accept_for_session")).toBeVisible();
await shot(page, "desktop-08-request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"resolved",
{ timeout: 15_000 },
);
await expect(page.getByTestId("request-card").first()).toContainText("resolved — Approve");
await shot(page, "desktop-09-request-resolved");
// Second request only offers accept and decline.
const second = page.getByTestId("request-card").nth(1);
await expect(second).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await expect(second.getByTestId("request-action-accept_for_session")).toHaveCount(0);
});
test("submits user-input and elicitation answers and lets an unanswered request expire", async ({ page }) => {
test.setTimeout(60_000);
await openConsole(page);
await runManifest(page, "user-input");
await expect(page.getByTestId("request-answer")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("request-answer").fill("staging");
await page.getByTestId("request-action-submit").click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"resolved",
{ timeout: 15_000 },
);
// An elicitation submits `content`, not `answers`. The runner validates the
// body against the request kind, so a card that sent the user-input shape
// would be rejected and stay pending here.
const elicitation = page.getByTestId("request-card").nth(1);
await expect(elicitation).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await elicitation.getByTestId("request-answer").fill("stable");
await elicitation.getByTestId("request-action-submit").click();
await expect(elicitation).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
await expect(elicitation).toContainText("resolved — Submit");
await expect(page.getByTestId("request-card").nth(2)).toHaveAttribute(
"data-status",
"expired",
{ timeout: 25_000 },
);
await expect(page.getByTestId("request-card").nth(2)).toContainText("expired before response");
await shot(page, "desktop-10-request-expired");
});
test("moves a goal through its lifecycle", async ({ page }) => {
await openConsole(page);
await runManifest(page, "goals");
await expect(page.getByTestId("goal-banner")).toHaveText("No goal set", { timeout: 15_000 });
await page.locator("#goal-menu").click();
await page.getByRole("menuitem", { name: "Set goal…" }).click();
await page.getByTestId("goal-objective").fill("Keep the demo goal visible");
await page.getByTestId("goal-set-confirm").click();
await expect(page.getByTestId("goal-banner")).toContainText("Keep the demo goal visible", {
timeout: 15_000,
});
await expect(page.getByTestId("goal-banner")).toContainText("active");
await page.locator("#goal-menu").click();
await page.getByRole("menuitem", { name: "Pause" }).click();
await expect(page.getByTestId("goal-banner")).toContainText("paused", { timeout: 15_000 });
await shot(page, "desktop-11-goal-supported");
});
test("disables the goal menu with the exact upstream diagnostic", async ({ page }) => {
await openConsole(page);
await runManifest(page, "goals-unsupported");
const trigger = page.locator("#goal-menu");
await expect(trigger).toHaveAttribute("aria-disabled", "true", { timeout: 15_000 });
const describedBy = await trigger.getAttribute("aria-describedby");
expect(describedBy).not.toBeNull();
await expect(page.locator(`#${describedBy}`)).toHaveText(
"Goal operations unsupported: app-server 0.132.0 does not advertise the goals capability",
);
await page.getByTestId("toggle-inspector").click();
await page.getByRole("tab", { name: "Capabilities" }).click();
await expect(page.getByTestId("inspector-capabilities")).toContainText(
"does not advertise the goals capability",
);
await shot(page, "desktop-12-goal-unsupported");
});
test("shows child threads and refuses to emulate child steering", async ({ page }) => {
await openConsole(page);
await runManifest(page, "subagents");
await expect(page.getByTestId("thread-thread-child-b")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("thread-thread-child-a")).toContainText("Completed");
await expect(page.getByTestId("thread-thread-child-b")).toContainText("Failed");
await page.getByTestId("thread-thread-child-a").click();
await expect(page.getByTestId("breadcrumb")).toContainText("thread-child-a");
await expect(page.getByTestId("composer-reason")).toHaveText(
"Direct steering of child threads is not supported by this app-server.",
);
await expect(page.getByTestId("composer-input")).toBeDisabled();
await shot(page, "desktop-13-lineage-child");
});
test("shows the failed item and diagnostic in the record", async ({ page }) => {
await openConsole(page);
await runManifest(page, "failure");
await expect(page.getByTestId("turn-failed")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("tool-failure")).toContainText("cannot find module");
await shot(page, "desktop-14-failed-turn");
});
test("resumes the same session after a transport drop", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
const before = await page.getByTestId("live-transcript").innerText();
await page.getByTestId("drop-connection").click();
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
await expect(page.getByTestId("composer-input")).toBeDisabled();
await shot(page, "desktop-15-reconnect-banner");
await page.getByTestId("retry-now").click();
await expect(page.getByTestId("connection-status")).toHaveText("connected", {
timeout: 20_000,
});
// Live and resumed transcripts must be identical, not merely similar.
expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim());
});
test("restores the same session across a page refresh", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
const before = await page.getByTestId("live-transcript").innerText();
await page.reload();
await page.getByRole("button", { name: "Live console" }).click();
await expect(page.getByTestId("live-transcript")).toContainText("finished the task", {
timeout: 20_000,
});
expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim());
await shot(page, "desktop-16-refresh-replay");
});
test("replays a recorded session with a stepper", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("toggle-replay").click();
await expect(page.getByTestId("replay-badge")).toBeVisible();
await expect(page.getByTestId("replay-stepper")).toBeVisible();
await expect(page.getByTestId("empty-transcript")).toBeVisible();
await page.getByRole("button", { name: "Step forward" }).click();
await page.getByRole("button", { name: "Step forward" }).click();
await expect(page.getByTestId("empty-transcript")).toHaveCount(0);
await shot(page, "desktop-17-replay-mode");
await page.getByTestId("toggle-replay").click();
await expect(page.getByTestId("replay-badge")).toHaveCount(0);
await expect(page.getByTestId("turn-completed")).toBeVisible();
});
test("inspects canonical events and reports reducer parity", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("toggle-inspector").click();
await expect(page.getByTestId("inspector-events")).toBeVisible();
await page.getByTestId("inspector-filter").fill("turn.completed");
await expect(page.getByTestId("inspector-events").getByRole("listitem")).toHaveCount(1);
await shot(page, "desktop-18-inspector-events");
await page.getByTestId("inspector-filter").fill("");
await page.getByRole("tab", { name: "Session" }).click();
await expect(page.getByTestId("inspector-session")).toContainText("Credentials in browser");
await expect(page.getByTestId("replay-parity")).toHaveText("match");
await shot(page, "desktop-19-inspector-session");
});
test("meets the keyboard contract", async ({ page }) => {
await openConsole(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
// K2: Escape moves focus to Stop and never interrupts directly.
await page.getByTestId("composer-input").focus();
await page.keyboard.press("Escape");
await expect(page.getByTestId("composer-stop")).toBeFocused();
await expect(page.getByTestId("turn-interrupted")).toHaveCount(0);
// K2: Enter steers the running turn.
await page.getByTestId("composer-input").fill("Steered with the keyboard.");
await page.getByTestId("composer-input").press("Enter");
await expect(page.getByTestId("steering-chip").first()).toContainText("acknowledged", {
timeout: 15_000,
});
// K7: inspector tabs follow the arrow-key tabs pattern.
await page.getByTestId("toggle-inspector").click();
await page.getByRole("tab", { name: "Events" }).focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Requests" })).toBeFocused();
await expect(page.getByRole("tab", { name: "Requests" })).toHaveAttribute(
"aria-selected",
"true",
);
// K5: lineage tree responds to arrow keys.
await page.getByTestId("lineage-tree").getByRole("treeitem").first().focus();
await page.keyboard.press("ArrowDown");
// K6/A1: the transcript is a labelled log landmark.
await expect(page.getByTestId("live-transcript")).toHaveAttribute("role", "log");
await expect(page.getByTestId("live-transcript")).toHaveAttribute("aria-live", "polite");
});
test("resets demo state behind a naming confirmation", async ({ page }) => {
await openConsole(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByRole("button", { name: "Reset demo state", exact: true }).first().click();
await expect(page.getByRole("dialog")).toContainText(
"Recorded evidence files are not touched.",
);
await page.getByTestId("reset-confirm").click();
await expect(page.getByTestId("empty-transcript")).toBeVisible();
});
});
test.describe("Phase 4b live console on a small screen", () => {
test("keeps the transcript and composer usable at 390 by 844", async ({ page }) => {
await page.setViewportSize(MOBILE);
await openConsole(page);
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
await expect(page.getByTestId("manifest-list")).toHaveCount(0);
await shot(page, "mobile-01-idle-empty");
await page.getByRole("tab", { name: "Session" }).click();
await expect(page.getByTestId("manifest-list")).toBeVisible();
await page.getByTestId("manifest-approvals").click();
await page.getByTestId("run-manifest").click();
await shot(page, "mobile-02-session-segment");
await page.getByRole("tab", { name: "Chat" }).click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"pending",
{ timeout: 20_000 },
);
await shot(page, "mobile-03-request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
"data-status",
"resolved",
{ timeout: 15_000 },
);
await page.getByRole("tab", { name: "Inspector" }).click();
await expect(page.getByTestId("inspector-events")).toBeVisible();
await shot(page, "mobile-04-inspector");
// WCAG 1.4.10 reflow: no horizontal scrolling of the page itself.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
});
});
@@ -0,0 +1,29 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: ["static-replay.spec.ts", "live-console.spec.ts"],
fullyParallel: false,
workers: 1,
reporter: "line",
use: {
baseURL: "http://127.0.0.1:4179",
viewport: { width: 1440, height: 1000 },
},
webServer: {
command:
"pnpm exec vite preview --config vite.config.ts --host 127.0.0.1 --port 4179",
env: {
// Keep streamed chunks slow enough that interrupt races are reachable
// from a test, and fast enough that the suite stays quick.
PAPERCLIP_PHASE4B_CHUNK_DELAY_MS: "60",
// One server serves the whole suite, and every test leaves its session
// behind when its page closes, so the interactive default of 16 is
// exhausted partway through.
PAPERCLIP_PHASE4B_MAX_SESSIONS: "64",
},
url: "http://127.0.0.1:4179",
reuseExistingServer: false,
timeout: 30_000,
},
});
@@ -0,0 +1,889 @@
import { useMemo, useRef, useState } from "react";
import type { PrpEvent, PrpRequest } from "../../../src/protocol/phase1-contract";
import type { SessionSnapshot } from "../../../src/reducer/session-reducer";
import type {
Phase2RunMetadata,
Phase2RunTrace,
Phase2Scenario,
} from "../../../src/contracts/phase2";
import {
PHASE3_FAULTS,
type Phase3Fault,
type Phase3RunTrace,
} from "../../../src/contracts/phase3";
import {
applyPhase2LiveEvent,
createPhase2LiveSnapshot,
phase2SnapshotsMatch,
replayPhase2Events,
} from "../../../src/tracer/phase2-live";
import { replayPhase1FixtureText } from "../../../src/tracer/phase1-replay";
import { Badge } from "./components/ui/badge";
import { Button } from "./components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./components/ui/card";
import { Textarea } from "./components/ui/textarea";
import { LiveConsole } from "./live/LiveConsole";
const fixtureFiles = import.meta.glob(
"../../../protocol/fixtures/phase-01/*.json",
{ eager: true, query: "?raw", import: "default" },
) as Record<string, string>;
const fixtureOrder = [
"happy-path",
"failed-run",
"interrupted-run",
"duplicate-event",
"source-gap",
"unknown-optional-fields",
"unsupported-required-version",
];
const liveScenarios: Array<{ key: Phase2Scenario; label: string }> = [
{ key: "happy-path", label: "Happy path" },
{ key: "permission-input", label: "Permission and input" },
{ key: "interrupted", label: "Interruption" },
{ key: "error", label: "Scripted error" },
{ key: "duplicate-terminal", label: "Duplicate terminal guard" },
];
type BrowserMode = "console" | "recovery" | "live" | "replay";
type LiveStatus = "idle" | "starting" | "running" | "terminal" | "error";
const phase3FaultLabels: Record<Phase3Fault, string> = {
none: "Normal connection",
"socket-drop": "Socket drop",
"lost-ack": "Lost ACK",
"duplicate-command": "Duplicate command",
"runner-restart": "Runner restart",
"harness-restart": "Harness restart",
"malformed-input": "Malformed input",
"lease-expiry": "Lease expiry",
"storage-pressure": "Storage pressure",
drain: "Drain",
revoke: "Revoke",
};
const modeCopy: Record<
BrowserMode,
{ eyebrow: string; title: string; description: string }
> = {
console: {
eyebrow: "Paperclip Runner Protocol · Phase 4b",
title: "Live Codex protocol console",
description:
"Chat with a live session, steer it, stop it, answer its requests, and inspect the canonical protocol behind every surface.",
},
recovery: {
eyebrow: "Paperclip Runner Protocol · Phase 3",
title: "Durable recovery diagnostics",
description:
"Break the outbound transport, recover the same session, and inspect durable state.",
},
live: {
eyebrow: "Paperclip Runner Protocol · Phase 2",
title: "Live runner diagnostics",
description:
"Run the local harness, stream validated events, and compare the live and replayed state.",
},
replay: {
eyebrow: "Paperclip Runner Protocol · Phase 1",
title: "Static protocol replay",
description:
"Validate a protocol fixture, replay its events, and inspect the resulting session state.",
},
};
interface BrowserFixture {
key: string;
label: string;
source: string;
}
type Phase2StreamRecord =
| { kind: "event"; event: unknown }
| { kind: "diagnostic"; message: string }
| { kind: "trace"; trace: Phase2RunTrace }
| { kind: "error"; message: string };
const browserFixtures = Object.entries(fixtureFiles)
.map(([path, source]): BrowserFixture => {
const key = path.split("/").at(-1)?.replace(/\.json$/, "") ?? path;
const parsed = JSON.parse(source) as { name?: string };
return { key, label: parsed.name ?? key, source };
})
.sort((left, right) => fixtureOrder.indexOf(left.key) - fixtureOrder.indexOf(right.key));
function terminalTone(snapshot: SessionSnapshot) {
if (snapshot.integrity === "gap_detected") {
return "warning" as const;
}
if (snapshot.terminal?.runTerminalState === "succeeded") {
return "success" as const;
}
if (snapshot.terminal?.runTerminalState === "failed") {
return "danger" as const;
}
return "neutral" as const;
}
function humanizeProtocolLabel(value: string): string {
const words = value.replaceAll(/[_-]/g, " ");
return words.length === 0 ? words : `${words[0]?.toUpperCase()}${words.slice(1)}`;
}
function recoveryOutcomeTone(outcome: Phase3RunTrace["diagnostics"]["recovery"]["outcome"]) {
if (outcome === "recovered") {
return "success" as const;
}
if (outcome === "unrecoverable") {
return "danger" as const;
}
return "warning" as const;
}
function RecoveryDiagnostics() {
const [fault, setFault] = useState<Phase3Fault>("lost-ack");
const [trace, setTrace] = useState<Phase3RunTrace | null>(null);
const [status, setStatus] = useState<"idle" | "running" | "complete" | "error">("idle");
const [error, setError] = useState<string | null>(null);
async function runRecovery() {
setStatus("running");
setError(null);
try {
const response = await fetch("/api/phase3/recovery", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fault }),
});
const result = (await response.json()) as Phase3RunTrace & { error?: string };
if (!response.ok) throw new Error(result.error ?? "The recovery trace failed.");
setTrace(result);
setStatus("complete");
} catch (cause) {
setStatus("error");
setError(cause instanceof Error ? cause.message : String(cause));
}
}
return (
<div className="workspace-grid recovery-workspace">
<Card aria-labelledby="recovery-title">
<CardHeader>
<CardTitle id="recovery-title">Break recovery on purpose</CardTitle>
<CardDescription>
Lose one ACK, drop the socket, restart the runner, and finish the same session.
</CardDescription>
</CardHeader>
<CardContent className="editor-content">
<label htmlFor="recovery-fault">Fault</label>
<select
id="recovery-fault"
value={fault}
disabled={status === "running"}
onChange={(event) => setFault(event.target.value as Phase3Fault)}
>
{PHASE3_FAULTS.map((candidate) => (
<option key={candidate} value={candidate}>
{phase3FaultLabels[candidate]}
</option>
))}
</select>
<Button type="button" disabled={status === "running"} onClick={() => void runRecovery()}>
{status === "running" ? "Running recovery…" : "Run Phase 3 recovery"}
</Button>
<div className="live-status" aria-live="polite">
<span>Recovery status</span>
<Badge tone={status === "error" ? "danger" : status === "complete" ? "success" : "neutral"}>
{status}
</Badge>
</div>
{error ? <div className="validation-errors" role="alert">{error}</div> : null}
<p className="diagnostic-note">
Diagnostics show IDs and counters only. Bootstrap tickets and lease tokens are hidden.
</p>
</CardContent>
</Card>
<Card aria-labelledby="recovery-state-title">
<CardHeader>
<CardTitle id="recovery-state-title">Recovery state</CardTitle>
<CardDescription>Inspect connection, lease, outbox, ACK, replay, storage, drain, and revoke state.</CardDescription>
</CardHeader>
<CardContent>
{trace === null ? (
<div className="empty-live-state"><p>Run the trace to collect recovery diagnostics.</p></div>
) : (
<>
<dl className="recovery-grid" data-testid="phase3-diagnostics">
<div>
<dt>Connection</dt>
<dd data-testid="phase3-connection-state">
{humanizeProtocolLabel(trace.diagnostics.connection.state)}
</dd>
</div>
<div>
<dt>Connections</dt>
<dd data-testid="phase3-connections">
{trace.diagnostics.connection.connectionCount}
</dd>
</div>
<div>
<dt>Reconnects</dt>
<dd data-testid="phase3-reconnects">
{trace.diagnostics.connection.reconnectCount}
</dd>
</div>
<div>
<dt>Lease</dt>
<dd>{trace.diagnostics.connection.leaseId ?? "closed"}</dd>
</div>
<div>
<dt>Outbox</dt>
<dd>{trace.diagnostics.outbox.events}</dd>
</div>
<div>
<dt>Last ACK</dt>
<dd>{trace.diagnostics.cursors.runnerAckedSourceSeq}</dd>
</div>
<div>
<dt>At-least-once redeliveries</dt>
<dd>{trace.diagnostics.recovery.replayDeliveries}</dd>
</div>
<div>
<dt>Runner restarts</dt>
<dd data-testid="phase3-runner-restarts">
{trace.diagnostics.recovery.runnerRestarts}
</dd>
</div>
<div>
<dt>Harness restarts</dt>
<dd>{trace.diagnostics.recovery.harnessRestarts}</dd>
</div>
<div>
<dt>Fresh bootstraps</dt>
<dd data-testid="phase3-fresh-bootstraps">
{trace.diagnostics.recovery.freshBootstraps}
</dd>
</div>
<div>
<dt>Duplicate commands</dt>
<dd>{trace.diagnostics.commands.duplicateDeliveries}</dd>
</div>
<div>
<dt>Storage</dt>
<dd className="storage-diagnostic" data-testid="phase3-storage">
<span>
{trace.diagnostics.outbox.bytes} bytes of {trace.diagnostics.outbox.maxBytes}{" "}
max
</span>
{trace.diagnostics.outbox.backpressure ? (
<Badge tone="warning">Backpressure</Badge>
) : null}
</dd>
</div>
<div>
<dt>Peak storage</dt>
<dd>{trace.diagnostics.outbox.peakBytes} bytes</dd>
</div>
<div>
<dt>Outcome</dt>
<dd className="outcome-diagnostic">
<Badge
tone={recoveryOutcomeTone(trace.diagnostics.recovery.outcome)}
data-testid="phase3-outcome"
>
{humanizeProtocolLabel(trace.diagnostics.recovery.outcome)}
</Badge>
{trace.diagnostics.recovery.outcome !== "recovered" ? (
<span>{humanizeProtocolLabel(trace.diagnostics.recovery.reason)}</span>
) : null}
</dd>
</div>
<div>
<dt>Secrets</dt>
<dd>{trace.diagnostics.security.secretLeakCount} leaks</dd>
</div>
</dl>
<p className="diagnostic-note">
At-least-once redeliveries include normal in-flight outbox resends. Use reconnects
and the per-event history to identify injected recovery.
</p>
<div className="timeline-heading">
<h3>Recovery history</h3>
<span>{trace.diagnostics.committedEvents.length} committed events</span>
</div>
<ol className="timeline" data-testid="recovery-history">
{trace.diagnostics.committedEvents.map((event) => (
<li key={event.sourceEventId}>
<span className="sequence" aria-hidden="true">
{event.sourceSeq}
</span>
<div className="timeline-title">
<code>{event.eventType}</code>
{event.deliveryCount > 1 ? (
<span className="delivery-marker">delivered {event.deliveryCount}×</span>
) : null}
</div>
</li>
))}
</ol>
<div className="parity-line">
<span>Same runner and session</span>
<Badge tone={trace.assertions.stableIdentity ? "success" : "danger"}>{trace.assertions.stableIdentity ? "Preserved" : "Changed"}</Badge>
</div>
<div className="parity-line">
<span>Secrets redacted</span>
<Badge tone={trace.assertions.secretsRedacted ? "success" : "danger"}>{trace.assertions.secretsRedacted ? "Yes" : "No"}</Badge>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
function liveStatusTone(status: LiveStatus, snapshot: SessionSnapshot | null) {
if (status === "error") {
return "danger" as const;
}
if (status !== "terminal") {
return "neutral" as const;
}
if (snapshot?.terminal?.runTerminalState === "succeeded") {
return "success" as const;
}
if (snapshot?.terminal?.runTerminalState === "failed") {
return "danger" as const;
}
return "neutral" as const;
}
function SnapshotSummary({
snapshot,
label = "Session snapshot",
}: {
snapshot: SessionSnapshot;
label?: string;
}) {
return (
<>
<div className="snapshot-heading">
<div>
<p className="eyebrow">{label}</p>
<h2>{snapshot.fixtureName}</h2>
</div>
<Badge tone={terminalTone(snapshot)} data-testid="terminal-badge">
{humanizeProtocolLabel(
snapshot.integrity === "gap_detected"
? "Gap detected"
: (snapshot.terminal?.runTerminalState ?? snapshot.runPhase),
)}
</Badge>
</div>
<dl className="snapshot-grid">
<div>
<dt>Run</dt>
<dd>{snapshot.identity.runId}</dd>
</div>
<div>
<dt>Session</dt>
<dd>{snapshot.identity.normalizedSessionId}</dd>
</div>
<div>
<dt>Turn</dt>
<dd>{snapshot.terminal?.turnTerminalState ?? snapshot.turnState}</dd>
</div>
<div>
<dt>Events</dt>
<dd>{snapshot.timeline.length}</dd>
</div>
</dl>
{snapshot.proposedResult ? (
<div className="result-summary" data-testid="result-summary">
<span>
Reported {humanizeProtocolLabel(snapshot.proposedResult.reportedWorkDisposition)}
</span>
<p>{snapshot.proposedResult.summary}</p>
</div>
) : null}
{snapshot.gaps.length > 0 ? (
<div className="diagnostic" role="status">
Missing source sequence {snapshot.gaps.flatMap((gap) => gap.missing).join(", ")}.
Replay remains inspectable but is not complete.
</div>
) : null}
<div className="timeline-heading">
<h3>Ordered timeline</h3>
<span>{snapshot.duplicateEventIds.length} duplicate events ignored</span>
</div>
<ol className="timeline" data-testid="timeline">
{snapshot.timeline.map((event) => (
<li key={event.sourceEventId}>
<span className="sequence" aria-hidden="true">
{event.sourceSeq}
</span>
<div>
<div className="timeline-title">
<code>{event.eventType}</code>
<time dateTime={event.emittedAt}>{event.emittedAt.slice(11, 23)}</time>
</div>
<p>{event.summary}</p>
</div>
</li>
))}
</ol>
<details className="snapshot-json">
<summary>Inspect snapshot JSON</summary>
<pre>{JSON.stringify(snapshot, null, 2)}</pre>
</details>
</>
);
}
function pendingRuntimeRequest(events: readonly PrpEvent[]): PrpRequest | null {
const resolved = new Set(
events
.filter((event) => event.eventType === "runtime_request.resolved")
.map((event) => (event.payload as Record<string, unknown>).requestId)
.filter((value): value is string => typeof value === "string"),
);
for (const event of events.toReversed()) {
if (event.eventType !== "runtime_request.created") {
continue;
}
const request = (event.payload as Record<string, unknown>).request;
if (
typeof request === "object" &&
request !== null &&
"requestId" in request &&
typeof request.requestId === "string" &&
!resolved.has(request.requestId)
) {
return request as PrpRequest;
}
}
return null;
}
function LiveRunner() {
const [scenario, setScenario] = useState<Phase2Scenario>("happy-path");
const [status, setStatus] = useState<LiveStatus>("idle");
const [runId, setRunId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<SessionSnapshot | null>(null);
const [events, setEvents] = useState<PrpEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [input, setInput] = useState("phase-02-live-trace");
const [trace, setTrace] = useState<Phase2RunTrace | null>(null);
const [parity, setParity] = useState<boolean | null>(null);
const snapshotRef = useRef<SessionSnapshot | null>(null);
const eventsRef = useRef<PrpEvent[]>([]);
const request = useMemo(() => pendingRuntimeRequest(events), [events]);
async function startRun() {
setStatus("starting");
setError(null);
setDiagnostic(null);
setTrace(null);
setParity(null);
setRunId(null);
setEvents([]);
eventsRef.current = [];
setSnapshot(null);
snapshotRef.current = null;
try {
const startResponse = await fetch("/api/phase2/runs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scenario }),
});
const started = (await startResponse.json()) as {
id?: string;
metadata?: Phase2RunMetadata;
error?: string;
};
if (!startResponse.ok || started.id === undefined || started.metadata === undefined) {
throw new Error(started.error ?? "The local runner did not start.");
}
setRunId(started.id);
const initial = createPhase2LiveSnapshot(started.metadata);
setSnapshot(initial);
snapshotRef.current = initial;
setStatus("running");
const streamResponse = await fetch(`/api/phase2/runs/${started.id}/events`);
if (!streamResponse.ok || streamResponse.body === null) {
throw new Error("The live event stream did not open.");
}
const reader = streamResponse.body.getReader();
const decoder = new TextDecoder();
let buffered = "";
while (true) {
const part = await reader.read();
buffered += decoder.decode(part.value, { stream: !part.done });
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
for (const line of lines) {
if (line.trim().length > 0) {
handleRecord(JSON.parse(line) as Phase2StreamRecord, started.metadata);
}
}
if (part.done) {
if (buffered.trim().length > 0) {
handleRecord(JSON.parse(buffered) as Phase2StreamRecord, started.metadata);
}
break;
}
}
} catch (cause) {
setStatus("error");
setError(cause instanceof Error ? cause.message : String(cause));
}
}
function handleRecord(record: Phase2StreamRecord, metadata: Phase2RunMetadata) {
if (record.kind === "event") {
const current = snapshotRef.current;
if (current === null) {
return;
}
const applied = applyPhase2LiveEvent(current, record.event);
if (!applied.ok) {
setStatus("error");
setError(applied.issues.join("; "));
return;
}
snapshotRef.current = applied.snapshot;
setSnapshot(applied.snapshot);
eventsRef.current = [...eventsRef.current, applied.event];
setEvents(eventsRef.current);
return;
}
if (record.kind === "diagnostic") {
setDiagnostic(record.message);
return;
}
if (record.kind === "error") {
setStatus("error");
setError(record.message);
return;
}
setTrace(record.trace);
const replay = replayPhase2Events(metadata, record.trace.events);
setParity(
snapshotRef.current === null
? false
: phase2SnapshotsMatch(snapshotRef.current, replay),
);
setStatus("terminal");
}
async function postAction(action: "interrupt" | "resolve", body?: unknown) {
if (runId === null) {
return;
}
const response = await fetch(`/api/phase2/runs/${runId}/${action}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!response.ok) {
const failure = (await response.json()) as { error?: string };
setError(failure.error ?? `The ${action} command failed.`);
setStatus("error");
}
}
return (
<div className="workspace-grid live-workspace">
<Card aria-labelledby="live-controls-title">
<CardHeader>
<CardTitle id="live-controls-title">Local runner controls</CardTitle>
<CardDescription>
Start one Rust runner and one scripted fake harness. No network or model is used.
</CardDescription>
</CardHeader>
<CardContent className="editor-content">
<label htmlFor="live-scenario">Scenario</label>
<select
id="live-scenario"
value={scenario}
disabled={status === "starting" || status === "running"}
onChange={(event) => setScenario(event.target.value as Phase2Scenario)}
>
{liveScenarios.map((candidate) => (
<option key={candidate.key} value={candidate.key}>
{candidate.label}
</option>
))}
</select>
<div className="scenario-actions">
<Button
type="button"
disabled={status === "starting" || status === "running"}
onClick={() => void startRun()}
>
{status === "starting" ? "Starting…" : "Start local run"}
</Button>
<Button
type="button"
disabled={status !== "running" || snapshot?.turnState !== "running"}
onClick={() => void postAction("interrupt")}
>
Interrupt turn
</Button>
</div>
<div className="live-status" aria-live="polite">
<span>Runner status</span>
<Badge
tone={liveStatusTone(status, snapshot)}
data-testid="live-status"
>
{humanizeProtocolLabel(status)}
</Badge>
</div>
{request?.requestKind === "runtime" ? (
<div className="request-card" data-testid="runtime-request">
<div>
<p className="eyebrow">{request.type} request</p>
<p>{request.prompt}</p>
</div>
{request.type === "permission" ? (
<Button
type="button"
onClick={() =>
void postAction("resolve", {
requestId: request.requestId,
response: { decision: "allow" },
})
}
>
Allow
</Button>
) : (
<>
<label htmlFor="runtime-input">Response</label>
<input
id="runtime-input"
value={input}
onChange={(event) => setInput(event.target.value)}
/>
<Button
type="button"
disabled={input.trim().length === 0}
onClick={() =>
void postAction("resolve", {
requestId: request.requestId,
response: { text: input },
})
}
>
Send input
</Button>
</>
)}
</div>
) : null}
{trace?.harnessProcessExit ? (
<dl className="process-facts" data-testid="process-facts">
<div>
<dt>Harness process exit</dt>
<dd>{trace.harnessProcessExit.exitCode ?? "signal"}</dd>
</div>
<div>
<dt>Semantic result</dt>
<dd>{trace.result?.reportedWorkDisposition ?? "missing"}</dd>
</div>
</dl>
) : null}
{parity !== null ? (
<div className="parity-line" data-testid="parity-result">
<span>Live and replay reducer output</span>
<Badge tone={parity ? "success" : "danger"}>
{parity ? "Match" : "Mismatch"}
</Badge>
</div>
) : null}
{diagnostic ? <p className="diagnostic">{diagnostic}</p> : null}
{error ? <p className="validation-errors" role="alert">{error}</p> : null}
</CardContent>
</Card>
<section className="replay-panel" aria-live="polite">
{snapshot ? (
<SnapshotSummary snapshot={snapshot} label="Validated live snapshot" />
) : (
<div className="empty-live-state">
<p className="eyebrow">Validated live snapshot</p>
<h2>Start a local run</h2>
<p>Each incoming event will pass the Phase 1 validator before the reducer applies it.</p>
</div>
)}
</section>
</div>
);
}
function StaticReplay() {
const defaultFixture = browserFixtures[0];
if (defaultFixture === undefined) {
throw new Error("No Phase 1 fixtures were bundled");
}
const [selectedFixture, setSelectedFixture] = useState(defaultFixture.key);
const [source, setSource] = useState(defaultFixture.source);
const [replay, setReplay] = useState(() => replayPhase1FixtureText(defaultFixture.source));
const selected = useMemo(
() => browserFixtures.find((fixture) => fixture.key === selectedFixture),
[selectedFixture],
);
function chooseFixture(key: string) {
const fixture = browserFixtures.find((candidate) => candidate.key === key);
if (fixture === undefined) {
return;
}
setSelectedFixture(key);
setSource(fixture.source);
setReplay(replayPhase1FixtureText(fixture.source));
}
return (
<div className="workspace-grid">
<Card aria-labelledby="fixture-editor-title">
<CardHeader>
<CardTitle id="fixture-editor-title">Fixture input</CardTitle>
<CardDescription>
Choose a conformance case or edit the JSON, then validate and replay it.
</CardDescription>
</CardHeader>
<CardContent className="editor-content">
<label htmlFor="fixture-select">Fixture</label>
<select
id="fixture-select"
value={selectedFixture}
onChange={(event) => chooseFixture(event.target.value)}
>
{browserFixtures.map((fixture) => (
<option key={fixture.key} value={fixture.key}>
{fixture.label}
</option>
))}
</select>
<label htmlFor="fixture-source">PRP JSON</label>
<Textarea
id="fixture-source"
spellCheck={false}
value={source}
onChange={(event) => setSource(event.target.value)}
/>
<div className="editor-actions">
<span>{selected?.label ?? "Edited fixture"}</span>
<Button type="button" onClick={() => setReplay(replayPhase1FixtureText(source))}>
Validate &amp; replay
</Button>
</div>
</CardContent>
</Card>
<section className="replay-panel" aria-live="polite">
{replay.ok ? (
<SnapshotSummary snapshot={replay.snapshot} label="Static replay snapshot" />
) : (
<div className="validation-errors" role="alert">
<Badge tone="danger">Validation failed</Badge>
<h2>Fixture cannot be replayed</h2>
<ul>
{replay.issues.map((issue, index) => (
<li key={`${issue.path}-${index}`}>
<code>{issue.path}</code> — {issue.message}
</li>
))}
</ul>
</div>
)}
</section>
</div>
);
}
export function App() {
// Phase 1-3 surfaces keep their landing mode: their screenshots are frozen
// QA evidence and their specs open on the Phase 2 runner.
const [mode, setMode] = useState<BrowserMode>("live");
const header = modeCopy[mode];
return (
<main className="app-shell">
<header className="page-header">
<div>
<p className="eyebrow">{header.eyebrow}</p>
<h1>{header.title}</h1>
<p>{header.description}</p>
</div>
<Badge tone="neutral">Standalone · no Paperclip core</Badge>
</header>
<nav className="mode-switch" aria-label="Runner devtool mode">
<Button
type="button"
aria-pressed={mode === "console"}
onClick={() => setMode("console")}
>
Live console
</Button>
<Button
type="button"
aria-pressed={mode === "recovery"}
onClick={() => setMode("recovery")}
>
Recovery
</Button>
<Button
type="button"
aria-pressed={mode === "live"}
onClick={() => setMode("live")}
>
Live runner
</Button>
<Button
type="button"
aria-pressed={mode === "replay"}
onClick={() => setMode("replay")}
>
Static replay
</Button>
</nav>
{mode === "console" ? (
<LiveConsole />
) : mode === "recovery" ? (
<RecoveryDiagnostics />
) : mode === "live" ? (
<LiveRunner />
) : (
<StaticReplay />
)}
</main>
);
}
@@ -0,0 +1,19 @@
import * as React from "react";
/** Source-adapted shadcn Badge with semantic state supplied through data-tone. */
export function Badge({
className = "",
tone = "neutral",
...props
}: React.ComponentProps<"span"> & {
tone?: "neutral" | "accent" | "success" | "warning" | "danger";
}) {
return (
<span
data-slot="badge"
data-tone={tone}
className={`ui-badge ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,40 @@
import * as React from "react";
/**
* Source-adapted shadcn Alert. Failures stay in the transcript record, so this
* is only used for standing conditions (reconnect, pending request, replay).
*/
export function Banner({
className = "",
tone = "neutral",
assertive = false,
...props
}: React.ComponentProps<"div"> & {
tone?: "neutral" | "accent" | "warning" | "danger";
assertive?: boolean;
}) {
return (
<div
data-slot="banner"
data-tone={tone}
role={assertive ? "alert" : "status"}
aria-live={assertive ? "assertive" : "polite"}
className={`ui-banner ${className}`.trim()}
{...props}
/>
);
}
export function BannerText({ className = "", ...props }: React.ComponentProps<"p">) {
return <p data-slot="banner-text" className={`ui-banner-text ${className}`.trim()} {...props} />;
}
export function BannerActions({ className = "", ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="banner-actions"
className={`ui-banner-actions ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,15 @@
import * as React from "react";
/** Source-adapted shadcn Button for the standalone, non-Tailwind devtool. */
export function Button({
className = "",
...props
}: React.ComponentProps<"button">) {
return (
<button
data-slot="button"
className={`ui-button ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,26 @@
import * as React from "react";
function slotClass(slot: string, className: string | undefined) {
return `${slot} ${className ?? ""}`.trim();
}
/** Source-adapted shadcn Card family for the standalone devtool. */
export function Card({ className, ...props }: React.ComponentProps<"section">) {
return <section data-slot="card" className={slotClass("ui-card", className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-header" className={slotClass("ui-card-header", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.ComponentProps<"h2">) {
return <h2 data-slot="card-title" className={slotClass("ui-card-title", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p data-slot="card-description" className={slotClass("ui-card-description", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={slotClass("ui-card-content", className)} {...props} />;
}
@@ -0,0 +1,133 @@
import * as React from "react";
import type { ComposerState } from "../../live/transcript-model";
import { Button } from "./button";
const MAX_ROWS = 8;
const PRIMARY_LABEL: Record<ComposerState, string> = {
idle: "Send",
submitting: "Sending…",
"active-turn": "Steer",
interrupting: "Stopping…",
disconnected: "Send",
terminal: "Send",
};
const HINT: Record<ComposerState, string> = {
idle: "Enter sends. Shift and Enter add a line.",
submitting: "Waiting for the turn to be accepted.",
"active-turn": "Enter steers the running turn. Escape moves focus to Stop.",
interrupting: "Stopping the turn. Waiting for the terminal turn event.",
disconnected: "The composer is disabled until the connection is restored.",
terminal: "This session is closed. Replay it or reset the demo state.",
};
/**
* Source-adapted AI Elements `PromptInput`. The textarea plus action-row
* anatomy and submit-on-Enter are kept; the Send/Steer/Stop tri-state comes
* from the runner protocol, and there are no attachments or model pickers.
*/
export function Composer({
state,
value,
onValueChange,
onSubmit,
onStop,
disabledReason = null,
label = "Message",
}: {
state: ComposerState;
value: string;
onValueChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
disabledReason?: string | null;
label?: string;
}) {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const stopRef = React.useRef<HTMLButtonElement>(null);
const inputId = React.useId();
const hintId = React.useId();
const reasonId = React.useId();
const locked =
disabledReason !== null ||
state === "submitting" ||
state === "interrupting" ||
state === "disconnected" ||
state === "terminal";
const canStop = state === "active-turn" || state === "submitting";
React.useEffect(() => {
const textarea = textareaRef.current;
if (textarea === null) return;
textarea.rows = 1;
const rows = Math.min(value.split("\n").length, MAX_ROWS);
textarea.rows = Math.max(rows, 2);
}, [value]);
function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (!locked && value.trim().length > 0) onSubmit();
return;
}
// Escape never interrupts directly; it only moves focus to Stop.
if (event.key === "Escape" && canStop) {
event.preventDefault();
stopRef.current?.focus();
}
}
return (
<div data-slot="composer" data-state={state} className="ui-composer">
<label className="ui-visually-hidden" htmlFor={inputId}>
{label}
</label>
<textarea
ref={textareaRef}
id={inputId}
className="ui-composer-input"
data-testid="composer-input"
rows={2}
value={value}
disabled={locked}
aria-describedby={disabledReason === null ? hintId : `${hintId} ${reasonId}`}
placeholder={state === "active-turn" ? "Steer the running turn…" : "Send a message…"}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={onKeyDown}
/>
<div className="ui-composer-actions">
<p id={hintId} className="ui-composer-hint">
{HINT[state]}
</p>
{disabledReason === null ? null : (
<p id={reasonId} className="ui-composer-reason" data-testid="composer-reason">
{disabledReason}
</p>
)}
<div className="ui-composer-buttons">
<Button
ref={stopRef}
type="button"
className="ui-button--danger"
data-testid="composer-stop"
disabled={!canStop}
onClick={onStop}
>
Stop
</Button>
<Button
type="button"
data-testid="composer-send"
disabled={locked || value.trim().length === 0}
onClick={onSubmit}
>
{PRIMARY_LABEL[state]}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
import * as React from "react";
/**
* Source-adapted AI Elements `Conversation`. The stick-to-bottom semantics and
* the "jump to latest" affordance are kept; the `use-stick-to-bottom`
* dependency is replaced by a scroll listener, and the unseen count comes from
* the reducer timeline length rather than a message store.
*/
export function Conversation({
itemCount,
label,
children,
}: {
itemCount: number;
label: string;
children: React.ReactNode;
}) {
const viewportRef = React.useRef<HTMLDivElement>(null);
const [following, setFollowing] = React.useState(true);
const [seenCount, setSeenCount] = React.useState(itemCount);
const scrollToBottom = React.useCallback((smooth: boolean) => {
const viewport = viewportRef.current;
if (viewport === null) return;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: smooth && !reduceMotion ? "smooth" : "auto",
});
}, []);
React.useEffect(() => {
if (!following) return;
scrollToBottom(true);
setSeenCount(itemCount);
}, [itemCount, following, scrollToBottom]);
function onScroll() {
const viewport = viewportRef.current;
if (viewport === null) return;
const atBottom =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 24;
setFollowing(atBottom);
if (atBottom) setSeenCount(itemCount);
}
function reengage() {
setFollowing(true);
setSeenCount(itemCount);
scrollToBottom(true);
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "End") {
event.preventDefault();
reengage();
} else if (event.key === "Home") {
event.preventDefault();
setFollowing(false);
viewportRef.current?.scrollTo({ top: 0 });
}
}
const unseen = Math.max(itemCount - seenCount, 0);
return (
<div data-slot="conversation" className="ui-conversation">
<div
ref={viewportRef}
role="log"
aria-label={label}
aria-live="polite"
aria-relevant="additions"
tabIndex={0}
className="ui-conversation-viewport"
data-following={following}
data-testid="live-transcript"
onScroll={onScroll}
onKeyDown={onKeyDown}
>
{children}
</div>
{following ? null : (
<button
type="button"
className="ui-jump-to-latest"
data-testid="jump-to-latest"
onClick={reengage}
>
Jump to latest{unseen > 0 ? ` (${unseen} new)` : ""}
</button>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* Source-adapted shadcn Dialog rebuilt on the native `<dialog>` element.
* `showModal()` supplies the focus trap, the backdrop, and Escape-to-close,
* so no radix runtime and no focus-trap dependency are needed.
*/
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children?: React.ReactNode;
footer: React.ReactNode;
}) {
const ref = React.useRef<HTMLDialogElement>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const element = ref.current;
if (element === null) return;
if (open && !element.open) element.showModal();
if (!open && element.open) element.close();
}, [open]);
return (
<dialog
ref={ref}
data-slot="dialog"
className="ui-dialog"
aria-labelledby={titleId}
{...(description === undefined ? {} : { "aria-describedby": descriptionId })}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClose={onClose}
>
<form method="dialog" className="ui-dialog-body">
<h2 id={titleId} className="ui-dialog-title">
{title}
</h2>
{description === undefined ? null : (
<p id={descriptionId} className="ui-dialog-description">
{description}
</p>
)}
{children}
<div className="ui-dialog-footer">{footer}</div>
</form>
</dialog>
);
}
@@ -0,0 +1,138 @@
import * as React from "react";
export interface MenuItemDefinition {
id: string;
label: string;
enabled: boolean;
/** Exact upstream diagnostic; exposed through aria-describedby, not hover. */
reason: string | null;
}
/**
* Source-adapted shadcn DropdownMenu narrowed to the menu-button pattern the
* goal surface needs. Five fixed verbs, no search, no radix portal.
*/
export function Menu({
label,
items,
onSelect,
disabled = false,
disabledReason = null,
triggerId,
}: {
label: string;
items: readonly MenuItemDefinition[];
onSelect: (id: string) => void;
disabled?: boolean;
disabledReason?: string | null;
triggerId: string;
}) {
const [open, setOpen] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const descriptionId = `${triggerId}-reason`;
React.useEffect(() => {
if (!open) return undefined;
function onPointerDown(event: MouseEvent) {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
function focusItem(index: number) {
const buttons = containerRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]',
);
if (buttons === undefined || buttons.length === 0) return;
buttons[(index + buttons.length) % buttons.length]?.focus();
}
function onTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (["ArrowDown", "Enter", " "].includes(event.key)) {
event.preventDefault();
setOpen(true);
window.setTimeout(() => focusItem(0), 0);
}
}
function onMenuKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const buttons = [
...(containerRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? []),
];
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
focusItem(index + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
focusItem(index - 1);
}
}
return (
<div ref={containerRef} data-slot="menu" className="ui-menu">
<button
ref={triggerRef}
id={triggerId}
type="button"
className="ui-button ui-button--quiet"
aria-haspopup="menu"
aria-expanded={open}
aria-disabled={disabled}
{...(disabled && disabledReason !== null ? { "aria-describedby": descriptionId } : {})}
onClick={() => {
if (disabled) return;
setOpen((current) => !current);
}}
onKeyDown={(event) => {
if (disabled) return;
onTriggerKeyDown(event);
}}
>
{label}
<span aria-hidden="true"> ▾</span>
</button>
{disabled && disabledReason !== null ? (
<p id={descriptionId} className="ui-menu-reason">
{disabledReason}
</p>
) : null}
{open && !disabled ? (
<div role="menu" aria-labelledby={triggerId} className="ui-menu-list" onKeyDown={onMenuKeyDown}>
{items.map((item) => {
const itemReasonId = `${triggerId}-${item.id}-reason`;
return (
<React.Fragment key={item.id}>
<button
type="button"
role="menuitem"
className="ui-menu-item"
aria-disabled={!item.enabled}
{...(item.reason === null ? {} : { "aria-describedby": itemReasonId })}
onClick={() => {
if (!item.enabled) return;
setOpen(false);
onSelect(item.id);
}}
>
{item.label}
</button>
{item.reason === null ? null : (
<p id={itemReasonId} className="ui-menu-reason">
{item.reason}
</p>
)}
</React.Fragment>
);
})}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,60 @@
import * as React from "react";
import type { TranscriptRole } from "../../live/transcript-model";
const ROLE_LABELS: Record<TranscriptRole, string> = {
user: "You",
assistant: "Assistant",
reasoning: "Reasoning",
tool: "Tool",
steering: "Steering acknowledged",
interrupt: "Interrupt acknowledged",
goal: "Goal",
lineage: "Child thread",
system: "System",
};
/**
* Source-adapted AI Elements `Message`. The role-based alignment and the
* compact avatar-less variant are kept; the props are reducer item snapshots
* rather than AI SDK message parts.
*/
export function Message({
role,
children,
interrupted = false,
failed = false,
streaming = false,
}: {
role: TranscriptRole;
children: React.ReactNode;
interrupted?: boolean;
failed?: boolean;
streaming?: boolean;
}) {
return (
<article
data-slot="message"
data-role={role}
data-state={failed ? "failed" : interrupted ? "interrupted" : streaming ? "streaming" : "settled"}
className="ui-message"
>
<p className="ui-message-role">{ROLE_LABELS[role]}</p>
<div className="ui-message-body">{children}</div>
{interrupted ? (
<p className="ui-message-divider" data-testid="interrupted-divider">
Interrupted — the text above is everything that arrived.
</p>
) : null}
</article>
);
}
export function MessageText({ text, streaming }: { text: string; streaming: boolean }) {
return (
<p className="ui-message-text">
{text}
{streaming ? <span className="ui-stream-cursor" aria-hidden="true" /> : null}
</p>
);
}
@@ -0,0 +1,33 @@
import * as React from "react";
/**
* Source-adapted AI Elements `Reasoning`. The collapsible block that opens
* while streaming is kept; it is rebuilt on native `<details>` and renders
* exact streamed text with no markdown pipeline.
*/
export function ReasoningItem({
text,
streaming,
interrupted,
}: {
text: string;
streaming: boolean;
interrupted: boolean;
}) {
const summary = text.split("\n")[0]?.slice(0, 96) ?? "Reasoning";
return (
<details
data-slot="reasoning"
className="ui-disclosure"
open={streaming}
data-testid="reasoning-item"
>
<summary className="ui-disclosure-summary">
<span>{streaming ? "Reasoning…" : "Reasoning"}</span>
<span className="ui-disclosure-preview">{summary}</span>
</summary>
<pre className="ui-payload">{text}</pre>
{interrupted ? <p className="ui-message-divider">Interrupted while reasoning.</p> : null}
</details>
);
}
@@ -0,0 +1,94 @@
import * as React from "react";
export interface TabDefinition {
id: string;
label: string;
}
/**
* Source-adapted shadcn Tabs rebuilt on the WAI-ARIA tabs pattern with a
* roving tabindex. No radix runtime: arrow keys move, Home/End jump, Tab
* enters the panel.
*/
export function Tabs({
tabs,
value,
onValueChange,
label,
className = "",
children,
}: {
tabs: readonly TabDefinition[];
value: string;
onValueChange: (value: string) => void;
label: string;
className?: string;
children: React.ReactNode;
}) {
const listRef = React.useRef<HTMLDivElement>(null);
function moveFocus(nextIndex: number) {
const next = tabs[(nextIndex + tabs.length) % tabs.length];
if (next === undefined) return;
onValueChange(next.id);
const button = listRef.current?.querySelector<HTMLButtonElement>(
`[data-tab-id="${next.id}"]`,
);
button?.focus();
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const index = tabs.findIndex((tab) => tab.id === value);
if (event.key === "ArrowRight") {
event.preventDefault();
moveFocus(index + 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
moveFocus(index - 1);
} else if (event.key === "Home") {
event.preventDefault();
moveFocus(0);
} else if (event.key === "End") {
event.preventDefault();
moveFocus(tabs.length - 1);
}
}
return (
<div data-slot="tabs" className={`ui-tabs ${className}`.trim()}>
<div
ref={listRef}
role="tablist"
aria-label={label}
className="ui-tablist"
onKeyDown={onKeyDown}
>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
data-tab-id={tab.id}
id={`tab-${tab.id}`}
aria-selected={tab.id === value}
aria-controls={`tabpanel-${tab.id}`}
tabIndex={tab.id === value ? 0 : -1}
className="ui-tab"
onClick={() => onValueChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div
role="tabpanel"
id={`tabpanel-${value}`}
aria-labelledby={`tab-${value}`}
tabIndex={0}
className="ui-tabpanel"
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import * as React from "react";
/** Source-adapted shadcn Textarea for editable fixture JSON. */
export function Textarea({
className = "",
...props
}: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={`ui-textarea ${className}`.trim()}
{...props}
/>
);
}
@@ -0,0 +1,80 @@
import * as React from "react";
import { Badge } from "./badge";
export type ToolStatus = "running" | "completed" | "failed" | "interrupted";
const STATUS_TONE: Record<ToolStatus, "neutral" | "accent" | "success" | "danger" | "warning"> = {
running: "accent",
completed: "success",
failed: "danger",
interrupted: "warning",
};
const STATUS_LABEL: Record<ToolStatus, string> = {
running: "Running",
completed: "Completed",
failed: "Failed",
interrupted: "Interrupted",
};
/**
* Source-adapted AI Elements `Tool`. The header/input/output anatomy is kept;
* the status enum comes from canonical item events instead of an AI SDK tool
* part, and payloads stay in `<pre>` so nothing is prettified away.
*/
export function ToolItem({
name,
status,
input,
output,
failure,
}: {
name: string;
status: ToolStatus;
input: string | null;
output: string | null;
failure: string | null;
}) {
return (
<details
data-slot="tool"
className="ui-disclosure"
open={status === "running" || status === "failed"}
data-testid="tool-item"
data-status={status}
>
<summary className="ui-disclosure-summary">
<span>
Tool <code>{name}</code>
</span>
<Badge tone={STATUS_TONE[status]}>{STATUS_LABEL[status]}</Badge>
</summary>
{input === null ? null : (
<>
<p className="ui-disclosure-label">Input</p>
<pre className="ui-payload">{input}</pre>
</>
)}
{output === null ? null : (
<>
<p className="ui-disclosure-label">Output</p>
<pre className="ui-payload">{output}</pre>
</>
)}
{failure === null ? null : (
<>
<p className="ui-disclosure-label">Diagnostic</p>
<pre className="ui-payload ui-payload--danger" data-testid="tool-failure">
{failure}
</pre>
</>
)}
{status === "interrupted" ? (
<p className="ui-message-divider" data-testid="interrupted-divider">
Interrupted — the last known status is shown and no result was fabricated.
</p>
) : null}
</details>
);
}
@@ -0,0 +1,25 @@
import * as React from "react";
/**
* Source-adapted shadcn Tooltip. The content is always mirrored into a
* described-by element, so a tooltip is never the only channel for a
* diagnostic (interaction map A5).
*/
export function Tooltip({
content,
id,
children,
}: {
content: string;
id: string;
children: React.ReactElement<{ "aria-describedby"?: string }>;
}) {
return (
<span data-slot="tooltip" className="ui-tooltip">
{React.cloneElement(children, { "aria-describedby": id })}
<span role="tooltip" id={id} className="ui-tooltip-content">
{content}
</span>
</span>
);
}
@@ -0,0 +1,229 @@
import * as React from "react";
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
import { Badge } from "../components/ui/badge";
import { Tabs } from "../components/ui/tabs";
import type { LiveConnectionStatus, LiveSessionState } from "./protocol";
import { capabilityRows } from "./transcript-model";
const TABS = [
{ id: "events", label: "Events" },
{ id: "requests", label: "Requests" },
{ id: "capabilities", label: "Capabilities" },
{ id: "session", label: "Session" },
];
function CopyButton({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = React.useState(false);
return (
<button
type="button"
className="ui-copy"
aria-label={label}
onClick={() => {
void navigator.clipboard?.writeText(value).then(
() => setCopied(true),
() => setCopied(false),
);
}}
>
{copied ? "Copied" : "Copy"}
</button>
);
}
/**
* The debugging surface. Redaction is the server's job: the inspector renders
* redaction markers verbatim and never tries to reconstruct a redacted value.
*/
export function Inspector({
events,
snapshot,
state,
connection,
reconnectAttempt,
replayParity,
highlightItemId,
onHighlightItem,
}: {
events: readonly PrpEvent[];
snapshot: SessionSnapshot | null;
state: LiveSessionState | null;
connection: LiveConnectionStatus;
reconnectAttempt: number;
replayParity: boolean | null;
highlightItemId: string | null;
onHighlightItem: (itemId: string | null) => void;
}) {
const [tab, setTab] = React.useState("events");
const [filter, setFilter] = React.useState("");
const [facet, setFacet] = React.useState<string | null>(null);
const facets = React.useMemo(
() => [...new Set(events.map((event) => event.eventType))].sort(),
[events],
);
const visible = React.useMemo(
() =>
events.filter((event) => {
if (facet !== null && event.eventType !== facet) return false;
if (filter.length === 0) return true;
return JSON.stringify(event).toLowerCase().includes(filter.toLowerCase());
}),
[events, facet, filter],
);
return (
<Tabs tabs={TABS} value={tab} onValueChange={setTab} label="Protocol inspector">
{tab === "events" ? (
<div className="ui-inspector-events">
<label className="ui-visually-hidden" htmlFor="inspector-filter">
Filter events
</label>
<input
id="inspector-filter"
className="ui-inspector-filter"
data-testid="inspector-filter"
placeholder="Filter events"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="ui-facets">
<button
type="button"
className="ui-facet"
aria-pressed={facet === null}
onClick={() => setFacet(null)}
>
All
</button>
{facets.map((candidate) => (
<button
key={candidate}
type="button"
className="ui-facet"
aria-pressed={facet === candidate}
onClick={() => setFacet(candidate)}
>
{candidate}
</button>
))}
</div>
<ol className="ui-inspector-list" data-testid="inspector-events">
{visible.map((event) => (
<li
key={event.sourceEventId}
data-highlighted={
highlightItemId !== null && event.itemId === highlightItemId
}
>
<details
onToggle={(toggle) =>
onHighlightItem(
(toggle.currentTarget as HTMLDetailsElement).open
? (event.itemId ?? null)
: null,
)
}
>
<summary className="ui-inspector-row">
<code className="ui-seq">{event.sourceSeq}</code>
<code>{event.eventType}</code>
<code className="ui-inspector-id">{event.itemId ?? event.turnId ?? "—"}</code>
</summary>
<pre className="ui-payload">{JSON.stringify(event, null, 2)}</pre>
<CopyButton
label={`Copy event ${event.sourceSeq} payload`}
value={JSON.stringify(event, null, 2)}
/>
</details>
</li>
))}
</ol>
</div>
) : null}
{tab === "requests" ? (
<dl className="ui-inspector-grid" data-testid="inspector-requests">
{(snapshot?.requests ?? []).map((request) => (
<div key={request.requestId}>
<dt>
<code>{request.requestId}</code>
</dt>
<dd>
{request.type} — {request.status}
</dd>
</div>
))}
{(snapshot?.requests.length ?? 0) === 0 ? <p>No runtime requests yet.</p> : null}
</dl>
) : null}
{tab === "capabilities" ? (
<ul className="ui-capability-list" data-testid="inspector-capabilities">
{capabilityRows(state?.capabilities ?? { resume: false, typedEvents: false, steering: false, interruption: false, structuredResult: false }).map(
(row) => (
<li key={row.name}>
<span>{row.name}</span>
<Badge tone={row.supported ? "success" : "warning"}>
{row.supported ? "supported" : "unsupported"}
</Badge>
{row.diagnostic === null ? null : (
<p className="ui-capability-diagnostic">{row.diagnostic}</p>
)}
</li>
),
)}
</ul>
) : null}
{tab === "session" ? (
<dl className="ui-inspector-grid" data-testid="inspector-session">
<div>
<dt>Run</dt>
<dd>{state?.runId ?? "—"}</dd>
</div>
<div>
<dt>Normalized session</dt>
<dd>{state?.normalizedSessionId ?? "—"}</dd>
</div>
<div>
<dt>Driver session</dt>
<dd>{state?.driverSession.driverSessionId ?? "—"}</dd>
</div>
<div>
<dt>Source cursors</dt>
<dd>{JSON.stringify(snapshot?.sourceCursors ?? {})}</dd>
</div>
<div>
<dt>Integrity</dt>
<dd>{snapshot?.integrity ?? "—"}</dd>
</div>
<div>
<dt>Gaps</dt>
<dd>{snapshot?.gaps.length ?? 0}</dd>
</div>
<div>
<dt>Connection</dt>
<dd>
{connection}
{connection === "reconnecting" ? ` (attempt ${reconnectAttempt})` : ""}
</dd>
</div>
<div>
<dt>Credentials in browser</dt>
<dd>{state?.credentialsExposed === true ? "yes" : "no"}</dd>
</div>
<div>
<dt>Live and replay reducer</dt>
<dd data-testid="replay-parity">
{replayParity === null ? "not compared yet" : replayParity ? "match" : "mismatch"}
</dd>
</div>
</dl>
) : null}
</Tabs>
);
}
@@ -0,0 +1,487 @@
import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Banner, BannerActions, BannerText } from "../components/ui/banner";
import { Button } from "../components/ui/button";
import { Composer } from "../components/ui/composer";
import { Conversation } from "../components/ui/conversation";
import { Dialog } from "../components/ui/dialog";
import { Message, MessageText } from "../components/ui/message";
import { ReasoningItem } from "../components/ui/reasoning-item";
import { Tabs } from "../components/ui/tabs";
import { ToolItem, type ToolStatus } from "../components/ui/tool-item";
import { Inspector } from "./Inspector";
import { RequestCard } from "./RequestCard";
import { ConnectionBlock, GoalBlock, LineageTree, ManifestList } from "./SessionPanel";
import { useLiveConsole } from "./use-live-console";
import type { TranscriptEntry } from "./transcript-model";
const MOBILE_SEGMENTS = [
{ id: "chat", label: "Chat" },
{ id: "session", label: "Session" },
{ id: "inspector", label: "Inspector" },
];
const TURN_TONE = {
completed: "success",
failed: "danger",
interrupted: "warning",
cancelled: "warning",
} as const;
const TURN_LABEL = {
completed: "Turn completed",
failed: "Turn failed",
interrupted: "Turn interrupted",
cancelled: "Cancelled before start",
} as const;
/**
* Renders exactly one layout. Hiding a second copy with CSS would duplicate
* the transcript log landmark and every control in the DOM.
*/
function useCompactLayout(): boolean {
const [compact, setCompact] = React.useState(
() => window.matchMedia?.("(max-width: 64rem)").matches === true,
);
React.useEffect(() => {
const query = window.matchMedia?.("(max-width: 64rem)");
if (query === undefined) return undefined;
const onChange = () => setCompact(query.matches);
onChange();
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return compact;
}
function toolStatus(entry: Extract<TranscriptEntry, { kind: "item" }>): ToolStatus {
if (entry.interrupted) return "interrupted";
if (entry.item.status === "failed") return "failed";
if (entry.item.status === "running") return "running";
return "completed";
}
export function LiveConsole() {
const console_ = useLiveConsole();
const [draft, setDraft] = React.useState("");
const [inspectorOpen, setInspectorOpen] = React.useState(false);
const [segment, setSegment] = React.useState("chat");
const [resetOpen, setResetOpen] = React.useState(false);
const [highlightItemId, setHighlightItemId] = React.useState<string | null>(null);
const [outcomes, setOutcomes] = React.useState<Record<string, string>>({});
const oldestPendingRef = React.useRef<HTMLLIElement>(null);
const compact = useCompactLayout();
React.useEffect(() => {
const stored = window.localStorage.getItem("paperclip-runner.phase4b.inspector");
if (stored === "open") setInspectorOpen(true);
}, []);
React.useEffect(() => {
window.localStorage.setItem(
"paperclip-runner.phase4b.inspector",
inspectorOpen ? "open" : "closed",
);
}, [inspectorOpen]);
const manifestId = console_.state?.manifest ?? console_.selectedManifestId;
const activeManifest = console_.manifests.find((entry) => entry.id === manifestId);
const pendingRequests = console_.transcript.filter(
(entry) => entry.kind === "request" && entry.status === "pending",
);
const viewingChild = console_.selectedThreadId !== null;
const childSteeringSupported = false;
React.useEffect(() => {
const snapshot = console_.snapshot;
if (snapshot === null || console_.state === null) return;
if (!["completed", "failed", "interrupted", "cancelled"].includes(snapshot.turnState)) return;
setOutcomes((current) => ({
...current,
[console_.state!.manifest ?? "unknown"]:
snapshot.turnState === "completed" ? "passed observations" : snapshot.turnState,
}));
}, [console_.snapshot, console_.state]);
function submit() {
const text = draft.trim();
if (text.length === 0) return;
setDraft("");
if (console_.composer === "active-turn") void console_.steer(text);
else void console_.send(text);
}
function runManifest(id: string) {
const manifest = console_.manifests.find((entry) => entry.id === id);
void console_.start({ manifestId: id, message: manifest?.prompt ?? "Run the demo." });
}
const composerReason = viewingChild && !childSteeringSupported
? "Direct steering of child threads is not supported by this app-server."
: null;
const transcriptBody = (
<Conversation itemCount={console_.transcript.length} label="Session transcript">
{console_.transcript.length === 0 ? (
<div className="ui-empty-transcript" data-testid="empty-transcript">
<p className="eyebrow">No session yet</p>
<h3>Pick a demo chat or start a blank session</h3>
<p>
The browser never holds a provider credential. Only the demo server starts a driver
and owns the working directory.
</p>
</div>
) : null}
<ol className="ui-transcript" role="list">
{console_.transcript.map((entry) => {
if (entry.kind === "user") {
return (
<li key={entry.key}>
<Message role="user">
<MessageText text={entry.text} streaming={false} />
</Message>
</li>
);
}
if (entry.kind === "turn") {
return (
<li key={entry.key} className="ui-turn-marker" data-testid={`turn-${entry.state}`}>
<Badge tone={TURN_TONE[entry.state]}>{TURN_LABEL[entry.state]}</Badge>
<span>{entry.detail}</span>
</li>
);
}
if (entry.kind === "diagnostic") {
return (
<li key={entry.key}>
<Message role="system" failed>
<MessageText text={entry.message} streaming={false} />
</Message>
</li>
);
}
if (entry.kind === "request") {
const isOldestPending =
entry.status === "pending" && pendingRequests[0]?.key === entry.key;
return (
<RequestCard
key={entry.key}
entry={entry}
{...(isOldestPending ? { focusRef: oldestPendingRef } : {})}
onResolve={(resolution) =>
void console_.resolve(entry.requestId, entry.turnId ?? "", resolution)
}
/>
);
}
if (entry.role === "reasoning") {
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<ReasoningItem
text={entry.item.text}
streaming={entry.streaming}
interrupted={entry.interrupted}
/>
</li>
);
}
if (entry.role === "tool") {
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<ToolItem
name={entry.toolName ?? entry.item.kind}
status={toolStatus(entry)}
input={entry.input}
output={entry.output}
failure={entry.failure}
/>
</li>
);
}
return (
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
<Message
role={entry.role}
streaming={entry.streaming}
interrupted={entry.interrupted}
failed={entry.item.status === "failed"}
>
<MessageText text={entry.item.text} streaming={entry.streaming} />
{entry.failure === null ? null : (
<pre className="ui-payload ui-payload--danger">{entry.failure}</pre>
)}
</Message>
</li>
);
})}
{console_.steeringChips.map((chip) => (
<li key={chip.id} className="ui-steering-chip" data-status={chip.status} data-testid="steering-chip">
<Badge
tone={
chip.status === "acknowledged"
? "success"
: chip.status === "pending"
? "accent"
: "danger"
}
>
{chip.status === "rejected"
? `rejected — ${chip.detail ?? "turn already ended"}`
: chip.status}
</Badge>
<span>{chip.text}</span>
{chip.status === "rejected" || chip.status === "failed" ? (
<Button
type="button"
className="ui-button--quiet"
data-testid="steer-as-new-message"
onClick={() => {
console_.dismissSteeringChip(chip.id);
void console_.send(chip.text);
}}
>
Send as new message
</Button>
) : null}
</li>
))}
</ol>
</Conversation>
);
const composerBlock = (
<div className="ui-composer-region">
{console_.connection === "disconnected" || console_.connection === "reconnecting" ? (
<Banner tone="warning" data-testid="reconnect-banner">
<BannerText>
{console_.connection === "reconnecting"
? `Connection lost — reconnecting (attempt ${console_.reconnectAttempt})…`
: "Connection lost — the session is still on the server."}
</BannerText>
<BannerActions>
<Button type="button" data-testid="retry-now" onClick={console_.retryNow}>
Retry now
</Button>
</BannerActions>
</Banner>
) : null}
{pendingRequests.length > 0 ? (
<Banner tone="accent" assertive data-testid="pending-request-banner">
<BannerText>{pendingRequests.length} request waiting — Review</BannerText>
<BannerActions>
<Button
type="button"
onClick={() => oldestPendingRef.current?.focus()}
data-testid="review-request"
>
Review
</Button>
</BannerActions>
</Banner>
) : null}
<Composer
state={console_.composer}
value={draft}
onValueChange={setDraft}
onSubmit={submit}
onStop={() => void console_.interrupt()}
disabledReason={composerReason}
/>
</div>
);
const sessionRail = (
<>
<ManifestList
manifests={console_.manifests}
selectedId={console_.selectedManifestId}
onSelect={console_.selectManifest}
onRun={runManifest}
lastOutcome={outcomes}
disabled={console_.busy}
/>
<ConnectionBlock
connection={console_.connection}
reconnectAttempt={console_.reconnectAttempt}
state={console_.state}
pendingRequestCount={pendingRequests.length}
onDrop={console_.dropConnection}
onRetry={console_.retryNow}
onScrollToOldestRequest={() => oldestPendingRef.current?.focus()}
/>
<GoalBlock state={console_.state} onGoal={(operation, body) => void console_.goal(operation, body)} />
<LineageTree
lineage={console_.state?.lineage ?? []}
selectedThreadId={console_.selectedThreadId}
onSelect={console_.selectThread}
/>
<section className="ui-panel">
<h2 className="ui-panel-title">Demo state</h2>
<Button type="button" className="ui-button--quiet" onClick={() => setResetOpen(true)}>
Reset demo state
</Button>
{activeManifest === undefined ? null : (
<ol className="ui-observation-list" data-testid="observation-checklist">
{activeManifest.expectedObservations.map((observation) => (
<li key={observation}>
<label>
<input type="checkbox" /> {observation}
</label>
</li>
))}
</ol>
)}
</section>
</>
);
const inspectorPanel = (
<Inspector
events={console_.events}
snapshot={console_.snapshot}
state={console_.state}
connection={console_.connection}
reconnectAttempt={console_.reconnectAttempt}
replayParity={console_.replayParity}
highlightItemId={highlightItemId}
onHighlightItem={setHighlightItemId}
/>
);
return (
<div className="live-console" data-inspector={inspectorOpen}>
<div className="live-console-header">
<div className="live-console-title">
{console_.replay.active ? (
<Badge tone="warning" data-testid="replay-badge">
REPLAY
</Badge>
) : null}
<h2>{activeManifest?.name ?? "Live console"}</h2>
{viewingChild ? (
<p className="ui-breadcrumb" data-testid="breadcrumb">
root ▸ {console_.selectedThreadId}
</p>
) : null}
</div>
<div className="live-console-header-actions">
<Button
type="button"
className="ui-button--quiet"
data-testid="toggle-inspector"
aria-pressed={inspectorOpen}
onClick={() => setInspectorOpen((current) => !current)}
>
Protocol inspector
</Button>
<Button
type="button"
className="ui-button--quiet"
data-testid="toggle-replay"
aria-pressed={console_.replay.active}
disabled={console_.events.length === 0}
onClick={() => (console_.replay.active ? console_.replay.exit() : console_.replay.enter())}
>
{console_.replay.active ? "Exit replay" : "Replay"}
</Button>
</div>
</div>
<p className="ui-visually-hidden" aria-live="polite" data-testid="live-announcement">
{console_.announcement}
</p>
{console_.error === null ? null : (
<Banner tone="danger" assertive data-testid="console-error">
<BannerText>{console_.error}</BannerText>
</Banner>
)}
{console_.replay.active ? (
<div className="ui-replay-stepper" data-testid="replay-stepper">
<Button type="button" onClick={console_.replay.togglePlay}>
{console_.replay.playing ? "Pause" : "Play"}
</Button>
<Button type="button" className="ui-button--quiet" onClick={() => console_.replay.step(-1)}>
Step back
</Button>
<Button type="button" className="ui-button--quiet" onClick={() => console_.replay.step(1)}>
Step forward
</Button>
<label htmlFor="replay-position">Position</label>
<input
id="replay-position"
type="range"
min={0}
max={console_.replay.length}
value={console_.replay.position}
data-testid="replay-position"
onChange={(event) => console_.replay.setPosition(Number(event.target.value))}
/>
<span>
{console_.replay.position} / {console_.replay.length}
</span>
</div>
) : null}
{compact ? (
<div className="live-console-mobile">
<Tabs
tabs={MOBILE_SEGMENTS}
value={segment}
onValueChange={setSegment}
label="Console sections"
>
{segment === "chat" ? (
<>
{transcriptBody}
{composerBlock}
</>
) : null}
{segment === "session" ? sessionRail : null}
{segment === "inspector" ? inspectorPanel : null}
</Tabs>
</div>
) : (
<div className="live-console-desktop">
<aside className="live-console-rail">{sessionRail}</aside>
<main className="live-console-center">
{transcriptBody}
{composerBlock}
</main>
{inspectorOpen ? (
<aside className="live-console-inspector" aria-label="Protocol inspector">
{inspectorPanel}
</aside>
) : null}
</div>
)}
<Dialog
open={resetOpen}
onClose={() => setResetOpen(false)}
title="Reset demo state"
description="Discards the current live session transcript. Recorded evidence files are not touched."
footer={
<>
<Button type="button" className="ui-button--quiet" onClick={() => setResetOpen(false)}>
Keep the session
</Button>
<Button
type="button"
className="ui-button--danger"
data-testid="reset-confirm"
onClick={() => {
setResetOpen(false);
setDraft("");
void console_.reset();
}}
>
Reset demo state
</Button>
</>
}
/>
</div>
);
}
@@ -0,0 +1,168 @@
import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { runtimeRequestSubmission, type TranscriptRequestEntry } from "./transcript-model";
const KIND_LABELS: Record<string, string> = {
command_approval: "Command",
file_approval: "File change",
permission_approval: "Permission",
user_input: "Input",
elicitation: "Input",
};
const ACTION_LABELS: Record<string, string> = {
accept: "Approve",
accept_for_session: "Approve for session",
decline: "Reject",
cancel: "Cancel",
submit: "Submit",
};
const STATUS_TONE = {
pending: "accent",
resolved: "success",
expired: "warning",
cancelled: "warning",
} as const;
function detailBlock(details: Record<string, unknown>): string | null {
const command = details.command;
if (typeof command === "string") return command;
const diff = details.diff;
if (typeof diff === "string") {
const path = typeof details.path === "string" ? `${details.path}\n` : "";
return `${path}${diff}`;
}
return null;
}
/**
* One card renders all five request kinds. Actions come from the upstream
* request contract, so the card can never invent an affordance the runner
* cannot honour, and the first click locks the row until the canonical
* resolved event arrives.
*/
export function RequestCard({
entry,
onResolve,
focusRef,
}: {
entry: TranscriptRequestEntry;
onResolve: (resolution: Record<string, unknown>) => void;
focusRef?: React.Ref<HTMLLIElement>;
}) {
const [answer, setAnswer] = React.useState("");
const [resolving, setResolving] = React.useState(false);
const answerId = React.useId();
const kindLabel = KIND_LABELS[entry.requestKind] ?? "Request";
const pending = entry.status === "pending";
const isInput = entry.requestKind === "user_input" || entry.requestKind === "elicitation";
const payload = detailBlock(entry.details);
React.useEffect(() => {
if (!pending) setResolving(false);
}, [pending]);
function submit(resolution: Record<string, unknown>) {
setResolving(true);
onResolve(resolution);
}
if (!pending) {
return (
<li
ref={focusRef}
className="ui-request ui-request--settled"
data-status={entry.status}
data-testid="request-card"
tabIndex={-1}
>
<details>
<summary className="ui-request-summary">
<span>
{kindLabel} · {entry.status === "resolved"
? `resolved — ${ACTION_LABELS[entry.resolvedAction ?? ""] ?? entry.resolvedAction ?? "unknown"}`
: entry.status === "expired"
? "expired before response"
: "cancelled"}
</span>
<Badge tone={STATUS_TONE[entry.status]}>{entry.status}</Badge>
</summary>
<p className="ui-request-prompt">{entry.prompt}</p>
<p className="ui-request-meta">
<code>{entry.requestId}</code>
{entry.resolvedAt === null ? null : <time dateTime={entry.resolvedAt}>{entry.resolvedAt}</time>}
</p>
{payload === null ? null : <pre className="ui-payload">{payload}</pre>}
</details>
</li>
);
}
return (
<li
ref={focusRef}
className="ui-request"
data-status="pending"
data-testid="request-card"
tabIndex={-1}
>
<div className="ui-request-header">
<span className="ui-request-kind">{kindLabel}</span>
<Badge tone="accent">pending</Badge>
</div>
<p className="ui-request-prompt">{entry.prompt}</p>
<p className="ui-request-meta">
<code>{entry.requestId}</code>
</p>
{payload === null ? null : <pre className="ui-payload">{payload}</pre>}
{isInput ? (
<div className="ui-request-input">
<label htmlFor={answerId}>Answer</label>
<input
id={answerId}
data-testid="request-answer"
value={answer}
disabled={resolving}
onChange={(event) => setAnswer(event.target.value)}
/>
</div>
) : null}
<div className="ui-request-actions">
{entry.actions.map((action) => (
<Button
key={action}
type="button"
className={
action === "decline"
? "ui-button--danger"
: action === "cancel"
? "ui-button--quiet"
: action === "accept" || action === "submit"
? ""
: "ui-button--secondary"
}
disabled={resolving || (action === "submit" && answer.trim().length === 0)}
data-testid={`request-action-${action}`}
onClick={() =>
submit(
action === "submit"
? runtimeRequestSubmission(entry.requestKind, answer)
: { action },
)
}
>
{ACTION_LABELS[action] ?? action}
</Button>
))}
</div>
{resolving ? (
<p className="ui-request-resolving" role="status">
Resolving — waiting for the canonical resolved event.
</p>
) : null}
</li>
);
}
@@ -0,0 +1,346 @@
import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Dialog } from "../components/ui/dialog";
import { Menu } from "../components/ui/menu";
import type {
GoalOperation,
LiveConnectionStatus,
LiveLineageEntry,
LiveManifestSummary,
LiveSessionState,
} from "./protocol";
import { GOAL_OPERATION_LABELS } from "./protocol";
import { goalAvailability } from "./transcript-model";
const CONNECTION_TONE = {
idle: "neutral",
connecting: "accent",
connected: "success",
reconnecting: "warning",
disconnected: "danger",
terminal: "neutral",
} as const;
const THREAD_STATE_LABEL: Record<string, string> = {
starting: "Starting",
running: "Running",
completed: "Completed",
failed: "Failed",
};
export function ManifestList({
manifests,
selectedId,
onSelect,
onRun,
lastOutcome,
disabled,
}: {
manifests: readonly LiveManifestSummary[];
selectedId: string;
onSelect: (id: string) => void;
onRun: (id: string) => void;
lastOutcome: Record<string, string>;
disabled: boolean;
}) {
return (
<section className="ui-panel" aria-labelledby="manifest-heading">
<h2 id="manifest-heading" className="ui-panel-title">
Demo chats
</h2>
<ul className="ui-manifest-list" data-testid="manifest-list">
{manifests.map((manifest) => (
<li key={manifest.id} data-selected={manifest.id === selectedId}>
<button
type="button"
className="ui-manifest-row"
aria-pressed={manifest.id === selectedId}
data-testid={`manifest-${manifest.id}`}
onClick={() => onSelect(manifest.id)}
>
<span className="ui-manifest-name">{manifest.name}</span>
<span className="ui-manifest-purpose">{manifest.purpose}</span>
<span className="ui-manifest-badges">
{manifest.scenarios.map((scenario) => (
<Badge key={scenario}>{scenario}</Badge>
))}
<Badge tone={lastOutcome[manifest.id] === undefined ? "neutral" : "success"}>
{lastOutcome[manifest.id] ?? "not run"}
</Badge>
</span>
</button>
{manifest.id === selectedId ? (
<>
<details className="ui-disclosure" data-testid="expected-observations">
<summary className="ui-disclosure-summary">Expected observations</summary>
<ol className="ui-observation-list">
{manifest.expectedObservations.map((observation) => (
<li key={observation}>{observation}</li>
))}
</ol>
</details>
<Button
type="button"
disabled={disabled}
data-testid="run-manifest"
onClick={() => onRun(manifest.id)}
>
Run this demo chat
</Button>
</>
) : null}
</li>
))}
</ul>
</section>
);
}
export function ConnectionBlock({
connection,
reconnectAttempt,
state,
pendingRequestCount,
onDrop,
onRetry,
onScrollToOldestRequest,
}: {
connection: LiveConnectionStatus;
reconnectAttempt: number;
state: LiveSessionState | null;
pendingRequestCount: number;
onDrop: () => void;
onRetry: () => void;
onScrollToOldestRequest: () => void;
}) {
return (
<section className="ui-panel" aria-labelledby="connection-heading">
<h2 id="connection-heading" className="ui-panel-title">
Session
</h2>
<div className="ui-status-row">
<span>Connection</span>
<Badge tone={CONNECTION_TONE[connection]} data-testid="connection-status">
{connection === "reconnecting" ? `reconnecting (attempt ${reconnectAttempt})` : connection}
</Badge>
</div>
<div className="ui-status-row">
<span>Pending requests</span>
<button
type="button"
className="ui-chip"
data-testid="pending-request-chip"
disabled={pendingRequestCount === 0}
onClick={onScrollToOldestRequest}
>
{pendingRequestCount}
</button>
</div>
<div className="ui-status-row">
<span>Credentials in browser</span>
<Badge tone={state?.credentialsExposed === true ? "danger" : "success"}>
{state?.credentialsExposed === true ? "exposed" : "none"}
</Badge>
</div>
<dl className="ui-identity-grid">
<div>
<dt>Run</dt>
<dd>{state?.runId ?? "—"}</dd>
</div>
<div>
<dt>Session</dt>
<dd>{state?.normalizedSessionId ?? "—"}</dd>
</div>
</dl>
<div className="ui-panel-actions">
<Button
type="button"
className="ui-button--quiet"
data-testid="drop-connection"
disabled={state === null || connection === "disconnected"}
onClick={onDrop}
>
Simulate connection loss
</Button>
<Button
type="button"
className="ui-button--quiet"
data-testid="resume-session"
disabled={state === null}
onClick={onRetry}
>
Resume this session
</Button>
</div>
</section>
);
}
export function GoalBlock({
state,
onGoal,
}: {
state: LiveSessionState | null;
onGoal: (operation: GoalOperation, body?: Record<string, unknown>) => void;
}) {
const [dialogOpen, setDialogOpen] = React.useState(false);
const [objective, setObjective] = React.useState("");
const objectiveId = React.useId();
const capabilities = state?.capabilities ?? {
resume: false,
typedEvents: false,
steering: false,
interruption: false,
structuredResult: false,
};
const availability = goalAvailability(capabilities, state?.goal?.status ?? null);
const unsupported = capabilities.goals !== true;
const unsupportedReason = availability[0]?.reason ?? null;
return (
<section className="ui-panel" aria-labelledby="goal-heading">
<div className="ui-panel-header">
<h2 id="goal-heading" className="ui-panel-title">
Goal
</h2>
<Menu
triggerId="goal-menu"
label="Goal"
disabled={unsupported || state === null}
disabledReason={
state === null ? "Start a session before changing the goal." : unsupportedReason
}
items={availability.map((entry) => ({
id: entry.operation,
label: GOAL_OPERATION_LABELS[entry.operation],
enabled: entry.enabled,
reason: entry.reason,
}))}
onSelect={(id) => {
if (id === "set") setDialogOpen(true);
else onGoal(id as GoalOperation);
}}
/>
</div>
{state === null ? null : state.goal === null ? (
<p className="ui-goal-empty" data-testid="goal-banner">
{unsupported ? "Goal operations are unsupported here." : "No goal set"}
</p>
) : (
<div className="ui-goal-banner" data-testid="goal-banner">
<p className="ui-goal-objective">{state.goal.objective}</p>
<div className="ui-status-row">
<Badge tone={state.goal.status === "active" ? "success" : "warning"}>
{state.goal.status}
</Badge>
<time dateTime={new Date(state.goal.updatedAt).toISOString()}>
{new Date(state.goal.updatedAt).toISOString().slice(11, 19)}
</time>
</div>
</div>
)}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title="Set goal"
description="The goal is durable on the thread and every change is also a transcript event."
footer={
<>
<Button type="button" className="ui-button--quiet" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
type="button"
data-testid="goal-set-confirm"
disabled={objective.trim().length === 0}
onClick={() => {
onGoal("set", { objective: objective.trim() });
setObjective("");
setDialogOpen(false);
}}
>
Set goal
</Button>
</>
}
>
<label htmlFor={objectiveId}>Objective</label>
<textarea
id={objectiveId}
className="ui-dialog-input"
data-testid="goal-objective"
rows={3}
value={objective}
onChange={(event) => setObjective(event.target.value)}
/>
</Dialog>
</section>
);
}
export function LineageTree({
lineage,
selectedThreadId,
onSelect,
}: {
lineage: readonly LiveLineageEntry[];
selectedThreadId: string | null;
onSelect: (threadId: string | null) => void;
}) {
const rootId = lineage.find((entry) => entry.depth === 0)?.threadId ?? null;
const current = selectedThreadId ?? rootId;
const treeRef = React.useRef<HTMLUListElement>(null);
function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
const rows = [
...(treeRef.current?.querySelectorAll<HTMLButtonElement>('[role="treeitem"]') ?? []),
];
const index = rows.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "ArrowDown") {
event.preventDefault();
rows[Math.min(index + 1, rows.length - 1)]?.focus();
} else if (event.key === "ArrowUp") {
event.preventDefault();
rows[Math.max(index - 1, 0)]?.focus();
}
}
return (
<section className="ui-panel" aria-labelledby="lineage-heading">
<h2 id="lineage-heading" className="ui-panel-title">
Threads
</h2>
<ul
ref={treeRef}
role="tree"
aria-label="Session thread lineage"
className="ui-tree"
data-testid="lineage-tree"
onKeyDown={onKeyDown}
>
{lineage.length === 0 ? <li className="ui-tree-empty">No threads yet.</li> : null}
{lineage.map((entry) => (
<li key={entry.threadId} role="none" data-depth={entry.depth}>
<button
type="button"
role="treeitem"
aria-current={entry.threadId === current}
aria-selected={entry.threadId === current}
className="ui-tree-row"
data-testid={`thread-${entry.threadId}`}
onClick={() => onSelect(entry.depth === 0 ? null : entry.threadId)}
>
<span className="ui-state-dot" data-state={entry.status} aria-hidden="true" />
<code>{entry.nickname ?? entry.threadId}</code>
<span className="ui-tree-state">
{THREAD_STATE_LABEL[entry.status] ?? entry.status}
</span>
</button>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,165 @@
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import type {
GoalOperation,
LiveManifestSummary,
LiveSessionState,
} from "./protocol";
const BASE = "/api/phase4b";
export class LiveConsoleError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "LiveConsoleError";
this.status = status;
this.code = code;
}
}
async function send<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
...init,
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
});
const body = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new LiveConsoleError(
response.status,
typeof body.error === "string" ? body.error : "request_failed",
typeof body.message === "string" ? body.message : `Request to ${path} failed.`,
);
}
return body as T;
}
export async function fetchManifests(): Promise<LiveManifestSummary[]> {
const body = await send<{ manifests: LiveManifestSummary[] }>("/manifests");
return body.manifests;
}
export async function createSession(input: {
manifest: string;
objective: string;
message?: string;
startTurn?: boolean;
}): Promise<LiveSessionState> {
return send<LiveSessionState>("/sessions", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function readSession(sessionId: string): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}`);
}
export async function readEvents(
sessionId: string,
after = 0,
): Promise<{ events: PrpEvent[]; cursor: number; replay: boolean }> {
return send(`/sessions/${sessionId}/events?after=${after}`);
}
export async function startTurn(
sessionId: string,
text: string,
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/turns`, {
method: "POST",
body: JSON.stringify({ text }),
});
}
export async function steerTurn(
sessionId: string,
turnId: string,
text: string,
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/steer`, {
method: "POST",
body: JSON.stringify({ turnId, text }),
});
}
export async function interruptTurn(
sessionId: string,
turnId: string | null,
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/interrupt`, {
method: "POST",
body: JSON.stringify({
reason: "browser_operator",
...(turnId === null ? {} : { turnId }),
}),
});
}
export async function resolveRequest(
sessionId: string,
requestId: string,
turnId: string,
resolution: Record<string, unknown>,
): Promise<LiveSessionState> {
return send<LiveSessionState>(
`/sessions/${sessionId}/requests/${encodeURIComponent(requestId)}/resolve`,
{ method: "POST", body: JSON.stringify({ turnId, resolution }) },
);
}
export async function goalOperation(
sessionId: string,
operation: GoalOperation,
body: Record<string, unknown> = {},
): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/goal/${operation}`, {
method: "POST",
body: JSON.stringify(body),
});
}
export async function reconnectSession(sessionId: string): Promise<LiveSessionState> {
return send<LiveSessionState>(`/sessions/${sessionId}/reconnect`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function closeSession(sessionId: string): Promise<void> {
await send(`/sessions/${sessionId}/close`, {
method: "POST",
body: JSON.stringify({}),
});
}
export interface EventStreamHandle {
close(): void;
}
/**
* Subscribes to the canonical event stream from a durable cursor. The stream
* never carries provider credentials; the server redacts every frame.
*/
export function openEventStream(
sessionId: string,
after: number,
handlers: {
onEvent: (event: PrpEvent) => void;
onOpen?: () => void;
onError?: () => void;
},
): EventStreamHandle {
const source = new EventSource(`${BASE}/sessions/${sessionId}/stream?after=${after}`);
source.onopen = () => handlers.onOpen?.();
source.onmessage = (message) => {
try {
handlers.onEvent(JSON.parse(message.data) as PrpEvent);
} catch {
handlers.onError?.();
}
};
source.onerror = () => handlers.onError?.();
return { close: () => source.close() };
}
@@ -0,0 +1,114 @@
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
/**
* The browser only ever sees the demo server's public state and the canonical
* PRP event stream. There is no second event model and no client-side session
* cache: every surface reads these shapes or the reducer snapshot they carry.
*/
export interface LiveCapabilities {
resume: boolean;
typedEvents: boolean;
steering: boolean;
interruption: boolean;
structuredResult: boolean;
read?: boolean;
reconciliation?: boolean;
usage?: boolean;
runtimeRequestResolution?: boolean;
goals?: boolean;
threadLineage?: boolean;
unsupported?: string[];
}
export interface LivePendingRequest {
requestId: string;
requestKind: string;
method: string;
turnId: string;
itemId: string;
status: string;
prompt: string;
details: Record<string, unknown>;
}
export interface LiveGoal {
threadId: string;
objective: string;
status: string;
tokenBudget: number | null;
tokensUsed: number;
timeUsedSeconds: number;
createdAt: number;
updatedAt: number;
}
export interface LiveLineageEntry {
threadId: string;
providerSessionId: string | null;
parentThreadId: string | null;
depth: number;
nickname: string | null;
role: string | null;
status: string;
}
export interface LiveSessionState {
sessionId: string;
runId: string;
normalizedSessionId: string;
manifest: string | null;
providerAuthentication: string;
credentialsExposed: boolean;
capabilities: LiveCapabilities;
driverSession: {
driverSessionId: string;
providerSessionId?: string | null;
displayId?: string | null;
};
activeTurnId: string | null;
pendingRequests: LivePendingRequest[];
goal: LiveGoal | null;
lineage: LiveLineageEntry[];
cursor: number;
snapshot: SessionSnapshot;
}
export interface LiveManifestSummary {
id: string;
name: string;
purpose: string;
scenarios: string[];
objective: string;
prompt: string;
expectedObservations: string[];
}
export type LiveConnectionStatus =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected"
| "terminal";
export type GoalOperation = "get" | "set" | "pause" | "resume" | "clear";
export type LiveEventList = readonly PrpEvent[];
export const GOAL_OPERATIONS: readonly GoalOperation[] = [
"set",
"get",
"pause",
"resume",
"clear",
];
/** Human labels for the five fixed goal verbs (interaction map §5). */
export const GOAL_OPERATION_LABELS: Record<GoalOperation, string> = {
set: "Set goal…",
get: "View",
pause: "Pause",
resume: "Resume",
clear: "Clear",
};
@@ -0,0 +1,293 @@
import { describe, expect, it } from "vitest";
import { Phase4bScriptedDriver } from "../../../../src/mock-core/phase4b-scripted-driver";
import {
parseHarnessRuntimeRequestResolution,
type HarnessSession,
} from "../../../../src/contracts/harness-driver";
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
type SessionSnapshot,
} from "../../../../src/reducer/session-reducer";
import type { LiveCapabilities } from "./protocol";
import {
buildTranscript,
capabilityRows,
composerState,
goalAvailability,
runtimeRequestSubmission,
transcriptRole,
type TranscriptEntry,
} from "./transcript-model";
const FULL: LiveCapabilities = {
resume: true,
typedEvents: true,
steering: true,
interruption: true,
structuredResult: true,
runtimeRequestResolution: true,
goals: true,
threadLineage: true,
};
function seed(runId: string, sessionId: string): SessionSnapshot {
return createSessionSnapshotFromMetadata({
fixtureName: "phase4b-demo",
identity: {
schema: "paperclip.prp.identity.v1",
companyId: "package-local-demo",
issueId: "phase4b-demo",
runId,
environmentLeaseId: "package-local",
runnerInstanceId: "phase4b-demo-server",
normalizedSessionId: sessionId,
driverSessionId: `thread-${sessionId}`,
},
capabilities: {
schema: "paperclip.prp.capabilities.v1",
sessionReusePolicy: "reuse_per_issue",
driver: { kind: "phase4b-demo", version: "1" },
steer: true,
interrupt: true,
resume: true,
runtimeRequests: true,
structuredResult: true,
typedEvents: true,
},
});
}
async function drive(
manifestId: string,
interact?: (session: HarnessSession, turnId: string) => Promise<void>,
): Promise<{ events: PrpEvent[]; snapshot: SessionSnapshot }> {
const driver = new Phase4bScriptedDriver({ manifestId, chunkDelayMs: 0 });
const session = await driver.openSession({
runId: `run-${manifestId}`,
normalizedSessionId: `session-${manifestId}`,
workingDirectory: "/demo",
});
const events: PrpEvent[] = [];
const pump = (async () => {
for await (const event of session.events()) events.push(event);
})();
const { turnId } = await session.startTurn({ message: { role: "user", text: "Go." } });
if (interact !== undefined) await interact(session, turnId);
await new Promise<void>((resolve) => setTimeout(resolve, 60));
await session.close({ reason: "test" });
await pump;
return {
events,
snapshot: events.reduce(applyPrpEvent, seed(`run-${manifestId}`, `session-${manifestId}`)),
};
}
function kinds(entries: readonly TranscriptEntry[]): string[] {
return entries.map((entry) => (entry.kind === "item" ? `item:${entry.role}` : entry.kind));
}
describe("transcriptRole", () => {
it("maps canonical item kinds onto transcript roles", () => {
expect(transcriptRole("reasoning")).toBe("reasoning");
expect(transcriptRole("agentMessage")).toBe("assistant");
expect(transcriptRole("commandExecution")).toBe("tool");
expect(transcriptRole("steering_acknowledgement")).toBe("steering");
expect(transcriptRole("thread_lineage")).toBe("lineage");
expect(transcriptRole("something_new")).toBe("system");
});
});
describe("buildTranscript", () => {
it("orders entries by the reducer timeline and shows the submitted message", async () => {
const { events, snapshot } = await drive("completion");
const entries = buildTranscript(snapshot, events);
expect(kinds(entries)).toEqual([
"user",
"item:reasoning",
"item:tool",
"item:assistant",
"turn",
]);
const user = entries[0];
expect(user.kind === "user" && user.text).toBe("Go.");
const tool = entries[2];
expect(tool.kind === "item" && tool.input).toBe("docs/phase-04b-protocol-server.md");
expect(tool.kind === "item" && tool.output).toContain("server-sent events");
});
it("marks an interrupted item and never claims it completed cleanly", async () => {
const { events, snapshot } = await drive("interrupt-during-tool", async (session, turnId) => {
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await session.interrupt?.({ turnId });
});
const entries = buildTranscript(snapshot, events);
const tool = entries.find((entry) => entry.kind === "item" && entry.role === "tool");
expect(tool?.kind === "item" && tool.interrupted).toBe(true);
expect(tool?.kind === "item" && tool.output).toBeNull();
const turn = entries.find((entry) => entry.kind === "turn");
expect(turn?.kind === "turn" && turn.state).toBe("interrupted");
});
it("keeps a resolved request in the record with the chosen action", async () => {
const { events, snapshot } = await drive("approvals", async (session, turnId) => {
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await session.resolveRuntimeRequest?.({
requestId: "request-command",
turnId,
resolution: { action: "accept_for_session" },
});
});
const request = buildTranscript(snapshot, events).find(
(entry) => entry.kind === "request" && entry.requestId === "request-command",
);
expect(request?.kind === "request" && request.status).toBe("resolved");
expect(request?.kind === "request" && request.resolvedAction).toBe("accept_for_session");
expect(request?.kind === "request" && request.actions).toEqual([
"accept",
"accept_for_session",
"decline",
"cancel",
]);
});
it("renders a failed item with its exact diagnostic", async () => {
const { events, snapshot } = await drive("failure");
const failed = buildTranscript(snapshot, events).find(
(entry) => entry.kind === "item" && entry.failure !== null,
);
expect(failed?.kind === "item" && failed.failure).toContain("cannot find module");
});
it("keeps a replayed prefix a real prefix of the finished transcript", async () => {
// Replay mode reduces events[0..n]. Stepping forward must only ever add
// entries in the same positions the live session produced.
const { events, snapshot } = await drive("completion");
const full = buildTranscript(snapshot, events);
for (let cut = 1; cut <= events.length; cut += 1) {
const prefix = events.slice(0, cut);
const stepped = buildTranscript(
prefix.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
prefix,
);
expect(stepped.length).toBeLessThanOrEqual(full.length);
stepped.forEach((entry, index) => {
expect(entry.key).toBe(full[index]?.key);
expect(entry.position).toBe(full[index]?.position);
});
}
expect(
buildTranscript(
events.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
events,
),
).toEqual(full);
});
});
describe("composerState", () => {
const base = {
connection: "connected" as const,
submitting: false,
interrupting: false,
activeTurnId: null,
sessionState: "running" as const,
};
it("shows exactly one state per protocol situation", () => {
expect(composerState(base)).toBe("idle");
expect(composerState({ ...base, submitting: true })).toBe("submitting");
expect(composerState({ ...base, activeTurnId: "turn-1" })).toBe("active-turn");
expect(composerState({ ...base, interrupting: true, activeTurnId: "turn-1" })).toBe(
"interrupting",
);
expect(composerState({ ...base, connection: "disconnected" })).toBe("disconnected");
expect(composerState({ ...base, sessionState: "closed" })).toBe("terminal");
});
it("puts a terminal session ahead of every other state", () => {
expect(
composerState({
...base,
sessionState: "failed",
activeTurnId: "turn-1",
connection: "disconnected",
}),
).toBe("terminal");
});
});
describe("goalAvailability", () => {
it("disables every verb with the exact diagnostic when goals are unsupported", () => {
const rows = goalAvailability(
{
...FULL,
goals: false,
unsupported: ["Goal operations unsupported: app-server 0.132.0 does not advertise goals"],
},
null,
);
expect(rows).toHaveLength(5);
expect(rows.every((row) => !row.enabled)).toBe(true);
expect(rows[0]?.reason).toContain("does not advertise goals");
});
it("disables only the verbs that are invalid for the current goal state", () => {
const active = goalAvailability(FULL, "active");
expect(active.find((row) => row.operation === "pause")?.enabled).toBe(true);
expect(active.find((row) => row.operation === "resume")?.enabled).toBe(false);
const paused = goalAvailability(FULL, "paused");
expect(paused.find((row) => row.operation === "resume")?.enabled).toBe(true);
expect(paused.find((row) => row.operation === "pause")?.enabled).toBe(false);
const none = goalAvailability(FULL, null);
expect(none.find((row) => row.operation === "set")?.enabled).toBe(true);
expect(none.find((row) => row.operation === "clear")?.enabled).toBe(false);
});
});
describe("runtimeRequestSubmission", () => {
it("shapes each submit body the way its request kind is validated", async () => {
expect(runtimeRequestSubmission("user_input", "staging")).toEqual({
action: "submit",
answers: { answer: { answers: ["staging"] } },
});
expect(runtimeRequestSubmission("elicitation", "stable")).toEqual({
action: "submit",
content: { answer: "stable" },
});
// The driver is the authority on shape, so both bodies are checked against
// the real validator rather than against this test's expectations alone.
for (const [requestKind, answer] of [
["user_input", "staging"],
["elicitation", "stable"],
] as const) {
expect(() =>
parseHarnessRuntimeRequestResolution(
requestKind,
runtimeRequestSubmission(requestKind, answer),
)).not.toThrow();
// ...and the other kind's body is rejected, which is what the browser
// used to send for elicitation.
const wrongKind = requestKind === "user_input" ? "elicitation" : "user_input";
expect(() =>
parseHarnessRuntimeRequestResolution(
requestKind,
runtimeRequestSubmission(wrongKind, answer),
)).toThrow(/rejected its resolution/);
}
});
});
describe("capabilityRows", () => {
it("gives every unsupported capability a diagnostic string", () => {
const rows = capabilityRows({ ...FULL, goals: false, usage: false });
expect(rows.filter((row) => !row.supported).every((row) => row.diagnostic !== null)).toBe(true);
expect(rows.find((row) => row.name === "steer")?.supported).toBe(true);
});
});
@@ -0,0 +1,401 @@
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import type {
SessionItemSnapshot,
SessionSnapshot,
} from "../../../../src/reducer/session-reducer";
import type { GoalOperation, LiveCapabilities } from "./protocol";
/**
* A projection of reducer state plus the canonical events that produced it.
* It never stores anything the protocol did not send, so a replayed session
* and a live session with the same events project identically.
*/
export type TranscriptRole =
| "user"
| "assistant"
| "reasoning"
| "tool"
| "steering"
| "interrupt"
| "goal"
| "lineage"
| "system";
export interface TranscriptItemEntry {
kind: "item";
key: string;
position: number;
role: TranscriptRole;
item: SessionItemSnapshot;
interrupted: boolean;
streaming: boolean;
toolName: string | null;
input: string | null;
output: string | null;
failure: string | null;
}
export interface TranscriptUserEntry {
kind: "user";
key: string;
position: number;
turnId: string | null;
text: string;
}
export interface TranscriptRequestEntry {
kind: "request";
key: string;
position: number;
requestId: string;
requestKind: string;
type: string;
prompt: string;
status: "pending" | "resolved" | "expired" | "cancelled";
actions: string[];
details: Record<string, unknown>;
resolvedAction: string | null;
resolvedAt: string | null;
turnId: string | null;
}
export interface TranscriptTurnEntry {
kind: "turn";
key: string;
position: number;
turnId: string | null;
state: "completed" | "failed" | "interrupted" | "cancelled";
detail: string;
}
export interface TranscriptDiagnosticEntry {
kind: "diagnostic";
key: string;
position: number;
message: string;
code: string | null;
}
export type TranscriptEntry =
| TranscriptItemEntry
| TranscriptUserEntry
| TranscriptRequestEntry
| TranscriptTurnEntry
| TranscriptDiagnosticEntry;
const TOOL_KINDS = new Set([
"commandExecution",
"command_execution",
"fileChange",
"file_change",
"mcpToolCall",
"tool",
"webSearch",
"patchApply",
]);
const ASSISTANT_KINDS = new Set(["agentMessage", "assistant", "assistantMessage", "message"]);
export function transcriptRole(kind: string): TranscriptRole {
if (kind === "reasoning") return "reasoning";
if (ASSISTANT_KINDS.has(kind)) return "assistant";
if (TOOL_KINDS.has(kind)) return "tool";
if (kind === "steering_acknowledgement") return "steering";
if (kind === "interrupt_acknowledgement") return "interrupt";
if (kind === "goal" || kind === "goal_cleared") return "goal";
if (kind === "thread_lineage") return "lineage";
return "system";
}
function payloadRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function payloadText(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
interface ItemFacts {
interrupted: boolean;
toolName: string | null;
input: string | null;
output: string | null;
failure: string | null;
terminal: boolean;
}
function collectItemFacts(events: readonly PrpEvent[]): Map<string, ItemFacts> {
const facts = new Map<string, ItemFacts>();
for (const event of events) {
if (event.itemId === undefined || !event.eventType.startsWith("item.")) continue;
const payload = payloadRecord(event.payload);
const current = facts.get(event.itemId) ?? {
interrupted: false,
toolName: null,
input: null,
output: null,
failure: null,
terminal: false,
};
if (payload.interrupted === true || payload.status === "interrupted") {
current.interrupted = true;
}
current.toolName = payloadText(payload.toolName) ?? current.toolName;
current.input = payloadText(payload.input) ?? current.input;
current.output = payloadText(payload.output) ?? current.output;
if (event.eventType === "item.failed") {
current.failure = payloadText(payload.message) ?? "The item failed.";
}
if (event.eventType === "item.completed" || event.eventType === "item.failed") {
current.terminal = true;
}
facts.set(event.itemId, current);
}
return facts;
}
/**
* Builds the submit body a request kind actually accepts. Elicitation answers
* travel as `content` and user-input answers as `answers`; the runner validates
* both against the request kind, so sending the wrong one fails closed rather
* than resolving the request with an empty response.
*/
export function runtimeRequestSubmission(
requestKind: string,
answer: string,
): Record<string, unknown> {
if (requestKind === "elicitation") {
return { action: "submit", content: { answer } };
}
return { action: "submit", answers: { answer: { answers: [answer] } } };
}
function requestActions(details: unknown): string[] {
const record = payloadRecord(details);
return Array.isArray(record.actions)
? record.actions.filter((action): action is string => typeof action === "string")
: [];
}
/**
* Builds the ordered transcript. Order comes from the reducer timeline, which
* is authoritative; nothing is re-sorted client-side.
*/
export function buildTranscript(
snapshot: SessionSnapshot,
events: readonly PrpEvent[],
): TranscriptEntry[] {
const facts = collectItemFacts(events);
const items = new Map(snapshot.items.map((item) => [item.itemId, item]));
const seenItems = new Set<string>();
const entries: TranscriptEntry[] = [];
const eventsById = new Map(events.map((event) => [event.sourceEventId, event]));
for (const timelineEntry of snapshot.timeline) {
const event = eventsById.get(timelineEntry.sourceEventId);
const payload = payloadRecord(event?.payload);
const position = timelineEntry.position;
if (timelineEntry.eventType === "turn.submitted") {
entries.push({
kind: "user",
key: `user:${timelineEntry.sourceEventId}`,
position,
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
text: payloadText(payload.text) ?? "(no message text in the canonical event)",
});
continue;
}
if (
["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(
timelineEntry.eventType,
)
) {
const state = timelineEntry.eventType.split(".").at(-1) as TranscriptTurnEntry["state"];
entries.push({
kind: "turn",
key: `turn:${timelineEntry.sourceEventId}`,
position,
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
state,
detail:
payloadText(payload.message) ??
payloadText(payload.reason) ??
timelineEntry.summary,
});
continue;
}
if (timelineEntry.eventType === "runtime_request.created") {
const request = payloadRecord(payload.request ?? payload);
const requestId = typeof request.requestId === "string" ? request.requestId : "";
const projected = snapshot.requests.find(
(candidate) => candidate.requestId === requestId,
);
const resolution = events.find(
(candidate) =>
candidate.eventType.startsWith("runtime_request.") &&
candidate.eventType !== "runtime_request.created" &&
payloadRecord(candidate.payload).requestId === requestId,
);
entries.push({
kind: "request",
key: `request:${requestId}`,
position,
requestId,
requestKind: typeof request.requestKind === "string" ? request.requestKind : "runtime",
type: typeof request.type === "string" ? request.type : "runtime",
prompt: payloadText(request.prompt) ?? "Runtime request",
status: (projected?.status ?? "pending") as TranscriptRequestEntry["status"],
actions: requestActions(request),
details: payloadRecord(request.details),
resolvedAction: payloadText(payloadRecord(resolution?.payload).action),
resolvedAt: resolution?.emittedAt ?? null,
turnId: typeof request.turnId === "string" ? request.turnId : null,
});
continue;
}
if (
timelineEntry.eventType === "harness.diagnostic" ||
timelineEntry.eventType === "runner.diagnostic"
) {
entries.push({
kind: "diagnostic",
key: `diagnostic:${timelineEntry.sourceEventId}`,
position,
message: payloadText(payload.message) ?? timelineEntry.summary,
code: payloadText(payload.code),
});
continue;
}
const itemId = timelineEntry.itemId;
if (itemId === undefined || seenItems.has(itemId)) continue;
const item = items.get(itemId);
if (item === undefined) continue;
seenItems.add(itemId);
const itemFacts = facts.get(itemId);
entries.push({
kind: "item",
key: `item:${itemId}`,
position,
role: transcriptRole(item.kind),
item,
interrupted: itemFacts?.interrupted ?? false,
streaming: item.status === "running" && itemFacts?.terminal !== true,
toolName: itemFacts?.toolName ?? null,
input: itemFacts?.input ?? null,
output: itemFacts?.output ?? null,
failure: itemFacts?.failure ?? null,
});
}
return entries;
}
export type ComposerState =
| "idle"
| "submitting"
| "active-turn"
| "interrupting"
| "disconnected"
| "terminal";
export interface ComposerStateInput {
connection: "idle" | "connecting" | "connected" | "reconnecting" | "disconnected" | "terminal";
submitting: boolean;
interrupting: boolean;
activeTurnId: string | null;
sessionState: SessionSnapshot["sessionState"];
}
/** One state machine with exactly one visible primary action. */
export function composerState(input: ComposerStateInput): ComposerState {
if (input.sessionState === "closed" || input.sessionState === "failed") return "terminal";
if (input.connection === "disconnected" || input.connection === "reconnecting") {
return "disconnected";
}
if (input.interrupting) return "interrupting";
if (input.activeTurnId !== null) return "active-turn";
if (input.submitting) return "submitting";
return "idle";
}
export interface GoalOperationAvailability {
operation: GoalOperation;
enabled: boolean;
reason: string | null;
}
const GOAL_UNSUPPORTED_FALLBACK =
"Goal operations unsupported: this app-server does not advertise the goals capability";
/**
* Capability gating for the goal menu. An unsupported verb is disabled with
* the exact upstream diagnostic — never hidden and never emulated.
*/
export function goalAvailability(
capabilities: LiveCapabilities,
goalStatus: string | null,
): GoalOperationAvailability[] {
const unsupported = capabilities.unsupported ?? [];
if (capabilities.goals !== true) {
const reason = unsupported[0] ?? GOAL_UNSUPPORTED_FALLBACK;
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => ({
operation,
enabled: false,
reason,
}));
}
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => {
if (operation === "set") return { operation, enabled: true, reason: null };
if (goalStatus === null) {
return {
operation,
enabled: false,
reason: "No goal is set on this thread yet.",
};
}
if (operation === "pause" && goalStatus !== "active") {
return { operation, enabled: false, reason: `The goal is already ${goalStatus}.` };
}
if (operation === "resume" && goalStatus === "active") {
return { operation, enabled: false, reason: "The goal is already active." };
}
return { operation, enabled: true, reason: null };
});
}
export interface CapabilityRow {
name: string;
supported: boolean;
diagnostic: string | null;
}
export function capabilityRows(capabilities: LiveCapabilities): CapabilityRow[] {
const unsupported = capabilities.unsupported ?? [];
const rows: Array<[string, boolean | undefined]> = [
["steer", capabilities.steering],
["interrupt", capabilities.interruption],
["resume", capabilities.resume],
["runtime requests", capabilities.runtimeRequestResolution],
["goals", capabilities.goals],
["child threads", capabilities.threadLineage],
["structured result", capabilities.structuredResult],
["typed events", capabilities.typedEvents],
["usage", capabilities.usage],
];
return rows.map(([name, supported]) => ({
name,
supported: supported === true,
diagnostic:
supported === true
? null
: (unsupported.find((entry) => entry.toLowerCase().includes(name.split(" ")[0]!)) ??
`${name} is not advertised by this app-server`),
}));
}
@@ -0,0 +1,561 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
type SessionSnapshot,
} from "../../../../src/reducer/session-reducer";
import * as client from "./client";
import type {
GoalOperation,
LiveConnectionStatus,
LiveManifestSummary,
LiveSessionState,
} from "./protocol";
import {
buildTranscript,
composerState,
type ComposerState,
type TranscriptEntry,
} from "./transcript-model";
const SESSION_STORAGE_KEY = "paperclip-runner.phase4b.session";
const RECONNECT_DELAY_MS = 900;
const MAX_RECONNECT_ATTEMPTS = 5;
export type SteeringChipStatus = "pending" | "acknowledged" | "rejected" | "failed";
export interface SteeringChip {
id: string;
text: string;
expectedTurnId: string;
status: SteeringChipStatus;
detail: string | null;
}
function reduceEvents(
state: LiveSessionState | null,
events: readonly PrpEvent[],
): SessionSnapshot | null {
if (state === null) return null;
const seed = createSessionSnapshotFromMetadata({
fixtureName: state.snapshot.fixtureName,
identity: state.snapshot.identity,
capabilities: state.snapshot.capabilities,
});
return events.reduce(applyPrpEvent, seed);
}
function errorMessage(cause: unknown): string {
if (cause instanceof client.LiveConsoleError) return cause.message;
return cause instanceof Error ? cause.message : String(cause);
}
export interface LiveConsole {
manifests: LiveManifestSummary[];
selectedManifestId: string;
selectManifest: (id: string) => void;
state: LiveSessionState | null;
events: PrpEvent[];
snapshot: SessionSnapshot | null;
transcript: TranscriptEntry[];
connection: LiveConnectionStatus;
reconnectAttempt: number;
replayParity: boolean | null;
composer: ComposerState;
steeringChips: SteeringChip[];
announcement: string;
error: string | null;
busy: boolean;
selectedThreadId: string | null;
selectThread: (threadId: string | null) => void;
replay: {
active: boolean;
position: number;
length: number;
playing: boolean;
enter: () => void;
exit: () => void;
setPosition: (position: number) => void;
togglePlay: () => void;
step: (delta: number) => void;
};
start: (input: { manifestId: string; message: string }) => Promise<void>;
send: (text: string) => Promise<void>;
steer: (text: string) => Promise<void>;
interrupt: () => Promise<void>;
resolve: (
requestId: string,
turnId: string,
resolution: Record<string, unknown>,
) => Promise<void>;
goal: (operation: GoalOperation, body?: Record<string, unknown>) => Promise<void>;
dropConnection: () => void;
retryNow: () => void;
reset: () => Promise<void>;
dismissSteeringChip: (id: string) => void;
}
export function useLiveConsole(): LiveConsole {
const [manifests, setManifests] = useState<LiveManifestSummary[]>([]);
const [selectedManifestId, setSelectedManifestId] = useState("completion");
const [state, setState] = useState<LiveSessionState | null>(null);
const [events, setEvents] = useState<PrpEvent[]>([]);
const [connection, setConnection] = useState<LiveConnectionStatus>("idle");
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [steeringChips, setSteeringChips] = useState<SteeringChip[]>([]);
const [announcement, setAnnouncement] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [interrupting, setInterrupting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [replayActive, setReplayActive] = useState(false);
const [replayPosition, setReplayPosition] = useState(0);
const [replayPlaying, setReplayPlaying] = useState(false);
const streamRef = useRef<client.EventStreamHandle | null>(null);
const cursorRef = useRef(0);
const sessionIdRef = useRef<string | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeStream = useCallback(() => {
streamRef.current?.close();
streamRef.current = null;
if (retryTimerRef.current !== null) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
}, []);
const subscribe = useCallback(
(sessionId: string, attempt = 0) => {
closeStream();
setConnection(attempt === 0 ? "connecting" : "reconnecting");
setReconnectAttempt(attempt);
streamRef.current = client.openEventStream(sessionId, cursorRef.current, {
onOpen: () => {
setConnection("connected");
setReconnectAttempt(0);
setAnnouncement("Connected");
},
onEvent: (event) => {
cursorRef.current += 1;
setEvents((current) =>
current.some((candidate) => candidate.sourceEventId === event.sourceEventId)
? current
: [...current, event],
);
},
onError: () => {
closeStream();
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
setConnection("disconnected");
setAnnouncement("Disconnected");
return;
}
setConnection("reconnecting");
setReconnectAttempt(attempt + 1);
setAnnouncement(`Reconnecting, attempt ${attempt + 1}`);
retryTimerRef.current = setTimeout(
() => subscribe(sessionId, attempt + 1),
RECONNECT_DELAY_MS,
);
},
});
},
[closeStream],
);
const adopt = useCallback(
(next: LiveSessionState) => {
setState(next);
setError(null);
},
[],
);
const attach = useCallback(
async (sessionId: string, resume: boolean) => {
const [next, history] = await Promise.all([
client.readSession(sessionId),
client.readEvents(sessionId, 0),
]);
sessionIdRef.current = sessionId;
window.sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId);
adopt(next);
setEvents(history.events);
cursorRef.current = history.cursor;
if (resume) setAnnouncement("Replayed the durable transcript");
subscribe(sessionId, 0);
},
[adopt, subscribe],
);
useEffect(() => {
let cancelled = false;
void client
.fetchManifests()
.then((list) => {
if (cancelled) return;
setManifests(list);
if (list[0] !== undefined) setSelectedManifestId((current) => current || list[0]!.id);
})
.catch((cause) => {
if (!cancelled) setError(errorMessage(cause));
});
const stored = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
if (stored !== null) {
void attach(stored, true).catch(() => {
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
});
}
return () => {
cancelled = true;
};
// Runs once: session restoration is a mount concern.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => () => closeStream(), [closeStream]);
// Lineage, goal, pending requests, and the server cursor live in the public
// state, so a stream that only carries events would leave them stale.
useEffect(() => {
const sessionId = sessionIdRef.current;
if (sessionId === null || events.length === 0) return undefined;
const timer = setTimeout(() => {
void client
.readSession(sessionId)
.then((next) => setState(next))
.catch(() => undefined);
}, 150);
return () => clearTimeout(timer);
}, [events.length]);
const liveSnapshot = useMemo(() => reduceEvents(state, events), [state, events]);
const visibleEvents = useMemo(
() => (replayActive ? events.slice(0, replayPosition) : events),
[events, replayActive, replayPosition],
);
const snapshot = useMemo(
() => (replayActive ? reduceEvents(state, visibleEvents) : liveSnapshot),
[replayActive, state, visibleEvents, liveSnapshot],
);
const replayParity = useMemo(() => {
if (state === null || liveSnapshot === null) return null;
if (state.cursor !== events.length) return null;
return (
JSON.stringify(liveSnapshot.timeline) === JSON.stringify(state.snapshot.timeline) &&
liveSnapshot.integrity === state.snapshot.integrity
);
}, [state, liveSnapshot, events.length]);
const transcript = useMemo(
() => (snapshot === null ? [] : buildTranscript(snapshot, visibleEvents)),
[snapshot, visibleEvents],
);
useEffect(() => {
if (!replayPlaying) return undefined;
if (replayPosition >= events.length) {
setReplayPlaying(false);
return undefined;
}
const timer = setTimeout(() => setReplayPosition((current) => current + 1), 140);
return () => clearTimeout(timer);
}, [replayPlaying, replayPosition, events.length]);
// Steering chips resolve against canonical acknowledgements only.
useEffect(() => {
setSteeringChips((chips) => {
if (chips.every((chip) => chip.status !== "pending")) return chips;
const acknowledged = events.filter(
(event) =>
event.eventType === "item.completed" &&
(event.payload as { kind?: string }).kind === "steering_acknowledgement",
);
let index = 0;
return chips.map((chip) => {
if (chip.status !== "pending") return chip;
const match = acknowledged[index];
index += 1;
return match === undefined
? chip
: { ...chip, status: "acknowledged" as const, detail: null };
});
});
}, [events]);
// The reducer is authoritative for the active turn. Falling back to the
// last public-state read would keep a finished turn alive in the composer.
const activeTurnId = snapshot === null ? (state?.activeTurnId ?? null) : snapshot.activeTurnId;
// A submitted turn that has not been accepted yet is a protocol fact, not a
// local flag: it is what makes interrupt-before-start reachable.
const awaitingTurnStart = useMemo(() => {
let pending = false;
for (const entry of snapshot?.timeline ?? []) {
if (entry.eventType === "turn.submitted") pending = true;
else if (entry.eventType.startsWith("turn.")) pending = false;
}
return pending;
}, [snapshot]);
const composer = composerState({
connection,
submitting: submitting || awaitingTurnStart,
interrupting,
activeTurnId,
sessionState: snapshot?.sessionState ?? "not_started",
});
const guard = useCallback(async (task: () => Promise<void>) => {
setBusy(true);
try {
await task();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setBusy(false);
}
}, []);
const start = useCallback(
async (input: { manifestId: string; message: string }) => {
await guard(async () => {
closeStream();
setEvents([]);
cursorRef.current = 0;
setSteeringChips([]);
setReplayActive(false);
setReplayPosition(0);
setSelectedThreadId(null);
setSubmitting(true);
const manifest = manifests.find((entry) => entry.id === input.manifestId);
try {
const created = await client.createSession({
manifest: input.manifestId,
objective: manifest?.objective ?? "Run the Phase 4b demo.",
message: input.message,
});
sessionIdRef.current = created.sessionId;
window.sessionStorage.setItem(SESSION_STORAGE_KEY, created.sessionId);
adopt(created);
setAnnouncement("Turn running");
subscribe(created.sessionId, 0);
} finally {
setSubmitting(false);
}
});
},
[adopt, closeStream, guard, manifests, subscribe],
);
const refreshState = useCallback(async () => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
adopt(await client.readSession(sessionId));
}, [adopt]);
const send = useCallback(
async (text: string) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) {
await start({ manifestId: selectedManifestId, message: text });
return;
}
await guard(async () => {
setSubmitting(true);
try {
adopt(await client.startTurn(sessionId, text));
setAnnouncement("Turn running");
} finally {
setSubmitting(false);
}
});
},
[adopt, guard, selectedManifestId, start],
);
const steer = useCallback(
async (text: string) => {
const sessionId = sessionIdRef.current;
const expectedTurnId = activeTurnId;
if (sessionId === null || expectedTurnId === null) return;
const chipId = `steer-${Date.now()}-${steeringChips.length}`;
setSteeringChips((chips) => [
...chips,
{ id: chipId, text, expectedTurnId, status: "pending", detail: null },
]);
try {
adopt(await client.steerTurn(sessionId, expectedTurnId, text));
setAnnouncement("Steering sent");
} catch (cause) {
const stale =
cause instanceof client.LiveConsoleError &&
(cause.code === "stale_turn" || cause.code === "already_terminal");
setSteeringChips((chips) =>
chips.map((chip) =>
chip.id === chipId
? {
...chip,
status: stale ? "rejected" : "failed",
detail: stale ? "turn already ended" : errorMessage(cause),
}
: chip,
),
);
setAnnouncement(stale ? "Steering rejected: turn already ended" : "Steering failed");
await refreshState().catch(() => undefined);
}
},
[activeTurnId, adopt, refreshState, steeringChips.length],
);
const interrupt = useCallback(async () => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
setInterrupting(true);
setAnnouncement("Stopping");
try {
adopt(await client.interruptTurn(sessionId, activeTurnId));
setAnnouncement("Turn stopped");
} catch (cause) {
setError(errorMessage(cause));
} finally {
setInterrupting(false);
}
}, [activeTurnId, adopt]);
const resolve = useCallback(
async (requestId: string, turnId: string, resolution: Record<string, unknown>) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
await guard(async () => {
adopt(await client.resolveRequest(sessionId, requestId, turnId, resolution));
setAnnouncement("Request resolved");
});
},
[adopt, guard],
);
const goal = useCallback(
async (operation: GoalOperation, body: Record<string, unknown> = {}) => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
await guard(async () => {
adopt(await client.goalOperation(sessionId, operation, body));
setAnnouncement(`Goal ${operation}`);
});
},
[adopt, guard],
);
const dropConnection = useCallback(() => {
closeStream();
setConnection("disconnected");
setAnnouncement("Connection lost");
}, [closeStream]);
const retryNow = useCallback(() => {
const sessionId = sessionIdRef.current;
if (sessionId === null) return;
void guard(async () => {
const resumed = await client.reconnectSession(sessionId);
adopt(resumed);
const history = await client.readEvents(sessionId, 0);
setEvents(history.events);
cursorRef.current = history.cursor;
subscribe(sessionId, 0);
setAnnouncement("Resumed the same session");
});
}, [adopt, guard, subscribe]);
const reset = useCallback(async () => {
const sessionId = sessionIdRef.current;
closeStream();
if (sessionId !== null) {
try {
await client.closeSession(sessionId);
} catch (cause) {
setConnection("disconnected");
setError(errorMessage(cause));
setAnnouncement("Demo reset failed");
return;
}
}
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
sessionIdRef.current = null;
cursorRef.current = 0;
setState(null);
setEvents([]);
setSteeringChips([]);
setConnection("idle");
setReplayActive(false);
setReplayPosition(0);
setSelectedThreadId(null);
setError(null);
setAnnouncement("Demo state reset");
}, [closeStream]);
const replay = useMemo(
() => ({
active: replayActive,
position: replayPosition,
length: events.length,
playing: replayPlaying,
enter: () => {
setReplayActive(true);
setReplayPosition(0);
setReplayPlaying(false);
},
exit: () => {
setReplayActive(false);
setReplayPlaying(false);
},
setPosition: (position: number) =>
setReplayPosition(Math.min(Math.max(position, 0), events.length)),
togglePlay: () => setReplayPlaying((current) => !current),
step: (delta: number) =>
setReplayPosition((current) =>
Math.min(Math.max(current + delta, 0), events.length),
),
}),
[events.length, replayActive, replayPlaying, replayPosition],
);
return {
manifests,
selectedManifestId,
selectManifest: setSelectedManifestId,
state,
events,
snapshot,
transcript,
connection,
reconnectAttempt,
replayParity,
composer,
steeringChips,
announcement,
error,
busy,
selectedThreadId,
selectThread: setSelectedThreadId,
replay,
start,
send,
steer,
interrupt,
resolve,
goal,
dropConnection,
retryNow,
reset,
dismissSteeringChip: (id: string) =>
setSteeringChips((chips) => chips.filter((chip) => chip.id !== id)),
};
}
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
const root = document.getElementById("root");
if (root === null) {
throw new Error("Browser replay root element is missing");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,196 @@
import { expect, test } from "@playwright/test";
test("validates and renders the shared Phase 1 fixture", async ({ page }, testInfo) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Live runner diagnostics" })).toBeVisible();
await page.getByRole("button", { name: "Static replay" }).click();
await expect(page.getByRole("heading", { name: "Static protocol replay" })).toBeVisible();
await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded");
await expect(page.getByTestId("timeline").getByRole("listitem")).toHaveCount(9);
await expect(page.getByTestId("timeline")).not.toContainText("workspace_preparing");
await expect(page.getByTestId("result-summary")).toContainText(
"The scripted run completed successfully.",
);
await page.screenshot({
path: testInfo.outputPath("phase-01-static-replay.png"),
fullPage: true,
});
});
test("shows Phase 3 recovery diagnostics without secrets", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByRole("button", { name: "Recovery", exact: true }).click();
await expect(page.getByRole("heading", { name: "Durable recovery diagnostics" })).toBeVisible();
const fault = page.getByLabel("Fault", { exact: true });
await expect(fault).toHaveValue("lost-ack");
expect(await fault.locator("option").allTextContents()).toEqual([
"Normal connection",
"Socket drop",
"Lost ACK",
"Duplicate command",
"Runner restart",
"Harness restart",
"Malformed input",
"Lease expiry",
"Storage pressure",
"Drain",
"Revoke",
]);
await fault.selectOption("none");
await page.getByRole("button", { name: "Run Phase 3 recovery" }).click();
await expect(fault).toBeDisabled();
await expect(page.getByText("complete", { exact: true })).toBeVisible();
await expect(fault).toBeEnabled();
await expect(page.getByTestId("phase3-connection-state")).toHaveText("Stopped");
await expect(page.getByTestId("phase3-connections")).toHaveText("1");
await expect(page.getByTestId("phase3-reconnects")).toHaveText("0");
await expect(page.getByTestId("phase3-runner-restarts")).toHaveText("0");
await expect(page.getByTestId("phase3-fresh-bootstraps")).toHaveText("1");
await expect(page.getByTestId("phase3-diagnostics")).toContainText("At-least-once redeliveries");
await expect(page.getByTestId("phase3-diagnostics")).toContainText("Duplicate commands");
await expect(page.getByTestId("phase3-storage")).toContainText("0 bytes of 65536 max");
await expect(page.getByTestId("phase3-outcome")).toHaveText("Recovered");
await expect(page.getByTestId("phase3-outcome")).toHaveAttribute("data-tone", "success");
await expect(page.getByTestId("recovery-history").getByRole("listitem")).toHaveCount(9);
const firstRecoveryEvent = page.getByTestId("recovery-history").getByRole("listitem").first();
await expect(firstRecoveryEvent).toContainText("workspace.ready");
await expect(firstRecoveryEvent.getByText("delivered 2×")).toBeVisible();
await expect(page.getByText("Preserved", { exact: true })).toBeVisible();
await expect(page.getByText("Yes", { exact: true })).toBeVisible();
await expect(page.locator("body")).not.toContainText("leaseToken");
await expect(page.locator("body")).not.toContainText("bootstrapTicket");
await page.screenshot({ path: testInfo.outputPath("phase-03-recovery-diagnostics.png"), fullPage: true });
});
test("tones failure outcomes and storage backpressure", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Recovery", exact: true }).click();
const fault = page.getByLabel("Fault", { exact: true });
const run = page.getByRole("button", { name: "Run Phase 3 recovery" });
await fault.selectOption("revoke");
await run.click();
await expect(page.getByTestId("phase3-outcome")).toHaveText("Revoked");
await expect(page.getByTestId("phase3-outcome")).toHaveAttribute("data-tone", "warning");
await expect(page.getByText("Revoke completed", { exact: true })).toBeVisible();
await fault.selectOption("storage-pressure");
await run.click();
await expect(page.getByTestId("phase3-storage")).toContainText("0 bytes of 16384 max");
await expect(page.getByTestId("phase3-storage").getByText("Backpressure")).toHaveAttribute(
"data-tone",
"warning",
);
});
test("keeps recovery diagnostics within the narrow viewport", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await page.getByRole("button", { name: "Recovery", exact: true }).click();
await page.getByLabel("Fault", { exact: true }).selectOption("none");
await page.getByRole("button", { name: "Run Phase 3 recovery" }).click();
await expect(page.getByTestId("recovery-history")).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await page.screenshot({
path: testInfo.outputPath("phase-03-recovery-diagnostics-mobile.png"),
fullPage: true,
});
});
test("shows duplicate and unsupported-version replay states", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Static replay" }).click();
await page.getByLabel("Fixture", { exact: true }).selectOption("duplicate-event");
await expect(page.getByText("1 duplicate events ignored")).toBeVisible();
await page
.getByLabel("Fixture", { exact: true })
.selectOption("unsupported-required-version");
await expect(page.getByRole("heading", { name: "Fixture cannot be replayed" })).toBeVisible();
await expect(page.getByText(/protocolVersion 2 is unsupported/)).toBeVisible();
});
test("streams a live run and proves replay parity", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded");
await expect(page.getByTestId("process-facts")).toContainText("Harness process exit");
await expect(page.getByTestId("process-facts")).toContainText("done");
await expect(page.getByTestId("parity-result")).toContainText("Match");
await page.screenshot({
path: testInfo.outputPath("phase-02-live-complete.png"),
fullPage: true,
});
});
test("resolves live permission and input requests", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByLabel("Scenario").selectOption("permission-input");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("runtime-request")).toContainText("permission request");
await expect(page.getByTestId("terminal-badge")).toHaveText("Executing");
await expect(
page.getByTestId("timeline").getByRole("listitem").filter({
hasText: "runtime_request.created",
}).first(),
).toContainText("permission: Allow the fake driver to write its local fixture?");
await page.screenshot({
path: testInfo.outputPath("phase-02-live-permission.png"),
fullPage: true,
});
await page.getByRole("button", { name: "Allow" }).click();
await expect(page.getByTestId("runtime-request")).toContainText("input request");
await expect(
page.getByTestId("timeline").getByRole("listitem").filter({
hasText: "runtime_request.resolved",
}).first(),
).toContainText("Resolved permission: Allow the fake driver to write its local fixture?");
await page.waitForTimeout(10_500);
await page.getByLabel("Response").fill("phase-02-browser-trace");
await page.getByRole("button", { name: "Send input" }).click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(page.getByTestId("parity-result")).toContainText("Match");
});
test("interrupts a live turn without duplicating terminal state", async ({ page }, testInfo) => {
await page.goto("/");
await page.getByLabel("Scenario").selectOption("interrupted");
await page.getByRole("button", { name: "Start local run" }).click();
const interrupt = page.getByRole("button", { name: "Interrupt turn" });
await expect(interrupt).toBeEnabled();
await interrupt.click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(page.getByTestId("terminal-badge")).toHaveText("Cancelled");
await expect(page.getByTestId("live-status")).toHaveAttribute("data-tone", "neutral");
await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "neutral");
await expect(page.getByTestId("timeline").getByText("run.terminal")).toHaveCount(1);
await page.screenshot({
path: testInfo.outputPath("phase-02-live-interrupted.png"),
fullPage: true,
});
});
test("shows failed outcomes as danger and names the duplicate-terminal guard", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Scenario").selectOption("error");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(page.getByTestId("live-status")).toHaveAttribute("data-tone", "danger");
await expect(page.getByTestId("terminal-badge")).toHaveText("Failed");
await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "danger");
await page.getByLabel("Scenario").selectOption("duplicate-terminal");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
await expect(
page.getByTestId("timeline").getByRole("listitem").filter({
hasText: "harness.diagnostic",
}),
).toContainText(
"Duplicate terminal event ignored; the first terminal event remains authoritative.",
);
});
@@ -0,0 +1,212 @@
# Architecture and Standalone Boundary
## Dependency direction
```text
language-neutral protocol fixture
|
+---------------+---------------+
v v
Rust runner-core + mock path TypeScript contracts + mock path
| |
+---------------+---------------+
v
byte-identical Phase 0 result
Paperclip core (later) --> implements ControlPlanePort / NativeSessionBackend
```
The dependency arrow always points from an implementation toward a contract.
The standalone package does not reach backward into a Paperclip implementation.
## Initial contracts
- `ControlPlanePort` is the narrow surface through which a runner opens a run,
appends ordered events, and submits a terminal structured result.
- `HarnessDriver` owns a local harness session and its provider-specific
identity, event, turn, snapshot, and close behavior.
- `NativeSessionBackend` normalizes local runner and hosted-provider sessions for
a future control-plane consumer. Environment placement is not implied by the
backend type.
The Phase 0 TypeScript contracts are deliberately sketches: they name
responsibility and dependency direction without prematurely implementing the
runtime transport. Phase 1 adds this executable static path:
```text
protocol/schemas/*.json
| generate/check | shared fixtures
v v
TypeScript schema constants/types -> validator -> deterministic reducer
| |
v v
CLI browser devtool
|
golden parity summaries
|
v
Rust runner-core oracle
```
The Rust `runner-core` crate establishes the production language/package
boundary and checks the same fixture summaries. Phase 2 adds the package-local
`paperclip-runnerd` and `fake-harness` binaries without changing that dependency
direction.
## Language ownership
- Rust is the production direction for deterministic runner behavior,
supervision, durable delivery, and the eventual `paperclip-runnerd` binary.
- TypeScript owns the control-plane/browser side and remains a useful reference
client/test oracle.
- JSON Schema and shared fixtures are the language-neutral authority. Phase 0
keeps its narrow tracer fixture; Phase 1 adds the executable PRP v1 schema and
conformance corpus without silently changing the accepted Phase 0 path.
- `check:phase0-parity` prevents either implementation from introducing a
language-specific observable result.
- `check:phase1-parity` prevents TypeScript replay and the Rust production
direction from disagreeing on identity, terminal state, duplicates, or gaps.
## Allowed dependencies
- Rust crates declared by the package-local Cargo workspace.
- Node.js standard-library modules.
- Third-party packages declared by this workspace when a later phase needs them.
- Files within `packages/paperclip-runner/`.
- A future explicit generated-schema package only after architecture review and
an allowlist change in the boundary checker.
## Forbidden dependencies
The following imports and package dependencies are rejected:
- `server/`, `ui/`, and `cli/` implementation paths;
- `@paperclipai/db` and production database schema or client modules;
- `@paperclipai/shared`, adapter utilities, and other Paperclip workspace
internals unless a later boundary review explicitly allows a public contract;
- relative or absolute imports that escape `packages/paperclip-runner/`.
This rule applies to type-only imports, exports, dynamic imports, CommonJS
`require` calls, Rust include/path attributes, and Cargo path dependencies. The
negative fixtures under `test-fixtures/` intentionally reference `server/` and
must fail the checker.
## Enforcement
```sh
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
pnpm --filter @paperclipai/paperclip-runner test
pnpm --filter @paperclipai/paperclip-runner check:phase1-parity
```
The first command scans the package source, scripts, and manifest. The test
command additionally asserts that the negative fixture is rejected. The normal
scan excludes that fixture so a deliberate proof does not make the package fail.
## Phase 0 process boundary
The mock core is an in-memory adapter, not a Paperclip server. Starting it only
changes local object state. The tracer performs this sequence:
1. load and validate `protocol/fixtures/phase-00-minimal-run.json`;
2. start the mock adapter;
3. open the fixture run through `ControlPlanePort`;
4. append contiguous typed events;
5. submit the matching terminal result;
6. print a stable JSON identity/result and stop the adapter.
No socket, database, browser, Paperclip process, or model process is started.
The default command executes this sequence in Rust. The TypeScript reference
executes the same sequence, and the parity check compares their complete stdout.
## Phase 1 static replay boundary
`replayPhase1FixtureText` is the single entry point used by the CLI and browser.
It parses JSON, validates JSON Schema plus cross-record bindings, and only then
calls the reducer. The reducer is pure: it clones input state, applies an event
at most once by source event ID, records source gaps/out-of-order deliveries,
and never performs I/O.
The browser is a Vite application under `devtools/browser/`. Its Button, Badge,
Card, and Textarea are source-compatible adaptations of shadcn primitives; all
visual values live in its local `styles.css` token layer. It imports the same
replay module as the CLI and does not create a browser-only protocol model.
## Phase 2 local process boundary
```text
TypeScript mock core
| PRP commands over stdin JSONL
v
paperclip-runnerd (Rust supervisor)
| fake-harness commands over stdin JSONL
v
fake-harness (Rust scripted driver)
| typed messages over stdout JSONL
v
paperclip-runnerd -> canonical PRP events -> mock core
|
v
browser NDJSON stream
```
The mock core starts one runner process. The runner creates a new process group
for one fake harness and its workers. The runner clears the inherited
environment and restores only the path needed to launch local executables. It
captures stderr and scripted log messages in a bounded tail.
The controller and harness links use newline-delimited JSON over stdio. This is
the smallest local transport that keeps process ownership clear. The browser
does not connect to the runner. A package-local Vite middleware exposes an HTTP
start/action API and an NDJSON event stream from the TypeScript mock core.
The runner publishes the structured semantic result before it publishes the
harness process exit fact. It then emits one `run.terminal` event. A non-zero
harness exit can coexist with a valid yielded result. Duplicate commands and
duplicate terminal messages cannot repeat side effects or close the run twice.
Every browser event passes `validatePrpEvent` and `applyPrpEvent`. When the run
ends, the browser reduces the complete event list again and compares the replay
snapshot with the live snapshot.
## Phase 3 durable transport boundary
```text
TypeScript mock core
| one-time ticket -> short-lived connection lease
| PRP v1 hello/welcome, commands, events, cumulative ACKs
v
paperclip-runnerd (Rust WebSocket client)
| atomic private JSON state
+-- durable outbox and processed-command cache
+-- stable runner/session/turn/item identities
+-- Phase 2 fake-harness process for restart proof
```
The runner initiates the loopback WebSocket. The bootstrap ticket is present
only in the runner process environment. The returned connection-lease token is
kept only in runner memory. The mock core stores SHA-256 digests of capabilities
and the runner state stores neither capability. The runner writes each event and
command result before network delivery, then removes outbox events only after a
valid cumulative ACK.
The TypeScript peer is a package-local test controller, not production
Paperclip. It injects lost ACKs, socket loss, malformed input, process restarts,
lease expiry, storage pressure, drain, and revoke. The browser recovery page
calls that peer through guarded package-local Vite middleware and renders only
safe trace fields.
## Future integration rule
The [implementation plan](../spec/paperclip-native-runner-implementation-plan.md)
keeps production integration in a separately reviewed phase. Paperclip core may
implement these contracts later, but this package must remain independently
buildable, testable, and runnable against the mock adapter.
The proposed Phase 6 seam is recorded in
[Phase 6 Thin Paperclip Adapter Boundary](design/phase-6-thin-paperclip-adapter.md).
It keeps one dependency direction, branches only after Paperclip workspace and
environment realization, composes a package-owned `NativeSessionBackend` with
a server-bound `ControlPlanePort`, and returns to the existing Paperclip
finalization path. The core seam contains no runner behavior. The proposal is
design-only until the CTO gate accepts it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,374 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Phase 4b Live Console — annotated mockup (PAP-16832)</title>
<style>
/* Token layer: values mirror devtools/browser/src/styles.css, plus the
Phase 4b token additions proposed in the component decision record §5. */
:root {
color-scheme: light;
--background: #f7f7f5; --foreground: #191917;
--card: #ffffff; --card-foreground: #191917;
--muted: #efefeb; --muted-foreground: #66665f;
--border: #deded7;
--primary: #242420; --primary-foreground: #ffffff;
--success: #17633a; --success-surface: #e9f5ed;
--warning: #8a5a10; --warning-surface: #fff4dc;
--danger: #a02f2f; --danger-surface: #fceaea;
--accent: #2f5fa8; --accent-surface: #e9f0fa;
--surface-raised: #fcfcfa;
--ring: #66665f;
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--fs-xs: 0.75rem; --fs-sm: 0.875rem; --fs-md: 1rem; --fs-lg: 1.25rem;
--w-med: 500; --w-semi: 600;
--s-1: 0.25rem; --s-2: 0.5rem; --s-3: 0.75rem; --s-4: 1rem; --s-5: 1.25rem; --s-6: 1.5rem; --s-8: 2rem;
--r-sm: 0.375rem; --r-md: 0.625rem;
--shadow-sm: 0 0.0625rem 0.125rem rgb(0 0 0 / 0.05);
}
* { box-sizing: border-box; }
body { margin:0; background:var(--background); color:var(--foreground);
font:var(--fs-sm)/1.5 var(--font-sans); }
code, .mono { font-family: var(--font-mono); font-size: var(--fs-xs); }
h1,h2,h3,p { margin:0; }
.console { display:grid; grid-template-columns: 17rem minmax(0,1fr) 24rem;
gap:var(--s-4); width:min(100%,90rem); margin-inline:auto; padding:var(--s-4); }
header.top { grid-column:1/-1; display:flex; align-items:center; gap:var(--s-3);
padding:var(--s-2) var(--s-1); }
.eyebrow { font-size:var(--fs-xs); letter-spacing:.08em; text-transform:uppercase;
color:var(--muted-foreground); font-weight:var(--w-semi); }
header.top h1 { font-size:var(--fs-lg); font-weight:var(--w-semi); margin-right:auto; }
.panel { background:var(--card); border:1px solid var(--border); border-radius:var(--r-md);
box-shadow:var(--shadow-sm); }
.panel + .panel { margin-top:var(--s-4); }
.panel-h { display:flex; align-items:center; gap:var(--s-2); padding:var(--s-3) var(--s-4);
border-bottom:1px solid var(--border); font-weight:var(--w-semi); font-size:var(--fs-sm); }
.panel-b { padding:var(--s-3) var(--s-4); }
.badge { display:inline-flex; align-items:center; gap:var(--s-1); border-radius:999px;
padding:0 var(--s-2); font-size:var(--fs-xs); font-weight:var(--w-med);
border:1px solid var(--border); background:var(--muted); color:var(--muted-foreground); height:1.25rem;}
.badge.ok { background:var(--success-surface); color:var(--success); border-color:transparent;}
.badge.warn { background:var(--warning-surface); color:var(--warning); border-color:transparent;}
.badge.err { background:var(--danger-surface); color:var(--danger); border-color:transparent;}
.badge.info { background:var(--accent-surface); color:var(--accent); border-color:transparent;}
.dot { width:.5rem; height:.5rem; border-radius:999px; display:inline-block; }
.dot.ok{background:var(--success);} .dot.run{background:var(--accent);}
.dot.err{background:var(--danger);} .dot.mut{background:var(--muted-foreground);}
.btn { display:inline-flex; align-items:center; justify-content:center; gap:var(--s-1);
height:2.25rem; padding:0 var(--s-4); border-radius:var(--r-sm); font-weight:var(--w-med);
font-size:var(--fs-sm); border:1px solid var(--border); background:var(--card); cursor:default;}
.btn.primary { background:var(--primary); color:var(--primary-foreground); border-color:var(--primary);}
.btn.danger { background:var(--danger-surface); color:var(--danger); border-color:transparent;}
.btn.ghost { border-color:transparent; background:transparent; color:var(--muted-foreground);}
.btn.sm { height:1.75rem; padding:0 var(--s-3); font-size:var(--fs-xs);}
.btn[aria-disabled="true"] { opacity:.5; }
/* left rail */
.manifest { display:flex; flex-direction:column; gap:var(--s-1); padding:var(--s-2) var(--s-3);
border-radius:var(--r-sm); border:1px solid transparent; }
.manifest.active { border-color:var(--accent); background:var(--accent-surface); }
.manifest .t { font-weight:var(--w-med); display:flex; justify-content:space-between; align-items:center;}
.manifest .d { color:var(--muted-foreground); font-size:var(--fs-xs); }
.kv { display:flex; justify-content:space-between; gap:var(--s-2); padding:var(--s-1) 0;
font-size:var(--fs-xs); }
.kv .k { color:var(--muted-foreground); }
.goal { border-left:3px solid var(--accent); background:var(--accent-surface);
border-radius:var(--r-sm); padding:var(--s-2) var(--s-3); font-size:var(--fs-xs); }
.tree { list-style:none; margin:0; padding:0; font-size:var(--fs-xs); }
.tree li { display:flex; align-items:center; gap:var(--s-2); padding:var(--s-1) var(--s-2);
border-radius:var(--r-sm);}
.tree li.cur { background:var(--muted); font-weight:var(--w-med); }
.tree li.child { padding-left:var(--s-6); }
/* transcript */
.chat { display:flex; flex-direction:column; min-height:34rem; }
.stream { flex:1; display:flex; flex-direction:column; gap:var(--s-3); padding:var(--s-4); }
.msg { max-width:85%; }
.msg.user { align-self:flex-end; background:var(--primary); color:var(--primary-foreground);
border-radius:var(--r-md); padding:var(--s-2) var(--s-3); }
.msg.assistant { align-self:stretch; max-width:100%; }
.item-label { font-size:var(--fs-xs); color:var(--muted-foreground); margin-bottom:var(--s-1);
display:flex; gap:var(--s-2); align-items:center;}
details.fold { border:1px solid var(--border); border-radius:var(--r-sm);
background:var(--surface-raised); font-size:var(--fs-xs); }
details.fold summary { list-style:none; cursor:default; padding:var(--s-2) var(--s-3);
display:flex; align-items:center; gap:var(--s-2); color:var(--muted-foreground);}
details.fold pre { margin:0; padding:var(--s-2) var(--s-3); border-top:1px solid var(--border);
overflow:auto; font-size:var(--fs-xs); }
.chip { display:inline-flex; align-items:center; gap:var(--s-2); border-radius:999px;
padding:var(--s-1) var(--s-3); font-size:var(--fs-xs); border:1px dashed var(--border);
color:var(--muted-foreground); align-self:flex-end; }
.chip.ack { border-style:solid; border-color:var(--success); color:var(--success);
background:var(--success-surface);}
.chip.rej { border-style:solid; border-color:var(--danger); color:var(--danger);
background:var(--danger-surface);}
.divider { display:flex; align-items:center; gap:var(--s-2); color:var(--warning);
font-size:var(--fs-xs); }
.divider::before,.divider::after { content:""; flex:1; border-top:1px dashed var(--warning); }
/* request card */
.req { border:1px solid var(--border); border-left:3px solid var(--accent);
border-radius:var(--r-sm); background:var(--card); }
.req .rh { display:flex; gap:var(--s-2); align-items:center; padding:var(--s-2) var(--s-3);
font-weight:var(--w-med); font-size:var(--fs-sm);}
.req .rid { margin-left:auto; color:var(--muted-foreground); }
.req pre { margin:0 var(--s-3); padding:var(--s-2) var(--s-3); background:var(--muted);
border-radius:var(--r-sm); overflow:auto;}
.req .ra { display:flex; gap:var(--s-2); padding:var(--s-3); flex-wrap:wrap;}
.req.resolved { border-left-color:var(--success); }
.req.resolved .rh { color:var(--muted-foreground); font-weight:var(--w-med); }
.streaming-cursor { display:inline-block; width:.5em; height:1em; background:var(--accent);
vertical-align:text-bottom; }
.jump { position:sticky; bottom:5.5rem; align-self:center; }
/* composer */
.composer { border-top:1px solid var(--border); padding:var(--s-3) var(--s-4); display:flex;
flex-direction:column; gap:var(--s-2); background:var(--surface-raised);
border-radius:0 0 var(--r-md) var(--r-md); }
.composer .row { display:flex; gap:var(--s-3); align-items:flex-end; }
.composer textarea { flex:1; min-height:2.5rem; resize:none; border:1px solid var(--border);
border-radius:var(--r-sm); padding:var(--s-2) var(--s-3); font:inherit; background:var(--card);}
.status-line { font-size:var(--fs-xs); color:var(--muted-foreground); display:flex; gap:var(--s-2);
align-items:center;}
.banner { display:flex; gap:var(--s-2); align-items:center; padding:var(--s-2) var(--s-4);
font-size:var(--fs-xs); border-top:1px solid var(--border); }
.banner.pending { background:var(--warning-surface); color:var(--warning); }
kbd { font-family:var(--font-mono); font-size:var(--fs-xs); background:var(--muted);
border:1px solid var(--border); border-bottom-width:2px; border-radius:var(--r-sm);
padding:0 var(--s-1); }
/* inspector */
.tabs { display:flex; gap:var(--s-1); padding:var(--s-2) var(--s-3) 0; border-bottom:1px solid var(--border);}
.tab { padding:var(--s-1) var(--s-3); border-radius:var(--r-sm) var(--r-sm) 0 0; font-size:var(--fs-xs);
color:var(--muted-foreground);}
.tab.on { background:var(--muted); color:var(--foreground); font-weight:var(--w-med); }
.evt { display:grid; grid-template-columns:2.5rem 1fr auto; gap:var(--s-2); padding:var(--s-1) var(--s-3);
font-size:var(--fs-xs); font-family:var(--font-mono); border-bottom:1px solid var(--border); }
.evt .seq { color:var(--muted-foreground); }
.evt.hl { background:var(--accent-surface); }
.cap { display:flex; justify-content:space-between; gap:var(--s-2); padding:var(--s-1) var(--s-3);
font-size:var(--fs-xs); border-bottom:1px solid var(--border);}
/* annotation markers */
.anno { position:relative; }
.pin { position:absolute; z-index:5; width:1.4rem; height:1.4rem; border-radius:999px;
background:var(--accent); color:#fff; font-size:var(--fs-xs); font-weight:var(--w-semi);
display:flex; align-items:center; justify-content:center; box-shadow:0 0 0 3px var(--accent-surface); }
.legend { grid-column:1/-1; }
.legend ol { margin:var(--s-2) 0 0; padding-left:1.2rem; font-size:var(--fs-xs);
columns:2; column-gap:var(--s-8); }
.legend li { margin-bottom:var(--s-1); }
/* mobile */
.seg { display:none; }
@media (max-width: 64rem) {
.console { grid-template-columns: minmax(0,1fr); padding:var(--s-3); }
.rail-left, .rail-right { display:none; }
header.top { flex-wrap:wrap; row-gap:var(--s-2); }
header.top h1 { font-size:var(--fs-md); }
header.top .btn.ghost, header.top .badge.mono { display:none; }
.eyebrow { letter-spacing:.04em; }
.seg { display:flex; gap:var(--s-1); background:var(--muted); border-radius:var(--r-sm);
padding:var(--s-1); }
.seg span { flex:1; text-align:center; padding:var(--s-1) 0; border-radius:var(--r-sm);
font-size:var(--fs-xs); color:var(--muted-foreground);}
.seg span.on { background:var(--card); color:var(--foreground); font-weight:var(--w-med);
box-shadow:var(--shadow-sm);}
.legend ol { columns:1; }
.msg { max-width:92%; }
.btn { height:2.75rem; } /* V2: 44px touch targets */
}
</style>
</head>
<body>
<div class="console anno">
<header class="top">
<div>
<div class="eyebrow">Paperclip Runner Protocol · Phase 4b</div>
<h1>Live Codex console</h1>
</div>
<span class="badge info"><span class="dot run"></span> connected</span>
<span class="badge mono">session prp-7f3a…c2</span>
<button class="btn">Goal ▾</button>
<button class="btn ghost">Inspector</button>
</header>
<!-- LEFT RAIL -->
<aside class="rail-left" style="position:relative">
<span class="pin" style="top:-0.5rem; left:-0.5rem">9</span>
<section class="panel">
<div class="panel-h">Demo chats</div>
<div class="panel-b" style="display:flex;flex-direction:column;gap:var(--s-1)">
<div class="manifest active">
<div class="t">Steer &amp; interrupt <span class="badge info">running</span></div>
<div class="d">Mid-turn steering ack, stale rejection, 3 interrupt races</div>
</div>
<div class="manifest">
<div class="t">Approvals <span class="badge ok">passed</span></div>
<div class="d">Command, file-change, permission, input requests</div>
</div>
<div class="manifest">
<div class="t">Reconnect &amp; replay <span class="badge">not run</span></div>
<div class="d">Drop socket, refresh, byte-identical replay</div>
</div>
<button class="btn ghost sm">Reset demo state…</button>
</div>
</section>
<section class="panel" style="position:relative">
<span class="pin" style="top:-0.5rem; right:-0.5rem">5</span>
<div class="panel-h">Session</div>
<div class="panel-b">
<div class="kv"><span class="k">Connection</span><span><span class="dot ok"></span> connected</span></div>
<div class="kv"><span class="k">Turn</span><span class="mono">turn-19 · active</span></div>
<div class="kv"><span class="k">Pending requests</span><span class="badge warn">1</span></div>
<div class="goal" style="margin-top:var(--s-2)">
<strong>Goal · active</strong><br/>
Ship the fixture validator with tests
<div style="margin-top:var(--s-1)"><button class="btn ghost sm">Pause</button>
<button class="btn ghost sm" aria-disabled="true" title="Goal is active">Resume</button></div>
</div>
</div>
</section>
<section class="panel" style="position:relative">
<span class="pin" style="top:-0.5rem; right:-0.5rem">6</span>
<div class="panel-h">Threads</div>
<div class="panel-b">
<ul class="tree">
<li class="cur"><span class="dot run"></span> <span class="mono">root · thr-a91f</span></li>
<li class="child"><span class="dot run"></span> <span class="mono">child · thr-b02c</span> <span class="badge info">running</span></li>
<li class="child"><span class="dot mut"></span> <span class="mono">child · thr-9d44</span> <span class="badge ok">done</span></li>
</ul>
</div>
</section>
</aside>
<!-- CENTER: transcript + composer -->
<main class="panel chat anno" style="position:relative">
<span class="pin" style="top:-0.5rem; left:-0.5rem">1</span>
<div class="seg" role="tablist" aria-label="Console sections">
<span class="on">Chat</span><span>Session</span><span>Inspector</span>
</div>
<div class="stream" role="log" aria-label="Session transcript">
<div class="msg user">Validate the fixture corpus and summarize gaps.</div>
<div class="msg assistant">
<div class="item-label"><span class="badge">reasoning</span> 2.1s</div>
<details class="fold"><summary>▸ Planned validation order and edge cases…</summary></details>
</div>
<div class="msg assistant" style="position:relative">
<span class="pin" style="top:-0.6rem; right:0">4</span>
<div class="req">
<div class="rh"><span class="badge warn">Command</span> Approve running this command?
<span class="rid mono">req-77b1…</span></div>
<pre>pnpm vitest run protocol/fixtures --reporter=json</pre>
<div class="ra">
<button class="btn primary">Approve</button>
<button class="btn">Approve for session</button>
<button class="btn danger">Reject</button>
</div>
</div>
</div>
<div class="msg assistant">
<div class="item-label"><span class="badge ok">tool · resolved</span> validate_fixtures</div>
<details class="fold"><summary>▸ 37 fixtures validated · 0 gaps</summary></details>
</div>
<div class="divider" style="position:relative">
<span class="pin" style="top:-1.4rem; left:40%">3</span>
turn-18 interrupted — partial output preserved
</div>
<div class="chip ack" style="position:relative">
<span class="pin" style="top:-1.2rem; right:-0.5rem">2</span>
✓ Steering acknowledged · “prefer the JSON reporter”
</div>
<div class="chip rej">✕ Steering rejected — turn already ended · <u>Send as new message</u></div>
<div class="msg assistant">
<div class="item-label"><span class="badge info">assistant · streaming</span></div>
<p>Re-running with the JSON reporter. Two fixtures need regenerated goldens: <code>source-gap</code> and <code>duplicate-event</code>. Starting with<span class="streaming-cursor"></span></p>
</div>
<button class="btn sm jump">↓ Jump to latest · 2 new</button>
</div>
<div class="banner pending" style="position:relative">
<span class="pin" style="top:-0.7rem; left:30%">4</span>
⏳ 1 request waiting — the turn is paused until you respond. <u>Review</u>
</div>
<div class="composer anno" style="position:relative">
<span class="pin" style="top:-0.7rem; right:-0.5rem">2</span>
<div class="status-line" aria-live="polite"><span class="dot run"></span> Turn running · steering will apply to <span class="mono">turn-19</span></div>
<div class="row">
<textarea aria-label="Steer the active turn">Focus only on the two failing goldens.</textarea>
<button class="btn primary">Steer</button>
<button class="btn danger" aria-label="Stop turn">■ Stop</button>
</div>
<div class="status-line"><kbd>Enter</kbd> steer · <kbd>Shift</kbd>+<kbd>Enter</kbd> newline · <kbd>End</kbd> jump to latest</div>
</div>
</main>
<!-- RIGHT RAIL: inspector -->
<aside class="rail-right anno" style="position:relative">
<span class="pin" style="top:-0.5rem; left:-0.5rem">8</span>
<section class="panel">
<div class="panel-h">Protocol inspector <span class="badge">live</span></div>
<div class="tabs">
<span class="tab on">Events</span><span class="tab">Requests</span>
<span class="tab">Capabilities</span><span class="tab">Session</span>
</div>
<div>
<div class="evt"><span class="seq">41</span><span>turn.started</span><span class="mono">turn-19</span></div>
<div class="evt hl"><span class="seq">42</span><span>item.delta</span><span class="mono">item-63</span></div>
<div class="evt"><span class="seq">43</span><span>runtime_request.created</span><span class="mono">req-77b1</span></div>
<div class="evt"><span class="seq">44</span><span>item.delta</span><span class="mono">item-63</span></div>
</div>
<div class="panel-b" style="border-top:1px solid var(--border)">
<div class="eyebrow" style="margin-bottom:var(--s-1)">Capabilities</div>
<div class="cap"><span>turn/steer</span><span class="badge ok">supported</span></div>
<div class="cap"><span>turn/interrupt</span><span class="badge ok">supported</span></div>
<div class="cap" style="position:relative"><span class="pin" style="right:-0.9rem; top:-0.2rem">5</span>
<span>goals/pause</span><span class="badge err" title="app-server 0.132.0 does not advertise goals.pause">unsupported</span></div>
</div>
</section>
<section class="panel">
<div class="panel-b" style="font-size:var(--fs-xs); color:var(--muted-foreground)">
<strong style="color:var(--foreground)">Replay state</strong>
<div class="kv"><span class="k">Cursor</span><span class="mono">seq 44 · no gaps</span></div>
<div class="kv"><span class="k">Mode</span><span><span class="badge info">LIVE</span> <span style="position:relative"><span class="pin" style="top:-1rem; right:-1.4rem">7</span></span></span></div>
</div>
</section>
</aside>
<!-- LEGEND -->
<section class="panel legend">
<div class="panel-b">
<div class="eyebrow">Annotations — interaction map sections (docs/design/phase-4b-interaction-map.md)</div>
<ol>
<li><strong>§1 Transcript & composer</strong> — reducer-ordered `role="log"`, streaming affordance, jump-to-latest pill, composer tri-state (Send → Steer/Stop).</li>
<li><strong>§2 Steering</strong> — pending chip → acknowledged / stale-rejected with “Send as new message” recovery; `expectedTurnId` shown in status line.</li>
<li><strong>§3 Interruption</strong> — interrupted divider preserves partial output; three races get distinct terminal states; session never replaced.</li>
<li><strong>§4 Request cards</strong> — inline at timeline position + blocking banner; actions only as offered upstream; single-response guarantee.</li>
<li><strong>§5 Goal capability</strong> — durable banner + fixed five-verb menu; unsupported verbs disabled with the exact upstream diagnostic.</li>
<li><strong>§6 Lineage</strong> — parent/child tree from upstream identities; child steering shown as unsupported, never emulated.</li>
<li><strong>§7 Reconnect/replay</strong> — connection status, replayed-to-here divider, REPLAY vs LIVE eyebrow badge, cursor/gap state.</li>
<li><strong>§8 Protocol inspector</strong> — canonical events + raw upstream names, requests, capabilities, identities; redaction markers verbatim.</li>
<li><strong>§9 Demo manifests</strong> — scenario list with expected observations, per-manifest outcome, reset with explicit destruction copy.</li>
</ol>
</div>
</section>
</div>
</body>
</html>
@@ -0,0 +1,145 @@
# Phase 4b Component Decision Record — shadcn/ui and Vercel AI Elements
Status: **approved** (UX gate PAP-16832, 2026-08-08). Sources checked
2026-08-08 against the live shadcn/ui and AI Elements documentation; this
supersedes and extends the
[2026-08-07 compatibility note](../research/2026-08-07-ui-library-compatibility.md)
for the authorized Phase 4b browser surface.
## 1. Ground rules (binding on the implementation)
1. **Protocol authority.** The package's canonical PRP events and
`SessionSnapshot` reducer state are the only data contract. Third-party
chat message types, AI SDK `UIMessage`/message-part types, and
component-local state machines must not become inputs to, or shadows of,
the runner protocol. Adapted components accept props typed against
`src/reducer/session-reducer.ts` / `src/contracts/*` shapes only.
2. **Source adaptation, not dependency adoption.** Components are copied into
`devtools/browser/src/components/` and rewritten in the established local
idiom: plain function components, `data-slot` attributes, semantic `ui-*`
class names, all visual values from the token layer in `src/styles.css`.
No Tailwind, no `class-variance-authority`, no `radix-ui` runtime, no
`cn()` utility chain, no AI SDK. (Precedent: `components/ui/button.tsx`
already follows this idiom from the earlier phases.)
3. **Dependency budget: zero new runtime dependencies** for the demo app
beyond the existing `react`/`react-dom`. Every candidate below that would
pull a runtime dep (radix primitives, `cmdk`, `streamdown`, `shiki`,
`use-stick-to-bottom`, `nanoid`, `zod`) is either rebuilt on native
platform primitives (`<dialog>`, `<details>`, ARIA patterns) or rejected.
Rationale: Phase 4b is a proof under `packages/paperclip-runner/` that
Phase 5 will freeze into an SDK — every dependency added here becomes an
SDK liability decision later.
4. **Accessibility parity or better.** Where we rebuild a radix-backed
pattern on native primitives, the keyboard/SR contract in interaction map
§10 is the acceptance bar; "the library would have done it" is not
available as an excuse once we adapt source.
5. **Token gaps are system changes.** Phase 4b needs a few new tokens (see
§5). Add them to `src/styles.css` `:root` in one commit with this record —
never inline values in component files.
## 2. Existing package-local primitives — REUSE
| Component | Decision | Notes |
|---|---|---|
| `ui/button.tsx` | Reuse | Add `ui-button--danger` and `ui-button--ghost` class variants (CSS only) for Stop / Cancel actions. |
| `ui/badge.tsx` | Reuse | Status badges for turn/request/connection states. |
| `ui/card.tsx` | Reuse | Base for request cards, manifest rows, inspector panels. |
| `ui/textarea.tsx` | Reuse | Composer input (wrapped with auto-grow behavior). |
## 3. Vercel AI Elements — decisions per primitive
AI Elements is a shadcn-registry distribution targeting React 19 + Tailwind 4
with Next.js + AI SDK prerequisites (docs, checked 2026-08-08). The turnkey
stack does not fit this standalone Vite package (already established in the
2026-08-07 note). We adapt **shapes and interaction patterns from source**,
re-typed to reducer snapshots and restyled to tokens.
**ADAPT (5):**
| Primitive | Adapted as | What we keep | What we change |
|---|---|---|---|
| `Conversation` | `ui/conversation.tsx` | stick-to-bottom semantics, "jump to latest" affordance, `role="log"` | drop `use-stick-to-bottom` dep — implement with a scroll listener + `scrollTo`; wire unseen-count from reducer timeline length |
| `Message` | `ui/message.tsx` | role-based alignment/grouping anatomy, avatar-less compact variant | props become `SessionItemSnapshot`; roles extended with `reasoning`/`tool`/`system`; no AI SDK message parts |
| `PromptInput` | `ui/composer.tsx` | textarea + action-row anatomy, submit-on-Enter, status-driven submit button | add Send/Steer/Stop tri-state from interaction map §1–§3; no attachments, no model picker, no AI SDK `sendMessage` |
| `Reasoning` | `ui/reasoning-item.tsx` | collapsible streaming reasoning with auto-open-while-streaming, duration caption | rebuild on `<details>`/summary with ARIA disclosure; content = plain streamed text (no markdown dep) |
| `Tool` | `ui/tool-item.tsx` | collapsible tool block: header (name + status badge) / input / output sections | status enum mapped from canonical item/turn events, not AI SDK `ToolUIPart` states; payloads render in `<pre>` |
**REJECT (with reasons; revisit in Phase 5 where noted):**
| Primitive | Reason |
|---|---|
| `Response` (streamed markdown) | pulls `streamdown`/markdown pipeline; tracer renders exact text — markdown prettification can hide protocol truth. Phase 5 candidate for the SDK console. |
| `CodeBlock` | needs `shiki` highlighting dep; `<pre>` + mono token suffices for a tracer. Phase 5 candidate. |
| `Actions` (retry/like/copy row) | retry/regenerate/vote have no protocol backing; copy-to-clipboard is implemented locally in the inspector. |
| `Branch` | message branching does not exist in the PRP session model. |
| `Sources` / `InlineCitation` | no citation events in the protocol. |
| `Task` | overlapping with adapted `Tool`; subagent lineage uses the tree (map §6), not a task widget. |
| `ChainOfThought` | duplicate of `Reasoning` at higher visual weight. |
| `Context` (token/cost meter) | usage belongs in the inspector Session tab as plain data; a persistent meter overweights cost in a tracer. Phase 5 candidate. |
| `Artifact`, `WebPreview`, `Image`, `Attachments`, `OpenIn`, `Suggestion`, `Queue` | no protocol counterpart in Phase 4b; adopting them would invent affordances the runner cannot honor (fabricated-controls rule). |
| `Loader`/`Shimmer` | trivially replaced by a token-compliant CSS pulse honoring `prefers-reduced-motion`. |
## 4. shadcn/ui — decisions per primitive
**ADAPT (6):**
| Primitive | Adapted as | Implementation note |
|---|---|---|
| `Tabs` | `ui/tabs.tsx` | inspector tabs + mobile segments; WAI-ARIA tabs pattern, roving tabindex, no radix |
| `DropdownMenu` (menu-button subset) | `ui/menu.tsx` | Goal menu (§5) — five fixed items; menu-button ARIA pattern; no radix portal |
| `Dialog` | `ui/dialog.tsx` | native `<dialog>` element (`showModal()` gives focus trap + `Escape` for free); used by Set goal… and reset confirm |
| `Tooltip` | `ui/tooltip.tsx` | hover/focus tooltip for diagnostics; content mirrored to `aria-describedby` per map A5 (tooltip is never the only channel) |
| `Alert` | `ui/banner.tsx` | reconnect / pending-request / replay banners; tone variants from status tokens |
| `Separator`, `Kbd` | folded into `styles.css` | pure CSS (`ui-separator`, `ui-kbd`) — no component file needed |
**REJECT:**
| Primitive | Reason |
|---|---|
| `Command` (cmdk palette) | the goal surface has five fixed verbs — a searchable palette adds a dep and search UI for nothing (Hick's law works in our favor with a plain menu). Revisit only if goal/command vocabulary grows in Phase 5+. |
| `ScrollArea` | custom scrollbars are cosmetic; native scrolling is more accessible and free. |
| `Accordion`/`Collapsible` | native `<details>` covers reasoning/tool/observation disclosures. |
| `Sheet`/`Drawer`, `Popover`, `Select`, `Switch`, `Progress`, `Skeleton`, `Toast`/`Sonner` | no Phase 4b surface needs them; toasts specifically are rejected because failures must live in the transcript record, not ephemeral notifications (map §1.5). |
| `Table` | inspector uses definition lists / simple grids; sortable tables are not needed. |
| `Sidebar` | the existing app-shell layout already provides rails; adopting the shadcn sidebar would restructure the proven shell. |
## 5. Token additions (system change, one commit)
Add to `:root` in `devtools/browser/src/styles.css`:
- `--accent: #2f5fa8` / `--accent-surface: #e9f0fa` — pending/streaming/info
states (currently only success/warning/danger exist; pending ≠ warning).
- `--surface-raised: #fcfcfa` — inspector rows and collapsed-card summaries.
- `--motion-medium: 200ms` — banner/dialog enter (fast is too abrupt for
modal context changes); both motion tokens gated by
`prefers-reduced-motion`.
- `--z-banner: 10`, `--z-dialog: 20` — stacking discipline for the new
layered surfaces.
Contrast pairs must pass map §10-V1; verify `--accent` on `--accent-surface`
≥ 4.5:1 before merge (measured 5.1:1 at the values above).
## 6. What the implementer must NOT do
- Do not `npx shadcn add` or use the AI Elements CLI/registry into this
package (they scaffold Tailwind/radix/AI SDK). Copy source manually,
then rewrite per §1.2.
- Do not import from `ai`, `@ai-sdk/*`, `zod`, `radix-ui`, `cmdk`,
`streamdown`, or `shiki` anywhere under `devtools/browser/`.
- Do not add a second event model, message store, or client-side session
cache that could diverge from the reducer (map §7.2).
- Do not restyle existing Phase 1–3 surfaces beyond shared-token additions;
their screenshots are frozen QA evidence.
## 7. Evidence checklist for this record
- [x] Live docs re-checked 2026-08-08 (shadcn/ui Vite path; AI Elements
prerequisites: Node 18+, Next.js + AI SDK project, React 19,
Tailwind 4, shadcn/ui auto-install; registry install paths).
- [x] Existing package idiom confirmed (`ui/button.tsx` et al. — plain CSS
classes on tokens, React 19, no Tailwind).
- [x] Interaction map cross-references: every ADAPT row maps to a surface in
the [interaction map](phase-4b-interaction-map.md); every surface has a
component owner.
- [x] Annotated mockups rendered at 1440×900 and 390×844 — see
[`mockups/`](mockups/).
@@ -0,0 +1,381 @@
# Phase 4b Interaction Map — Live Codex Protocol Web UI
Status: **approved for implementation** (UX gate PAP-16832, 2026-08-08).
Owner: UXDesigner. Implementer: browser tracer owner (PAP-16834).
Companion record: [component decisions](phase-4b-component-decisions.md).
Mockups: [`mockups/`](mockups/) (annotated desktop + mobile renders).
This map is the interaction contract for the Phase 4b live console required by
spec §29.1. It binds every surface to canonical Paperclip Runner Protocol (PRP)
events and reducer state only. No surface may derive state from third-party
component message types, and no surface may show a control the upstream
capability set does not support.
## 0. Shell and information architecture
Extend the existing devtools browser app (`devtools/browser/`) with a new
**Live console** mode next to the existing Phase 1–3 modes. Do not build a
second app shell. Reuse the existing token layer in `src/styles.css` and the
existing `ui-*` primitives.
Layout (desktop ≥ 64rem):
- **Left rail (fixed, ~17rem):** demo-chat manifest list, session/connection
status block, goal banner, parent/child lineage tree.
- **Center column (fluid, max ~48rem):** chat transcript + composer. This is
the primary surface; it gets the widest column and the strongest visual
hierarchy.
- **Right rail (collapsible, ~24rem):** protocol inspector. Closed by default
on first load; its toggle lives in the header. State persists per session in
`localStorage` (UI preference only — never protocol state).
Layout (mobile < 64rem): single column. Transcript + composer first; manifest
picker, goal banner, lineage, and inspector become stacked collapsible sections
behind a segmented control (`Chat · Session · Inspector`). Composer stays
docked to the bottom of the viewport in the Chat segment.
Density: this is a diagnostic console — lean dense (Doherty: prioritize
feedback latency over decoration). All spacing from the existing scale.
## 1. Transcript and composer
**Source of truth:** the reducer timeline (`SessionSnapshot.timeline` /
`SessionItemSnapshot`), fed by `item.started` / `item.delta` /
`item.completed` / `item.failed` and turn lifecycle events.
Transcript rules:
1. Items render in reducer order. Never reorder client-side; the reducer's
sequence is authoritative (protocol authority rule).
2. Item roles: `user`, `assistant`, `reasoning`, `tool`, `system/diagnostic`.
User items align right with a filled surface; everything else aligns left
full-width. Reasoning and tool items render collapsed by default with a
one-line summary (Progressive Disclosure); assistant text renders expanded.
3. Streaming: an in-flight item (`item.started` + deltas, no terminal event)
shows a streaming affordance (pulsing cursor block, `--motion-fast`
respecting `prefers-reduced-motion`). Text appends without layout jumps.
4. Auto-follow: transcript sticks to bottom while the user is at bottom. Any
upward scroll disengages following and shows a **"Jump to latest"** pill
(fixed above the composer) with unseen-item count. Clicking or pressing
`End` re-engages. Never yank scroll position while disengaged.
5. Failed items (`item.failed`) render in place with a danger surface and the
exact diagnostic string — no toast-only errors; failures are part of the
record (Visibility of System Status).
6. Empty state: before any session, the transcript shows the manifest picker
inline ("Pick a demo chat or start a blank session") — not a bare void.
Composer states (one state machine, one visible primary action):
| State | Trigger | Primary action | Notes |
|---|---|---|---|
| `idle` | no active turn | **Send** (`turn.submitted`) | textarea enabled |
| `submitting` | send clicked, no `turn.accepted` yet | disabled, spinner in button | keep text visible |
| `active-turn` | `turn.started` for my turn | **Steer** (see §2) + **Stop** icon-button | composer relabels; input stays enabled for steering |
| `interrupting` | stop clicked | both disabled, "Stopping…" | see §3 |
| `disconnected` | transport lost | disabled + reconnect banner | see §7 |
| `terminal` | `session.closed`/`session.failed` | disabled + replay affordance | see §7 |
Composer behavior: `Enter` submits, `Shift+Enter` inserts newline. Textarea
auto-grows to 8 lines, then scrolls. The active state is announced via a
visually-hidden `aria-live="polite"` status line ("Turn running", "Steering
sent", "Turn stopped").
## 2. Steering: acknowledgement and stale-turn rejection
While a turn is active the composer's primary action is **Steer** and sends
`turn/steer` with `expectedTurnId` bound at submit time.
1. **Optimistic pending, explicit ack.** On send, append a local "steering"
chip to the transcript in a `pending` visual state (muted, spinner).
Promote it to `acknowledged` (solid, check) only on driver acknowledgement.
Never silently merge steering text into the user-message stream before ack.
2. **Stale-turn rejection is a first-class outcome, not an error toast.** If
the turn completed/failed/was interrupted between typing and send, the
driver rejects on `expectedTurnId` mismatch. The chip flips to `rejected —
turn already ended`, with the composed text preserved in an inline
**"Send as new message"** action (Forgiveness: one keystroke recovers the
intent; never discard typed input).
3. The pending chip must resolve to exactly one of `acknowledged`, `rejected
(stale turn)`, or `failed (diagnostic)` — no indefinite pending. If the
transport drops mid-steer, resolve to `failed` with the reconnect banner.
4. `aria-live="polite"` announces ack/rejection with the same copy as the
visual chip.
## 3. Interruption: three races, no session replacement
Stop is a distinct destructive-adjacent action: icon button with `Stop`
label, danger-tinted only while `active`, never adjacent enough to Send to
misclick (Fitts: minimum 2.75rem target, `--space-3` gap).
Race matrix (all three must be visually distinguishable — spec test contract):
| Race | UI before | UI after |
|---|---|---|
| interrupt **before start** (`turn.submitted`, no `turn.started`) | composer `submitting` | pending turn card resolves to `turn.cancelled` state: "Cancelled before start" |
| interrupt **during generation** | streaming assistant item | stream stops in place; item keeps partial text with an "interrupted" divider; `turn.interrupted` badge on the turn group |
| interrupt **during tool** | tool item running | tool item resolves to interrupted state with its last known status; no fabricated tool result |
Rules:
1. Stop → `interrupting` state ("Stopping…", both actions disabled) until the
terminal turn event arrives. If the driver reports interrupt unsupported,
show the exact diagnostic and re-enable — never kill or replace the session
(spec: "without silently replacing the session").
2. Partial output is preserved and labeled; never blank an interrupted item
(the partial record is the point of a tracer).
3. After `turn.interrupted`, composer returns to `idle` — resume is just a new
send. The turn group shows its terminal badge permanently.
## 4. Runtime request cards (command / file-change / permission / tool / user-input)
**Source of truth:** `runtime_request.created` / `.resolved` / `.expired` /
`.cancelled` plus `SessionRequestSnapshot` (`requestId`, `requestKind`,
`type`, `status`, `prompt`). Action availability comes from the upstream
request contract per request — the card renders only the actions the request
offers (never invent "approve for session" if the request does not offer it).
Placement: inline in the transcript at timeline position, plus a compact
"pending requests" counter chip in the session status block that scrolls to
the oldest pending card. A pending blocking request also pins a slim banner
above the composer ("1 request waiting — Review") because the turn cannot
proceed (Zeigarnik: make the open loop visible where the user is looking).
Card anatomy (one component, five kinds):
- Kind icon + kind label (`Command`, `File change`, `Permission`, `Tool`,
`Input`) + requestId (mono, truncated, full value in tooltip/inspector).
- Prompt/body: exact upstream prompt text. Command and file-change payloads
render in a mono block (command line, file diff summary) — never paraphrase.
- Action row, in this order when offered: **Approve** (primary),
**Approve for session** (secondary, only when upstream offers it),
**Reject** (quiet danger), **Cancel** (ghost, only when upstream allows
requester-side cancel). User-input kind renders a text field + **Submit**
instead of approve/reject.
- Resolution states: `pending` (accent left-border), `resolved — <outcome>`
(success surface, chosen action named, actor + time), `expired` (muted,
"expired before response"), `cancelled` (muted). Resolved cards collapse to
a single summary line, expandable (transcript stays scannable).
Rules:
1. **Single-response guarantee in the UI:** on first click, disable the whole
action row and show a resolving spinner; the card resolves only on the
`runtime_request.resolved` event, not optimistically. A late duplicate
click therefore cannot double-respond (mirrors the driver's no-double-
response contract).
2. A request resolved elsewhere (driver timeout, upstream cancel) must update
the card in place with the true outcome.
3. Keyboard: cards are focusable list items; action row is reachable by `Tab`;
see §10 for the full contract.
## 5. Goal capability states (set / view / pause / resume / clear)
**Source of truth:** capability negotiation from the app-server (per spec
§29.1.5), surfaced through the driver's capability snapshot. Goal state
changes arrive as canonical events; the banner derives from reducer state.
Surfaces:
- **Durable goal banner** in the left rail (and Session segment on mobile):
shows active goal text, state (`active`/`paused`), and last transition time.
Hidden entirely when no goal has ever been set this session; shows a quiet
"No goal set" row once goal capability is confirmed supported.
- **Goal menu** (not a fuzzy palette — five fixed operations, Hick's law):
a menu button `Goal ▾` in the session status block opening a menu with
`Set goal…`, `View`, `Pause`, `Resume`, `Clear`. `Set goal…` opens a small
dialog with one textarea. `View` focuses/expands the banner. Operations
invalid in the current goal state (e.g. `Resume` while active) are disabled
with the reason in a tooltip and `aria-disabled` + description.
Capability gating (spec-critical):
1. If the installed app-server does not advertise goal support, the whole menu
renders disabled with the **exact diagnostic** from capability negotiation
(e.g. `Goal operations unsupported: app-server 0.132.0 does not advertise
goals capability`) — copied verbatim into tooltip and inspector. Never hide
the control silently (discoverability of the gap is a deliverable) and
never fabricate a client-side goal.
2. Partial support (some verbs missing) disables only the missing verbs, same
exact-diagnostic rule per verb.
3. Every goal mutation is also a transcript/system event ("Goal paused") so
replay shows the same history.
## 6. Parent/child lineage and subagent activity
**Source of truth:** upstream thread identities (parent/child thread lineage
from the driver; `normalizedSessionId` / thread ids). No client-side
inference.
- Left-rail **lineage tree**: root session plus child threads, each row =
name/id (mono, truncated) + live state dot (`starting`, `running`,
`terminal-ok`, `terminal-failed`) + last-activity relative time. The row for
the currently viewed thread is marked with `aria-current="true"`.
- Selecting a child switches the center transcript to that child's timeline
(read-only if the driver does not support steering children). Breadcrumb at
the transcript top: `root ▸ child-a` for orientation (Jakob: matches file
tree/thread conventions).
- **Unsupported child steering renders as an explicit disabled composer** with
the exact diagnostic ("Direct steering of child threads is not supported by
this app-server"), not an absent composer and not an emulated one (spec:
"shown as unsupported rather than emulated").
- Child terminal states persist in the tree (terminal dot + badge); activity
is history, not just presence.
## 7. Reconnect, refresh, resume, replay
**Source of truth:** transport status + `session.resuming` / `session.resumed`
/ `session.reconciled` / `runner.reconnected` events and reducer cursor state.
1. **Connection status** lives in the session status block: `connected`,
`reconnecting (attempt n)`, `disconnected`, with the socket/cursor detail
in the inspector. State changes announce via `aria-live="polite"`.
2. **Transport drop:** banner above composer ("Connection lost — reconnecting…
/ Retry now"). Composer disabled while disconnected (§1). On resume, the
reducer replays from the durable cursor; the transcript must end
byte-identical to a never-disconnected session (live/replay parity is a
test contract, and the UI must not maintain shadow state that could
diverge).
3. **Page refresh:** reload restores the same normalized session and renders
the full replayed transcript, then continues live. A subtle divider marks
"replayed to here" for diagnostic honesty; it disappears on the next live
event group.
4. **Replay mode:** a terminal session offers **Replay** (from the manifest
list or terminal banner): re-runs the recorded canonical events through the
reducer with a stepper (play/pause, step, position slider). Replay is
visually distinguished by an eyebrow badge `REPLAY` in the header — never
let replay be mistakable for live (trust).
5. Never offer "start new session" as the recovery path for a drop — resume
the exact session (Phase 4 rule: never replace a lost session).
## 8. Protocol inspector
**Source of truth:** the raw upstream method/event names and the canonical
event stream, side by side. The inspector is the debugging surface; the
transcript stays clean (Tesler: complexity lives here, deliberately).
Right-rail panel with four tabs:
- **Events:** virtualized list, one row per canonical event — `seq`,
`eventType`, turn/item id (mono). Row expands to the full canonical payload
and, when present, the raw upstream method/notification that produced it
(`debug` channel). Filter box (plain substring) + eventType facet chips.
Clicking a transcript item highlights its events and vice versa
(Uniform Connectedness across panels).
- **Requests:** pending + resolved runtime requests with full lifecycle
timestamps; row links to the transcript card.
- **Capabilities:** negotiated capability/version table (steer, interrupt,
goals, child threads, usage…), each row `supported / unsupported —
<exact diagnostic>`. This is the authoritative view backing every disabled
control in §5/§6.
- **Session:** identities (runId, normalizedSessionId, thread ids,
sourceInstanceId), sequence/cursor state, gap detection status, usage
counters, connection history.
Rules: monospace for identifiers and payloads; copy-to-clipboard per row;
**redaction is upstream's job but the inspector must render redaction markers
verbatim and never attempt client-side un-redaction or pretty-printing that
could reconstruct secrets**. No credential ever reaches the browser (spec
security rule), so the inspector renders only what the demo server sends.
## 9. Demo-chat manifests
**Source of truth:** deterministic manifest files shipped with the package
(one per spec scenario: completion, steering, interruption/resume, approvals,
user input, subagents, goals, reconnect, replay).
- Manifest list in the left rail: name, one-line purpose, scenario badges,
and an **expected observations** disclosure listing exactly what the runner
should show (from the manifest, not hand-written in the UI).
- Running a manifest pins a compact **observation checklist** panel the user
can tick off manually (recognition over recall — the human checkpoint
script is embedded in the UI, not in a separate doc).
- **Reset control** per manifest and a global "Reset demo state": resets
demo-server state and clears the local session, with a confirm dialog
naming exactly what is destroyed ("Discards the current live session
transcript. Recorded evidence files are not touched.").
- Manifests must be runnable in any order; the list shows last-run outcome
per manifest (`passed observations`, `not run`, `interrupted`).
## 10. Keyboard and accessibility acceptance criteria
These are the testable ACs for the component/keyboard/a11y checks required by
the spec evidence list. Implementation must satisfy every row; QA verifies
each with keyboard-only and screen-reader passes.
**Keyboard**
- K1. Every interactive control is reachable and operable with `Tab`/
`Shift+Tab`/`Enter`/`Space` alone; no keyboard trap anywhere, including
dialogs and the inspector.
- K2. Composer: `Enter` sends (or steers when a turn is active),
`Shift+Enter` newline. `Escape` while a turn is active moves focus to the
Stop button (it must never interrupt directly — destructive actions require
explicit activation).
- K3. Request cards: card list is an `role="list"`; each card's actions are
reachable in visual order; the oldest pending card is focusable via the
pending-requests banner (`Enter` on banner focuses it).
- K4. Goal menu: standard menu-button pattern (`Enter`/`Space`/`ArrowDown`
opens, arrows navigate, `Escape` closes, focus returns to trigger).
- K5. Lineage tree: `role="tree"` arrow-key navigation; `Enter` selects a
thread; selection is announced.
- K6. Transcript: `End` jumps to latest and re-engages follow; `Home` jumps
to first item; transcript region is a labeled `role="log"` landmark.
- K7. Inspector tabs: standard tabs pattern (arrow keys switch, `Tab` enters
panel); event rows expand/collapse with `Enter`.
- K8. Replay stepper: `Space` play/pause, `ArrowLeft`/`ArrowRight` step,
slider operable by arrows with value announced.
- K9. Visible focus indicator (`--ring`, ≥2px, ≥3:1 contrast against both
adjacent colors) on every focusable element; focus order matches visual
order.
**Screen reader / semantics**
- A1. Transcript is `role="log"` with `aria-live="polite"`; streaming deltas
do **not** announce per-token — announce on item completion and on state
transitions only (avoid SR flooding).
- A2. Turn/steer/interrupt/connection/goal state changes each produce exactly
one polite announcement with the same copy as the visible state.
- A3. A pending blocking request announces assertively once
(`aria-live="assertive"` on the banner), then stays quiet.
- A4. Every state conveyed by color (status dots, card borders, badges) has a
text or icon equivalent (WCAG 1.4.1 color-independence).
- A5. Disabled capability controls expose the exact diagnostic via
`aria-describedby`, not only a hover tooltip.
- A6. Dialogs (`Set goal…`, reset confirm) are proper modal dialogs: labeled,
focus moved in, trapped, restored on close.
- A7. All identifiers/payload blocks are in `<code>`/`<pre>` semantics; copy
buttons are labeled with what they copy ("Copy event 42 payload").
**Visual/WCAG**
- V1. Text contrast ≥ 4.5:1 (normal) / 3:1 (large); non-text UI ≥ 3:1 —
verify the token pairs used by new surfaces (notably `--muted-foreground`
on `--muted` and status-on-surface pairs).
- V2. Pointer targets ≥ 2.75rem (44px) on mobile breakpoints; Stop and Send
separated by ≥ `--space-3`.
- V3. `prefers-reduced-motion` disables streaming pulse, auto-scroll
smoothing, and skeleton shimmer (fall back to instant updates + static
placeholders).
- V4. Layout holds at 320px width and 200% zoom without horizontal scroll of
the transcript (WCAG 1.4.10 reflow); the inspector may scroll internally.
- V5. Both viewports in evidence: 1440×900 and 390×844 screenshots for every
major state (QA gate).
## 11. UX acceptance criteria for the implementation handoff
1. All surfaces derive from canonical events/reducer snapshots; grep-level
check: no import from any AI SDK, and adapted component files contain no
third-party message-type imports (see decision record).
2. Every state named in §1–§9 is reachable via the demo manifests, and each
has a distinct, labeled visual state (no two states share identical UI).
3. All K/A/V criteria in §10 pass keyboard-only and screen-reader passes.
4. Unsupported capabilities render exact diagnostics (§5, §6, §8-Capabilities)
— zero fabricated controls, zero silently hidden controls.
5. Token compliance: no raw color/spacing/radius/type values in new component
files; everything through `src/styles.css` custom properties (extend the
token set via PR if a needed token is missing — see decision record §5).
6. Screenshots at both evidence viewports for: idle+empty, streaming turn,
steering ack + stale rejection, all three interrupt races, each request
kind (pending/resolved/expired), goal supported+unsupported, lineage with
an active child, reconnect banner + replayed divider, replay mode,
inspector (Events + Capabilities tabs), and one full mobile pass.
@@ -0,0 +1,70 @@
# Phase 5 SDK Extraction Decision Record
Date: 2026-08-08
Status: implemented from the approved [Phase 5 component plan](phase-5-component-plan.md)
## Outcome
The accepted Phase 4b transport and reducer contracts were copied into a
versioned `0.1.0` public surface. The reference console and a deliberately
small second consumer import only public package subpaths. Phase 4b and the
Phase 1–3 surfaces remain the comparison baseline.
## Promoted contracts
The implementation promotes the framework-free client, `useRunnerConsole`,
all approved primitives and protocol views, and `RunnerConsoleApp`. Reducer
state and canonical events remain the only rendering authority. File, tool,
plan, terminal, failure, request, goal, lineage, connection, and replay views
do not maintain private protocol state.
The package exports `.`, `./browser`, `./react`, and `./styles.css`. React and
React DOM are peers; the surface adds zero runtime dependencies.
## Extension surface
The implementation has exactly the five approved extension points:
1. item-body renderer;
2. request-detail renderer;
3. Composer leading and trailing slots;
4. scoped `--pcr-*` token overrides;
5. Fetch/EventSource/base-URL transport injection.
The mini consumer demonstrates all five. No new extension registry or
headless contract was added during implementation.
## Kept out
Markdown `Response`, highlighted `CodeBlock`, a persistent Context meter, a
command palette, headless distribution, dark mode, Tailwind/Radix, toasts,
and transcript virtualization remain rejected for the reasons in the approved
plan. Default text and `<pre>` output keeps protocol evidence inspectable;
consumers can opt into rich item bodies through the one renderer contract.
## Implementation findings
- Event IDs must not be deduplicated by the hook. Exact duplicate delivery is
reducer input, so the shared reducer remains the sole duplicate authority.
- Node types are required only for browser-test and Vite configuration. They
are not part of the browser runtime or public dependency surface.
- Token enforcement must inspect CSS custom-property definitions as well as
component usage. The `pcr-` namespace is enforced across SDK sources.
- The reference console renders one React tree selected at the 900px
breakpoint. Rendering two trees would duplicate landmarks, test IDs, and
live regions.
- A real provider may explicitly disable goals. The consumer gates buttons on
that capability and preserves the upstream diagnostic instead of emulating
a goal.
- Real-provider steering timing is not a stable visual fixture. Fake-driver
tests own race coverage; a safe real completion owns transport, identity,
redaction, reconnect, and replay evidence.
- Minimal hosts may lack Playwright shared libraries. The package rootless
helper runs the same browser commands from a run-owned cache.
## Compatibility rule
`0.1.0` is the first frozen SDK surface. Any later removal or semantic change
to an export, hook field, component prop, `data-slot`, extension point, or
token needs a versioned compatibility decision. Additive protocol fields stay
forward-compatible; the schema and shared reducer remain authoritative.
@@ -0,0 +1,297 @@
# Phase 5 Component and SDK Surface Plan — Reference Console and Reusable Contracts
Status: **approved** (UX gate PAP-16842, 2026-08-08). This is the binding
UX/component plan for the Phase 5 implementation (PAP-16843). It extends the
approved [Phase 4b interaction map](phase-4b-interaction-map.md) and
[Phase 4b component decision record](phase-4b-component-decisions.md); where
this document is silent, those documents govern.
Inputs reviewed for this gate: the accepted Phase 4b live console
(`devtools/browser/`, shipped `96092b832d`, corrected `cda1fb03ab`, QA
evidence `09b91b63f7` — 23 accepted screenshots in
`knowledge/evidence/phase-04b/` at 1440×900 and 390×844), the Phase 4b
reducer/protocol contracts (`src/reducer/session-reducer.ts`,
`src/protocol/`), and the live client layer
(`devtools/browser/src/live/{client,protocol,transcript-model,use-live-console}.ts`).
## 0. Scope and boundary
- Everything lands under `packages/paperclip-runner/` on branch
`PAP-16679-paperclip-runner`. **Do not modify or import production
`server/`, `ui/`, `packages/db/`, routes, persistence, or legacy
adapters.**
- Extraction is **copy-and-freeze, not move**: the Phase 4b demo console at
`devtools/browser/` and its Phase 1–3 surfaces stay untouched — their
screenshots are frozen QA evidence. The SDK is a new, versioned copy with
stable names.
- The canonical PRP event stream and `SessionSnapshot` reducer remain the
only UI data contract (Phase 4b ground rule §1.1 carries forward
unchanged).
## 1. Public package surface
Add subpath exports to `@paperclipai/paperclip-runner`:
| Subpath | Contents | Dependencies |
|---|---|---|
| `.` | existing node/driver contracts (unchanged) | none |
| `./browser` | framework-free session client: HTTP + SSE transport functions (today's `live/client.ts`), wire types (today's `live/protocol.ts`), transcript model, re-exported reducer | none |
| `./react` | hooks + components (today's `use-live-console.ts` + `components/ui/*` + `live/*` views) | `react`/`react-dom` as **peer** deps only |
| `./styles.css` | token sheet + component styles, scoped under `.pcr-root` | none |
Rules:
1. **Zero new runtime dependencies** (carried from Phase 4b §1.3). `react`
and `react-dom` become declared peer dependencies of the `./react`
subpath; nothing else is added. No Tailwind, radix, cva, AI SDK, zod,
shiki, streamdown — same ban list as Phase 4b §6.
2. **Public naming drops `live`/`phase4b`.** Renames (shape unchanged):
`useLiveConsole` → `useRunnerConsole`, `LiveConsole` (interface) →
`RunnerConsole`, `LiveSessionState` → `RunnerSessionState`,
`LiveConnectionStatus` → `ConnectionStatus`, `LiveManifestSummary` →
`ManifestSummary`, `LiveConsoleError` → `RunnerClientError`.
`SessionSnapshot`, `SessionItemSnapshot`, `SessionRequestSnapshot`,
`SequenceGap`, `PrpEvent` are already canonical and keep their names.
3. **The hook contract is frozen as shipped.** `useRunnerConsole` keeps the
accepted `RunnerConsole` shape: `manifests`, `snapshot`, `transcript`,
`connection`, `reconnectAttempt`, `replayParity`, `composer`,
`steeringChips`, `announcement`, `error`, `busy`, thread selection, the
`replay` control group, and the verbs `start/send/steer/interrupt/
resolve/goal/dropConnection/retryNow/reset/dismissSteeringChip`. The
`announcement` string is a load-bearing accessibility channel (map §10
A-criteria) and must remain in the public contract.
4. **Semver discipline.** Any change to an exported prop shape, class name,
token name, or keyboard behavior after extraction requires a decision
recorded in this document's follow-ups, not a silent edit.
## 2. Component contracts
All components: plain function components, `data-slot` attributes, semantic
class names, every visual value from the token layer. SDK class names and
tokens use the **`pcr-` prefix** (see §4) so they cannot collide in consumer
apps; the frozen `ui-*` demo classes stay behind in `devtools/browser/`.
Promoted primitives (from Phase 4b REUSE/ADAPT, contracts unchanged):
| Component | Props typed against | Keyboard contract |
|---|---|---|
| `Button` (default/danger/ghost) | native button props | native |
| `Badge` | tone + children | n/a |
| `Card` | native section props | n/a |
| `Textarea` | native | native |
| `Tabs` | items + selected + onSelect | WAI-ARIA tabs, roving tabindex, arrow keys (map K) |
| `Menu` (menu-button) | fixed item list + onSelect | menu-button pattern: arrows, Home/End, Escape |
| `Dialog` | open/onClose/title/children | native `<dialog>` `showModal()`: focus trap + Escape |
| `Tooltip` | content mirrored to `aria-describedby` | focus-visible trigger; never the only channel (A5) |
| `Banner` | tone (info/warning/danger/success) + actions | actions are real buttons in tab order |
Promoted console components:
| Component | Props typed against | Notes |
|---|---|---|
| `Conversation` | `TranscriptEntry[]` + unseen count | `role="log"`, stick-to-bottom + "jump to latest" as accepted |
| `Message` | `SessionItemSnapshot` | roles: user/assistant/reasoning/tool/system |
| `Composer` | `ComposerState` + submit/steer/stop verbs | Enter submits, Shift+Enter newline; Send/Steer/Stop tri-state per map §1–§3 |
| `ReasoningItem` | `SessionItemSnapshot` | `<details>` disclosure, auto-open while streaming |
| `ToolItem` | `SessionItemSnapshot` | header/input/output sections, status from canonical events |
| `RequestCard` | `SessionRequestSnapshot` + resolve verb | one card per pending request kind; terminal states preserved (PAP-16839 semantics) |
| `SessionTimeline` (today's `SessionPanel`) | `SessionSnapshot` | timeline, lineage tree, goal state |
| `Inspector` | `SessionSnapshot` + `PrpEvent[]` | Events/Session tabs; copy affordance stays local |
| `ReplayControls` | `RunnerConsole["replay"]` | scrub/step/play; arrow-key step |
| `ConnectionBanner` | `ConnectionStatus` + retry verb | reconnect/backoff states as accepted |
Layout components (`.pcr-root` shell, three-pane grid, mobile segments) are
exported as the **reference console** (`RunnerConsoleApp`) — see §8. Grid
tracks that hold identifiers must use `minmax(0, 1fr)` with `min-width: 0`
at every level (measured Phase 4b regression; do not rely on CSS reasoning —
verify with `getBoundingClientRect` in the browser tests).
## 3. Extension points (exactly these five)
1. **Item body renderer.** `Conversation`/`Message`/`ReasoningItem`/
`ToolItem` accept optional `renderItemBody?(item: SessionItemSnapshot) =>
ReactNode`. Default remains exact plain text. This is where consumers add
markdown or syntax highlighting — which is why `Response` and `CodeBlock`
stay out of the core (§9).
2. **Request detail renderer.** `RequestCard` accepts optional
`renderRequestDetail?(request: SessionRequestSnapshot) => ReactNode` for
the payload section only. Status header, resolution controls, and
terminal-state behavior are not overridable.
3. **Composer slots.** Optional `leadingActions`/`trailingActions`
ReactNode slots in the action row. Submit semantics, tri-state button,
and keyboard behavior are fixed.
4. **Token theming.** Consumers restyle exclusively by overriding `--pcr-*`
custom properties on `.pcr-root` (or a descendant scope). Class names and
DOM structure are not a supported theming API.
5. **Transport injection.** The `./browser` client accepts `{ baseUrl,
fetchImpl?, eventSourceFactory? }` so consumers can add auth headers,
proxies, or test doubles without forking the client.
Anything not listed is **not** an extension point. No render-prop shells, no
headless mode, no component-swap registry in Phase 5.
## 4. Design-token rules
1. The Phase 4b token inventory (color/status pairs, accent, surfaces, type
scale, spacing, radii, shadow, motion, z-index, layout, touch-target)
carries over verbatim, renamed `--background` → `--pcr-background` etc.,
declared on `.pcr-root` (not `:root`) in the shipped `./styles.css`.
2. `color-scheme: light` is declared on `.pcr-root`. **Dark mode is a
non-goal** for Phase 5 and is recorded as such (§9).
3. No inline visual values in any SDK component — extend
`check:browser-tokens` to cover the SDK source directories.
4. New tokens are a system change: add to the sheet and record the addition
in this document in the same commit (Phase 4b §5 rule).
5. Motion tokens stay gated by `prefers-reduced-motion`.
6. Shipped default pairs must keep ≥ 4.5:1 contrast (map V1). The package
docs must list the foreground/surface pairs so a consumer who overrides
tokens knows exactly which combinations to re-verify; contrast for
overridden values is the consumer's responsibility and the docs say so.
## 5. Responsive states
- Reference console: three-pane (rail / transcript / inspector) at desktop,
single-pane with segment tabs at mobile, exactly as accepted in Phase 4b
(map §0). Canonical review viewports stay 1440×900 and 390×844.
- **Single-tree rule (binding):** one rendered layout selected via a
`matchMedia` hook (breakpoint constant 900px). Never render both layouts
and hide one with CSS — it duplicates every `data-testid`, control, and
ARIA landmark (Phase 4b lesson, recorded in the 4b journal).
- Component minimum widths (document in package docs): `Conversation` and
`Inspector` ≥ 320px, `RequestCard` ≥ 280px, `Composer` ≥ 320px. Components
must not overflow their container at these widths with realistic long
identifiers (verify in-browser, not by CSS inspection).
- Touch targets ≥ `--pcr-touch-target` (2.75rem) for all mobile controls.
## 6. Keyboard behavior
Map §10 K1–K9 carry forward as SDK acceptance criteria, owned per component:
- `Composer`: Enter submits (Send or Steer per state), Shift+Enter inserts
newline, disabled states never trap focus.
- `Tabs`: arrow-key roving tabindex, Home/End.
- `Menu`: menu-button pattern — Enter/Space/ArrowDown opens, arrows cycle,
Escape closes and restores focus to the trigger.
- `Dialog`: native `showModal()` focus trap, Escape closes, focus returns to
the invoking control.
- `ReasoningItem`/`ToolItem`: `<details>` summary toggles with Enter/Space.
- `ReplayControls`: Left/Right step one event, Space toggles play when the
scrubber has focus.
- Banners: focus is moved to a banner only when it blocks the primary action
(reconnect-failed); informational banners never steal focus.
## 7. Accessibility acceptance criteria
Map §10 A1–A7 and V1–V5 are the SDK acceptance bar, verified on the
reference console **and** the mini consumer:
1. Transcript is `role="log"`; state changes surface through the hook's
`announcement` channel into a single `aria-live="polite"` region.
2. Every interactive control has an accessible name; tooltips mirror to
`aria-describedby` and are never the only channel (A5).
3. Status is never color-only — badge text/icon carries the distinction
(V-criteria).
4. Rebuilt-on-native patterns (dialog, menu, tabs, disclosure) meet the
WAI-ARIA pattern they replace; "the library would have done it" remains
unavailable as an excuse (Phase 4b §1.4).
5. Components keep their ARIA and keyboard contracts under any token
override — theming can never remove semantics.
6. `prefers-reduced-motion` disables all non-essential motion.
## 8. Reference console and the second SDK consumer
**Reference console** (`RunnerConsoleApp`): a standalone app entry that
composes only public SDK exports and reproduces the accepted Phase 4b
lifecycle. It is the first consumer and the visual-truth surface for review
screenshots. It must not import from `devtools/browser/` demo internals.
**Mini consumer** (the tracer-bullet gate): a deliberately minimal second
app (suggested location `examples/mini-consumer/`) that proves the SDK
surface is sufficient and the extension points are real:
- Imports **only** `@paperclipai/paperclip-runner/browser`, `/react`, and
`/styles.css` — extend `check:forbidden-imports` to fail on any deep or
demo-internal import from either consumer.
- Single-column layout using only `Conversation`, `Composer`, `RequestCard`,
`ConnectionBanner`, and `ReplayControls`. It is intentionally not a second
console: no rail, no inspector, no goal menu.
- Scripted flow (against the fake scripted driver and the real Codex
driver): list manifests → create session → send a turn → steer mid-turn →
interrupt → resolve one command request → set and clear a goal via the
hook verb → drop connection → automatic reconnect with gap recovery →
enter replay and scrub → show `replayParity`.
- Exercises extension points: a custom `renderItemBody` (e.g. wraps assistant
text with a visible marker) and at least one `--pcr-*` token override,
both visible in screenshots.
## 9. Component decision record — Phase 5
Carried ground rules: protocol authority (§1.1), source adaptation over
dependency adoption (§1.2), zero new runtime deps (§1.3), accessibility
parity (§1.4), token gaps are system changes (§1.5).
**PROMOTE to SDK (copy from accepted Phase 4b, rename classes/tokens to
`pcr-`):** Button, Badge, Card, Textarea, Tabs, Menu, Dialog, Tooltip,
Banner, Conversation, Message, Composer, ReasoningItem, ToolItem,
RequestCard, SessionTimeline, Inspector, ReplayControls, ConnectionBanner,
plus the `useRunnerConsole` hook, transcript model, and browser client.
**Phase 4b "Phase 5 candidates" — re-decided for Phase 5:**
| Candidate | Decision | Reason |
|---|---|---|
| `Response` (streamed markdown) | **REJECT in core** | markdown pipelines pull deps and can hide protocol truth; served by the `renderItemBody` extension point instead |
| `CodeBlock` (shiki) | **REJECT in core** | same; `<pre>` + mono token remains the default, consumers plug highlighters via `renderItemBody` |
| `Context` (token/cost meter) | **REJECT as persistent meter** | usage stays plain data in the Inspector Session tab; a meter overweights cost in a protocol surface |
| `Command` palette | **REJECT** | goal vocabulary is still five fixed verbs; plain menu wins on Hick's law and zero deps |
**NEW REJECT (Phase 5):**
| Proposal | Reason |
|---|---|
| Headless/unstyled distribution | doubles the API surface and QA matrix before there is a second real consumer; token theming covers stated needs |
| Dark theme | no consuming surface requires it yet; a second palette doubles every V-criterion — record as explicit non-goal, revisit at product integration (Phase 6) |
| Tailwind/radix port | contradicts the zero-dep rule and the accepted idiom |
| Toast/notification layer | failures must live in the transcript record, not ephemeral toasts (map §1.5) |
| Virtualized transcript | no evidence of need at accepted session sizes; adds focus/scroll a11y risk. Revisit with data if real sessions exceed ~2k items |
## 10. Review evidence requirements (binding on PAP-16843)
1. Implementation handoff must attach screenshots at **1440×900 and
390×844**: reference console in idle/empty, streaming turn, steering
acknowledged, interrupt, request pending, request resolved, goal set,
reconnect banner, replay mode, and failed turn; mini consumer in idle,
streaming, request pending, reconnect, replay — including one shot each
proving the custom `renderItemBody` and the token override.
2. Real-driver evidence: at least one reference-console screenshot from a
live Codex session (Phase 4b QA recipe applies).
3. The implementer creates a **UX re-review child issue assigned to
UXDesigner** with those screenshots before the Phase 5 contract review
(PAP-16845) is asked to approve. Code inspection alone is not a UX pass.
## 11. Acceptance criteria for PAP-16843 (UX gate)
- AC1 Subpath exports `.`, `./browser`, `./react`, `./styles.css` exist with
the dependency rules of §1; public names per §1.2; hook contract per §1.3.
- AC2 All §2 components exported with props typed against reducer/protocol
shapes; `data-slot` attributes and `pcr-` classes throughout.
- AC3 Exactly the five §3 extension points, demonstrated in the mini
consumer.
- AC4 Token sheet scoped to `.pcr-root`, `pcr-`-prefixed, light-only,
lint-enforced; contrast pairs documented.
- AC5 Responsive rules of §5 hold, verified by in-browser measurement at
both canonical viewports.
- AC6 Keyboard contracts of §6 and accessibility criteria of §7 pass on both
consumers (component + browser tests reference K/A/V ids).
- AC7 Mini consumer runs the full §8 flow against fake and real drivers with
no demo-internal imports (lint-enforced).
- AC8 Reference console reproduces the accepted Phase 4b lifecycle from
public APIs only; Phase 4b demo and Phase 1–3 surfaces are byte-untouched.
- AC9 Evidence per §10 attached; UX re-review issue created.
## 12. Non-goals
Production `ui/` integration (Phase 6, gated on board acceptance + CTO
design approval), dark mode, i18n, SSR support (document client-only), npm
publishing/registry work, and any second event model or client-side session
cache (map §7.2 stands).
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
# Native Runner Tutorials
The tutorials are cumulative and are always run from the repository root.
Each tutorial starts with an explanation of what the phase is and what the phase proves.
## Implemented phases
- [Phase 0: standalone tracer](tutorials/phase-00-standalone-tracer.md) — install the workspace, validate its Rust/TypeScript boundary and knowledge bundle, then run the deterministic Rust mock-core path and cross-language parity check.
- [Phase 1: static PRP replay](tutorials/phase-01-static-replay.md) — validate the shared schema/fixture corpus, reduce a fixture in the CLI, and inspect the same final snapshot in the standalone browser page.
- [Phase 2: local runner and fake harness](tutorials/phase-02-local-runner.md) — run a supervised Rust process, exercise scripted live scenarios, resolve requests, interrupt a turn, and confirm live/replay parity.
- [Phase 3: break recovery on purpose](tutorials/phase-03-break-recovery.md) — lose an ACK, drop the socket, restart the runner side, replay durable events, and inspect recovery diagnostics.
- [Phase 4: run the skillless Codex driver](tutorials/phase-04-skillless-codex.md) — run a safe real-model task, inspect the exact context boundary, steer or interrupt it, and confirm one replay-safe result.
- [Phase 4b: run the protocol demo server](tutorials/phase-04b-protocol-server.md) — exercise the server-only Codex boundary, canonical replay, typed controls, and reconnect with `curl`.
- [Phase 4b: run the live Codex protocol console](tutorials/phase-04b-live-console.md) — chat with a live session in the browser, steer it, stop it three ways, answer its requests, change its goal, break its connection, and replay the record.
- [Phase 5: run the SDK console and mini consumer](tutorials/phase-05-sdk-console.md) — exercise the versioned public browser/React surface in two independent consumers against fake and real drivers.
- [Phase 6: run the thin Paperclip adapter](tutorials/phase-06-thin-paperclip-adapter.md) — prove mock/real conformance, run one feature-flagged local task, inspect replay/finalization, disable the kill switch, and verify legacy fallback.
- [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase.
## Reference
- [Architecture and dependency boundary](architecture.md)
- [PRP compatibility and versioning policy](protocol-compatibility.md)
- [Phase 2 local protocol and supervision](phase-02-local-protocol.md)
- [Phase 3 durable transport and recovery](phase-03-durable-transport.md)
- [Phase 4 skillless Codex driver](phase-04-skillless-codex-driver.md)
- [Phase 4b protocol and demo server](phase-04b-protocol-server.md)
- [Phase 4b live console](phase-04b-live-console.md)
- [Phase 5 browser SDK and reference console](phase-05-sdk.md)
- [Phase 6 thin Paperclip adapter](phase-06-thin-paperclip-adapter.md)
- [Engineering journal guide](journal.md)
- [Dated shadcn/ui and AI Elements compatibility note](research/2026-08-07-ui-library-compatibility.md)
- [Phase 4b live-console interaction map](design/phase-4b-interaction-map.md)
- [Phase 4b component decision record (shadcn/ui, AI Elements)](design/phase-4b-component-decisions.md)
- [Phase 5 component and SDK surface plan](design/phase-5-component-plan.md)
- [Phase 5 SDK extraction decision record](design/phase-5-component-decisions.md)
- [Phase 6 thin Paperclip adapter boundary](design/phase-6-thin-paperclip-adapter.md)
- [Package README](../README.md)
Phase 6 is implemented as a default-off server integration at the public
package boundary. Production Paperclip UI integration remains deferred.
+81
View File
@@ -0,0 +1,81 @@
# Engineering Journal Guide
The `knowledge/` directory is a package-local Google Open Knowledge Format
(OKF) v0.2 bundle. It uses ordinary Markdown concept files with YAML
frontmatter, progressive-disclosure indexes, and a chronological `log.md`.
## Bundle layout
```text
knowledge/
index.md
log.md
journal/
index.md
YYYY-MM-DD-phase-NN.md
evidence/
index.md
YYYY-MM-DD-phase-NN-verification.md
templates/
index.md
journal-entry.md
```
## Add an entry
1. Open the [journal entry template](../knowledge/templates/journal-entry.md).
2. Copy the fenced template into `knowledge/journal/YYYY-MM-DD-topic.md`.
3. Set a descriptive `type`; phase journals use
`Engineering Journal Entry` plus `phase` and `entry_kind` extension fields.
4. Record `generated` with the OKF actor convention and an ISO 8601 UTC instant.
5. Include decisions, evidence, failures, known gaps, and follow-up questions.
6. Link the entry from `knowledge/journal/index.md` and update
`knowledge/log.md`.
7. Run `pnpm --filter @paperclipai/paperclip-runner docs:validate`.
OKF v0.2 requires only `type`, but this bundle also requires `title`,
`description`, and `generated` so engineering entries remain searchable and
auditable. Unknown producer-defined fields remain valid.
## Query entries
List journal concepts:
```sh
rg -l '^type: Engineering Journal Entry$' packages/paperclip-runner/knowledge
```
Find Phase 0 entries:
```sh
rg -l '^phase: "0"$' packages/paperclip-runner/knowledge
```
Find unresolved questions or failures:
```sh
rg -n '^## (Failures|Follow-up questions)$' packages/paperclip-runner/knowledge/journal
```
Find concepts by tag:
```sh
rg -l '^tags: .*boundary' packages/paperclip-runner/knowledge
```
Indexes are the human and agent entry point; `rg` is the deterministic local
query path. No database or Paperclip service is required.
## Validation contract
The OKF validator enforces:
- root `okf_version: "0.2"` and reserved index/log rules;
- required concept frontmatter and valid lifecycle values;
- OKF actor and UTC timestamp syntax in `generated`;
- typed phase journal extension fields;
- ISO date headings in `log.md`;
- index coverage for concepts and child directories.
The [OKF v0.2 specification](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)
remains authoritative; the local requirements only tighten authoring quality.
@@ -0,0 +1,145 @@
# Phase 2 Local Protocol and Supervision Reference
## Scope
Phase 2 proves one native local session. It uses no Paperclip service, database,
network provider, or model. The TypeScript mock core, Rust runner, and Rust fake
harness are all inside `packages/paperclip-runner/`.
## Processes and transports
| Boundary | Transport | Input | Output |
|---|---|---|---|
| Mock core → runner | stdin JSONL | `paperclip.prp.command.v1` | — |
| Runner → mock core | stdout JSONL | — | `paperclip.runner.stream.v1` |
| Runner → fake harness | stdin JSONL | `paperclip.fake_harness.command.v1` | — |
| Fake harness → runner | stdout JSONL | — | `paperclip.fake_harness.message.v1` |
| Mock core → browser | local HTTP + NDJSON | start/actions | validated events and final trace |
Stderr is diagnostic input. It never becomes a command or canonical event.
## Deterministic lifecycle
1. `run.prepare` creates the harness process group.
2. The harness sends `ready`.
3. `session.open` starts one fake driver session.
4. `turn.start` starts one scripted turn.
5. The harness emits typed events, requests, logs, a semantic result, and a
terminal proposal.
6. The runner waits for the harness process and records `harness.exited`.
7. The runner emits one `run.terminal` event and exits zero.
The runner gives each canonical event a deterministic source ID, source
sequence, and timestamp. Script delay can be overridden for fast tests.
## Command rules
- `controllerSeq` starts at 1 and is contiguous.
- The runner stores the canonical JSON for each accepted `commandId`.
- The same ID and same content returns `duplicate` without repeating an effect.
- The same ID with different content is rejected.
- A sequence gap is rejected.
- Phase 2 supports prepare, open, turn start, request resolution, interruption,
stop, close, and cancel command shapes. A script consumes only the commands it
expects.
## Fake-driver scripts
Scripts live under `protocol/fixtures/phase-02/scripts/`.
| Script | Proof |
|---|---|
| `happy-path` | Lifecycle, tool/command item, file item, logs, result, and success terminal |
| `permission-input` | Permission request and free-text input round-trip |
| `interrupted` | Operator interruption, yielded result, exit 130, and cancelled terminal |
| `error` | Non-zero process exit stays separate from the yielded semantic result |
| `duplicate-terminal` | A second terminal proposal is ignored and diagnosed |
| `linger` | Supervisor terminates the harness and its worker process group |
## Cleanup and credential boundary
The runner starts the harness in a new process group on Unix. Normal exit waits
for the process. Controller EOF or explicit cleanup sends `TERM`, waits for the
configured grace time, and then sends `KILL` to the full group. Drop cleanup is
a final guard.
The mock core passes only a small process environment. The runner also clears
the inherited environment before it starts the fake harness. Paperclip keys,
model keys, and AgentMail keys are not forwarded.
## Bounded diagnostics
The supervisor frames harness stdout and stderr with a 64 KiB per-line default
before it creates a `String`; a newline-free or otherwise oversized stdout
frame is a protocol violation and fails the run. Oversized stderr frames are
drained with bounded memory and represented by a short diagnostic marker. The
separate log-tail buffer then keeps only the configured number of lines and
bytes. It reports retained lines, retained bytes, and the dropped-line count
with `harness.exited`.
## Result and terminal authority
These facts are independent:
- `run.result.proposed` is the fake driver's structured work claim;
- `harness.exited` is the operating-system process fact;
- `run.terminal` is the one canonical terminal event for the local trace.
A scripted error can report useful yielded work and exit 7. An interruption can
report yielded work and exit 130. Neither process fact silently replaces the
semantic result.
The fake harness terminal object is only a proposal. The runner preserves it as
`harnessTerminalProposal`, validates its enum fields, and derives the canonical
turn/run terminal states from runner-owned facts: the process exit, whether a
semantic result was observed, an accepted interrupt/cancel command, controller
closure, and protocol violations. `reconciliation` records the decision and
whether the proposal contradicted those facts. A success proposal followed by a
non-zero exit therefore becomes a failed canonical terminal; the reverse
contradiction becomes succeeded only when both the process and semantic result
succeeded.
## Browser live mode
The Vite middleware starts the same TypeScript mock-core controller used by
tests. It streams history plus new events as NDJSON. The browser validates each
event with the Phase 1 schema, checks run/session binding, and applies the Phase
1 reducer. At completion it replays the full event list and compares both final
snapshots.
The browser reuses package-local shadcn-style Button, Badge, Card, and Textarea
components. All visual values remain in `styles.css`.
### Local transport trust model
The browser server is a developer-only loopback transport, not an authenticated
Paperclip API. Vite dev and preview default to `127.0.0.1`. Every
`/api/phase2/*` request is checked before route lookup or process startup:
- both ends of the accepted socket and the HTTP `Host` must be loopback;
- an `Origin`, when a browser supplies one, must exactly match the request
origin, and cross-site Fetch Metadata values are rejected;
- JSON actions require `application/json` and bodies are capped at 16 KiB,
including streamed/chunked requests;
- the server permits at most 4 active runs, retains at most 16 total runs (old
completed entries are evicted), and permits at most 8 live stream clients per
run.
These are secure defaults plus defense in depth: even if Vite is accidentally
started with a broad `--host`, non-loopback Phase 2 API traffic is rejected.
There is intentionally no user identity, session cookie, or reusable bearer
credential in this spike.
Residual risk: another process running as the same local user is inside this
trust boundary and can make loopback requests. The scripted Phase 2 event set is
finite, but the retained event history inside one active run is not yet a
general-purpose per-session byte budget. A later real harness/network phase must
add authenticated capabilities, per-session event/byte budgets, durable
backpressure, and production authorization instead of widening this local
transport.
## Deferred work
Phase 2 has no durable outbox, ACK, reconnect, runner restart recovery, real
harness, production Paperclip bridge, or browser-to-runner connection. Those
items require later authorized phases.
@@ -0,0 +1,244 @@
# Phase 3 Durable Transport and Recovery
Phase 3 adds a real outbound WebSocket path to the standalone package. The
Rust `paperclip-runnerd` process is the client. The TypeScript mock core is the
remote peer. Neither side imports Paperclip server, UI, database, or shared
control-plane code.
This is a local reliability and authenticated-transport proof. The mock keeps
the RFC 6455 carrier on `ws://127.0.0.1`, but PRP application frames use a
mutually authenticated, encrypted session. Loopback position and the
WebSocket acceptance header are not authentication. A production bridge should
still use `wss://` for defense in depth and remains a separately reviewed
deployment phase.
## Connection and authentication
The connection starts in this order:
1. The mock core creates a random bootstrap ticket with a five-second lifetime.
2. The ticket is passed to the runner through
`PAPERCLIP_RUNNER_BOOTSTRAP_TICKET`. It is not a command-line argument.
3. The runner opens an unauthenticated WebSocket upgrade with no bearer header,
then sends only a public credential locator, a fresh client nonce, complete
runner/run/session identity, approved runner version and digest, negotiated
protocol range, and durable resume cursors.
4. The core returns a fresh server nonce plus an HMAC-SHA-256 proof over the
complete transcript. The proof binds runner, environment lease, run,
normalized session, turn, item, runner artifact, selected protocol,
credential/connection lease identity, expiry, and revocation epoch.
5. The runner validates that proof before returning its own transcript-bound
proof. The bootstrap ticket or connection lease token itself never crosses
the socket. A failed proof does not consume a one-use bootstrap ticket.
6. Both sides derive directional AES-256-GCM keys from the capability and both
nonces. Strict per-direction counters reject replays and out-of-order frames.
7. Only after mutual authentication does the core send an encrypted `welcome`.
The welcome selects PRP v1, returns a short-lived connection lease, reports
the cumulative committed event cursor, and carries at most one pending
command. Every later ACK, command, revoke, event, and command result remains
inside the encrypted session.
8. Later connections authenticate with the connection lease. A real runner
process restart receives a new one-time bootstrap ticket and loads the same
durable state before it connects.
The runner keeps the live connection lease token in memory only. Its state file
does not contain the bootstrap ticket or connection lease token. The mock core
persists domain-separated SHA-256 authentication keys instead of raw
capabilities. Used, expired, revoked, or identity-mismatched credentials fail
closed during the challenge. The runner validates the complete encrypted
welcome and every control envelope against the authenticated connection,
runner, environment lease, run, normalized session, turn, item, protocol,
lease ID, expiry, and revocation metadata before applying an ACK or command.
The daemon captures and removes the bootstrap environment variable before it
parses arguments or starts child work. Secret buffers are overwritten when they
are dropped. It resolves the destination once before sending a bearer value and
accepts only concrete loopback addresses. Userinfo, query strings, fragments,
wildcards, private-network addresses, public addresses, and mixed DNS answers
fail closed.
## WebSocket limits
The package client implements the RFC 6455 upgrade and masked client text
frames with Node-free Rust standard-library code. The mock core validates the
upgrade and parses bounded masked frames.
- Maximum HTTP upgrade headers: 16 KiB.
- Maximum PRP frame: 1 MiB.
- Unknown frame opcodes and unmasked client frames close the connection.
- Malformed JSON is recorded as a bounded diagnostic. Durable state remains
available for reconnect.
- WebSocket ping/pong and PRP diagnostic pong state are supported.
## Durable runner state
The runner writes `runner-state.json` below its private state directory. The
directory uses mode `0700` and the file uses mode `0600` on Unix. Every update
is written to an exclusive unpredictable sibling file, synchronized, atomically
renamed, and followed by a parent-directory sync. Symlinked directories or state
files, wrong ownership, and wrong modes fail closed.
The state contains:
- stable runner, environment lease, run, session, turn, and item IDs;
- next source event sequence and cumulative acknowledged source sequence;
- unacknowledged event envelopes and their byte counts;
- a bounded recent processed-command cache with a SHA-256 command digest and
prior result, plus a fixed-size fail-closed replay filter for compacted IDs;
- lifecycle, reconnect, backpressure, harness generation, and recovery facts;
- bounded, redacted diagnostics.
Authentication capabilities and arbitrary command bodies are not stored. A
recent command is represented by a SHA-256 comparison digest from the vetted
RustCrypto implementation, its stable ID and controller sequence, the redacted
result, and the logical-effect count. The exact cache keeps at most 128 entries.
Older IDs are added to a fixed 4 KiB Bloom filter before their exact records are
removed. A possible filter match is rejected with zero effects, so Bloom false
positives can reject new work but can never make an old command effective again.
The durable controller sequence remains monotonic across compaction and restart.
## Event delivery and ACKs
The runner writes an event to the outbox before it sends the event. Event IDs
and source sequence numbers do not change after reconnect or restart.
The mock core commits or deduplicates an event before it sends this cumulative
ACK:
```json
{
"kind": "ack",
"payload": { "ackedSourceSeq": 9 }
}
```
The ACK means that every source event through sequence 9 is committed or
deduplicated. The runner rejects an ACK that moves backward or beyond its
produced source cursor. It removes only events at or below a valid ACK.
For the lost-ACK fault, the mock commits an event, drops the ACK and socket, and
reports the prior cursor once after reconnect. The runner sends the same bytes
again. The mock increments delivery count, keeps one logical event, and sends
the committed cumulative ACK.
## Command delivery and effects
Mock-core commands are durable before delivery. Only the lowest pending
controller sequence is sent. A command result advances the queue.
The runner stores a command result before it sends the result. When a result or
socket is lost, the same command is delivered again. An equal command ID and
digest returns the stored result. It does not add events or repeat a process
effect. Reusing the ID with different bytes is rejected.
The trace records `logicalEffectCount`. Every completed command has exactly one
logical effect. A policy rejection, such as a new turn during drain or storage
pressure, has zero effects.
## Residual local trust and revocation window
The authenticated session removes trust in whichever process wins the configured
loopback port: a relay can forward opaque bytes, but it cannot learn a bootstrap
or lease capability, decrypt control data, or forge a welcome, ACK, command, or
revoke frame. The remaining local-host assumption is that the OS protects the
runner process memory, inherited bootstrap environment at launch, private
`0700`/`0600` state paths, and the mock core's derived authentication keys from
other same-user processes with debugging, memory-reading, or filesystem access.
An attacker with those privileges is outside this transport boundary.
Lease expiry is checked locally before control data is applied. Explicit
revocation reaches an already-connected runner through an authenticated revoke
frame; if that frame cannot be delivered, the session remains usable until the
connection closes or the runner reaches the signed lease expiry. The mock uses
a 30-second lease, so that is the maximum demonstrated revocation window. A
production core should close active sessions when it revokes a lease and choose
the lease TTL to match its required revocation bound.
## Restart and reconciliation
A socket drop keeps the same Rust process and in-memory lease. The process
reconnects and reloads the mock-core command and event cursor.
A runner restart kills the real Rust process with unacknowledged work. A new
Rust process receives a fresh ticket, reads the same state file, emits a P0
`runner.reconciled` event, and continues the same runner, session, turn, item,
command, and source-event identities.
The harness-restart fault starts the real Phase 2 `fake-harness` child, waits
for its ready message, terminates its process group, and starts a second child.
The runner then emits `harness.exited`, `harness.ready`, and
`session.reconciled` events with the same normalized session, turn, and item
IDs.
If a lease expires, reconnect fails closed. The runner records
`lease_expired_requires_bootstrap` and exits with its state intact. The mock
then gives the replacement runner a fresh ticket. Recovery continues from the
same cursor.
If recovery cannot be truthful, state names the outcome. For example, failure
to reserve storage for a P0 event records `p0_storage_exhausted` and the
`unrecoverable` lifecycle. It never reports a fresh session as resumed.
## Backpressure and bounded storage
The runner has a byte limit and a reserved P0 region.
- P2 item deltas coalesce before delivery.
- P1 events cannot consume the P0 reserve.
- New turns are rejected while backpressure is active.
- A P0 `runner.backpressure` event explains the state.
- P0 events are never dropped to make room.
- The state records peak outbox bytes so the final empty outbox does not hide a
limit violation.
The storage-pressure trace emits 250 P2 updates. They become one coalesced
event, all P0 facts reach the mock core, peak bytes remain below the configured
limit, and the next turn is rejected without an effect.
## Drain and revoke
`runner.drain` persists the `draining` lifecycle and a P0
`runner.draining` event. It rejects later `turn.start` commands but continues to
deliver the existing outbox. `runner.shutdown` stops only after the outbox is
acknowledged.
A `revoke` envelope persists the `revoked` lifecycle. The runner flushes any
existing durable events and exits. It does not accept new work or delete
unacknowledged facts.
## Diagnostics
Run a fault and print operator-safe diagnostics:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lost-ack
```
Supported fault names are:
```text
none
socket-drop
lost-ack
duplicate-command
runner-restart
harness-restart
malformed-input
lease-expiry
storage-pressure
drain
revoke
```
Use `--json` for the complete trace or `--output <path>` to write it. The CLI
and browser show connection counts, safe lease ID and expiry, stable identities,
source and ACK cursors, outbox current/peak bytes, command delivery/effect
counts, replay counts, restart counts, outcomes, and redaction assertions.
They never show bootstrap tickets or connection lease tokens.
Regenerate the checked fault matrix and every exact per-fault trace with:
```sh
pnpm --filter @paperclipai/paperclip-runner record:phase3
```
@@ -0,0 +1,164 @@
# Phase 4 Skillless Codex Driver
## Scope
Phase 4 implements a direct Codex app-server v2 driver behind the package's
existing `HarnessDriver` contract. The driver, mock core, example CLI, tests,
and evidence stay inside `packages/paperclip-runner/`. They do not import or
change Paperclip server, UI, database, or production control-plane behavior.
The app-server process is local to the execution environment and uses newline
delimited JSON-RPC over stdio. It is not exposed as a network service.
## Identity mapping
| Runner identity | Codex source | Persistence rule |
| --- | --- | --- |
| run ID | mock-core input | Never replaced during recovery. |
| normalized session ID | controller-owned mock-core input | A distinct identity, independent of the run and provider IDs, that stays stable across transport/process recovery. |
| driver session ID | `thread.id` | Resumed by exact ID. A different returned ID fails recovery. |
| provider session ID | `thread.sessionId` | Kept separately from the driver thread ID. |
| turn ID | `turn.id` | Required by steer and interrupt preconditions. |
| item ID | `item.id`, request ID, or deterministic turn/kind key | Preserved on lifecycle and delta events. |
| source event ID | runner instance + run + source sequence | Source sequence continues from the persisted snapshot. |
The persisted session snapshot records run, normalized session, driver
session, provider session, the exact active turn, committed semantic-result
content/call binding, observed terminal-turn fingerprints, and the last source
sequence. Recovery starts a new local app-server transport, reads the exact
persisted thread to validate identity and working directory, resumes that
thread, and then reads it again for reconciliation. Reconciliation considers
only the persisted active turn: it retains that turn when active, terminalizes
that turn when terminal, and fails recoverably when it is missing or a
different active turn appears. Historical terminal turns never substitute for
the persisted active turn, and a terminal already in the durable snapshot is
not emitted again.
## App-server operations
| Driver operation | App-server method | Degradation |
| --- | --- | --- |
| initialize | `initialize`, then `initialized` | Startup fails visibly. |
| create | `thread/start` | Required. |
| resume | `thread/resume` | `recovered: false` with a redacted reason. |
| read | `thread/read` | Explicit `HarnessCapabilityUnavailableError`. |
| start turn | `turn/start` | Required. |
| steer | `turn/steer` with `expectedTurnId` | Explicit unsupported diagnostic; no stdin fallback. |
| interrupt | `turn/interrupt` | Explicit unsupported diagnostic; session is not killed. |
| usage | `thread/tokenUsage/updated` | Returns the last snapshot or explicit unsupported error. |
| reconcile | `thread/read` plus `session.reconciled` | Disabled when read is unavailable. |
Capability flags are descriptive and executable. Unsupported operations emit
canonical `harness.diagnostic` events with secret-redacted detail. No
harness-specific branch is required in the mock core.
## Skillless context boundary
The model receives one text input containing `paperclip.skillless_task.v1`:
- objective;
- completion-contract revision and criteria;
- task constraints; and
- the expected canonical result schema name.
The thread config explicitly disables automatic skill, app, and collaboration
instruction blocks. The model input accepts only text, never a Codex `skill`
input. The driver captures the returned instruction-source list and requires it
to be empty for the skillless assertion.
The trusted app-server process has an allowlisted environment. It retains host
`HOME` and `CODEX_HOME` only so the provider can authenticate. Model-issued
commands have a separate boundary: an empty-by-default environment with no
`HOME` or `CODEX_HOME`, no network, and a named Codex
permission profile requesting read-only minimal runtime files, no host-home or
Codex-home access, and write access to the assigned workspace. The driver
refuses filesystem-root workspaces, workspaces containing host `HOME`, and any
workspace overlapping host `CODEX_HOME`.
The returned sandbox facts remain authoritative. Codex 0.132.0 may inject a
provider-managed writable root such as `~/.codex/memories` after a first run,
even with `features.memories=false` and an explicit Codex-home deny. That makes
the Codex-home directory discoverable in a warmed environment, so Phase 4 does
not claim whole-directory unreadability. Its authenticated proof instead
requires each readable `auth.json`/`config.toml` file and an unrelated host
secret to remain unreadable and unwritable, while recording any injected root
in `context.sandbox.legacyPolicy`.
Paperclip bearer values, `OPENAI_API_KEY`, arbitrary skill paths, and other
inherited variables are not passed. Diagnostics redact bearer/basic
credentials, credentialed proxy URLs, secret query parameters, sensitive JSON
keys, and common key assignments.
The context snapshot records configuration and environment **key names**, not
secret values.
## Semantic completion
The provider-facing structured-output schema covers `done` and `needs_review`.
It uses the strict OpenAI shape: every object rejects additional properties and
the constant schema field includes both `type: "string"` and `const`.
Two dynamic semantic tools are registered when supported:
- `paperclip_finish` accepts `done` or `needs_review`;
- `paperclip_block` accepts `blocked` and requires a blocker owner, action,
reason, and scope.
Both normalize through the canonical `paperclip.run_result.v1` validator. The
first valid result is proposed. Any canonically identical result retry is
idempotent even when the provider assigned a new call ID; the original call
binding remains persisted for audit, while changed content is rejected. Tool
calls and provider notifications must name the exact opened thread and active turn. Missing,
pre-turn, cross-thread, cross-turn, and post-terminal bindings fail the provider
session closed. Canonically identical terminal replays are no-ops; conflicting
terminal facts are rejected. Process exit or prose alone never implies
completion.
The provider cannot commit controller state. It emits `run.result.proposed` and
a provider turn terminal; the mock core validates the proposal against its
task envelope, emits `run.result.accepted` or `run.result.rejected`, and alone
emits `run.terminal`.
## Canonical event mapping
- thread lifecycle -> `session.started`, `session.resumed`,
`session.reconciled`;
- turn lifecycle -> `turn.submitted`, `turn.accepted`, `turn.started`, and one
terminal turn event;
- messages, reasoning, plans, commands, file changes, dynamic tools, and diffs
-> `item.started`, `item.delta`, `item.completed`;
- model selection -> a completed `model` item;
- app-server decisions -> `runtime_request.created` and
`runtime_request.resolved` with redacted detail;
- token snapshots -> completed `usage` items;
- semantic verification rows -> completed `verification` items;
- provider completion -> at most one `run.result.proposed` and one turn
terminal;
- controller decision -> one `run.result.accepted` or `run.result.rejected`,
followed by one `run.terminal`.
The JSON-RPC transport limits each input line, pending client requests,
in-flight server requests, queued notification count and bytes, diagnostic
lines, and retained provider payloads. Malformed or oversized messages close
the transport and reject pending work.
The existing Phase 1 reducer consumes the live stream. Replay crosses a
serialized JSONL boundary that validates byte and event counts, line size,
schema, run/session binding, unique source event IDs, continuous per-source
sequence, and exactly one final run terminal before reducing. The Phase 4
tracer requires byte-equivalent live and replay snapshots.
## Runnable example
`trace:phase4` starts a real local `codex app-server` session through the mock
core. Its safe task creates `hello.txt` with network disabled. The evidence
recorder additionally probes all readable host Codex credential/config files
and an unrelated host secret, requiring reads and writes to be denied while
workspace output and app-server authentication still succeed. It gates output
reads on an accepted `done` result and reports missing files by name rather
than surfacing a raw filesystem `ENOENT`. See the
[Phase 4 tutorial](tutorials/phase-04-skillless-codex.md).
This phase changes no browser surface, so no new browser screenshot applies.
The canonical events are proved through the existing reducer/replay path and
JSON trace evidence.
@@ -0,0 +1,143 @@
# Phase 4b Live Console
## Scope
This is the browser layer of Phase 4b. It adds a **Live console** mode to the
existing standalone devtool in `devtools/browser/`. It builds on the
[protocol and demo server](phase-04b-protocol-server.md) and follows the
approved [interaction map](design/phase-4b-interaction-map.md) and
[component decision record](design/phase-4b-component-decisions.md).
Every file lives under `packages/paperclip-runner/`. The console does not
import or change Paperclip server, UI, database, or control-plane code.
## Start it
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
Then open `http://127.0.0.1:4180/` and choose **Live console**.
Phase 4b is deliberately loopback-only. A wildcard or LAN `--host` fails
startup because this developer console can start provider-backed turns and
resolve approvals. There is no unauthenticated remote mode.
Two environment variables change the driver behind the console:
| Variable | Default | Effect |
| --- | --- | --- |
| `PAPERCLIP_PHASE4B_DRIVER` | `demo` | `codex` starts the real `codex app-server` driver behind the same routes |
| `PAPERCLIP_PHASE4B_CHUNK_DELAY_MS` | `45` | Milliseconds between streamed chunks in the demo driver |
## Where state comes from
The console has one data contract: the canonical PRP event stream and the
`SessionSnapshot` the shared reducer produces from it. There is no second event
model, no message store, and no client-side session cache.
- The browser reduces the events it receives with the same
`applyPrpEvent` the server and the CLI use, so a live session and a replayed
session project identically. The inspector's **Session** tab reports that
comparison as `match` or `mismatch`.
- Transcript order is the reducer timeline order. Nothing is re-sorted.
- Lineage, goal state, pending requests, and the durable cursor come from the
demo server's public state, which is refreshed whenever new events arrive.
- The steering chip is the only local UI intent. It resolves to
`acknowledged`, `rejected`, or `failed` from canonical facts and never
merges text into the transcript before an acknowledgement.
## Surfaces
| Surface | Source of truth | Notes |
| --- | --- | --- |
| Transcript | reducer timeline and items | user messages come from `turn.submitted` payload text |
| Composer | composer state machine | one visible primary action: Send, Steer, or Stop |
| Steering chip | `steering_acknowledgement` item or a rejection code | rejected text stays recoverable with **Send as new message** |
| Request card | `runtime_request.*` events | renders only the actions the upstream request offers |
| Goal banner and menu | capability snapshot and goal events | unsupported verbs are disabled with the exact diagnostic |
| Lineage tree | driver thread lineage | child steering is shown as unsupported, never emulated |
| Connection block | transport status and durable cursor | includes a deliberate connection-loss control |
| Inspector | raw canonical events | four tabs: Events, Requests, Capabilities, Session |
| Replay stepper | recorded canonical events | marked with a `REPLAY` eyebrow badge |
## Interruption races
The console distinguishes all three races the spec requires:
| Race | What the console shows |
| --- | --- |
| interrupt before start | the pending turn resolves to `Cancelled before start`; no assistant item is ever created |
| interrupt during generation | the stream stops in place, the partial text stays, and an interrupted divider is added |
| interrupt during a tool call | the tool item keeps its last known status with no fabricated result |
`Escape` in the composer moves focus to **Stop**. It never interrupts
directly, because a destructive action needs an explicit activation.
## Demo chats
The demo server owns the manifest catalogue and serves it at
`GET /api/phase4b/manifests`. Each manifest names the scenario it proves and
the observations a human should confirm; the console renders that list as a
checklist rather than restating it in the UI.
| Manifest | Proves |
| --- | --- |
| `completion` | streaming, reasoning, tool, answer, structured result |
| `steering` | same-turn steering acknowledgement and stale-turn rejection |
| `interrupt-before-start` | cancellation before the provider accepts the turn |
| `interrupt-during-generation` | preserved partial text |
| `interrupt-during-tool` | preserved tool status with no fabricated result |
| `approvals` | command and file-change approvals, single-response guarantee |
| `user-input` | typed answers and request expiry |
| `subagents` | parent/child lineage and unsupported child steering |
| `goals` | set, view, pause, resume, clear |
| `goals-unsupported` | exact capability diagnostic instead of a hidden control |
| `failure` | failed item and failed turn inside the record |
Manifests run in any order. **Reset demo state** names exactly what it
discards and leaves recorded evidence files untouched.
## Credentials
No provider or Paperclip credential reaches the browser. The Node process owns
the driver, the working directory, and any provider login. Every JSON and event
frame passes the demo server's redaction layer, and the inspector renders
redaction markers verbatim without attempting to reconstruct them.
The live console and standalone server share one transport admission guard:
exact loopback Host/port, loopback sockets, same-origin Origin and Fetch
Metadata, plus JSON-only mutations. Browser reset closes the server session;
active sessions and SSE subscribers are hard-capped. Local untrusted processes
remain a residual risk, and the Codex sandbox/approval policy is the final
execution boundary.
## Components
Adapted from shadcn/ui and Vercel AI Elements **source**, rewritten in the
package's local idiom, with zero new runtime dependencies:
- AI Elements: `conversation`, `message`, `composer`, `reasoning-item`,
`tool-item`.
- shadcn/ui: `tabs`, `menu`, `dialog`, `tooltip`, `banner`.
`scripts/check-forbidden-imports.mjs` fails the build if anything under
`devtools/browser/` imports `ai`, `@ai-sdk/*`, `zod`, `radix-ui`, `cmdk`,
`streamdown`, `shiki`, `use-stick-to-bottom`, `class-variance-authority`,
`tailwindcss`, or `nanoid`. `scripts/check-browser-tokens.mjs` fails if a
component file carries a raw colour, pixel, or font value.
## Verification
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/mock-core/phase4b-scripted-driver.test.ts \
src/mock-core/phase4b-demo-server.test.ts \
devtools/browser/src/live/transcript-model.test.ts
pnpm --filter @paperclipai/paperclip-runner test:browser
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
See the [verification record](../knowledge/evidence/2026-08-08-phase-04b-live-console-verification.md)
and the [tutorial](tutorials/phase-04b-live-console.md).
@@ -0,0 +1,139 @@
# Phase 4b Protocol and Demo Server
## Scope
This is the lowest Phase 4b layer. It extends the package-local Codex driver,
typed harness contract, deterministic fixtures, and mock-core demo server. It
does not import or change Paperclip server, UI, database, or production routes.
The browser boundary is HTTP plus server-sent events. The browser sends typed
actions to the demo server. Only the server starts `codex app-server`, reads
the existing Codex login, owns the working directory, and resumes provider
threads. No provider or Paperclip credential is serialized to browser state.
## Deterministic fixture
`protocol/fixtures/phase-04b/driver-conformance.json` records the exact Codex
0.132.0 wire shapes used by conformance tests:
- command, file, permission, user-input, and MCP elicitation requests;
- accept, accept-for-session, decline, cancel, and submitted answers;
- goal get, set, pause, resume, and clear calls;
- subagent parent/child thread source metadata;
- same-turn and stale-turn controls;
- interrupt-before-start and terminal-race outcomes;
- stable reconnect identities and source cursor; and
- hostile diagnostic strings that must be redacted.
The fixture loader rejects missing cases, duplicate request identities,
incomplete goal operations, and incomplete lineage or recovery identity.
## Browser-resolved requests
The driver no longer automatically declines supported runtime requests. It
emits `runtime_request.created`, keeps the JSON-RPC response pending, and waits
for `resolveRuntimeRequest`.
| Upstream request | Canonical kind | Allowed response shape |
| --- | --- | --- |
| `item/commandExecution/requestApproval` | `command_approval` | accept, session accept, decline, cancel |
| `item/fileChange/requestApproval` | `file_approval` | accept, session accept, decline, cancel |
| `item/permissions/requestApproval` | `permission_approval` | empty safe permission grant for turn or session |
| `item/tool/requestUserInput` | `user_input` | typed answer map, decline, cancel |
| `mcpServer/elicitation/request` | `elicitation` | typed content, decline, cancel |
The permission response cannot widen the skillless filesystem or network
boundary. A stale turn, duplicate response, provider cleanup, terminal turn,
protocol failure, close, or reconnect settles the pending request exactly once.
Only the resolution action enters the canonical event; submitted secrets or
answers do not enter the acknowledgement payload.
## Steering and interruption
Successful same-turn steering emits a completed
`steering_acknowledgement` item. A different or child identity is rejected
with `HarnessStaleTurnError` and `stale_turn_rejected`; direct child steering
is never emulated.
An interrupt received while `turn/start` is waiting for a provider turn ID is
queued and sent as soon as the ID arrives. After a terminal fact wins the race,
further steering or interruption returns `already_terminal`. A successful
interrupt request emits an `interrupt_acknowledgement` item without replacing
the session.
## Goals and lineage
The driver probes `thread/goal/get` after create or resume. Success enables
get, set, pause, resume, and clear. A method-not-found response disables goals
and advertises the precise unsupported capability. The installed Codex 0.132.0
binary generated goal bindings but did not enable the method at runtime in the
recorded real session, so the evidence correctly shows `goals: false`.
Root and child threads use `thread.id`, `thread.sessionId`, `forkedFromId`, and
`source.subAgent.thread_spawn` to build lineage. Child start, status, and close
notifications become canonical `thread_lineage` items. Unrelated thread
notifications are ignored instead of being attached to the active session.
## Demo-server API
Start it with:
```sh
pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- \
--host 127.0.0.1 --port 4174
```
The transport fails before binding unless `--host` is an explicit loopback
address. The standalone server and Vite middleware use the same admission
guard. It requires loopback local and remote sockets, the exact configured
loopback `Host` plus listening port, same-origin Fetch Metadata, a matching
browser `Origin` on mutations and streams, and `application/json` on every
mutation. Server-side automation uses a random per-launch bearer capability;
it is never returned by an API response or browser state.
Important routes:
- `GET /api/phase4b/health`
- `POST /api/phase4b/sessions`
- `GET /api/phase4b/sessions/:id`
- `GET /api/phase4b/sessions/:id/events?after=:cursor`
- `GET /api/phase4b/sessions/:id/stream?after=:cursor`
- `POST /api/phase4b/sessions/:id/steer`
- `POST /api/phase4b/sessions/:id/interrupt`
- `POST /api/phase4b/sessions/:id/requests/:requestId/resolve`
- `POST /api/phase4b/sessions/:id/goal/:operation`
- `POST /api/phase4b/sessions/:id/reconnect`
- `POST /api/phase4b/sessions/:id/close`
The server chooses the working directory. A create body cannot override it.
Provider, Paperclip, cookie, and bearer credential fields in create bodies are
rejected. Runtime-request resolution must match the pending request, its turn,
and the session named by the route; stale, cross-scope, and duplicate responses
fail with `409` before reaching the driver.
Every JSON and event response passes a second bounded redaction layer. Replay
uses the same validated PRP events and reducer as the live view.
The demo keeps at most 16 active sessions and four SSE subscribers per session
by default. Call the close route (the browser does this on reset) to release a
session immediately; server shutdown closes every remaining session and
subscriber. Configuration may lower these limits and is hard-capped at 64
sessions and 16 subscribers.
Residual risk: a loopback service is still reachable by untrusted local
processes, which can reproduce browser headers. The Codex sandbox and approval
policy remain the final execution boundary; this demo transport is not a
multi-user authorization boundary.
## Evidence
Run deterministic conformance first, then the real Codex boundary:
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts \
src/mock-core/phase4b-demo-server.test.ts
pnpm --filter @paperclipai/paperclip-runner record:phase4b
```
See the [verification record](../knowledge/evidence/2026-08-08-phase-04b-protocol-server-verification.md)
and [real server trace](../knowledge/evidence/phase-04b-real-codex-server.json).
@@ -0,0 +1,153 @@
# Phase 5 Browser SDK and Reference Console
## Public package surface
Phase 5 freezes the first browser SDK as package version `0.1.0`.
| Import | Purpose |
| --- | --- |
| `@paperclipai/paperclip-runner` | Protocol, reducer, and existing package contracts |
| `@paperclipai/paperclip-runner/browser` | Framework-free HTTP/SSE client, protocol types, transcript projection |
| `@paperclipai/paperclip-runner/react` | Hook, reference console, and reusable React components |
| `@paperclipai/paperclip-runner/styles.css` | Scoped light-theme token and component styles |
React and React DOM are peer dependencies. The extracted surface adds no
runtime dependency. It is client-only: it uses `window`, `sessionStorage`,
Fetch, and EventSource, so applications must mount it in the browser rather
than render it on the server.
## Framework-free client
```ts
import { createRunnerClient } from "@paperclipai/paperclip-runner/browser";
const client = createRunnerClient({ baseUrl: "/api/phase4b" });
const manifests = await client.fetchManifests();
```
`RunnerClient` exposes manifest listing; session create/read/close; event
history and SSE streaming; turn start, steering, and interrupt; typed request
resolution; goal operations; and reconnect. `RunnerClientError` preserves the
HTTP status, stable error code, and redacted server message.
The protocol server remains authoritative. The browser client never creates a
second event model, invents capabilities, changes run/session/provider
identities, or removes duplicate events before the shared reducer sees them.
## React hook
```tsx
import { useRunnerConsole } from "@paperclipai/paperclip-runner/react";
function Console() {
const runner = useRunnerConsole({ baseUrl: "/api/phase4b" });
return <p>{runner.connection}: {runner.replayParity ? "match" : "waiting"}</p>;
}
```
`useRunnerConsole` returns manifests and selection, canonical server state,
the exact event list, reducer snapshot, transcript projection, connection and
retry state, composer state, steering acknowledgements, a single polite
announcement channel, errors, busy state, lineage selection, replay controls,
and verbs for every supported mutation. Durable history is re-read before a
reconnected stream opens. Replay uses the same reducer as live state.
## Components
The `./react` export includes:
- primitives: `Button`, `Badge`, `Card`, `Textarea`, `Tabs`, `Menu`, `Dialog`,
`Tooltip`, and `Banner`;
- protocol views: `Conversation`, `Message`, `ReasoningItem`, `ToolItem`,
`RequestCard`, `SessionTimeline`, `Inspector`, `ReplayControls`, and
`ConnectionBanner`;
- composition: `Composer`, `RunnerConsoleApp`, and `useRunnerConsole`.
Components take reducer/protocol shapes. Every component owns a stable
`data-slot` and `pcr-` class. Terminal, file, tool, plan, request, failure,
lineage, goal, connection, and replay states come only from canonical events
or public session state.
## Five extension points
These are the complete extension surface for `0.1.0`:
1. `Conversation`, `Message`, `ReasoningItem`, and `ToolItem` accept
`renderItemBody(item)` for markdown, highlighting, or another item body.
2. `RequestCard` accepts `renderRequestDetail(request)` for its detail region.
3. `Composer` accepts `leadingActions` and `trailingActions`.
4. Consumers may override `--pcr-*` properties under `.pcr-root`.
5. `createRunnerClient` and `useRunnerConsole` accept `baseUrl`, `fetchImpl`,
and `eventSourceFactory` transport injection.
There is no headless distribution, component registry, render-prop shell,
dark theme, toast layer, or virtualized transcript in this version.
## Transport and credential boundary
```tsx
const runner = useRunnerConsole({
baseUrl: "/runner",
fetchImpl: (input, init) => fetch(input, {
...init,
headers: { ...Object.fromEntries(new Headers(init?.headers)), "x-app-session": appSession },
}),
eventSourceFactory: (url) => new EventSource(url),
});
```
Transport injection is for browser-to-protocol-server authentication, proxies,
and test doubles. Provider credentials stay on that server. Do not place a
provider bearer token in component props, manifest text, events, diagnostics,
browser storage, or an EventSource query string. The package demo fixes and
validates its workspace server-side and redacts provider paths and credentials.
## Tokens and contrast
`styles.css` declares a light-only `color-scheme` and all visual values under
`.pcr-root`. The shipped foreground/surface pairs are:
| Foreground | Surface | Contrast |
| --- | --- | ---: |
| `--pcr-foreground` | `--pcr-background` | 16.4:1 |
| `--pcr-card-foreground` | `--pcr-card` | 17.6:1 |
| `--pcr-primary-foreground` | `--pcr-primary` | 15.6:1 |
| `--pcr-muted-foreground` | `--pcr-muted` | 5.0:1 |
| `--pcr-success` | `--pcr-success-surface` | 6.5:1 |
| `--pcr-warning` | `--pcr-warning-surface` | 5.4:1 |
| `--pcr-danger` | `--pcr-danger-surface` | 6.2:1 |
| `--pcr-accent` | `--pcr-accent-surface` | 5.5:1 |
When a consumer overrides either side of a pair, that consumer owns a new
contrast check. Keep normal text at 4.5:1 or better. Motion respects
`prefers-reduced-motion`. All mobile controls use the `--pcr-touch-target`
minimum.
Minimum supported widths are 320px for `Conversation`, `Composer`, and
`Inspector`, and 280px for `RequestCard`. `RunnerConsoleApp` renders one
responsive tree: three panes above 900px and one selected pane below it.
## Keyboard and accessibility contract
- The transcript is a labelled `role="log"`; consumers render the hook's
`announcement` once in an `aria-live="polite"` region.
- Composer Enter sends or steers; Shift+Enter adds a line. Stop remains a
separate button.
- Tabs use arrow-key roving focus plus Home/End.
- Menu supports Enter/Space/ArrowDown, arrows, Home/End, and Escape with focus
restoration.
- The native dialog traps focus, closes on Escape, and restores its trigger.
- Reasoning and tool disclosures use native summary keyboard behavior.
- Replay Left/Right steps; Space toggles play from the scrubber.
- Status is always named in text, not communicated by color alone.
- Informational banners do not steal focus. A blocking reconnect failure may.
## Reference applications
`examples/reference-console/` composes `RunnerConsoleApp` only from public
exports. `examples/mini-consumer/` imports only the three public SDK subpaths
and visibly exercises every extension point. Both run against the package fake
driver or the real Codex driver through the same server-only adapter.
See the [hand-run tutorial](tutorials/phase-05-sdk-console.md) and the
[implementation decision record](design/phase-5-component-decisions.md).
@@ -0,0 +1,174 @@
# Phase 6 Thin Paperclip Adapter
Phase 6 adds one production integration seam without moving runner behavior
into Paperclip core. Paperclip owns workspace lifecycle, company/auth scope,
budgets, approvals, audit, cancellation, durable persistence, and issue status.
The package owns normalized sessions, provider-driver construction, PRP event
production, and semantic result production.
## Selection and rollback
Native mode requires both `experimental.enableNativeRunner=true` and an
eligible agent profile:
```json
{
"nativeRunner": {
"mode": "native",
"backend": "codex_app_server",
"protocolVersion": 1
}
}
```
Only active local `codex_local` agents running standard, workspace-backed
issues are eligible. The default and the kill switch are legacy. An explicit
but ineligible native profile fails closed; there is no same-run fallback.
Selection, resolver version/reason, profile, runner instance, and completion
contract are persisted before provider execution.
## Public boundary
`PaperclipControlPlanePort` implements the package's `ControlPlanePort`:
- `openRun` verifies company/run/issue/agent/contract binding;
- `loadSessionCheckpoint` and `checkpointSession` bind restart state to the
same company/run/issue/agent/session identity;
- `appendEvent` validates PRP, commits through the shared per-run allocator,
deduplicates source identity by canonical digest, and acknowledges afterward;
- `replayEvents` uses an exclusive source cursor;
- `completeRun` validates and idempotently persists the immutable structured
result plus terminal fact.
The package-owned Codex native backend is the production
`NativeSessionBackend`; the harness backend remains the deterministic test
adapter. Core passes only the closed `NativeExecutionInputV1`.
The model receives the smaller `NativeModelEnvelopeV1`; bindings and credential
references do not cross that boundary.
## Persistence and authority
Migration 0211 adds immutable completion contracts/results/assessments,
restart-safe finalization coordinators, status decisions/effects, issue status
versions, native run metadata, and native source identity on heartbeat events.
All event writers allocate from the row-locked `heartbeat_runs.next_event_seq`.
Finalization runs only after result persistence and workspace finalization. The
server verifies cited evidence against company- and issue-scoped durable
events, work products, approvals, interactions, or attachments; model claims
alone cannot complete an issue. The pure arbiter marks `done` only when the
terminal state succeeded, every criterion and verification has accepted
durable evidence, and no blocking work remains. It marks `blocked` only for a
task-wide first-class blocker with a named owner and action. Review, retry,
continuation, blocker, and recovery effects materialize atomically with the
status-version CAS and audit record. The committer has no catch-all delivery:
all native effect kinds must create or mutate their named target, and unknown
effects fail the transaction closed.
## Recovery and safety
Recovery selects persisted `runtime_mode=native` coordinators and does not
consult the current flag. Result-less transport loss retains the original run,
closed native input, provider checkpoint, retry attempt, and recovery action;
a database lease dispatches that same run after restart. Missing checkpoints,
cancellation, and retry exhaustion fail closed without opening a new provider
session or falling into legacy. Result-bearing rows continue through the
workspace/finalization reconciler. A recovered package session inspects the
provider snapshot before starting a turn, so an active provider turn is not
duplicated. Cancellation uses a run-scoped normalized session handle before the
existing process cleanup. Native execution does not construct a Paperclip JWT,
managed MCP access, legacy context, or raw adapter environment.
Resolved `request_confirmation` and `ask_user_questions` wakes are projected
through the existing authorized issue-interaction service and bound to the
company, issue, run, agent, and interaction identities in the persisted input.
Governed tool actions, unsupported kinds, unresolved rows, and self-resolution
fail closed; no credentials enter the model envelope.
## Verification status
The deterministic package suite and the embedded-PostgreSQL Paperclip suite
prove selection, replay/conflict handling, authoritative evidence,
status/liveness atomicity, migration repair, bounded cancellation/retry, and a
byte-equivalent legacy read snapshot. The thirteen-file database gate contains
46 tests with zero skips. The database suite is the internal
native canary: it executes one selected task through the public package session
contract and applies one server-owned decision. Its post-kill-switch legacy
case has zero native history rows.
The Section 18.13 database test executes fixture-specific production consumers
for all 52 fixtures. It derives and checks all eleven expected fields from
consumer return values and persisted production rows: run status,
status/preserve action, reason, required and forbidden effects, live-path kind,
claim preservation, native-record behavior, decision count, maximum wake
count, and maximum notification count. Each of the 70 unique matrix rows names
the responsible finalizer, terminal projection, attention, cancellation,
committer, reconciliation, compatibility, or migration consumer and fails if
that consumer did not execute or returned different semantics. A per-fixture
mutation check independently changes every expected field and proves the
comparison rejects it; there is no test-owned policy table supplying observed
semantics. Finalizer observations come from the live fact-based arbiter rather
than a fixture-state switch. Every delivered effect is joined to an actual
persisted target, replay is required to retain the same decision with one
delivery attempt, and an unknown-effect test proves the transaction leaves no
decision, ledger, issue-version, or coordinator mutation.
Operational rows use the production call graph, not resolver calls as a proxy.
Cancellation is committed and audited by the normalized session cancellation
entrypoint. Accepted attention starts at the immutable package-result row:
`finalizeNativeRun` calls `routePersistedNativeResultAttention`, records a
request-specific assessment, and then delegates an eligible same-company
target through `issueService`, creates a human issue-thread interaction, or
persists a cross-company rejection decision/recovery/audit trail.
Duplicate and stale targets finish as committed audit-only coordinators with
zero decisions, wakes, notifications, or issue status/version changes. Their
run receipt names the exact interaction target, and replay neither adds an
assessment nor updates that interaction again. Missing and cross-company audit
targets fail closed. `routePersistedNativeResultAttention` and
`routeNativeAttention` are internal helpers, not operational test ingresses.
Reconciliation selects the persisted
status/evidence/policy branches and executes workspace recovery through the
workspace operation recorder. The rollout kill switch is only the global
`experimental.enableNativeRunner` flag. Persisted native runs keep their mode
and finish/reconcile natively after flag-off; fresh unresolved runs for the
same agent persist legacy mode with `instance_flag_disabled`. The opted-in
agent profile remains unchanged, so re-enabling the flag does not require a
profile reset. Pending replay
only acknowledges its original decision-scoped target after verifying that the
target still exists.
Direct resolver and persisted-router calls are explicitly policy/internal
helper tests. They do not
satisfy an operational fixture without a runtime-reachable entrypoint receipt
and concrete durable target. Sabotage removes the finalizer-owned accepted-
attention path for the duplicate and stale fixtures, changes the real global-
flag input, removes cancellation/reconciliation actions, and deletes a pending
replay target; mapped fixtures fail while their pure resolver labels remain
unchanged.
Result-less same-run recovery is also proven through the production heartbeat
seam: a persisted envelope/checkpoint is leased by `reapOrphanedRuns`, enters
the original `executeRun`, recovers an already-active provider turn, and reaches
one persisted result, assessment, decision/effect set, and terminal heartbeat
projection. The test runs with the native flag disabled and asserts one run,
no new provider session or turn, no legacy adapter execution, lease-race
exclusion, cancellation/exhaustion exclusion, and missing-checkpoint
fail-closed behavior. The provider itself is scripted; this is not live Codex
evidence.
No new live provider task was dispatched during the remediation review. A live
Codex canary remains an operator-run rollout check because it requires changing
the instance flag and one agent profile. The package `trace:phase6` command is a
mock contract tracer; real Paperclip proof is the database-backed server matrix
and the explicitly gated procedure in the tutorial.
Deferred boundaries are unchanged: there is no attention UI or public
attention API, no external-system action execution, no credential delegation,
and no auto-approval. The Codex v1 structured-output schema authors compact
`kind`/`summary` attention requests; richer canonical PRP target metadata is
accepted and company-validated at the server boundary but does not yet have
provider authoring UX.
See the [runnable tutorial](tutorials/phase-06-thin-paperclip-adapter.md), the
[verification record](../knowledge/evidence/2026-08-09-phase-06-verification.md),
and the [approved design](design/phase-6-thin-paperclip-adapter.md).
@@ -0,0 +1,113 @@
# PRP Compatibility and Versioning Policy
## Authority
The JSON Schema files in [`protocol/schemas/`](../protocol/schemas/) are the
language-neutral source of truth for the Phase 1 and Phase 2 executable contract. The
generated TypeScript schema module is checked against those files before every
TypeScript typecheck. Rust consumes the same fixtures and must produce the same
golden parity summaries.
The broader normative protocol remains the
[native-runner spike specification](../spec/paperclip-native-runner-spike-spec.md).
Phase 2 reuses this contract for local live events. It adds package-local stdio
and stream envelopes, but it does not add durable transport, persistence, or
production control-plane behavior.
## Version fields
| Field | Phase 1 support | Compatibility rule |
|---|---:|---|
| `protocolVersion` | `1` | Required. Negotiate the highest overlapping version; no overlap fails closed. |
| `fixtureVersion` | `1` | Required by the conformance corpus. Unknown values fail closed. |
| `event.schemaVersion` | `1` | Required on every event. Unknown values fail closed before reduction. |
| Typed `schema` discriminators | `*.v1` | Required. Unknown required schema identities fail JSON Schema validation. |
Wire protocol versions and fixture-corpus versions are independent. A fixture
format can evolve without changing PRP, and a future PRP version can be
represented only after the consumer advertises support for it.
## Forward compatibility
- Unknown object properties are accepted and preserved by validation. Reducers
ignore fields they do not understand until a later schema version gives those
fields defined behavior.
- Unknown required versions, schema discriminators, enum values, and required
fields fail closed. A consumer must never guess at their semantics.
- Scripted fixtures bind every event to the fixture run/session, require
contiguous controller command order, exactly one unique proposed result, and
exactly one unique terminal event.
- The top-level fixture result must equal the `run.result.proposed` payload after
canonical key ordering. Repeated `sourceEventId` deliveries must be
byte-equivalent after the same normalization.
The forward-compatibility fixture proves that optional fields survive validation
without changing the v1 snapshot. The unsupported-version fixture proves that a
required v2 protocol cannot be replayed by this consumer.
## Replay semantics
- Events are applied in fixture order and ordered independently by
`(sourceKind, sourceInstanceId, sourceSeq)`.
- A repeated source event ID has no second projection effect.
- A forward source-sequence gap is recorded explicitly; the reducer never
invents a missing event.
- An event at or behind the committed source cursor is ignored and recorded as
out of order.
- Replaying an already-applied batch leaves the snapshot unchanged.
The CLI and browser import the same `replayPhase1FixtureText` function, so
validation, compatibility errors, and final snapshots cannot drift between the
two surfaces.
## Phase 2 local envelope rules
- Mock-core commands use `paperclip.prp.command.v1` over stdin JSONL.
- Runner output uses `paperclip.runner.stream.v1` over stdout JSONL.
- Fake-harness commands use `paperclip.fake_harness.command.v1`.
- Fake-harness output uses `paperclip.fake_harness.message.v1`.
- An equivalent repeated `commandId` returns a duplicate receipt and has no
second driver effect. Reuse with different data is rejected.
- A new command must use the next contiguous `controllerSeq`.
- Harness logs are bounded diagnostic data. They are not canonical PRP events.
- `run.result.proposed`, `harness.exited`, and `run.terminal` are separate facts
and appear in that order when a semantic result exists.
- The live browser rejects an event with an invalid schema, run ID, or session
ID before it reaches the reducer.
These envelopes are local Phase 2 implementation contracts.
## Phase 3 durable wire rules
- The runner opens an outbound WebSocket and sends PRP v1 `hello` before any
command result or event.
- A one-use bootstrap bearer capability returns a short-lived connection lease
in `welcome`. Later connections use that lease. Neither raw capability is
durable state.
- `hello.resume` reports the last processed controller sequence, next source
sequence, cumulative ACK cursor, and current unacknowledged range.
- `welcome` selects the one overlapping protocol version, returns the core's
cumulative ACK cursor, and carries at most one durable pending command.
- An event is durable before send. Event IDs and source sequences stay stable
across replay and process restart.
- An ACK is cumulative. The runner rejects a cursor behind its durable ACK or
beyond its produced source cursor.
- An equal repeated command ID and canonical digest returns its stored result.
Reuse with different bytes fails closed and cannot repeat an effect.
- Frames are bounded at 1 MiB and upgrade headers at 16 KiB. Unknown or invalid
required protocol data fails closed; malformed JSON is a bounded diagnostic.
These are package-local Phase 3 rules. Production TLS, control-plane admission,
and deployment policy remain separately reviewed work.
## Change policy
1. Change JSON Schema first.
2. Regenerate the TypeScript schema module.
3. Add or revise a shared fixture and its golden snapshot/summary.
4. Prove TypeScript and Rust parity.
5. Update this policy and the normative spike specification when behavior
changes.
Breaking changes require a new required version. Additive optional fields may
remain in v1 only when old consumers can safely ignore them.
@@ -0,0 +1,41 @@
# 2026-08-07 shadcn/ui and AI Elements Compatibility
## Decision
Phase 0 adds no UI runtime and no shadcn/ui, AI Elements, React, Tailwind, Next.js,
or AI SDK dependency to `@paperclipai/paperclip-runner`. A later browser phase
should adapt source components selectively after protocol and reducer contracts
exist.
## Dated findings
Sources were checked on 2026-08-07.
| Surface | Finding | Runner decision |
|---|---|---|
| shadcn/ui | Official documentation describes open-code component distribution and provides first-class Vite and monorepo setup paths. | Compatible with Paperclip's Vite UI model. Reuse or adapt checked-in source while keeping Paperclip tokens and accessibility rules authoritative. |
| AI Elements | Official documentation describes a registry built on shadcn/ui, targets React 19 and Tailwind CSS 4, and lists Next.js plus AI SDK as prerequisites. | Source shapes are promising, but the documented turnkey setup does not match a standalone Vite runner console. Evaluate individual source components; do not adopt the Next.js/AI SDK runtime stack by default. |
| Phase 0 package | This phase has no browser surface. | Keep the dependency graph empty at runtime and defer all component selection. |
## Candidate later evaluation
When the browser phase is authorized, evaluate conversation/message,
prompt/composer, plan, tool, code, terminal, and queue-style source components
against these gates:
- data contract maps to the shared runner reducer rather than component-local
state;
- styles use Paperclip's token layer with no raw color, spacing, radius,
typography, shadow, or motion values in Paperclip UI files;
- keyboard and screen-reader behavior passes local accessibility review;
- Vite and Storybook builds do not require a Next.js runtime;
- no AI SDK dependency is added merely to render already-normalized runner data.
## Primary sources
- [shadcn/ui introduction](https://ui.shadcn.com/docs)
- [shadcn/ui Vite installation](https://ui.shadcn.com/docs/installation/vite)
- [Vercel AI Elements introduction and prerequisites](https://elements.ai-sdk.dev/docs)
This note is a compatibility record, not authorization to add a UI surface in
Phase 0.
@@ -0,0 +1,175 @@
# Native Runner Cumulative End-to-End Tutorial
## What this tutorial is
This tutorial combines each implemented Native Runner phase into one procedure.
It currently includes Phase 0 through the Phase 5 browser SDK layer.
## What this tutorial proves
This tutorial proves that the standalone package boundary, static replay path,
local live-run path, durable recovery path, and direct Codex driver work
together. It does not use the Paperclip control plane. Phase 4 uses a real
local Codex session through the mock core.
The current system includes the Rust mock-core tracer, shared protocol fixtures,
the Rust supervisor, a scripted fake harness, CLI live runs, and browser live and
replay modes. It also includes the Rust outbound WebSocket client, durable
outbox, package-local mock core, recovery CLI, and recovery browser view.
The final phase adds a skillless task envelope, direct app-server driver,
semantic completion tools, and the same reducer/replay proof used by fixtures.
Phase 5 freezes that browser transport and reducer projection as public SDK
subpaths, then proves them with a reference console and a second consumer.
## Current end-to-end path
1. Follow [Phase 0: Run the Standalone Tracer](phase-00-standalone-tracer.md).
2. Confirm the final JSON contains `run_phase0_0001`,
`session_phase0_0001`, and `succeeded`.
3. Confirm the cross-language parity check passes.
4. Confirm the shell prompt returns and no Paperclip service was started.
5. Open the [Phase 0 journal entry](../../knowledge/journal/2026-08-07-phase-00.md)
and its linked verification evidence.
6. Follow [Phase 1: Validate and Replay a PRP Fixture](phase-01-static-replay.md).
7. Compare the happy-path CLI snapshot with the browser page.
8. Exercise the duplicate, gap, unknown-field, and unsupported-version fixtures.
9. Open the [Phase 1 journal entry](../../knowledge/journal/2026-08-07-phase-01.md)
and its linked verification evidence.
10. Follow [Phase 2: Run the Local Runner and Fake Harness](phase-02-local-runner.md).
11. Run the happy, permission/input, interruption, error, and duplicate-terminal scenarios.
12. Open the browser live mode and confirm the completed run says `Match` for live and replay output.
13. Open the [Phase 2 journal entry](../../knowledge/journal/2026-08-07-phase-02.md)
and its linked verification evidence and screenshots.
14. Follow [Phase 3: Break Recovery on Purpose](phase-03-break-recovery.md).
15. Confirm that the recovered runner and session IDs stay the same.
16. Confirm that the outbox is empty after replay and cumulative acknowledgement.
17. Open the [Phase 3 journal entry](../../knowledge/journal/2026-08-07-phase-03.md).
18. Follow [Phase 4: Run the Skillless Codex Driver](phase-04-skillless-codex.md).
19. Inspect the exact model-context snapshot and confirm that it has no
Paperclip instructions, bearer credentials, or unrelated skills.
20. Run the safe task, then steer and interrupt separate sessions. Confirm
stable session identities and exactly one result and terminal event.
21. Open the [Phase 4 journal entry](../../knowledge/journal/2026-08-08-phase-04.md)
and its linked real-session trace and verification evidence.
22. Follow [Phase 4b: Run the Protocol Demo Server](phase-04b-protocol-server.md).
23. Confirm requests stay pending for a typed browser decision, stale steering
is rejected, and reconnect keeps the same run and session identities.
24. Open the [Phase 4b protocol/server journal](../../knowledge/journal/2026-08-08-phase-04b-protocol-server.md)
and its linked deterministic and real-Codex evidence.
25. Follow [Phase 5: Run the SDK Console and Mini Consumer](phase-05-sdk-console.md).
26. Run the fake lifecycle in both consumers, then confirm the mini consumer
reaches `Replay parity: match` after reconnect and replay.
27. Run the safe real-Codex browser smoke and inspect the Phase 5 screenshots.
28. Open the [Phase 5 journal](../../knowledge/journal/2026-08-08-phase-05-sdk.md)
and its linked package-acceptance evidence.
The one-command form after installation is:
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
On a minimal Debian or Ubuntu host without root access, use the rootless browser
dependency path:
```sh
pnpm --filter @paperclipai/paperclip-runner verify:rootless
```
## Cumulative guarantees
- the fixture is validated before any mock-core mutation;
- event sequence and run identity agree through the terminal result;
- Rust and TypeScript printed output is covered by exact string and parity assertions;
- deliberate TypeScript and Cargo references to Paperclip core are rejected;
- documentation and journal indexes are machine checked;
- the package remains runnable without Paperclip core;
- JSON Schema remains the language-neutral authority for TypeScript and Rust;
- replay is deterministic and idempotent under duplicate delivery;
- source gaps are visible and never synthesized away;
- CLI and browser paths use the same validator/reducer module;
- browser components keep visual values in the package-local token layer.
- the Rust supervisor owns the fake harness process group and cleans it up when the controller closes;
- command IDs are idempotent and controller sequence numbers stay contiguous;
- runtime permission and input requests round-trip over the local protocol;
- process exit is recorded separately from the structured semantic result;
- bounded logs retain only their configured tail;
- exactly one terminal event closes every completed local trace;
- every live browser event passes the Phase 1 validator and reducer before display;
- replaying the completed live event list produces the same final snapshot.
- a lost cumulative ACK replays the same durable event ID without a second
logical event;
- repeated commands return the stored result and cause one logical effect;
- runner and harness restarts preserve runner, session, turn, and item IDs;
- backpressure bounds local storage without dropping P0 events;
- lease expiry, drain, revoke, and unrecoverable storage outcomes are explicit;
- CLI and browser diagnostics do not expose bootstrap or connection-lease
tokens.
- the Codex child receives an allowlisted environment without Paperclip or
OpenAI bearer credentials;
- automatic skill, app, and collaboration instruction blocks are disabled;
- direct app-server create, resume, read, turn, steer, interrupt, usage, and
reconciliation operations preserve stable identities;
- provider events normalize to canonical lifecycle, model, tool, file,
request, usage, verification, result, and terminal events;
- the first validated semantic completion wins, identical duplicates are
idempotent, and a changed duplicate is rejected;
- unsupported capabilities degrade through explicit redacted diagnostics;
- the real trace and its replay reduce to the same final snapshot.
- supported provider requests wait for one typed browser resolution and clean
up exactly once;
- same-turn steering is acknowledged while stale and direct-child steering are
rejected;
- pre-start interrupts queue until the provider turn ID exists and terminal
races return `already_terminal`;
- goal controls are capability probed and disabled precisely when unavailable;
- parent/child activity derives from provider thread identities;
- the demo server fixes the workspace and keeps Codex authentication out of
browser JSON, events, and diagnostics;
- refresh/reconnect replays canonical events and resumes the exact persisted
provider thread.
- the browser console renders only reducer state and canonical events, with no
second event model and no client-side session cache;
- steering resolves to exactly one of acknowledged, stale-rejected, or failed,
and rejected text stays recoverable;
- interrupt before start, during generation, and during a tool call each end in
a distinct visible state, and the session is never replaced;
- request cards offer only the actions the upstream request offers and lock on
the first click until the canonical resolved event arrives;
- unsupported capabilities render disabled controls carrying the exact upstream
diagnostic, never hidden and never emulated;
- a transport drop, a page refresh, and replay all reproduce the same
transcript from the durable cursor;
- no provider credential reaches the browser DOM, and adapted components add no
new runtime dependency.
- the browser and React contracts are versioned package subpaths with React as
a peer and no new runtime dependency;
- the reference console and mini consumer import public APIs only;
- exactly five extension points cover item bodies, request details, Composer
actions, token theming, and transport injection;
- duplicate canonical events reach the shared reducer unchanged;
- both consumers preserve identity through reconnect and reduce replay to the
same final state.
## Step 6: Chat with a live session in the browser
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
Open `http://127.0.0.1:4180/` and press **Live console**. Work through the
[Phase 4b live console tutorial](phase-04b-live-console.md) to reach every
state above from the eleven deterministic demo chats. Add
`PAPERCLIP_PHASE4B_DRIVER=codex` to run the identical screens against a real
Codex session.
## Step 7: Run the reusable SDK consumers
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase5
```
Open `http://127.0.0.1:4181/reference-console/` and
`http://127.0.0.1:4181/mini-consumer/`. Follow the
[Phase 5 tutorial](phase-05-sdk-console.md) for the deterministic lifecycle,
real-Codex smoke, keyboard checks, and package acceptance command.
@@ -0,0 +1,106 @@
# Phase 0: Run the Standalone Tracer
## What this phase is
Phase 0 is the smallest standalone runner path. It uses a mock control plane and does not start Paperclip.
## What this phase proves
This phase proves that the package has an independent Rust boundary. It also proves that Rust and TypeScript accept the same fixture.
You will install the package tools. You will compile the Rust and TypeScript code. You will run the tests and checks.
You will start the Rust mock core. The tracer validates one fixture and prints a deterministic result. The process then stops.
## Prerequisites
- repository checkout on the assigned runner branch;
- Node.js 20 or newer;
- pnpm 9 or newer;
- stable Rust with `cargo` on `PATH`;
- commands run from the repository root.
## 1. Install the package tools
```sh
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev
```
`--lockfile=false` follows this repository's policy that automation owns the
root lockfile. `--offline` proves Phase 0 needs no newly downloaded package.
`--dev` makes the package tooling explicit even when the calling shell has
`NODE_ENV=production`.
## 2. Build the standalone package
```sh
pnpm --filter @paperclipai/paperclip-runner build
```
Expected result: TypeScript writes package-local `dist/` files and Cargo builds
the `paperclip-runner-core` crate under `runner/target/`.
## 3. Run the behavior and boundary tests
```sh
pnpm --filter @paperclipai/paperclip-runner test
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
The tests cover fixture validation, complete Rust and TypeScript mock-core paths,
stable output, and negative TypeScript/Cargo core-dependency fixtures. The
standalone boundary check must print `Standalone boundary check passed.`
## 4. Validate the documentation and OKF bundle
```sh
pnpm --filter @paperclipai/paperclip-runner docs:validate
```
Expected result: both documentation-link validation and OKF v0.2 validation
pass. The OKF validator checks concept frontmatter, typed journal fields, log
dates, the root version declaration, and index coverage.
## 5. Run the tracer
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase0
```
Expected final line:
```json
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
```
The command exits successfully after the mock core stops. No service remains
The default tracer is Rust. Prove that the TypeScript reference produces the
same bytes with:
```sh
pnpm --filter @paperclipai/paperclip-runner check:phase0-parity
```
Expected result: `Rust and TypeScript Phase 0 tracer output matches the shared
golden fixture.`
## 6. Inspect and query the journal
```sh
sed -n '1,240p' packages/paperclip-runner/knowledge/journal/2026-08-07-phase-00.md
rg -l '^type: Engineering Journal Entry$' packages/paperclip-runner/knowledge
```
For more query and authoring examples, see the [journal guide](../journal.md).
## One-command rerun
After installation, the same checks can be repeated with:
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
Continue with the [cumulative end-to-end tutorial](end-to-end.md), which points
to this tutorial as the current complete path.
@@ -0,0 +1,115 @@
# Phase 1: Validate and Replay a PRP Fixture
## What this phase is
Phase 1 is a static replay path for a Paperclip Runner Protocol fixture. It uses the same reducer in the CLI and browser.
## What this phase proves
This phase proves that Rust and TypeScript accept the same protocol data. It also proves that duplicate events do not change the result.
The tutorial validates one fixture. It reduces the events in the CLI. It then shows the same snapshot in the browser.
## Prerequisites
- Node.js 20 or newer
- pnpm 9 or newer
- a stable Rust toolchain with `cargo`
- a Chromium-compatible browser
Run every command from the repository root on the
`PAP-16679-paperclip-runner` branch. Install only this workspace without writing
the root lockfile:
```sh
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --ignore-scripts --dev
pnpm --filter @paperclipai/paperclip-runner exec playwright install chromium
```
On a minimal Linux host, Playwright may also request system browser libraries:
```sh
pnpm --filter @paperclipai/paperclip-runner exec playwright install-deps chromium
```
## 1. Check the contract and golden corpus
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck
pnpm --filter @paperclipai/paperclip-runner check:phase1-goldens
pnpm --filter @paperclipai/paperclip-runner check:phase1-parity
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
Expected: generated schema sources are current, TypeScript and Rust accept the
same fixtures, all parity summaries match, and the standalone boundary passes.
## 2. Replay the happy path in the CLI
```sh
pnpm --filter @paperclipai/paperclip-runner replay:phase1
```
Expected JSON facts:
- `ok` is `true`;
- `snapshot.integrity` is `complete`;
- `snapshot.timeline` contains 9 entries;
- `snapshot.terminal.runTerminalState` is `succeeded`.
Pass a different fixture path to inspect another case:
```sh
pnpm --filter @paperclipai/paperclip-runner replay:phase1 packages/paperclip-runner/protocol/fixtures/phase-01/source-gap.json
```
Expected: `snapshot.integrity` is `gap_detected` and the missing source sequence
is `3`.
An unsupported required version exits non-zero and returns a structured error:
```sh
pnpm --filter @paperclipai/paperclip-runner replay:phase1 packages/paperclip-runner/protocol/fixtures/phase-01/unsupported-required-version.json
```
## 3. Open the standalone replay page
Start the package-local Vite server:
```sh
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
```
Open `http://127.0.0.1:4179`, then:
1. Select `Happy path` and confirm the terminal badge reads `succeeded`.
2. Edit the fixture summary in the JSON textarea and select **Validate & replay**.
3. Select `Duplicate event` and confirm one duplicate is ignored.
4. Select `Source sequence gap` and confirm the missing sequence diagnostic.
5. Select `Unsupported required version` and confirm replay is rejected.
6. Expand **Inspect snapshot JSON** and compare it with the CLI output.
Stop Vite with `Ctrl+C`.
## 4. Run the browser regression path
```sh
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner test:browser
```
Expected: the Playwright tests pass and temporary screenshots are written under
the ignored `packages/paperclip-runner/test-results/` directory. The regression
suite does not rewrite committed evidence images.
## What this proves
- JSON Schema is the executable source for validation and TypeScript types.
- The CLI and browser call the same validator and reducer.
- Duplicate delivery is idempotent and sequence gaps remain visible.
- Unknown optional fields do not change the v1 projection.
- No Paperclip server, UI, CLI, adapter, or production database module starts or
imports into the standalone path.
Phase 2 is outside this tutorial and must not start before the Phase 1 human
checkpoint.
@@ -0,0 +1,145 @@
# Phase 2: Run the Local Runner and Fake Harness
## What this phase is
Phase 2 is a local live-run path. A TypeScript mock core starts a Rust runner.
The Rust runner starts a scripted fake harness. The processes use JSON lines over
stdio.
## What this phase proves
This phase proves that a native session can run from start to finish without
Paperclip, a network provider, or a real model. It proves cleanup, request
resolution, interruption, bounded logs, one terminal result, and live/replay
parity.
## Prerequisites
- Node.js 20 or newer
- pnpm 9 or newer
- a stable Rust toolchain with `cargo`
- a Chromium-compatible browser
Run every command from the repository root on the
`PAP-16679-paperclip-runner` branch. Install the package dependencies and browser:
```sh
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --ignore-scripts --dev
pnpm --filter @paperclipai/paperclip-runner exec playwright install chromium
```
On a minimal Linux host, install the browser libraries too:
```sh
pnpm --filter @paperclipai/paperclip-runner exec playwright install-deps chromium
```
`playwright install-deps` requires root. On a Debian or Ubuntu host where root
is unavailable, use the package's rootless verification command instead. It
downloads the required browser-library packages into a user-owned cache,
extracts them without installing system packages, and scopes `LD_LIBRARY_PATH`
to the verification process:
```sh
pnpm --filter @paperclipai/paperclip-runner verify:rootless
```
## 1. Run the package verification path
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
Expected: Rust, TypeScript, protocol, boundary, documentation, and browser
checks pass. The final Phase 2 line reports one terminal event and a successful
semantic result.
## 2. Run the happy path in the CLI
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario happy-path --quiet
```
Expected summary facts:
- `eventCount` is `19`;
- `terminalCount` is `1`;
- `semanticResult` is `done`;
- harness and runner exit codes are `0`.
Capture the live trace as a validated replay fixture:
```sh
pnpm --filter @paperclipai/paperclip-runner record:phase2
```
The command writes
`packages/paperclip-runner/knowledge/evidence/phase-02-happy-path.json`.
## 3. Run the scripted control flows
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario permission-input --quiet
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario interrupted --quiet
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario error --quiet
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario duplicate-terminal --quiet
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario happy-path --duplicate-turn-command --quiet
```
Expected:
- permission and input finish with `done`;
- interruption exits the harness with `130` and reports `yielded`;
- scripted error exits the harness with `7` and reports `yielded`;
- duplicate terminal still reports `terminalCount: 1`;
- duplicate turn command does not start a second turn.
## 4. Use the live browser
Start the package-local server:
```sh
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
```
Open `http://127.0.0.1:4179`, then follow these steps:
1. Keep `Happy path` selected and choose **Start local run**.
2. Confirm the terminal badge says `succeeded`.
3. Confirm `Harness process exit` is `0` and `Semantic result` is `done`.
4. Confirm `Live and replay reducer output` says `Match`.
5. Select `Permission and input`, start the run, and choose **Allow**.
6. Enter a trace name and choose **Send input**. Confirm the run completes.
Each interactive request remains open for five minutes. If it expires, the
page reports that the run finished and asks you to start a new run.
7. Select `Interruption`, start the run, and choose **Interrupt turn** when the
button becomes active.
8. Confirm the terminal badge says `cancelled`, the semantic result says
`yielded`, and the timeline contains one `run.terminal` event.
9. Switch to **Static replay** and confirm the Phase 1 fixture path still works.
Stop Vite with `Ctrl+C`.
The browser regression suite writes its temporary screenshots under the
ignored `packages/paperclip-runner/test-results/` directory. It never rewrites
the committed evidence images linked below.
## 5. Inspect the evidence
- [Complete live run](../../knowledge/evidence/phase-02-live-complete.png)
- [Permission request](../../knowledge/evidence/phase-02-live-permission.png)
- [Interrupted run](../../knowledge/evidence/phase-02-live-interrupted.png)
- [Exact verification record](../../knowledge/evidence/2026-08-07-phase-02-verification.md)
- [Engineering journal](../../knowledge/journal/2026-08-07-phase-02.md)
## What this proves
- the Rust runner owns and cleans the fake-harness process group;
- the fake driver emits deterministic live events and requests;
- logs are bounded;
- process exit and semantic result stay separate;
- duplicate commands and terminal proposals do not repeat effects;
- every browser event passes the Phase 1 validator and reducer;
- replay after completion produces the same final snapshot.
Phase 3 must not begin until the Phase 2 human checkpoint is accepted.
@@ -0,0 +1,215 @@
# Phase 3: Break It on Purpose
## What this phase is
Phase 3 adds a durable outbound WebSocket connection.
The Rust runner connects to a package-local mock core. The runner stores events
before it sends them. It stores each command result before it replies.
This phase does not use the Paperclip server, UI, database, or a real model.
## What this phase proves
This tutorial proves that a broken connection does not create a false new
session.
You will drop a socket. You will lose an acknowledgement. You will restart the
real Rust runner process. You will also restart the real fake-harness child.
Each test keeps the same runner, session, turn, item, command, and event IDs.
Repeated delivery causes one logical effect. P0 events are not lost. The
diagnostics do not show authentication secrets.
## Before you start
From the repository root, build the package binaries:
```sh
pnpm --filter @paperclipai/paperclip-runner build:typescript
pnpm --filter @paperclipai/paperclip-runner build:phase2-binaries
```
The commands below use only loopback network access. They create temporary
state below the Paperclip run scratch directory when it is available. The CLI
removes that state after each successful proof.
## Step 1: Drop the socket
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault socket-drop
```
Confirm:
- `Outcome: recovered` is shown.
- The connection count is at least 2.
- The runner ACK and core ACK are equal.
- Every assertion says `PASS`.
## Step 2: Lose one ACK
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lost-ack
```
Confirm:
- the output shows at least one replay delivery;
- the outbox ends with 0 pending events;
- `P0 lost 0` is shown;
- `PASS no duplicate logical events` is shown;
- the runner, session, turn, and item IDs are unchanged.
The network may deliver one event more than once. The mock core keeps one
logical event and advances one cumulative cursor.
## Step 3: Restart the Rust runner
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault runner-restart
```
Confirm:
- the output shows 1 runner restart;
- the output shows 2 fresh bootstraps;
- a replay delivery is present;
- every assertion says `PASS`.
The test kills `paperclip-runnerd` while an event is unacknowledged. The next
process reads the same private state file. It emits `runner.reconciled` and
continues the same durable source cursor.
## Step 4: Restart the fake harness
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault harness-restart
```
Confirm:
- the output shows 1 harness restart;
- `PASS stable identity` is shown;
- `PASS source cursor continuity` is shown.
The test starts a real fake-harness child, stops its process group, and starts a
second child. The normalized session, turn, and item IDs do not change.
## Step 5: Repeat a command
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault duplicate-command
```
Confirm:
- the output shows at least one duplicate command delivery;
- `PASS one logical effect per accepted command` is shown;
- `workspace.ready` exists one time in JSON output when you add `--json`.
## Step 6: Apply storage pressure
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault storage-pressure
```
Confirm:
- backpressure is active in JSON output;
- peak storage is below the maximum;
- the next turn is rejected;
- `PASS P0 events preserved` is shown;
- `PASS bounded storage` is shown.
## Step 7: Test lease expiry, drain, and revoke
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lease-expiry
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault drain
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault revoke
```
Confirm:
- lease expiry uses a fresh bootstrap and resumes the same cursor;
- drain reports `Outcome: drained` and rejects new work;
- revoke reports `Outcome: revoked`;
- all three traces finish with an empty acknowledged outbox;
- every security assertion passes.
## Step 8: Open the browser recovery view
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
```
Open `http://127.0.0.1:4179/` and select **Recovery**. Use the **Fault** selector
to run these cases with **Run Phase 3 recovery**:
1. **Normal connection**
2. **Socket drop**
3. **Lost ACK**
4. **Runner restart**
5. **Revoke**
Also run **Storage pressure** when checking the bounded-storage state.
Confirm:
- recovery status becomes **complete** after every run;
- **Outcome** is **Recovered** for the normal, socket-drop, lost-ACK, and
runner-restart cases, while revoke shows the warning-toned **Revoked** outcome
and its reason;
- **Connections**, **Reconnects**, **Runner restarts**, and **Fresh bootstraps**
distinguish the normal, reconnect, process-restart, and lease-bootstrap paths;
- **Recovery history** lists every committed source sequence and event type, with
a `delivered N×` marker on events delivered more than once;
- **At-least-once redeliveries** can be non-zero for a normal connection because
the runner may resend a still-unacknowledged in-flight outbox batch. Use the
reconnect counters and per-event delivery markers to identify injected
recovery; a lost ACK adds an extra delivery to the affected event;
- **Storage** reports current bytes and the maximum, and storage pressure shows a
warning-toned **Backpressure** badge;
- the connection lifecycle is human-readable, such as **Stopped**;
- **Same runner and session** says **Preserved**;
- **Secrets redacted** says **Yes**;
- no bootstrap ticket or connection lease token appears.
## Step 9: Run the package verification path
Run:
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
The verification path builds TypeScript, Rust, and the browser. It runs unit,
integration, browser, boundary, documentation, parity, and tracer checks for
Phases 0 through 3.
## Evidence
- [Phase 3 verification record](../../knowledge/evidence/2026-08-07-phase-03-verification.md)
- [Phase 3 recovery screenshot](../../knowledge/evidence/phase-03-recovery-diagnostics.png)
- [Phase 3 OKF journal entry](../../knowledge/journal/2026-08-07-phase-03.md)
- [Phase 3 transport reference](../phase-03-durable-transport.md)
Phase 4 remains uncreated. A human must accept the Phase 3 checkpoint before
later work starts.
@@ -0,0 +1,204 @@
# Phase 4: Run the Skillless Codex Driver
## What this phase is
Phase 4 connects the mock core to a real Codex app-server session.
Codex gets one small task envelope. Codex does not get the Paperclip skill.
Codex does not get a Paperclip API key or API instructions.
## What this phase proves
This phase proves that a real Codex session can create a file, stream typed
events, return one checked result, and replay the same result.
It also proves that steer, interrupt, resume, and unsupported-operation paths
keep clear session identities and safe diagnostics.
## Before you start
- Run commands from the repository root.
- Install Node.js 20+, pnpm 9+, Rust, and the `codex` CLI.
- Sign in to Codex with the normal local Codex setup.
- Do not add a Paperclip or OpenAI API key for this tutorial.
Check the installed harness:
```sh
codex --version
codex app-server --help
```
Run the real-Codex steps from an ordinary operator shell with a workspace that
has no `AGENTS.md` in that directory or any ancestor. A child directory of this
repository is not skillless because Codex discovers the repository
`AGENTS.md`. In a Paperclip-managed agent run, `PAPERCLIP_WORKSPACE_CWD` also
prevents `mktemp -d` from selecting a directory outside the assigned workspace;
use a separate clean operator workspace rather than bypassing that guard.
## Step 1: Run the focused conformance checks
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/app-server-transport.test.ts \
src/drivers/codex/codex-app-server-driver.test.ts
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
Confirm that the driver tests pass and the package boundary check says
`Standalone boundary check passed.`
## Step 2: Run the safe real Codex task
Choose a temporary directory and trace file:
```sh
phase4_workspace="$(mktemp -d)"
phase4_trace="$phase4_workspace/trace.json"
pnpm --filter @paperclipai/paperclip-runner trace:phase4 -- \
--working-directory "$phase4_workspace" \
--output "$phase4_trace"
```
Confirm:
- the result is `done`;
- every printed assertion says `PASS`;
- the file has the exact text:
```sh
test "$(cat "$phase4_workspace/hello.txt")" = "hello from phase 4"
```
## Step 3: Inspect the exact model boundary
```sh
jq '.context | {
protocolVersion,
codexVersion,
model,
modelProvider,
workingDirectory,
sandbox,
approvalPolicy,
baseInstructions,
instructionSources,
instructionPolicy,
environmentKeys,
dynamicToolNames,
modelInputKinds,
envelope
}' "$phase4_trace"
```
Confirm:
- `instructionSources` is empty;
- all three instruction-policy values are `false`;
- `modelInputKinds` contains only `text`;
- the semantic tools are `paperclip_finish` and `paperclip_block`;
- no environment **value** is present;
- the requested permission profile denies root and host-home access, exposes
read-only minimal runtime files, grants write access to the assigned
workspace, and disables network access;
- the returned legacy sandbox facts are inspected separately because Codex may
add provider-managed state roots such as `~/.codex/memories` even when memory
instructions are disabled;
- command `HOME` and `CODEX_HOME` are absent;
- the envelope contains only this safe task and its completion criteria.
Check that no control-plane route or bearer credential appears:
```sh
if rg -ni 'authorization: bearer|paperclip_api_key|/api/issues/' "$phase4_trace"; then
echo "FAIL: forbidden control-plane context found"
exit 1
fi
```
## Step 4: Inspect canonical events and replay
```sh
jq '[.events[].eventType] | group_by(.) | map({type: .[0], count: length})' \
"$phase4_trace"
jq '.assertions' "$phase4_trace"
```
Confirm:
- session and turn lifecycle events exist;
- model, command/tool, usage, verification, result, and terminal events exist;
- `exactlyOneTerminalResult` is `true`;
- `liveReplayParity` is `true`;
- source sequence and item identity assertions are `true`.
The focused test suite supplies deterministic file-change and runtime-request
events because a real model may choose a shell command instead of a file patch
and may not need human input for this small task.
To record the authenticated isolation proof, run:
```sh
pnpm --filter @paperclipai/paperclip-runner record:phase4
```
The recorder selects every existing readable host Codex credential/config file
(`auth.json` and/or `config.toml`), creates a separate host secret, and asks the
model to prove that those files, the host-home root, and the unrelated secret
are neither readable nor writable. It intentionally does not assert that the
whole Codex-home directory is unreadable: warmed Codex 0.132.0 environments can
inject `~/.codex/memories` as a writable legacy sandbox root. The credential
files remain denied. The run must still authenticate, create the workspace
files, retain the successful command output, and pass every trace assertion.
If the tracer returns `blocked`/`needs_review`, or an accepted `done` result is
missing an expected file, the recorder exits with a named diagnostic before it
attempts to consume absent outputs.
## Step 5: Try steering
Use a new directory:
```sh
phase4_steer_workspace="$(mktemp -d)"
pnpm --filter @paperclipai/paperclip-runner trace:phase4 -- \
--working-directory "$phase4_steer_workspace" \
--steer "Keep the answer short and verify the exact file text."
```
Confirm that the same driver and provider session IDs are printed and the task
still returns one result.
## Step 6: Try interruption
Use a new directory:
```sh
phase4_interrupt_workspace="$(mktemp -d)"
pnpm --filter @paperclipai/paperclip-runner trace:phase4 -- \
--working-directory "$phase4_interrupt_workspace" \
--interrupt
```
An interrupt can win before the file is created. That is expected. Confirm that
the trace still has one session identity, one semantic result, and one terminal
event. It must not create a replacement session.
## Step 7: Run the package verification path
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
This command is the package acceptance path. The real Codex example remains a
separate command because automated environments may not have a signed-in Codex
session.
## Step 8: Read the evidence
- [Phase 4 driver reference](../phase-04-skillless-codex-driver.md)
- [Phase 4 verification evidence](../../knowledge/evidence/2026-08-08-phase-04-verification.md)
- [Phase 4 journal](../../knowledge/journal/2026-08-08-phase-04.md)
Phase 5 is not part of this tutorial and remains uncreated.
@@ -0,0 +1,203 @@
# Phase 4b: Run the Live Codex Protocol Console
## What this is
This tutorial runs the Phase 4b browser console. The
[protocol demo server](phase-04b-protocol-server.md) tutorial used `curl`. This
one uses the real user interface.
## What this proves
The console shows one live session. You can chat with it, steer it, stop it,
answer its requests, change its goal, watch its child threads, break its
connection, refresh the page, and replay the whole record. Every screen reads
the canonical protocol. Nothing in the browser holds a provider login.
## Before you start
Run every command from the repository root. You do not need a Codex account
for this tutorial. The default driver replays deterministic demo chats.
## Step 1: Run the deterministic checks
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/mock-core/phase4b-scripted-driver.test.ts \
src/mock-core/phase4b-demo-server.test.ts \
devtools/browser/src/live/transcript-model.test.ts
```
Expected result: all three files pass. They cover the demo driver, the server
routes, and the pure transcript model the console renders.
## Step 2: Start the console
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
Keep this terminal open. Open `http://127.0.0.1:4180/` in a browser and press
**Live console**.
Expected screen: an empty transcript that says
`Pick a demo chat or start a blank session`, a list of demo chats on the left,
and a disabled **Send** button.
## Step 3: Run one clean turn
1. Press **Completion** in the demo chat list.
2. Press **Run this demo chat**.
Expected facts:
- The connection badge says `connected`.
- Your own message appears first.
- A collapsed **Reasoning** block appears and opens while it streams.
- A **Tool** block shows its input and then its output.
- The answer streams into view with a blinking cursor.
- The turn ends with a `Turn completed` badge.
## Step 4: Steer a running turn
1. Press **Same-turn steering**, then **Run this demo chat**.
2. While the turn runs, the primary button says **Steer**.
3. Type `Keep it to one sentence.` and press Enter.
Expected facts:
- A steering chip appears as `pending` and then becomes `acknowledged`.
- The chip never merges into the answer before the acknowledgement.
Now type text while the turn is running, wait for `Turn completed`, and press
the button. The chip becomes `rejected — turn already ended` and offers
**Send as new message**. Your text is never discarded.
## Step 5: Stop a turn in three ways
Run each demo chat and press **Stop**:
| Demo chat | When to press Stop | Expected result |
| --- | --- | --- |
| Interrupt before start | right away | `Cancelled before start`, and no answer item is created |
| Interrupt during generation | while text streams | the stream stops, the partial text stays, and an interrupted divider appears |
| Interrupt during a tool call | while the tool runs | the tool keeps its last status and shows no result |
The session is never replaced. After a stop you can simply send again.
## Step 6: Answer requests
1. Press **Command and file approvals**, then **Run this demo chat**.
Expected facts:
- A blue banner above the composer says `1 request waiting — Review`.
- The command card offers **Approve**, **Approve for session**, **Reject**,
and **Cancel**, because the request offers those four actions.
- After you press **Approve**, the whole action row locks until the resolved
event arrives. A second click cannot answer twice.
- The resolved card collapses to one line that names the action you chose.
- The next card offers only **Approve** and **Reject**, because that request
offers only two actions.
2. Press **User input and expiry**, then **Run this demo chat**.
Type an answer and press **Submit**. Leave the second request alone. After a
few seconds it becomes `expired before response` and stays in the record.
## Step 7: Change the goal
1. Press **Goal lifecycle**, then **Run this demo chat**.
2. Press **Goal**, then **Set goal…**, type an objective, and confirm.
3. Press **Goal**, then **Pause**.
Expected facts: the banner shows the objective, the state, and the time of the
last change. **Resume** is disabled while the goal is active, and **Pause** is
disabled while it is paused.
Now press **Goals unsupported** and run it. The **Goal** button is disabled,
not hidden, and it names the exact upstream reason:
`Goal operations unsupported: app-server 0.132.0 does not advertise the goals capability`.
Open the **Protocol inspector** and the **Capabilities** tab. The same string
appears there.
## Step 8: Watch child threads
Press **Parent and child threads**, then **Run this demo chat**.
Expected facts:
- The **Threads** panel lists the root session and two child threads.
- One child ends `Completed` and one ends `Failed`. Both stay in the tree.
- Selecting a child shows a breadcrumb and a disabled composer that says
`Direct steering of child threads is not supported by this app-server.`
## Step 9: Break the connection and recover
1. Run any demo chat and wait for it to finish.
2. Press **Simulate connection loss**.
Expected facts: a banner says the connection was lost, and the composer is
disabled.
3. Press **Retry now**.
Expected facts: the badge returns to `connected` and the transcript is exactly
what it was before the drop. Nothing is lost and nothing is duplicated.
Now reload the page and press **Live console** again. The same session returns
with its full transcript.
## Step 10: Replay the record
Press **Replay**.
Expected facts: a `REPLAY` badge appears, the transcript resets, and a stepper
appears. Press **Step forward** to walk the session forward one event at a
time. Press **Play** to run it. Press **Exit replay** to return to live.
## Step 11: Inspect the protocol
Press **Protocol inspector**.
- **Events**: every canonical event with its sequence, type, and full payload.
Filter by text or by event type.
- **Requests**: every runtime request with its lifecycle.
- **Capabilities**: what this app-server supports, with a reason for each
unsupported row.
- **Session**: identities, cursors, gap state, and the live-versus-replay
reducer comparison. It should read `match`.
Check the **Credentials in browser** row. It must read `no`.
## Step 12: Check the keyboard
- `Tab` reaches every control, including the inspector and the dialogs.
- `Enter` sends. `Shift` and `Enter` add a line.
- `Escape` while a turn runs moves focus to **Stop**. It never stops the turn
on its own.
- Arrow keys move between inspector tabs and between threads.
- `End` jumps the transcript to the newest item.
## Step 13: Use the real Codex driver
This step needs a working Codex login on the machine.
```sh
PAPERCLIP_PHASE4B_DRIVER=codex \
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
The routes and the screens are the same. The demo chat list still appears, but
the manifest only supplies the first message; the real model produces the rest.
The Codex login stays on the server.
## Step 14: Clean up
Stop the console with `Ctrl+C`. The server-owned working directory is a
temporary directory and is not part of your repository.
## Where to go next
- [Cumulative end-to-end tutorial](end-to-end.md)
- [Phase 4b live console reference](../phase-04b-live-console.md)
- [Phase 4b interaction map](../design/phase-4b-interaction-map.md)
@@ -0,0 +1,129 @@
# Phase 4b: Run the Protocol Demo Server
## What this is
This tutorial runs the Phase 4b server layer. It has no browser UI yet. You can
use `curl` to act like the browser.
The server starts Codex. Codex login data stays on the server. The JSON replies
must not contain a Paperclip key, an OpenAI key, or a bearer value.
## Step 1: Run deterministic checks
From the repository root, run:
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts \
src/mock-core/phase4b-demo-server.test.ts
```
Expected result: both files pass. The tests cover requests, goals, lineage,
steering, interrupt races, reconnect, replay, and redaction.
## Step 2: Start the server
Use an empty directory outside your Codex home:
```sh
phase4b_workspace="$(mktemp -d)"
pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- \
--host 127.0.0.1 \
--port 4174 \
--working-directory "$phase4b_workspace"
```
Keep this terminal open. The first output line is JSON. It names the host,
port, and server-owned working directory. It also says
`"credentialsExposed":false`.
## Step 3: Check the boundary
In a second terminal, run:
```sh
curl -s http://127.0.0.1:4174/api/phase4b/health \
-H 'Origin: http://127.0.0.1:4174' \
-H 'Sec-Fetch-Site: same-origin' | jq
```
Expected facts:
- `status` is `ok`;
- `boundary` is `package-local-mock-core`;
- `providerAuthentication` is `server-side`; and
- `credentialsExposed` is `false`.
## Step 4: Start one safe session
```sh
curl -s -X POST http://127.0.0.1:4174/api/phase4b/sessions \
-H 'Origin: http://127.0.0.1:4174' \
-H 'Sec-Fetch-Site: same-origin' \
-H 'content-type: application/json' \
--data '{"objective":"Create demo.txt with exactly: phase4b demo","message":"Complete and verify the exact file task."}' \
| tee "$phase4b_workspace/session.json" | jq
```
Copy `sessionId` and `activeTurnId` from the reply. The server ignores a
browser-supplied working directory and rejects browser-supplied credential
fields.
## Step 5: Read live or replayed events
```sh
session_id="$(jq -r .sessionId "$phase4b_workspace/session.json")"
curl -s "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/events?after=0" \
-H 'Origin: http://127.0.0.1:4174' \
-H 'Sec-Fetch-Site: same-origin' | jq
```
The reply contains canonical PRP events and a cursor. A later request can pass
that cursor as `after`. The same events go through the same reducer during live
view and replay.
For a live stream, run:
```sh
curl -N "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/stream?after=0" \
-H 'Origin: http://127.0.0.1:4174' \
-H 'Sec-Fetch-Site: same-origin'
```
## Step 6: Reconnect the same session
```sh
curl -s -X POST \
"http://127.0.0.1:4174/api/phase4b/sessions/$session_id/reconnect" \
-H 'Origin: http://127.0.0.1:4174' \
-H 'Sec-Fetch-Site: same-origin' \
-H 'content-type: application/json' --data '{}' | jq
```
Confirm that `runId`, `normalizedSessionId`, `driverSessionId`, and
`providerSessionId` did not change. The new events include `session.resumed`.
The repeated browser headers are intentional admission evidence. DNS-rebinding
Hosts, cross-origin or missing Fetch Metadata, wildcard binds, and simple
`text/plain` mutations are rejected. This protects the browser boundary, but
untrusted local processes can still make loopback requests; Codex sandbox and
approval policy remain the final execution boundary.
## Step 7: Record real evidence
Stop the manual server. Then run:
```sh
pnpm --filter @paperclipai/paperclip-runner record:phase4b
```
This starts an ephemeral loopback server, runs one safe real Codex task,
checks goal capability, steers when the turn is still active, reconnects,
checks the exact file, and writes the redacted evidence JSON.
Read:
- `packages/paperclip-runner/knowledge/evidence/phase-04b-real-codex-server.json`
- `packages/paperclip-runner/knowledge/evidence/2026-08-08-phase-04b-protocol-server-verification.md`
The deterministic fixture is the conformance authority. A real model can vary.
@@ -0,0 +1,131 @@
# Phase 5: Run the SDK Console and Mini Consumer
## What this phase is
Phase 5 makes the accepted browser tracer reusable. It has a browser client,
a React hook, reusable components, one reference console, and one small second
consumer.
The two apps use public package imports. They do not import the old demo UI.
Provider credentials stay in the local server.
## What this phase proves
This tutorial proves that a second app can use the SDK without a private
import. It also proves that live events and replay events reduce to the same
state. You can run every hard state with the fake driver. You can then run a
safe turn with your real local Codex login.
Run commands from the repository root.
## 1. Run the small SDK tests
```sh
pnpm --filter @paperclipai/paperclip-runner test:phase5
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
These tests check reducer parity, components, tokens, and package boundaries.
## 2. Open both apps with the fake driver
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase5
```
Open these pages:
- `http://127.0.0.1:4181/reference-console/`
- `http://127.0.0.1:4181/mini-consumer/`
The fake driver is the default. It does not need a provider login.
## 3. Check the reference console
Use the manifest list. Run these flows:
1. Run **Completion**. Read the streaming and terminal messages.
2. Run **Same-turn steering**. Type a new message while the turn is active.
The button says **Steer**. Check the acknowledgement.
3. Run each interrupt flow. Confirm that the visible outcome names the race.
4. Run **Command and file approvals**. Resolve one request. Confirm that the
card locks after the first click and later shows the canonical result.
5. Run **Goal lifecycle**. Set and clear the goal from the Goal menu.
6. Press **Drop connection**. Watch reconnect and gap recovery use the same
session.
7. Enter replay. Step with Left and Right. Confirm the inspector says the
replay matches live state.
8. Run **Item and turn failure**. Confirm that the exact server diagnostic is
part of the transcript.
Resize the browser to 390 by 844. The app must show one pane at a time. It must
not create a second hidden transcript or a horizontal page scrollbar.
## 4. Check the mini consumer
The mini app is intentionally small. Run a turn, steer it, stop it, resolve a
request, set and clear a goal, drop the connection, and enter replay.
Look for these proof points:
- **Mini renderer** shows the custom item-body renderer.
- **Mini detail** shows the custom request-detail renderer.
- Set goal and Clear goal use the two Composer slots.
- The blue accent is a local `--pcr-*` token override.
- Fetch and EventSource are injected by the app.
- **Replay parity: match** appears after durable history is complete.
If the real driver says goals are unavailable, the two goal buttons stay
disabled. This is correct. The SDK must not pretend the provider supports a
feature.
## 5. Run the browser acceptance tests
On a machine with Playwright system libraries:
```sh
pnpm --filter @paperclipai/paperclip-runner test:browser:phase5
```
On a minimal Debian or Ubuntu machine without root access:
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run test:browser:phase5
```
This runs keyboard, accessibility, reconnect, gap-recovery, replay, fake
driver, viewport measurement, and screenshot checks.
## 6. Run a real Codex session
Use a machine where the local Codex CLI is already authenticated.
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run record:phase5:codex
```
The test runs a safe completion through both public consumers. It checks that
the browser contains no bearer token or `auth.json` path. The mini consumer
then drops and reconnects the transport, enters replay, and confirms parity.
Real provider timing is not used to prove every race. The deterministic fake
driver proves steering, interrupt, requests, and goal branches. The real run
proves the same public transport and identity boundary against Codex.
## 7. Run the package acceptance command
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
Use `verify:rootless` if the host needs package-local browser libraries.
## Expected result
All commands exit with status 0. The fake and real apps use public imports.
Reconnect keeps run, session, and provider identity. Replay reports `match`.
Provider credentials do not appear in browser state, DOM text, events, or
diagnostics.
@@ -0,0 +1,258 @@
# Phase 6: Run the Thin Paperclip Adapter
Phase 6 connects one local, issue-bound `codex_local` task to the runner's
public `ControlPlanePort`/`NativeSessionBackend` contract. Selection is
default-off, explicit per agent, and persisted before provider execution. The
runner never receives a Paperclip API key, local JWT, managed MCP token, raw
environment, wake payload, or skill instructions.
## Prerequisites
- a local Paperclip instance built from this branch;
- a board/operator API key in `PAPERCLIP_API_KEY` (never print it);
- `PAPERCLIP_API_URL`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_TASK_ID`;
- an active `codex_local` agent, a standard issue assigned to it, and a
realized local execution workspace;
- Codex already authenticated on the host.
Normalize the API base once:
```sh
PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"
PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"
PHASE6_SCRATCH="${PAPERCLIP_RUN_SCRATCH_DIR:-$(mktemp -d)}"
```
## 1. Prove the mock package contract
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/conformance/control-plane-port.test.ts \
src/backends/harness-driver-backend.test.ts \
src/contracts/native-execution.test.ts \
src/native-session-runtime.test.ts
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
--target mock --scenario happy-path
```
Expected: four targeted files and seven tests pass, and the trace reports
`resolvedMode` as `native`, three replay-stable events, a successful workspace
barrier, and one mock server-owned `done` decision. This tracer does not connect
to Paperclip.
## 2. Prove the database-backed Paperclip port
```sh
pnpm --filter @paperclipai/server exec vitest run \
src/__tests__/native-runner-phase6.integration.test.ts \
src/__tests__/heartbeat-native-runner-selection.test.ts \
src/__tests__/heartbeat-native-runner-cancellation.test.ts \
src/__tests__/heartbeat-run-event-sequencing.test.ts \
src/__tests__/native-runner-input-boundary.test.ts \
src/__tests__/native-run-finalizer.test.ts \
src/__tests__/native-status-arbiter-corpus.test.ts \
src/__tests__/native-interaction-bridge.test.ts \
src/__tests__/native-session-resumption.test.ts \
src/__tests__/native-finalization-recovery.test.ts \
src/__tests__/native-finalization-migration.test.ts \
src/__tests__/legacy-finalization-regression.test.ts \
src/services/native-runtime/native-session-executor.test.ts
```
This runs the unchanged package conformance suite against the real Paperclip
port. Forty-six targeted tests check company binding, duplicate/gap replay,
checkpoint recovery, immutable result ingestion, durable-evidence authority,
workspace-gated finalization, atomic liveness rollback, status-version CAS,
migration repair, legacy byte equivalence, typed interaction materialization,
same-run pre-result resumption, lease races, and audit output. All database tests
start embedded PostgreSQL and do not skip when a developer database URL is
absent.
The corpus cases dispatch fixture-specific database shapes through live
cancellation, persisted-result attention, reconciliation, and heartbeat
selection entrypoints whenever an operational effect is claimed. Workspace
recovery records the actual operation result. Accepted attention is read back
from `native_run_results`, records a request-specific assessment, delegates an
eligible same-company agent through the issue service, routes human authority
through an issue-thread interaction, and rejects a cross-company target with a
durable decision/recovery/audit trail. The kill-switch case uses the global
instance flag: an active persisted native run remains native while a fresh run
for the same unchanged agent profile selects legacy. Pure resolver and
read-model calls remain useful policy tests but cannot satisfy these rows.
Each native effect must have a delivered, company-bound target or concrete
target-state mutation, and pending replay may only acknowledge its original
decision-scoped target. Sabotage tests remove each live action and the replay
target and require failure even while the pure resolver label remains correct.
Duplicate and stale audit-only cases start at
`PaperclipControlPlanePort.completeRun`, continue through `finalizeNativeRun`,
and commit the coordinator with no status decision. The run succeeds, the issue
status/version remains unchanged, and the receipt names the exact mutated
interaction. Replaying the finalizer does not create an assessment or update
the interaction again. A mixed duplicate/stale list has the same behavior;
missing and cross-company interaction bindings fail closed.
All eleven expected fields and all 70 matrix-row joins are still checked, and
an unknown-effect case proves the status transaction fails closed without
partial rows. The recovery case uses a scripted provider only at the backend
boundary while the real heartbeat reaper, lease claim, original run execution,
persistence port, finalizer, and terminal projection execute.
The command above is the repeatable scripted internal canary and actual
flag-off legacy-heartbeat proof. It is not a live-provider canary. The remaining
sections are an optional live-provider rollout procedure;
they were not run during remediation and require explicit operator authority to
change an instance flag and agent profile.
## 3. Enable one isolated agent
Save the agent's current runtime profile, merge the native profile, then enable
the instance flag. These endpoints require board/operator authority.
```sh
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" \
| jq '.runtimeConfig' > "$PHASE6_SCRATCH/phase6-runtime-before.json"
jq -n \
--slurpfile current "$PHASE6_SCRATCH/phase6-runtime-before.json" \
'{runtimeConfig: ($current[0] * {nativeRunner: {mode: "native", backend: "codex_app_server", protocolVersion: 1}})}' \
| curl -fsS -X PATCH \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" >/dev/null
curl -fsS -X PATCH \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enableNativeRunner":true}' \
"$PAPERCLIP_API_BASE/api/instance/settings/experimental" >/dev/null
```
## 4. Run one local native Paperclip task
```sh
PAPERCLIP_NATIVE_RUN_ID="$({
curl -fsS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"source\":\"on_demand\",\"triggerDetail\":\"manual\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\"},\"forceFreshSession\":true}" \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID/wakeup"
} | jq -r '.id')"
while :; do
PAPERCLIP_NATIVE_STATUS="$({
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID"
} | jq -r '.status')"
case "$PAPERCLIP_NATIVE_STATUS" in
succeeded|failed|cancelled|timed_out) break ;;
esac
sleep 2
done
test "$PAPERCLIP_NATIVE_STATUS" = succeeded
```
The expected run has `runtimeMode=native`, a persisted completion contract and
runner instance, no legacy adapter invocation, and a typed terminal fact.
## 5. Inspect replay and finalization
```sh
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID/events?afterSeq=0&limit=200" \
| jq '[.[] | select(.payload.prpEvent != null)] | {count: length, events: map({seq, sourceSeq, sourceEventId, eventType})}'
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID" \
| jq '{runtimeMode, nativePhase, runnerInstanceId, completionContractId, resultJson}'
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID" \
| jq '{status, statusVersion, lastStatusDecisionId, unblockDescriptor}'
```
Repeat the events request with `afterSeq=<seq>`. Replay is exclusive and stable;
native result/finalization rows remain authoritative even if the flag changes.
## 6. Exercise cancellation and budget hard-stop
Start a deliberately long native task, then use the existing cancellation API:
```sh
curl -fsS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{}' \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID/cancel" >/dev/null
```
The same run-scoped native cancel handle is invoked by explicit cancellation,
agent pause, and budget-pause enforcement. Resource, environment, scratch, and
workspace cleanup remain owned by the existing heartbeat path.
## 7. Disable the flag and prove legacy fallback
Set `PAPERCLIP_LEGACY_TASK_ID` to a fresh eligible issue assigned to the same
agent; the native proof issue may already be terminal.
```sh
curl -fsS -X PATCH \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enableNativeRunner":false}' \
"$PAPERCLIP_API_BASE/api/instance/settings/experimental" >/dev/null
PAPERCLIP_LEGACY_RUN_ID="$({
curl -fsS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"source\":\"on_demand\",\"triggerDetail\":\"manual\",\"payload\":{\"issueId\":\"$PAPERCLIP_LEGACY_TASK_ID\"},\"forceFreshSession\":true}" \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID/wakeup"
} | jq -r '.id')"
curl -fsS \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_LEGACY_RUN_ID" \
| jq '{runtimeMode, runtimeModeReason}'
```
Expected: the new run reports `legacy` and `instance_flag_disabled`; an already
selected native run still reconciles from its persisted native coordinator and
never falls through to the legacy adapter. The agent's `nativeRunner` profile
must still say `mode=native`; flag-off must not persist a second per-agent
disable bit.
Restore the saved profile after the proof:
```sh
jq -n \
--slurpfile saved "$PHASE6_SCRATCH/phase6-runtime-before.json" \
'{runtimeConfig: $saved[0]}' \
| curl -fsS -X PATCH \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" >/dev/null
```
## Stop conditions
Stop if a native failure invokes legacy, credentials appear in model input or
stored PRP bytes, a workspace-finalization failure reports success, replay
changes bytes, a model-reported disposition bypasses the server arbiter, or
flag-off rewrites the agent profile.
This procedure does not exercise an attention UI or public attention endpoint;
neither exists in Phase 6. The Codex v1 result tool authors compact
`kind`/`summary` attention requests. Rich target metadata is a canonical PRP
input accepted and company-validated by the server, but provider authoring UX,
external-system action execution, credential delegation, and auto-approval are
deferred.
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paperclip Runner mini consumer</title>
</head>
<body><div id="root"></div><script type="module" src="/mini-consumer/src/main.tsx"></script></body>
</html>
@@ -0,0 +1,97 @@
import * as React from "react";
import { createRoot } from "react-dom/client";
import type {
EventSourceLike,
SessionItemSnapshot,
} from "@paperclipai/paperclip-runner/browser";
import {
Composer,
ConnectionBanner,
Conversation,
ReplayControls,
RequestCard,
useRunnerConsole,
} from "@paperclipai/paperclip-runner/react";
import "@paperclipai/paperclip-runner/styles.css";
import "./mini.css";
const injectedFetch: typeof fetch = (input, init) => window.fetch(input, init);
const injectedEventSource = (url: string): EventSourceLike => new window.EventSource(url);
function MiniConsumer() {
// This pass-through wrapper is deliberate: the second consumer proves the
// fifth extension point without adding provider credentials to component
// props or browser state.
const console_ = useRunnerConsole({
baseUrl: "/api/phase4b",
fetchImpl: injectedFetch,
eventSourceFactory: injectedEventSource,
});
const [draft, setDraft] = React.useState("");
const requests = console_.transcript.filter((entry) => entry.kind === "request");
const conversation = console_.transcript.filter((entry) => entry.kind !== "request");
const customItemBody = (item: SessionItemSnapshot) => (
<p className="pcr-mini-custom-body"><strong>Mini renderer:</strong> {item.text}</p>
);
function submit() {
const text = draft.trim();
if (text.length === 0) return;
setDraft("");
if (console_.composer === "active-turn") void console_.steer(text);
else void console_.send(text);
}
return (
<main className="pcr-root pcr-mini-theme" data-slot="mini-consumer" data-testid="mini-consumer">
<header className="pcr-mini-header">
<p className="pcr-eyebrow">Second public-API consumer</p>
<h1>Runner mini consumer</h1>
<span className="pcr-mini-theme-proof" data-testid="mini-token-override">Mini blue token theme</span>
<select data-testid="mini-manifest" aria-label="Demo manifest" value={console_.selectedManifestId} onChange={(event) => console_.selectManifest(event.target.value)}>
{console_.manifests.map((manifest) => <option key={manifest.id} value={manifest.id}>{manifest.name}</option>)}
</select>
<button data-testid="mini-start" type="button" onClick={() => { const manifest = console_.manifests.find((entry) => entry.id === console_.selectedManifestId); void console_.start({ manifestId: console_.selectedManifestId, message: manifest?.prompt ?? "Run the SDK flow." }); }}>Start selected flow</button>
</header>
<ConnectionBanner connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} onRetry={console_.retryNow} />
<Conversation entries={conversation} renderItemBody={customItemBody} />
<ul className="pcr-mini-steering" aria-label="Steering acknowledgements">
{console_.steeringChips.map((chip) => (
<li key={chip.id} data-testid="mini-steering-chip" data-status={chip.status}>
<strong>{chip.status}</strong> {chip.text}
</li>
))}
</ul>
<ol className="pcr-mini-requests" aria-label="Runtime requests">
{requests.map((request) => (
<RequestCard key={request.key} request={request} onResolve={(resolution) => void console_.resolve(request.requestId, request.turnId ?? "", resolution)} renderRequestDetail={(current) => <pre className="pcr-payload">Mini detail: {JSON.stringify(current.details, null, 2)}</pre>} />
))}
</ol>
<Composer
state={console_.composer}
value={draft}
onValueChange={setDraft}
onSubmit={submit}
onStop={() => void console_.interrupt()}
leadingActions={<button data-testid="mini-goal-set" type="button" disabled={console_.state?.capabilities.goals !== true} onClick={() => void console_.goal("set", { objective: "Exercise the public SDK." })}>Set goal</button>}
trailingActions={<button data-testid="mini-goal-clear" type="button" disabled={console_.state?.capabilities.goals !== true || console_.state.goal === null} onClick={() => void console_.goal("clear")}>Clear goal</button>}
/>
<nav className="pcr-mini-actions" aria-label="SDK lifecycle controls">
<button data-testid="mini-drop-connection" type="button" onClick={console_.dropConnection}>Drop connection</button>
<button data-testid="mini-recover-gap" type="button" onClick={console_.retryNow}>Recover gap</button>
<button data-testid="mini-toggle-replay" type="button" disabled={console_.events.length === 0} onClick={console_.replay.active ? console_.replay.exit : console_.replay.enter}>{console_.replay.active ? "Exit replay" : "Enter replay"}</button>
</nav>
<p data-testid="mini-goal-state">
Goal: {console_.state?.goal?.objective ?? "none"}
</p>
{console_.replay.active ? <ReplayControls replay={console_.replay} /> : null}
<p className="pcr-mini-parity" data-testid="mini-replay-parity">Replay parity: {console_.replayParity === null ? "waiting" : console_.replayParity ? "match" : "mismatch"}</p>
<p className="pcr-visually-hidden" aria-live="polite" data-testid="mini-announcement">{console_.announcement}</p>
</main>
);
}
const root = document.getElementById("root");
if (root === null) throw new Error("Mini consumer root is missing.");
createRoot(root).render(<React.StrictMode><MiniConsumer /></React.StrictMode>);
@@ -0,0 +1,70 @@
.pcr-mini-theme {
--pcr-accent: oklch(0.52 0.14 250);
--pcr-accent-foreground: oklch(0.99 0 0);
max-width: 48rem;
min-height: 100vh;
margin: 0 auto;
padding: var(--pcr-space-5);
}
.pcr-mini-header,
.pcr-mini-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--pcr-space-3);
}
.pcr-mini-header button,
.pcr-mini-actions button,
.pcr-mini-theme button {
min-height: var(--pcr-touch-target);
padding: var(--pcr-space-2) var(--pcr-space-3);
border: 0.0625rem solid var(--pcr-border);
border-radius: var(--pcr-radius-sm);
background: var(--pcr-card);
color: var(--pcr-foreground);
cursor: pointer;
}
.pcr-mini-theme button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.pcr-mini-custom-body {
color: var(--pcr-accent);
}
.pcr-mini-theme-proof {
padding: var(--pcr-space-2) var(--pcr-space-3);
border-radius: var(--pcr-radius-sm);
background: var(--pcr-accent);
color: var(--pcr-accent-foreground);
font-weight: var(--pcr-weight-semibold);
}
.pcr-mini-requests {
display: grid;
gap: var(--pcr-space-3);
padding: 0;
list-style: none;
}
.pcr-mini-steering {
display: grid;
gap: var(--pcr-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.pcr-mini-steering li {
padding: var(--pcr-space-2);
border: 0.0625rem solid var(--pcr-border);
border-radius: var(--pcr-radius-sm);
}
.pcr-mini-parity {
font-family: var(--pcr-font-mono);
}
@@ -0,0 +1,56 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
const evidenceRoot = resolve(
fileURLToPath(new URL("../knowledge/evidence/phase-05", import.meta.url)),
);
mkdirSync(evidenceRoot, { recursive: true });
test("runs the reference console through the real Codex driver", async ({ page }) => {
await page.goto("/reference-console/");
await expect(page.getByTestId("manifest-completion")).toBeVisible();
await page.getByTestId("manifest-completion").click();
await page.getByTestId("run-manifest").click();
await expect(page.locator("body")).toContainText("Turn completed", { timeout: 180_000 });
await expect(page.getByText("Credentials in browser").locator("..")).toContainText("none");
const body = await page.locator("body").innerText();
expect(body).not.toContain("Bearer ");
expect(body).not.toContain("auth.json");
await page.screenshot({
path: resolve(evidenceRoot, "reference-live-codex-1440x900.png"),
});
});
test("runs the mini consumer through real session, goal, reconnect, and replay paths", async ({
page,
}) => {
await page.goto("/mini-consumer/");
await expect(page.getByRole("heading", { name: "Runner mini consumer" })).toBeVisible();
await page.getByLabel("Demo manifest").selectOption("completion");
await page.getByRole("button", { name: "Start selected flow" }).click();
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 180_000 });
if (await page.getByTestId("mini-goal-set").isEnabled()) {
await page.getByTestId("mini-goal-set").click();
await expect(page.getByTestId("mini-goal-state")).toContainText(
"Exercise the public SDK.",
{ timeout: 30_000 },
);
await page.getByTestId("mini-goal-clear").click();
await expect(page.getByTestId("mini-goal-state")).toContainText("none", {
timeout: 30_000,
});
}
await page.getByTestId("mini-drop-connection").click();
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
await expect(page.getByTestId("reconnect-banner")).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("mini-toggle-replay").click();
await expect(page.getByTestId("replay-controls")).toBeVisible();
await page.getByTestId("replay-position").press("ArrowRight");
await expect(page.getByTestId("mini-replay-parity")).toContainText("match");
await page.screenshot({ path: resolve(evidenceRoot, "mini-live-codex-1440x900.png") });
});
@@ -0,0 +1,222 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "@playwright/test";
const evidenceRoot = resolve(fileURLToPath(new URL("../knowledge/evidence/phase-05", import.meta.url)));
const DESKTOP = { width: 1440, height: 900 };
const MOBILE = { width: 390, height: 844 };
mkdirSync(evidenceRoot, { recursive: true });
async function screenshotPair(page: Page, name: string, mobileSegment = "chat") {
await page.setViewportSize(DESKTOP);
await page.screenshot({ path: resolve(evidenceRoot, `reference-${name}-1440x900.png`) });
await page.setViewportSize(MOBILE);
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
const segment = page.getByRole("tab", { name: new RegExp(`^${mobileSegment}$`, "i") });
await segment.click();
await expect(page.locator('[data-slot="runner-console-app"]')).toBeVisible();
await page.screenshot({ path: resolve(evidenceRoot, `reference-${name}-390x844.png`) });
await page.setViewportSize(DESKTOP);
}
async function miniScreenshotPair(page: Page, name: string) {
await page.setViewportSize(DESKTOP);
await page.screenshot({ path: resolve(evidenceRoot, `mini-${name}-1440x900.png`) });
await page.setViewportSize(MOBILE);
await page.screenshot({ path: resolve(evidenceRoot, `mini-${name}-390x844.png`) });
await page.setViewportSize(DESKTOP);
}
async function openReference(page: Page) {
await page.setViewportSize(DESKTOP);
await page.goto("/reference-console/");
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page.getByTestId("manifest-list")).toBeVisible();
}
async function runManifest(page: Page, id: string) {
await page.getByTestId(`manifest-${id}`).click();
await page.getByTestId("run-manifest").click();
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 15_000 });
}
test.describe("Phase 5 reference console evidence", () => {
test("captures idle and streaming states at both canonical viewports", async ({ page }) => {
await openReference(page);
await expect(page.getByTestId("empty-transcript")).toBeVisible();
await screenshotPair(page, "idle-empty");
await runManifest(page, "completion");
await expect(page.getByTestId("reasoning-item")).toBeVisible();
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
await screenshotPair(page, "streaming-turn");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
expect(await page.locator("body").innerText()).not.toContain("Bearer ");
expect(await page.locator("body").innerText()).not.toContain("auth.json");
});
test("captures steering and interrupt states", async ({ page }) => {
await openReference(page);
await runManifest(page, "steering");
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
await page.getByTestId("composer-input").fill("Keep it short.");
await page.getByTestId("composer-send").click();
await expect(page.getByTestId("steering-chip")).toContainText("acknowledged", { timeout: 15_000 });
await screenshotPair(page, "steering-acknowledged");
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
await page.getByTestId("reset-confirm").click();
await runManifest(page, "interrupt-during-generation");
await expect(page.getByTestId("runner-transcript")).toContainText("The tracer keeps", { timeout: 15_000 });
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
await screenshotPair(page, "interrupt");
});
test("captures request, goal, reconnect, replay, and failure states", async ({ page }) => {
await openReference(page);
await runManifest(page, "approvals");
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await screenshotPair(page, "request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
await screenshotPair(page, "request-resolved");
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
await page.getByTestId("reset-confirm").click();
await runManifest(page, "goals");
await page.getByRole("button", { name: "Goal", exact: true }).click();
await page.getByRole("menuitem", { name: "Set goal…" }).click();
await page.getByTestId("goal-objective").fill("Prove the SDK lifecycle.");
await page.getByTestId("goal-set-confirm").click();
await expect(page.getByTestId("goal-banner")).toContainText("Prove the SDK lifecycle.");
await screenshotPair(page, "goal-set", "session");
await page.setViewportSize(DESKTOP);
await page.getByTestId("drop-connection").click();
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
await screenshotPair(page, "reconnect-banner");
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 10_000 });
await page.getByTestId("toggle-replay").click();
await expect(page.getByTestId("replay-controls")).toBeVisible();
await page.getByRole("button", { name: "Step forward" }).click();
await screenshotPair(page, "replay-mode");
await page.setViewportSize(DESKTOP);
await page.getByTestId("toggle-replay").click();
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
await page.getByTestId("reset-confirm").click();
await runManifest(page, "failure");
await expect(page.getByTestId("turn-failed")).toBeVisible({ timeout: 20_000 });
await screenshotPair(page, "failed-turn");
});
});
test.describe("Phase 5 mini consumer evidence", () => {
test("uses public render, token, request, reconnect, and replay extension points", async ({ page }) => {
await page.goto("/mini-consumer/");
await expect(page.getByRole("heading", { name: "Runner mini consumer" })).toBeVisible();
await miniScreenshotPair(page, "idle");
await page.getByRole("button", { name: "Start selected flow" }).click();
await expect(page.getByText("Mini renderer:").first()).toBeVisible({ timeout: 15_000 });
await miniScreenshotPair(page, "streaming-custom-renderer-token");
await page.getByTestId("composer-input").fill("Keep the mini answer short.");
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
await page.getByTestId("composer-send").click();
await expect(page.getByTestId("mini-steering-chip")).toContainText("acknowledged", { timeout: 15_000 });
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByLabel("Demo manifest").selectOption("interrupt-during-generation");
await page.getByRole("button", { name: "Start selected flow" }).click();
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
await page.getByLabel("Demo manifest").selectOption("approvals");
await page.getByRole("button", { name: "Start selected flow" }).click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await miniScreenshotPair(page, "request-pending");
await page.getByTestId("request-action-accept").first().click();
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
await page.getByTestId("mini-goal-set").click();
await expect(page.getByTestId("mini-goal-state")).toContainText("Exercise the public SDK.", { timeout: 15_000 });
await page.getByTestId("mini-goal-clear").click();
await expect(page.getByTestId("mini-goal-state")).toContainText("none", { timeout: 15_000 });
await page.getByRole("button", { name: "Drop connection" }).click();
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
await miniScreenshotPair(page, "reconnect");
await expect(page.getByTestId("reconnect-banner")).toHaveCount(0, { timeout: 10_000 });
await page.getByRole("button", { name: "Enter replay" }).click();
await expect(page.getByTestId("replay-controls")).toBeVisible();
await page.getByTestId("replay-position").press("ArrowRight");
await expect(page.getByTestId("mini-replay-parity")).toContainText("match");
await miniScreenshotPair(page, "replay");
});
});
test("meets keyboard, accessibility, responsive, and grid contracts", async ({ page }) => {
await openReference(page);
await runManifest(page, "completion");
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
await page.getByTestId("composer-input").fill("first");
await page.getByTestId("composer-input").press("Shift+Enter");
await expect(page.getByTestId("composer-input")).toHaveValue("first\n");
await page.getByTestId("toggle-inspector").click();
await page.getByRole("tab", { name: "Events" }).focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Requests" })).toBeFocused();
const goalMenu = page.getByRole("button", { name: "Goal", exact: true });
await goalMenu.focus();
await page.keyboard.press("ArrowDown");
await expect(page.getByRole("menu")).toBeVisible();
await expect(page.getByRole("menuitem").first()).toBeFocused();
await page.keyboard.press("Escape");
await expect(goalMenu).toBeFocused();
await expect(page.getByRole("menu")).toHaveCount(0);
await goalMenu.press("ArrowDown");
await expect(page.getByRole("menu")).toBeVisible();
await expect(page.getByRole("menuitem").first()).toBeFocused();
await page.getByRole("menuitem", { name: "Set goal…" }).click();
await expect(page.getByRole("dialog", { name: "Set goal" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Set goal" })).not.toBeVisible();
await page.getByTestId("toggle-replay").click();
await page.getByTestId("replay-position").focus();
await page.keyboard.press("ArrowRight");
await expect(page.getByTestId("replay-position")).toHaveValue("1");
await page.keyboard.press("Space");
await expect(page.getByTestId("replay-controls").getByRole("button", { name: "Pause", exact: true })).toBeVisible();
expect(await page.getByRole("log").count()).toBe(1);
expect(await page.locator('[aria-live="polite"]').count()).toBe(1);
const unnamed = await page.locator("button").evaluateAll((buttons) => buttons.filter((button) => (button.getAttribute("aria-label") ?? button.textContent ?? "").trim().length === 0).length);
expect(unnamed).toBe(0);
const boxes = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>('[data-slot="runner-console-app"]')!;
const rail = document.querySelector<HTMLElement>(".pcr-console-rail")!;
const center = document.querySelector<HTMLElement>(".pcr-console-center")!;
return { root: root.getBoundingClientRect(), rail: rail.getBoundingClientRect(), center: center.getBoundingClientRect() };
});
expect(boxes.rail.right).toBeLessThanOrEqual(boxes.center.left);
expect(boxes.center.right).toBeLessThanOrEqual(boxes.root.right);
await page.setViewportSize(MOBILE);
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
expect(await page.getByRole("log").count()).toBe(1);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
});
@@ -0,0 +1,24 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "phase5.spec.ts",
fullyParallel: false,
workers: 1,
timeout: 120_000,
reporter: "line",
use: {
baseURL: "http://127.0.0.1:4192",
viewport: { width: 1440, height: 900 },
},
webServer: {
command: "pnpm exec vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4192",
env: {
PAPERCLIP_PHASE4B_CHUNK_DELAY_MS: "250",
PAPERCLIP_PHASE4B_MAX_SESSIONS: "64",
},
url: "http://127.0.0.1:4192/reference-console/",
reuseExistingServer: false,
timeout: 30_000,
},
});
@@ -0,0 +1,29 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "phase5-real.spec.ts",
fullyParallel: false,
workers: 1,
timeout: 240_000,
reporter: "line",
use: {
baseURL: "http://127.0.0.1:4193",
viewport: { width: 1440, height: 900 },
},
webServer: {
command: "pnpm exec vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4193",
env: {
PAPERCLIP_PHASE4B_DRIVER: "codex",
PAPERCLIP_PHASE4B_MAX_SESSIONS: "8",
// The browser middleware creates its disposable working directory below
// this run-owned root. Tell the nested Codex process about that exact
// assignment so its workspace-boundary guard remains fail closed.
PAPERCLIP_WORKSPACE_CWD:
process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.cwd(),
},
url: "http://127.0.0.1:4193/reference-console/",
reuseExistingServer: false,
timeout: 30_000,
},
});
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paperclip Runner reference console</title>
</head>
<body><div id="root"></div><script type="module" src="/reference-console/src/main.tsx"></script></body>
</html>
@@ -0,0 +1,9 @@
import * as React from "react";
import { createRoot } from "react-dom/client";
import { RunnerConsoleApp } from "@paperclipai/paperclip-runner/react";
import "@paperclipai/paperclip-runner/styles.css";
const root = document.getElementById("root");
if (root === null) throw new Error("Reference console root is missing.");
createRoot(root).render(<React.StrictMode><RunnerConsoleApp /></React.StrictMode>);
@@ -0,0 +1,66 @@
---
type: Verification Evidence
title: Phase 0 Rust correction verification
description: Tool versions and targeted results for the corrected Rust workspace and cross-language tracer.
tags: [native-runner, phase-0, rust, verification, parity]
status: stable
generated: { by: codex/gpt-5, at: 2026-08-07T18:09:35Z }
phase: "0"
---
# Environment
- OS: `Linux 6.17.0-1019-aws aarch64`
- Rust: `rustc 1.97.1 (8bab26f4f 2026-07-14)`
- Cargo: `cargo 1.97.1 (c980f4866 2026-06-30)`
- Node.js: `v22.22.2`
- pnpm: `9.15.4`
The Rust toolchain was installed only under `PAPERCLIP_RUN_SCRATCH_DIR`; it did
not modify the host user's Rust installation or shell profile.
# Commands and results
1. Rust formatting check:
```sh
cargo fmt --manifest-path packages/paperclip-runner/runner/Cargo.toml --all -- --check
```
Result: exit 0.
2. Complete package verification:
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
Result: exit 0. TypeScript build/tests, Rust build/tests, TypeScript and Cargo
negative boundary tests, documentation links, OKF v0.2 validation, language
parity, and the default Rust tracer all passed.
3. Rust tests included in the package verification:
```sh
cargo test --manifest-path runner/Cargo.toml --locked --workspace
```
Result: 3 Rust unit tests passed: shared-fixture validation, invalid sequence
rejection, and stable mock-core tracer output.
4. Stable tracer output:
```json
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
```
# Acceptance mapping
- Rust production package boundary exists: `runner/Cargo.toml` and
`runner/crates/runner-core/`.
- The package's default tracer executes Rust, not TypeScript.
- Rust and TypeScript read the same fixture and emit byte-identical output.
- Static boundary enforcement covers Rust source inclusion and Cargo path
dependencies, with a negative core-path fixture.
- The accepted plan, architecture, tutorials, journal, and README state Rust as
the production direction and TypeScript as the reference/control-plane side.
@@ -0,0 +1,93 @@
---
type: Verification Evidence
title: Phase 0 verification
description: Exact tools, commands, and results for the standalone tracer checkpoint.
tags: [native-runner, phase-0, verification, boundary]
status: stable
generated: { by: codex/gpt-5, at: 2026-08-07T16:28:59Z }
phase: "0"
---
# Environment
- OS: `Linux 6.17.0-1019-aws aarch64`
- Node.js: `v22.22.2`
- pnpm: `9.15.4`
- TypeScript: `5.9.3`
- Vitest: `4.1.10`
# Commands and results
1. Offline filtered install:
```sh
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev --force
```
Result: exit 0; 194 packages resolved and reused, 0 downloaded, 6 package links
added, and completion in 831 ms. The command explicitly reported that the active
configuration prohibited reading or writing the root lockfile.
2. Package build:
```sh
pnpm --filter @paperclipai/paperclip-runner build
```
Result: exit 0; `tsc -p tsconfig.json` emitted the package to `dist/`.
3. Unit, integration, stable-output, and negative-boundary tests:
```sh
pnpm --filter @paperclipai/paperclip-runner test
```
Result: exit 0; 3 Vitest files and 5 tests passed in 167 ms. The Node boundary
suite also passed, including its positive package scan and negative fixture
assertion.
4. Standalone dependency boundary:
```sh
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
Result: exit 0; `Standalone boundary check passed.`
5. Documentation and OKF bundle:
```sh
pnpm --filter @paperclipai/paperclip-runner docs:validate
```
Result: exit 0; documentation links passed across 15 files. OKF v0.2 validation
passed for 3 concepts and 4 indexes.
6. Runnable tracer:
```sh
pnpm --filter @paperclipai/paperclip-runner trace:phase0
```
Result: exit 0 after rebuilding, starting and stopping the in-memory mock core,
and printing exactly:
```json
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
```
# Acceptance mapping
- Fixture validation: passed in the package-owned fixture unit tests.
- Mock-core contract path: passed from open through ordered events and result.
- Stable output: passed against an exact serialized string assertion and the CLI
invocation above.
- Forbidden-import positive and negative paths: both passed.
- Documentation links and OKF v0.2 validation: passed.
- Human-followable tutorial: every documented command was exercised from the
repository root.
# Known limitations
See the linked [Phase 0 journal entry](../journal/2026-08-07-phase-00.md) for
intentional gaps and follow-up questions.
@@ -0,0 +1,138 @@
---
type: Verification Evidence
title: Phase 1 verification
description: Exact tools, commands, results, and browser artifact for PRP static replay.
tags: [native-runner, phase-1, verification, protocol, browser]
status: stable
generated: { by: openai/gpt-5, at: 2026-08-07T19:10:00Z }
phase: "1"
---
# Environment
- OS: `Linux 6.17.0-1019-aws aarch64`
- Node.js: `v22.22.2`
- pnpm: `9.15.4`
- TypeScript: `5.9.3`
- Vitest: `4.1.10`
- Vite: `6.4.1`
- Playwright: `1.61.1`
- Rust/Cargo: `1.97.1` in the run scratch directory
# Commands and observed results
1. TypeScript schema/type/reducer tests:
```sh
pnpm --filter @paperclipai/paperclip-runner test:typescript
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
```
Result: exit 0. Six Vitest files and 29 tests passed. The generated schema module
matched all eight JSON Schema sources, and both TypeScript configurations passed.
2. Golden corpus and standalone boundary:
```sh
pnpm --filter @paperclipai/paperclip-runner check:phase1-goldens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
```
Result: exit 0. All six valid fixture snapshots/summaries were current; forbidden
Paperclip core imports were absent; component visual values remained in the
standalone `styles.css` token layer.
3. Rust conformance/parity tests:
```sh
PATH="$PAPERCLIP_RUN_SCRATCH_DIR/cargo/bin:$PATH" \
CARGO_HOME="$PAPERCLIP_RUN_SCRATCH_DIR/cargo" \
RUSTUP_HOME="$PAPERCLIP_RUN_SCRATCH_DIR/rustup" \
pnpm --filter @paperclipai/paperclip-runner test:rust
```
Result: exit 0. Twelve Rust tests passed, including all six parity fixtures,
unsupported protocol/fixture versions, a mismatched declared result, the Phase 0
stable tracer, and sequence-gap rejection.
4. CLI tutorial paths after `build:typescript`:
```sh
node packages/paperclip-runner/dist/cli/phase1-replay.js
node packages/paperclip-runner/dist/cli/phase1-replay.js packages/paperclip-runner/protocol/fixtures/phase-01/source-gap.json
node packages/paperclip-runner/dist/cli/phase1-replay.js packages/paperclip-runner/protocol/fixtures/phase-01/unsupported-required-version.json
```
Observed summaries:
```json
{"ok":true,"integrity":"complete","timelineCount":9,"runTerminalState":"succeeded"}
{"ok":true,"integrity":"gap_detected","gaps":[{"sourceKey":"runner:runner_phase1","expected":3,"received":4,"missing":[3]}]}
{"ok":false,"issues":[{"code":"unsupported_required_version","path":"/protocolVersion"}]}
```
The first two commands exited 0. The unsupported required version exited 1.
5. Browser build and Playwright replay:
```sh
LD_LIBRARY_PATH="$PAPERCLIP_RUN_SCRATCH_DIR/playwright-libs/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu" \
pnpm --filter @paperclipai/paperclip-runner test:browser
```
Result: exit 0. Vite transformed 218 modules and emitted the standalone page;
both Playwright cases passed in 2.0 seconds. They covered the happy snapshot,
ordered timeline, duplicate diagnostic, and unsupported-version error state.
6. Repository UI token gate baseline:
```sh
pnpm check:token-gates
```
Result: exit 1 with 12 existing color-literal findings in
`ui/src/components/onboarding/PaperclipOrbit3D.tsx`. Phase 1 changes no `ui/`
file. The package-local token check above is green.
7. Complete package acceptance sequence:
```sh
PATH="$PAPERCLIP_RUN_SCRATCH_DIR/cargo/bin:$PATH" \
CARGO_HOME="$PAPERCLIP_RUN_SCRATCH_DIR/cargo" \
RUSTUP_HOME="$PAPERCLIP_RUN_SCRATCH_DIR/rustup" \
LD_LIBRARY_PATH="$PAPERCLIP_RUN_SCRATCH_DIR/playwright-libs/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu" \
pnpm --filter @paperclipai/paperclip-runner verify
```
Result: exit 0. Build, all three typechecks, current goldens, 29 TypeScript
tests, 12 Rust tests, 3 negative/positive boundary tests, browser token check,
2 Playwright tests, documentation/OKF validation, Phase 0 parity, Phase 1
parity, the Rust Phase 0 tracer, and the Phase 1 CLI replay all passed.
# Browser artifact
[Phase 1 static replay screenshot](phase-01-static-replay.png)
- PNG: 1440 × 1201, RGB (refreshed by the Phase 2 browser suite)
- SHA-256: `9e77087e6bc16d3f8b7e730bde70eb2da11e359b71e24cd8ad7402a28f6680d0`
- Shows editable happy-path fixture input, validated terminal snapshot, result
summary, ordered nine-event timeline, and snapshot JSON disclosure.
# Acceptance mapping
- Schema validation and schema-derived TypeScript types: passed.
- Happy/failure/interruption/duplicate/gap/unknown-field/version fixtures: passed.
- Determinism, idempotency, duplicate, gap, and forward compatibility: passed.
- CLI and browser use the same replay entry point: verified by imports and tests.
- Rust and TypeScript shared-fixture summaries: passed.
- Standalone browser build, interaction path, and screenshot: passed.
- Forbidden core imports: passed.
- Reference docs, tutorial index, cumulative tutorial, version policy, and OKF
journal: linked and machine-validated.
# Known limitations
See the linked [Phase 1 journal entry](../journal/2026-08-07-phase-01.md) for
intentional Phase 2+ gaps and the host-only browser dependency note.
@@ -0,0 +1,170 @@
---
type: Verification Evidence
title: Phase 2 verification
description: Exact tools, commands, results, and screenshots for the local runner and fake harness.
tags: [native-runner, phase-2, verification, rust, supervisor, fake-driver, browser]
status: stable
generated: { by: openai/gpt-5, at: 2026-08-07T22:13:00Z }
phase: "2"
---
# Environment
- OS: `Linux 6.17.0-1019-aws aarch64`
- Node.js: `v22.22.2`
- pnpm: `9.15.4`
- Vitest: `4.1.10`
- Vite: `6.4.1`
- Playwright: `1.61.1`
- Rust/Cargo: `1.97.1` in the user-local toolchain
# Commands and observed results
## 1. Rust formatting, tests, and process cleanup
```sh
cargo fmt --manifest-path packages/paperclip-runner/runner/Cargo.toml --all -- --check
cargo test --manifest-path packages/paperclip-runner/runner/Cargo.toml --locked --workspace
```
Result: exit 0. Rust formatting passed. Eighteen library tests and two supervisor
integration tests passed. The cleanup test proved that the harness and spawned
worker process group both stop.
## 2. TypeScript and live-run conformance
```sh
pnpm --filter @paperclipai/paperclip-runner test:typescript
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
```
Result: exit 0. Eight Vitest files and 46 tests passed. The Phase 2 cases cover
driver lifecycle, command and file items, bounded logs, duplicate commands,
permission/input, interruption, error exit versus semantic result, duplicate
terminal suppression, controller-disconnect cleanup, credentials, live/replay
parity, and the operator-safe rejection of a command sent after completion.
## 3. CLI scenario matrix
```sh
for scenario in happy-path permission-input interrupted error duplicate-terminal; do
node packages/paperclip-runner/dist/cli/phase2-mock-core.js --scenario "$scenario" --quiet
done
node packages/paperclip-runner/dist/cli/phase2-mock-core.js \
--scenario happy-path --quiet --duplicate-turn-command
```
Observed facts:
| Scenario | Events | Terminal | Semantic result | Harness exit | Runner exit |
|---|---:|---:|---|---:|---:|
| happy path | 19 | 1 | `done` | 0 | 0 |
| permission/input | 20 | 1 | `done` | 0 | 0 |
| interrupted | 16 | 1 | `yielded` | 130 | 0 |
| error | 15 | 1 | `yielded` | 7 | 0 |
| duplicate terminal | 15 | 1 | `done` | 0 | 0 |
The duplicate turn command returned one duplicate receipt and did not create a
second `turn.started` event.
## 4. Browser build, interactions, and screenshots
```sh
pnpm --filter @paperclipai/paperclip-runner build:browser
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner test:browser
```
Result: exit 0. Vite transformed 219 modules. The token check passed. Six
Playwright tests passed in 14.3 seconds. They cover Phase 1 static replay,
duplicate/version states, live completion and replay parity, permission/input,
and interruption with one terminal event. The permission/input test deliberately
waited 10.5 seconds before submitting input and still completed with replay
parity.
The documented development server was also started directly:
```sh
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
curl -fsS http://127.0.0.1:4179/
curl -fsS http://127.0.0.1:4179/src/main.tsx
```
Result: both requests returned successfully, the Vite process stayed alive, and
dependency optimization emitted none of the Ajv downlevel-transform errors.
The minimal host lacked Chromium libraries. The rootless helper downloaded
and extracted the matching Ubuntu packages under
`$PAPERCLIP_RUN_SCRATCH_DIR/arm64`, then scoped `LD_LIBRARY_PATH` to
the verification process. No system package was installed and no root access
was used. Playwright screenshots were written under ignored `test-results/`
paths, leaving the committed evidence images unchanged.
## 5. Boundary and knowledge checks
```sh
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
pnpm --filter @paperclipai/paperclip-runner docs:validate
```
Result: exit 0. The standalone boundary passed. Documentation links and the OKF
v0.2 bundle passed validation.
## 6. Complete package acceptance sequence
```sh
pnpm --filter @paperclipai/paperclip-runner verify:rootless
```
Result: exit 0 with the rootless browser-library path active. The command built
Rust, TypeScript, and browser outputs; ran all typechecks, tests, goldens,
parity, token, boundary, browser, docs, and OKF checks; ran the Phase 0 tracer
and Phase 1 replay; and finished with the Phase 2 happy-path summary.
# Browser artifacts
## Completed live run
[Phase 2 completed run screenshot](phase-02-live-complete.png)
- PNG: 1440 × 1851, RGB
- SHA-256: `01524691337ad58d1b9f4592685a7d3b67d225e1c8aede6afdc576d1a5b5dc15`
- Shows 19 validated events, process exit 0, semantic result `done`, one terminal
event, and live/replay `Match`.
## Permission request
[Phase 2 permission screenshot](phase-02-live-permission.png)
- PNG: 1440 × 1196, RGB
- SHA-256: `1b33f2345299badef63f8f9ea300aa3d9d46148e5f29aca0b8419c5526f7881d`
- Shows the live pending permission request before resolution.
## Interrupted run
[Phase 2 interrupted run screenshot](phase-02-live-interrupted.png)
- PNG: 1440 × 1656, RGB
- SHA-256: `35a6d90e9647bb3ac477c39240652ad9c427c93cec10f2eeada609001d6a45dc`
- Shows exit 130, semantic result `yielded`, cancelled terminal state, one
terminal event, and live/replay `Match`.
# Acceptance mapping
- Deterministic Rust runner and supervisor: passed.
- Small local harness transport: stdio JSONL passed.
- Timing, error, permission, input, interruption, and terminal scripts: passed.
- Mock-core command/event loop through package contracts: passed.
- Bounded logs and separate process/semantic facts: passed.
- Driver conformance and controller/harness cleanup: passed.
- Duplicate commands and exactly one terminal event: passed.
- Browser live mode with Phase 1 validator/reducer: passed.
- Live/replay parity: passed.
- Package reference, tutorial, cumulative steps, evidence, OKF journal, and
screenshot set: linked and validated.
# Remaining risk
The local process and in-memory browser path do not prove Phase 3 durable
delivery or reconnect behavior. Production Paperclip behavior remains unchanged.
@@ -0,0 +1,78 @@
---
type: Verification Evidence
title: Phase 3 durable transport verification
description: Package-only evidence for lost-ACK replay, restart recovery, command deduplication, and redacted diagnostics.
tags: [native-runner, phase-3, websocket, recovery, evidence]
status: stable
generated: { by: openai/gpt-5, at: 2026-08-07T23:31:00Z }
---
# Scope
This evidence covers the package-local mock core and runner transport. It does not use the Paperclip server or UI.
# Required checks
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck
pnpm --filter @paperclipai/paperclip-runner test
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
pnpm --filter @paperclipai/paperclip-runner docs:validate
pnpm --filter @paperclipai/paperclip-runner record:phase3
pnpm --filter @paperclipai/paperclip-runner verify:rootless
```
# Acceptance mapping
- The runner uses an outbound loopback WebSocket.
- A one-time bootstrap ticket creates a connection lease.
- A lost acknowledgement keeps one event in the durable outbox.
- A restarted runner loads the same identity and sends the event again.
- The mock core records one logical event and one duplicate delivery.
- The runner records one repeated command and one command effect.
- The terminal event uses the same runner and session IDs.
- Drain and revoke states are visible.
- CLI and browser diagnostics do not contain the ticket or lease token.
# Recorded outputs
- [CLI recovery trace](phase-03-recovery-trace.json)
- [Complete fault matrix](phase-03-fault-matrix.json)
- [Per-fault exact outputs](phase-03-faults/)
- [Browser recovery screenshot](phase-03-recovery-diagnostics.png), 1440 by
1000 pixels, SHA-256
`2c79ea7a9834943ee9dc3dfc6a5c4eb9359518cf773882dc75882031710f84d0`.
# Local verification result
- TypeScript tests: 61 passed.
- Rust tests: 34 passed, including fourteen Phase 3 transport, storage,
deduplication, secret-lifecycle, destination, and state-file tests.
- Focused Phase 3 and browser-middleware tests: 21 passed.
- Phase 3 fault tests: 14 passed, including one-time ticket reuse rejection,
cross-runner credential rejection, and symlink-safe mock-core state.
- Browser tests: 7 passed with the rootless Linux library path.
- TypeScript, Rust, and browser typechecks passed.
- Browser token, package boundary, documentation link, and OKF checks passed.
- All eleven recorded fault traces passed all seven assertions.
- The complete package `verify` path passed through `verify:rootless`.
- Destination tests reject public, private, wildcard, mixed-answer, userinfo,
query, fragment, and malformed WebSocket targets before a capability is sent.
- State tests reject symlinks, preserve an attacker-chosen sibling target, and
verify private file modes. A real child-process test proves the captured
bootstrap capability is absent from inherited environment data.
The first browser attempt found missing host libraries. The rootless package
script supplied private copies without changing the host.
The first documentation pass also found the linked Phase 3 screenshot was not
yet in the evidence directory. The passing browser run produced it, it was
visually inspected, and the final verification used the checked evidence file.
# Review state
- Security review: pending.
- UX review: pending.
- QA tutorial run: pending.
- Human checkpoint: pending.
@@ -0,0 +1,118 @@
---
type: Verification Evidence
title: Phase 4 skillless Codex driver verification
description: Package-only evidence for the direct Codex app-server driver, skillless context boundary, semantic completion, recovery, and replay.
tags: [native-runner, phase-4, codex, app-server, skillless, evidence]
status: stable
generated: { by: openai/gpt-5, at: 2026-08-08T00:00:00Z }
---
# Scope
This evidence covers the direct Codex app-server driver and package-local mock
core. It does not import or change Paperclip server, UI, database, CLI, or
production control-plane behavior.
# Required checks
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
pnpm --filter @paperclipai/paperclip-runner record:phase4
pnpm --filter @paperclipai/paperclip-runner verify
```
# Acceptance mapping
- Common driver conformance covers create, resume, read, turn, steer,
interrupt, usage, reconciliation, stable identities, and exactly one result.
- The exact real-session context records the Codex/app-server version, model,
provider, working directory, sandbox, approval policy, base instructions,
instruction sources and policy, environment key names, tools, input kinds,
and complete compact envelope.
- Automatic skill, app, and collaboration instructions are disabled. The
instruction-source list is empty, and the model receives no Paperclip API
instruction, bearer credential, unrelated skill, or inherited secret.
- Semantic finish and block tools normalize to one validated canonical result.
An identical duplicate is idempotent; a changed duplicate is rejected.
- Steering and interruption retain the same driver and provider session.
- Resume and reconciliation retain the persisted run, normalized session,
driver session, provider session, turn, item, and source cursor identities.
- Unsupported capabilities emit explicit redacted diagnostics without
driver-specific mock-core branches.
- All live events pass the existing Phase 1 validator/reducer. Replaying them
produces the same snapshot.
- The package boundary rejects imports from Paperclip core packages.
- The authenticated isolation proof targets each readable host
`auth.json`/`config.toml` file plus an unrelated secret, not the whole
Codex-home directory. The trace records Codex's injected memories root while
proving the credential files remain unreadable and unwritable.
- The recorder requires an accepted `done` result before reading outputs and
turns an absent `hello.txt` or `isolation-ok.txt` into a named diagnostic.
# Recorded outputs
- [Validated real Codex trace](phase-04-codex-trace.json)
- [Driver reference](../../docs/phase-04-skillless-codex-driver.md)
- [Hands-on tutorial](../../docs/tutorials/phase-04-skillless-codex.md)
The trace normalizes its temporary working directory, both readable Codex
credential/config paths, and writable Codex state root. It contains no
credential values. This phase changes no browser surface, so a browser
screenshot is not applicable.
# Local verification result
- Codex CLI/app-server: `0.132.0`.
- Real model/provider: `gpt-5.5` / `openai`.
- Focused recorder regressions: 3 passed, covering a warmed memories directory,
non-`done` output gating, and missing-file diagnostics.
- Focused driver tests: 7 passed.
- Full TypeScript suite: 73 passed.
- Full Rust suite: 39 passed (37 unit and 2 process-supervisor tests).
- Existing browser suite: 9 passed with the rootless Linux library path.
- The real safe task created `hello.txt` with exactly `hello from phase 4`.
- The refreshed warmed-environment trace contains 374 canonical events and all
nine assertions pass: exactly one terminal result, accepted proposal,
live/replay parity, stable run/session identity, contiguous source sequence,
stable item identity, skillless context, unrelated-skill absence, and
credential absence. The recorder separately verifies the exact safe-file
content.
- Real steering and interruption commands preserved the session identity and
ended with exactly one result and terminal event.
- TypeScript, Rust, and browser builds and typechecks passed. Golden, parity,
token, package-boundary, documentation-link, and OKF checks passed.
- The complete package `verify` path passed through `verify:rootless`.
# Failures found and fixed
- The first real session returned an app-server `400` because the strict JSON
Schema used `const` without its required `type`. The finish and block schemas
now use `type: "string"` with `const`, and tests lock the provider shape.
- The first sandboxed launch could not write Codex's local SQLite state. The
real-session proof uses the normal signed-in Codex environment; deterministic
conformance remains fully package-local and does not need that state.
- The installed Codex home contained an unrelated invalid skill. The driver
explicitly disables automatic skill instructions and verifies the returned
instruction-source list is empty instead of relying on the host setup.
- The normal package verifier reached Playwright but the host lacked
`libatk-1.0.so.0`. The supported rootless verifier supplied private browser
libraries, then completed the same `verify` path with all nine browser tests.
- The original recorder required the whole `~/.codex` directory to be
unreadable. Codex 0.132.0 injects `~/.codex/memories` as a legacy writable
root after warm-up, making that directory check unsatisfiable while leaving
`auth.json` and `config.toml` protected. The proof now checks credential files
directly and the refreshed trace records the injected root.
- A valid `blocked` result previously fell through to unconditional output
reads and surfaced an unhandled `ENOENT`. The recorder now validates the
decision/disposition first and guards each expected output with a clear
filename-specific diagnostic.
# Review state
- Security context/credential review: pending.
- CTO semantic-completion contract review: pending.
- QA hands-on tutorial run: pending.
- Human checkpoint: pending.
@@ -0,0 +1,126 @@
---
type: Verification Evidence
title: Phase 4b live console verification
description: Deterministic driver, transcript-model, browser, boundary, and screenshot evidence for the package-local live Codex protocol console.
tags: [native-runner, phase-4b, browser, console, evidence]
status: stable
generated: { by: anthropic/claude-opus-5, at: 2026-08-08T05:25:00Z }
---
# Scope
This record covers the Phase 4b browser layer: the deterministic demo driver,
the demo-chat manifests, the additive demo-server routes, and the **Live
console** mode in `devtools/browser/`. Every file is under
`packages/paperclip-runner/`. No Paperclip server, UI, database, or
control-plane file is imported or changed.
# Deterministic evidence
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
pnpm --filter @paperclipai/paperclip-runner exec vitest run
```
Result: typecheck clean; **14 test files, 133 tests passed**.
New deterministic coverage:
| File | Tests | Proves |
| --- | --- | --- |
| `src/mock-core/phase4b-scripted-driver.test.ts` | 15 | schema-valid canonical events, steering acknowledgement, stale-turn and already-terminal rejection, interrupt before start, preserved partial text, interrupted tool with no fabricated result, single-response requests, answers absent from acknowledgements, goal lifecycle, exact unsupported-goal diagnostic, child lineage terminal states, failed turn, resumed identity and continued source sequence |
| `devtools/browser/src/live/transcript-model.test.ts` | 11 | role mapping, timeline-ordered transcript, submitted-message rendering, interrupted projection, resolved-request record, exact failure diagnostic, live/replay projection equality, composer state machine, goal capability gating, capability diagnostics |
# Browser evidence
```sh
pnpm --filter @paperclipai/paperclip-runner test:browser
```
Result: **28 tests passed** — 19 new live-console tests plus the 9 pre-existing
Phase 1-3 tests, which are unchanged and still pass.
The live-console tests assert, at both evidence viewports where relevant:
1. empty state, streamed turn, and a completed turn with a structured result;
2. steering acknowledgement, and a stale steer that keeps its text recoverable;
3. all three interrupt races, each with a distinct visible outcome;
4. approval cards that render only the offered actions, lock on first click,
and collapse to a named resolution;
5. a submitted user-input answer and an unanswered request that expires;
6. the goal lifecycle, and the exact upstream diagnostic on a disabled menu
mirrored in the inspector's Capabilities tab;
7. child threads with persistent terminal states and an explicitly disabled
child composer carrying the exact diagnostic;
8. a failed item with its exact diagnostic in the transcript record;
9. a transport drop, a resume to a byte-identical transcript, and a page
refresh that restores the same session;
10. replay mode with its `REPLAY` badge and stepper;
11. the inspector, including a live-versus-replay reducer comparison reading
`match`;
12. the keyboard contract: `Escape` focuses Stop without interrupting, `Enter`
steers, inspector tabs and the lineage tree respond to arrow keys, and the
transcript is a labelled `role="log"` region with `aria-live="polite"`;
13. a reset dialog that names exactly what it discards;
14. a 390x844 pass with no horizontal page overflow.
Two negative assertions run against the rendered DOM: no `Bearer ` string and
no `auth.json` reference reaches the browser.
# Boundary and token evidence
```sh
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
node --test scripts/check-forbidden-imports.test.mjs
```
Result: all pass (4 boundary tests).
The boundary check now rejects `ai`, `@ai-sdk/*`, `zod`, `radix-ui`,
`@radix-ui/*`, `cmdk`, `streamdown`, `shiki`, `use-stick-to-bottom`,
`class-variance-authority`, `tailwindcss`, and `nanoid` anywhere under
`devtools/browser/`. A negative fixture in
`test-fixtures/forbidden-ui-runtime/` proves the rule fires. The console adds
**zero new runtime dependencies**.
# Screenshots
23 screenshots in [`phase-04b/`](phase-04b/), captured by the browser suite:
- Desktop (1440x900): idle and empty, streaming turn, completed turn, steering
acknowledged, interrupt before start, interrupt during generation, interrupt
during a tool call, pending request, resolved request, expired request, goal
supported, goal unsupported, lineage with a child selected, failed turn,
reconnect banner, refresh replay, replay mode, inspector events, inspector
session.
- Mobile (390x844): idle and empty, session segment, pending request,
inspector.
# Documentation evidence
```sh
pnpm --filter @paperclipai/paperclip-runner docs:validate
```
Result: documentation link validation and OKF v0.2 validation both pass.
# Known gaps
1. Real-Codex evidence for the console screens is a QA step. The scripted
driver proves every surface; `PAPERCLIP_PHASE4B_DRIVER=codex` runs the same
routes against a real session and needs a machine with a Codex login.
2. An assistive-technology pass (screen reader) is specified in the interaction
map and asserted here only at the semantics level.
3. Child-thread steering is unsupported upstream, so the console shows the
disabled composer rather than a live one.
# Startup command for QA
```sh
pnpm --filter @paperclipai/paperclip-runner console:phase4b
```
Then open `http://127.0.0.1:4180/` and press **Live console**. Add
`-- --host 0.0.0.0` to reach it from another host.
@@ -0,0 +1,100 @@
---
type: Verification Evidence
title: Phase 4b protocol and demo-server verification
description: Deterministic and real-Codex evidence for browser-resolved requests, goals, lineage, controls, reconnect, replay, and credential isolation.
tags: [native-runner, phase-4b, codex, protocol, demo-server, evidence]
status: stable
generated: { by: openai/gpt-5, at: 2026-08-08T04:43:00Z }
---
# Scope
This record covers the lowest Phase 4b driver, protocol fixture, and
package-local demo server. It changes nothing outside
`packages/paperclip-runner/` and exposes no Paperclip or provider credential to
the browser boundary.
# Deterministic evidence
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts \
src/mock-core/phase4b-demo-server.test.ts
```
Result: TypeScript typecheck passed. The two focused files passed 37 tests.
The demo-server test needed permission to bind an ephemeral loopback port; its
first in-sandbox run failed with `listen EPERM`, then the same test passed with
loopback access.
The deterministic fixture covers all five upstream request kinds, five goal
operations, root/child lineage, same-turn steering, stale rejection,
interrupt-before-start, terminal races, recovery identities, and redaction.
# Package acceptance
```sh
pnpm --filter @paperclipai/paperclip-runner verify
pnpm --filter @paperclipai/paperclip-runner verify:rootless
```
The direct acceptance command passed its build, all typechecks, 107 TypeScript
tests, 39 Rust tests, script tests, and token gate before Chromium failed to
launch because this host does not install `libatk-1.0.so.0`. The repository's
supported rootless wrapper downloaded the browser libraries into the run-owned
scratch directory and then passed the complete acceptance path: 9 Playwright
tests, standalone/import and documentation checks, Phase 0 and Phase 1 parity,
and the Phase 0 through Phase 3 traces.
# Real Codex evidence
```sh
pnpm --filter @paperclipai/paperclip-runner record:phase4b
```
The first attempt correctly failed because the run-owned scratch directory was
outside the injected `PAPERCLIP_WORKSPACE_CWD`. The recorder now binds the
driver's assigned workspace root to that exact scratch directory. The second
attempt reached Codex but found that the generated goal methods were not
advertised at runtime. The recorder now records this as a precise disabled
capability instead of invoking a fake control. The final attempt passed.
Recorded result:
- Codex CLI/app-server: `0.132.0`;
- model/provider: recorded in the canonical session context;
- 190 canonical events before reconnect;
- 5 canonical events after the replay cursor, including `session.resumed`;
- exact output: `phase4b.txt` contains `phase4b real codex evidence`;
- exactly one semantic result and one turn terminal;
- continuous source sequence;
- stable run, normalized session, driver thread, and provider session IDs;
- explicit `goals: false` with `capability not advertised`;
- server-side provider authentication and no credential value in evidence.
See the [real server trace](phase-04b-real-codex-server.json).
# Failures fixed
- Replaced automatic request decline with one pending typed browser resolution.
- Added cleanup for provider resolution, terminal, protocol failure, close, and
reconnect so a request cannot receive two responses.
- Added visible same-turn steering acknowledgements and exact stale-turn errors.
- Queued pre-start interrupts until the provider turn ID exists and return
`already_terminal` when completion wins.
- Added capability-probed goal methods rather than version-only assumptions.
- Added parent/child lineage derived from upstream identity instead of inferred
UI nesting.
- Added a second bounded browser redaction pass; narrowed its sensitive-key
matcher after a test found that `tokenBudget` was incorrectly redacted.
- Redacted nested Paperclip home paths from Codex sandbox metadata after the
final evidence scan found a server-side writable-root path.
- Made the demo server use only its configured workspace after the boundary
test supplied a hostile browser path.
# Remaining ownership
The Phase 4b browser issue owns the React UI, request cards, controls, protocol
inspector, screenshots, accessibility checks, and persistent live-service
handoff. This lowest layer is ready for that consumer.
@@ -0,0 +1,87 @@
---
type: QA Verification Evidence
title: Phase 4b live console — independent QA pass
description: QA Engineer independent execution of the Phase 4b clean-start tutorial, deterministic demos, real-Codex end-to-end session, full package acceptance, and browser screenshots.
tags: [native-runner, phase-4b, qa, browser, evidence]
status: stable
generated: { by: anthropic/claude-opus-4-8, at: 2026-08-08T06:35:00Z }
issue: PAP-16837
---
# Scope
Independent QA of the Phase 4b browser console and its protocol/demo server,
executed against a live service on the execution machine (branch
`PAP-16679-paperclip-runner`, head `cda1fb03ab`). This record supplements the
implementer's evidence
([2026-08-08-phase-04b-live-console-verification.md](2026-08-08-phase-04b-live-console-verification.md))
with a from-scratch QA execution: real browser interaction, a real Codex turn,
and the full package acceptance command run end to end.
# Environment
- node 22.22.2, pnpm 9.15.4, codex-cli 0.132.0 (login present at `~/.codex/auth.json`).
- Browser driver: agent-browser 0.33.2 using the ARM64 runtime chromium
(`.paperclip/browser-runtime/chromium-arm64/bin/chromium-agent-browser`).
- Live service (demo driver): `pnpm --filter @paperclipai/paperclip-runner console:phase4b`
→ vite dev server on **http://127.0.0.1:4180/** (loopback-bound by design;
the server enforces a loopback bind host and is intentionally NOT exposed on
the tailnet / `paperclip-dev` host).
- Real-Codex service: `env -u PAPERCLIP_WORKSPACE_CWD PAPERCLIP_PHASE4B_DRIVER=codex
pnpm --filter @paperclipai/paperclip-runner exec vite --config vite.config.ts
--host 127.0.0.1 --port 4185` → http://127.0.0.1:4185/.
# Automated acceptance
Run with `PATH` including `~/.cargo/bin` and
`LD_LIBRARY_PATH=.paperclip/browser-runtime/chromium-arm64/root/usr/lib/aarch64-linux-gnu`
(the two host-toolchain prerequisites on this aarch64 box: Rust toolchain and
the shared libraries Playwright's headless_shell needs — `libatk-1.0.so.0` etc.).
| Command | Result |
| --- | --- |
| Step 1 targeted suites (scripted-driver, demo-server, transcript-model) | **40 passed** |
| `verify` → vitest (unit) | **15 files, 151 passed** |
| `verify` → Playwright browser | **28 passed** (40.4s) |
| `verify` → Rust workspace (`cargo test`) | ok — 37 core + parity suites passed |
| `verify` → goldens / phase0+1 parity / traces / replay / docs / forbidden-imports / browser-tokens | all passed |
| **`pnpm --filter @paperclipai/paperclip-runner verify`** | **exit 0** |
The 28 Playwright tests deterministically cover every tutorial step, including
the two fast interrupt races (during-generation, during-tool), goal-unsupported
exact diagnostic + capabilities mirror, child threads + refused child steering,
transport-drop resume, refresh restore, replay stepper, reducer parity `match`,
and the keyboard contract.
# Manual browser evidence (screenshots in `phase-04b/qa-2026-08-08/`)
| Step | Observed | Shot |
| --- | --- | --- |
| Idle empty | "Pick a demo chat", disabled Send, "Standalone · No Paperclip Core" | `01-idle-empty.png` |
| Clean turn | YOU → Reasoning → Tool `read_file` (Completed) → answer → Turn Completed; card auto "Passed Observations" | `03-completion-done.png` |
| Same-turn steering | live `pending → acknowledged` chip; typed text preserved (recoverable, never discarded) | `04b-steer-acknowledged.png` |
| Approvals | banner "1 request waiting — Review"; 4-action card locks + collapses to "resolved — Approve for session"; next card offers only Approve/Reject; Reject resolves | `06b-approval-resolved.png` |
| User input + expiry | two inputs resolve; third → `expired before response`; submitted answer never in the ack | `07e-expired-clean.png` |
| Interrupt before start | `Cancelled Before Start — interrupted before the provider accepted the turn`; no answer item | `05a2-interrupt-before-start.png` |
| Goal supported | Goal menu enabled after demo; "Set goal"; card "Passed Observations" | `08a-goal-supported.png` |
| Protocol inspector | tabs Events/Requests/Capabilities/Session; **Credentials in browser = No** | `09-inspector.png` |
| Session panel | Connected; Pending 0; **Credentials in browser = None**; Run/Session ids | `09b-inspector-session.png` |
| **Real Codex turn** | live streaming (~14s); multiple Reasoning + real `commandExecution` tools + SYSTEM events + genuine answer; `providerAuthentication: server-side`, `credentialsExposed: false` | `10-codex-real-turn.png` |
# Security / boundary confirmations
- Session-create response: `providerAuthentication: "server-side"`,
`credentialsExposed: false`. Inspector reads **No**, session panel reads
**None**. The browser never holds a provider login.
- Same-origin transport enforcement is live: `GET /api/phase4b/…` from a
non-same-origin fetch returns `invalid_fetch_metadata` (Sec-Fetch-Site
required).
- The codex driver enforces its working-directory boundary
(`PAPERCLIP_WORKSPACE_CWD`); a temp dir outside it is rejected with
"Codex working directory is outside the assigned workspace".
# Verdict
PASS. Every acceptance gate ran and passed; the clean-start tutorial behaves
exactly as written in a real browser; a real Codex end-to-end session works
through the same canonical protocol UI with credentials held server-side.
@@ -0,0 +1,142 @@
---
type: Verification Evidence
title: Phase 5 browser SDK verification
description: Targeted and package-acceptance evidence for the versioned SDK, two public consumers, fake/real drivers, accessibility, reconnect, and replay.
tags: [native-runner, phase-5, sdk, browser, react, codex, evidence]
status: stable
generated: { by: openai/codex-local, at: 2026-08-08T07:50:00Z }
---
# Scope
This record covers Phase 5 under `packages/paperclip-runner/`: the versioned
browser and React surface, scoped styles, reference console, mini consumer,
package boundaries, fake and real drivers, documentation, and visual evidence.
It does not change production `server/`, `ui/`, database, routes, persistence,
or legacy adapters.
# Targeted tests
```sh
pnpm --filter @paperclipai/paperclip-runner test:phase5
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
Results:
- Phase 5 Vitest: **3 files, 20 tests passed**.
- TypeScript public and browser configurations: passed.
- Generated protocol source check: passed.
- SDK token gate: passed.
- Package/deep-import boundary gate: passed.
Coverage includes framework-free client errors and injection, exact duplicate
event delivery to the shared reducer, file/tool/plan projection, exact failure
diagnostics, component `data-slot` contracts, request locking, and the five
extension points.
# Fake-driver browser acceptance
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run test:browser:phase5
```
Result: **5 Playwright tests passed** in 16.4 seconds after adding an explicit
responsive-tree readiness check before mobile evidence capture.
The tests cover both public consumers, every required fake lifecycle state,
Composer/menu/dialog/tabs/replay keyboard behavior, one polite live region,
semantic control names, status text, reduced-motion-safe styles, exact mobile
and desktop bounding-box measurements, reconnect and gap recovery, durable
cursor replay, and live/replay reducer parity.
# Real Codex evidence
Direct server trace:
```sh
pnpm --filter @paperclipai/paperclip-runner record:phase4b -- \
knowledge/evidence/phase-05/phase5-real-codex-trace.json
```
Result: passed. The trace proves exact file output, a validated semantic
result, one terminal event, continuous source sequence, stable run/session and
provider identity after reconnect, replay, explicit unsupported-goal state,
and absent provider credentials and host paths.
Browser consumers:
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run record:phase5:codex
```
Result: reference console passed and produced its live screenshot. The mini
consumer was then rerun independently after replacing a timing-sensitive real
steering visual with the safe completion manifest:
```sh
bash scripts/verify-rootless-linux.sh pnpm exec playwright test \
--config examples/playwright.real.config.ts --grep 'mini consumer'
```
Result: **1 test passed** in 14.1 seconds. Codex explicitly reported goals as
disabled; the consumer kept its goal controls disabled. The public consumer
completed a safe real turn, reconnected the same session, entered replay, and
reported parity. Provider credentials were not rendered.
# Screenshots
The [`phase-05/`](phase-05/) directory contains 33 files:
- Reference console at 1440x900 and 390x844: idle, streaming, steering,
interrupt, pending and resolved requests, goal, reconnect, replay, and
failure.
- Mini consumer at both viewports: idle, custom renderer and token override,
pending request with custom detail, reconnect, and replay.
- Live Codex reference-console and mini-consumer captures at 1440x900.
- The redacted real Codex trace described above.
Visual inspection covered a desktop idle surface, mobile pending request,
custom renderer/token override, mini real replay, and reference real Codex
completion. The mobile request controls fit the viewport; the live reference
shows a completed terminal tool and passed observations.
# Package acceptance
```sh
cd packages/paperclip-runner
bash scripts/verify-rootless-linux.sh pnpm run verify
```
Result: **passed with exit status 0**. Notable totals in the cumulative run:
- TypeScript: **18 files, 171 tests passed**;
- Rust: **37 unit tests and 2 supervisor integration tests passed**;
- boundary/recorder Node tests: **8 passed**;
- Phase 5 targeted suite: **20 passed**;
- accepted Phase 1–4b browser suite: **28 passed**;
- Phase 5 browser suite: **5 passed**;
- Phase 0 Rust/TypeScript and Phase 1 fixture/reducer parity: passed;
- Phase 0 tracer, Phase 1 replay, Phase 2 happy path, and Phase 3 lost-ACK
recovery: passed.
# Documentation
```sh
pnpm --filter @paperclipai/paperclip-runner docs:validate
```
Result: documentation links and OKF v0.2 validation pass.
# Known provider constraints
Real-provider timing is not the oracle for race screenshots. The deterministic
driver owns steering, interrupt, request, and goal branches. Real Codex owns
the provider authentication boundary, normalized identity, redaction, safe
completion, reconnect, and replay. This preserves upstream terminal and
capability truth instead of hiding a real failure or fabricating support.
@@ -0,0 +1,287 @@
---
type: Verification Evidence
title: Phase 6 thin Paperclip adapter verification
description: Targeted package, database-port, finalization, security-boundary, feature-selection, and legacy fallback evidence.
tags: [native-runner, phase-6, verification, paperclip, replay, finalization]
status: stable
generated: { by: openai/gpt-5.6, at: 2026-08-09T07:42:08Z }
---
# Scope
This record verifies the default-off Phase 6 tracer at the approved
`ControlPlanePort`/`NativeSessionBackend` boundary. No browser surface changed,
so Phase 6 has command/database evidence rather than screenshots.
The counts below are from the remediation-7 final rerun on 2026-08-09.
“Internal canary” means the selected-task database test through the public
package session contract; it does not mean that a new live Codex provider task
was dispatched.
# Package contract and mock tracer
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/conformance/control-plane-port.test.ts \
src/backends/harness-driver-backend.test.ts \
src/contracts/native-execution.test.ts \
src/native-session-runtime.test.ts
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
--target mock --scenario happy-path
```
Result: four files and seven tests passed. The stable trace reported native
mode, `runStatus=succeeded`, three events, contiguous source sequence 3,
`workspaceFinalizeStatus=succeeded`, zero legacy invocations, and a server-owned
`done` decision with status version 0 -> 1. Restart recovery reused the bound
provider session checkpoint, opened no second session or turn, retained the
existing accepted result event, and appended only the missing terminal fact.
# Real Paperclip adapter and public-session task
The complete acceptance gate below passed 46 tests in thirteen files against
embedded PostgreSQL with zero skipped tests. Database-backed tests start their
own database instead of skipping when a developer `DATABASE_URL` is absent.
The same package conformance suite passed unchanged against
`PaperclipControlPlanePort`.
A selected task then ran end-to-end through `executeNativeSession`, the public
backend/port contracts, durable PRP events, immutable result ingestion,
workspace finalization, assessment, CAS status decision, and audit persistence.
The issue moved from `in_progress` version 0 to `done` version 1 only after its
criterion and verification cited an approved, issue-scoped durable work
product. A model-only result remained non-terminal and received a persisted
continuation wake.
The focused corpus also proved:
- global flag off and no agent opt-in both select legacy;
- an eligible explicit profile selects native;
- remote, non-standard, or non-Codex explicit profiles fail closed;
- company/run/agent/issue/source bindings reject mismatches;
- source gaps remain visible, identical retries deduplicate, and replay is
exclusive and byte-stable; mutated identities, sequences, results, and
bindings fail closed in the shared mock/real port suite;
- result-less transport loss dispatches the original persisted native run from
its closed envelope/provider checkpoint after a database lease, including
flag-off restart and active-turn recovery without a second run, provider
session, or turn; the scripted provider boundary returns one result and the
real heartbeat/finalizer path persists one assessment, decision/effect set,
and terminal projection without invoking the legacy adapter;
- resolved confirmations and question answers enter the persisted native input
only through the authorized issue-interaction service; governed, unresolved,
unsupported, or self-approved paths fail closed without credentials;
- accepted package-result attention enters through `finalizeNativeRun` and the
persisted-result router: same-company delegation uses `issueService`, human
authority creates an issue-thread interaction, and cross-company targeting
persists a rejection decision, recovery action, failed finalization, and
audit receipt;
- valid duplicate and stale targets, including a mixed list, enter through
`PaperclipControlPlanePort.completeRun -> finalizeNativeRun`, commit a
replay-stable zero-decision coordinator, project a successful run, preserve
issue status/version, create no wake or notification, and record the exact
durable interaction target; missing and cross-company bindings fail closed;
- incomplete/review/yield/cancelled results receive a durable review,
continuation, or recovery path;
- only a task-wide blocker with a named owner and action selects `blocked`;
- a completion decision writes immutable assessment/decision/effect records and
an activity-log audit row;
- a failed workspace barrier preserves the accepted semantic result, leaves the
issue status/version unchanged, commits the fact-based preserve decision and
concrete recovery target, and records a leased `retryable_failure`;
- six injected failures prove governance binding, interactions, wakes, blocker
bindings, recovery, status,
decisions, and effects roll back together before retry ownership is recorded.
The acceptance-matrix entry points passed as one 13-file, 46-test gate:
```sh
pnpm --filter @paperclipai/server exec vitest run \
src/__tests__/heartbeat-native-runner-cancellation.test.ts \
src/__tests__/heartbeat-native-runner-selection.test.ts \
src/__tests__/native-run-finalizer.test.ts \
src/__tests__/native-runner-input-boundary.test.ts \
src/__tests__/native-runner-phase6.integration.test.ts \
src/__tests__/native-finalization-recovery.test.ts \
src/__tests__/native-interaction-bridge.test.ts \
src/__tests__/native-session-resumption.test.ts \
src/__tests__/heartbeat-run-event-sequencing.test.ts \
src/__tests__/legacy-finalization-regression.test.ts \
src/__tests__/native-finalization-migration.test.ts \
src/__tests__/native-status-arbiter-corpus.test.ts \
src/services/native-runtime/native-session-executor.test.ts
```
This gate proves concurrent event allocation, duplicate-only migration repair,
bounded retry exhaustion, actual flag-off legacy execution, and executable
Section 18.13 fixture-to-consumer traceability. All 52 fixtures and 70 rows are
classified as either live operational proofs or explicit pure policy/read-model
checks. Cancellation persists its decision or audit-only receipt through
`cancelNativeSession`; attention enters from the accepted result through
`finalizeNativeRun`, which owns the persisted-result router, then resolves a
same-company eligible target through the issue service. The mapped duplicate
and stale fixtures require that finalizer receipt and fail sabotage that leaves
only the pure resolver/internal router. REC-04/06/07/08 enter the reconciler, with REC-04
recording an observed workspace operation and REC-06/07/08 writing a newly
classified append-only assessment before superseding the prior decision; and
MIG-08 is enforced by the production heartbeat selector using the global flag:
the persisted active run remains native, a fresh unresolved run selects legacy,
and the agent profile remains unchanged.
All eleven expected fields remain derived from entrypoint returns and durable
rows. Every required native effect is joined to its owning target state, each
decision replay retains one identity and one delivery attempt, and pending
replay verifies the original company/issue/decision target before acknowledging
it. Zero-decision audit replay retains the coordinator and named interaction
without another mutation. The negative suite removes each live action, changes the real global-flag
input, and deletes a replay target;
the mapped fixtures fail while the direct policy resolver still returns the
expected label. An unknown effect rolls the entire transaction back. No
test-owned scenario policy supplies an observation. The selected-task
and recovery canaries remain scripted only at the provider boundary, while the
production Paperclip persistence/finalization paths and the flag-off legacy
adapter/finalizer path execute for real.
# Remediation 7 rerun and call-graph self-review
The final rerun on 2026-08-09 produced:
- thirteen server files, 46 tests passed, zero skipped;
- Section 18.13 checker: 19 checks passed, zero failed;
- runner documentation validation: 58 links and the 25-concept/four-index OKF
bundle passed;
- server TypeScript and runner TypeScript/protocol typechecks passed;
- the design's authoritative 89-file allowlist matched the Phase 6 diff exactly;
- `git diff --check` passed.
The exact-file claim is mechanically fail-closed. This comparison extracts the
authoritative fenced block, asserts its count, and then compares it with the
committed Phase 6 diff. Any missing or extra path makes `diff` return non-zero:
```sh
PHASE6_SCRATCH="${PAPERCLIP_RUN_SCRATCH_DIR:-$(mktemp -d)}"
PHASE6_ALLOWLIST="$PHASE6_SCRATCH/phase6-allowlist.txt"
PHASE6_ACTUAL="$PHASE6_SCRATCH/phase6-actual.txt"
awk '
/^### Remediation 7 authoritative exact-file reconciliation / { section = 1; next }
section && /^```text$/ { block = 1; next }
block && /^```$/ { exit }
block { print }
' packages/paperclip-runner/docs/design/phase-6-thin-paperclip-adapter.md \
| LC_ALL=C sort > "$PHASE6_ALLOWLIST"
git diff --name-only 3a38c8f931..HEAD \
| LC_ALL=C sort > "$PHASE6_ACTUAL"
test "$(wc -l < "$PHASE6_ALLOWLIST")" -eq 89
test "$(wc -l < "$PHASE6_ACTUAL")" -eq 89
diff -u "$PHASE6_ALLOWLIST" "$PHASE6_ACTUAL"
```
Result: exit 0 with no diff output; 89 documented paths, 89 committed paths,
zero missing, and zero extra.
The final owner review traced the successful audit-only path as
`PaperclipControlPlanePort.completeRun -> native_run_results ->
finalizeNativeRun -> routePersistedNativeResultAttention ->
recordNativeAttentionAssessment -> routeNativeAttention ->
applyNativeAttentionStatusDecision -> native_run_finalizations/heartbeat_runs`.
The public port owns immutable result validation and persistence; the finalizer
owns coordinator lease/terminal projection; the internal router derives all
identity from persisted bindings; and the audit materializer requires the exact
same-company issue interaction. The finalizer accepts a null decision only when
every receipt is `attention_duplicate_suppressed` and names a durable target.
Committed replay validates those receipts before short-circuiting, so it cannot
create another assessment or mutate the interaction timestamp. Missing or
cross-company targets throw before the coordinator commits and enter named
retryable recovery with the issue status/version unchanged.
# Remediation 8 documentation self-review
The final documentation review confirmed that the exact allowlist is the same
89-path set as `git diff --name-only 3a38c8f931..HEAD`, the status header records
the remediation-8/final-gate state, this evidence identifies the remediation-7
final rerun, and the tutorial expects four package files and seven tests. The
reconciliation changes documentation only and does not alter runtime behavior.
# Compile and migration checks
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck
pnpm --filter @paperclipai/shared typecheck
pnpm --filter @paperclipai/server exec tsc --noEmit
pnpm --filter @paperclipai/db typecheck
```
Result: all checks passed. Migration numbering and safety passed. Migration
0211 preserves existing unique heartbeat cursors, deterministically moves only
duplicate rows above each run's former maximum, backfills the next allocator
value, and installs issue status-version tracking.
Focused migration, allocator, recovery, and legacy rehearsal:
```sh
pnpm --filter @paperclipai/server exec vitest run \
src/services/native-runtime/paperclip-control-plane-port.test.ts \
src/__tests__/native-finalization-recovery.test.ts \
src/__tests__/legacy-finalization-regression.test.ts \
src/__tests__/native-finalization-migration.test.ts \
src/__tests__/heartbeat-run-event-sequencing.test.ts
```
Result: five files and 11 tests passed with zero skips. The 0211 rehearsal
reconstructed a production-shaped pre-0211 schema, removed the 0211 journal
entry, seeded legacy event sequences `[1,5,5,9]`, and applied the complete
migration. It retained the
original unique cursors and first `5`, moved only the later duplicate to `10`,
seeded `next_event_seq=11`, preserved every non-sequence field byte-for-byte,
and enforced uniqueness. Thirty-two concurrent mixed event writers produced a
gap-free allocator stream. Invalid native finalization exhausted its
three-attempt budget into named recovery without touching issue status. An
actual flag-off heartbeat executed the legacy adapter/finalizer, remained
byte-equivalent across native reconciliation, and created zero native rows.
# Credential, governance, budget, and legacy boundaries
`native-execution.test.ts` rejects unknown top-level or nested launch fields and
proves the model envelope omits company/run/agent bindings and credential
references. The native branch never creates a local agent JWT, MCP gateway, raw
adapter environment, or legacy context. Runtime requests cannot auto-approve;
the native interaction bridge materializes supported typed responses through
the authorized service and rejects unsupported/governed/self-approved paths.
Explicit cancellation, agent pause, and budget hard-stop share
`cancelRunInternal`, which now invokes the run-scoped normalized native session
before existing process/resource cleanup. The default and kill-switch paths
execute the pre-existing adapter branch; no same-run native-to-legacy fallback
exists. Recovery queries persisted native mode/coordinator state rather than
the current flag. The kill switch does not mutate `agents.runtime_config` or
persist a per-agent disable bit.
No attention UI or public attention endpoint is claimed. The Codex v1 result
tool authors compact `kind`/`summary` requests; richer canonical PRP target
metadata is server-validated but does not yet have provider authoring UX.
External-system execution, credential delegation, and auto-approval remain
deferred.
```sh
pnpm --filter @paperclipai/server exec vitest run \
src/services/native-runtime/native-session-executor.test.ts
pnpm --filter @paperclipai/server exec vitest run \
src/__tests__/heartbeat-stale-queue-invalidation.test.ts \
-t "daily cost cap"
```
Result: native cancellation reached the active normalized session exactly once
across duplicate cancellation requests and removed the handle afterward. The
daily cost hard stop cancelled a queued run before any adapter execution.
# Deferred live-provider checkpoint
The [Phase 6 tutorial](../../docs/tutorials/phase-06-thin-paperclip-adapter.md)
contains the exact board-authorized commands for enabling one local agent,
running and inspecting a live Codex task, disabling the flag, proving a fresh
legacy selection, and restoring the agent profile. Those commands intentionally
do not pass credentials to the package or model. They were not run during this
remediation because changing live instance and agent rollout state requires
operator approval; no new live-provider evidence is claimed here.
@@ -0,0 +1,15 @@
# Verification Evidence
## Phase evidence
* [Phase 0 verification](2026-08-07-phase-00-verification.md) - Clean install, build, tests, boundary, documentation, OKF, and tracer results.
* [Phase 0 Rust correction verification](2026-08-07-phase-00-rust-correction-verification.md) - Rust build/tests, cross-language parity, and corrected boundary evidence.
* [Phase 1 verification](2026-08-07-phase-01-verification.md) - Schema, golden, reducer, Rust parity, CLI, browser, token, boundary, and screenshot evidence.
* [Phase 2 verification](2026-08-07-phase-02-verification.md) - Rust supervisor, fake-driver scenarios, cleanup, live/replay parity, browser flows, screenshots, and complete package verification.
* [Phase 3 verification](2026-08-07-phase-03-verification.md) - Lost-ACK replay, restart recovery, command deduplication, storage, and redacted diagnostics.
* [Phase 4 verification](2026-08-08-phase-04-verification.md) - Direct Codex conformance, exact skillless context, semantic completion, recovery, and real-session replay.
* [Phase 4b protocol/server verification](2026-08-08-phase-04b-protocol-server-verification.md) - Browser-resolved requests, goals, lineage, controls, reconnect, redaction, and real Codex server evidence.
* [Phase 4b live console verification](2026-08-08-phase-04b-live-console-verification.md) - Demo-driver, transcript-model, browser, keyboard, boundary, token, and screenshot evidence for the live console.
* [Phase 4b QA verification](2026-08-08-phase-04b-qa-verification.md) - Independent QA pass: clean-start tutorial in a real browser, deterministic demos, real Codex end-to-end session, full `verify` acceptance (exit 0), and screenshots (PAP-16837).
* [Phase 5 browser SDK verification](2026-08-08-phase-05-verification.md) - Targeted and package-acceptance evidence for the versioned SDK, both public consumers, fake/real drivers, accessibility, reconnect, replay, and screenshots.
* [Phase 6 thin Paperclip adapter verification](2026-08-09-phase-06-verification.md) - Mock and database-backed conformance, authoritative completion, restart recovery, migration repair, atomic liveness, internal canary, and deterministic post-kill-switch legacy evidence.
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

@@ -0,0 +1,423 @@
{
"schema": "paperclip.runner.phase3.evidence.v1",
"traces": [
{
"fault": "none",
"outcome": "recovered",
"connectionCount": 1,
"replayDeliveries": 8,
"runnerRestarts": 0,
"harnessRestarts": 0,
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5315,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "socket-drop",
"outcome": "recovered",
"connectionCount": 2,
"replayDeliveries": 8,
"runnerRestarts": 0,
"harnessRestarts": 0,
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5314,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "lost-ack",
"outcome": "recovered",
"connectionCount": 2,
"replayDeliveries": 9,
"runnerRestarts": 0,
"harnessRestarts": 0,
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 1
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 6023,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "duplicate-command",
"outcome": "recovered",
"connectionCount": 2,
"replayDeliveries": 8,
"runnerRestarts": 0,
"harnessRestarts": 0,
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 1
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5314,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "runner-restart",
"outcome": "recovered",
"connectionCount": 2,
"replayDeliveries": 10,
"runnerRestarts": 1,
"harnessRestarts": 0,
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 1
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 6883,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 6,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "harness-restart",
"outcome": "recovered",
"connectionCount": 1,
"replayDeliveries": 11,
"runnerRestarts": 0,
"harnessRestarts": 1,
"commands": {
"issued": 5,
"completed": 5,
"rejected": 0,
"logicalEffects": 5,
"duplicateDeliveries": 0
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 6827,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 8,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "malformed-input",
"outcome": "recovered",
"connectionCount": 2,
"replayDeliveries": 8,
"runnerRestarts": 0,
"harnessRestarts": 0,
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5314,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "lease-expiry",
"outcome": "recovered",
"connectionCount": 2,
"replayDeliveries": 9,
"runnerRestarts": 1,
"harnessRestarts": 0,
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5314,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 6,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "storage-pressure",
"outcome": "recovered",
"connectionCount": 1,
"replayDeliveries": 4,
"runnerRestarts": 0,
"harnessRestarts": 0,
"commands": {
"issued": 5,
"completed": 4,
"rejected": 1,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 2326,
"maxBytes": 16384,
"backpressure": true,
"p0Committed": 3,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "drain",
"outcome": "drained",
"connectionCount": 1,
"replayDeliveries": 3,
"runnerRestarts": 0,
"harnessRestarts": 0,
"commands": {
"issued": 5,
"completed": 4,
"rejected": 1,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 1478,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 3,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
},
{
"fault": "revoke",
"outcome": "revoked",
"connectionCount": 1,
"replayDeliveries": 4,
"runnerRestarts": 0,
"harnessRestarts": 0,
"commands": {
"issued": 4,
"completed": 3,
"rejected": 1,
"logicalEffects": 3,
"duplicateDeliveries": 0
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5315,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 4,
"p0Lost": 0
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
]
}
@@ -0,0 +1,399 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "drain",
"connection": {
"state": "stopped",
"connectionCount": 1,
"reconnectCount": 0,
"leaseId": "connection_lease_0c922c88-0cfe-4a1f-b58d-15278a816adf",
"leaseExpiresAt": "2026-08-07T23:36:40.794Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 4,
"runnerNextSourceSeq": 5,
"coreAckedSourceSeq": 4,
"highestCommittedSourceSeq": 4
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 1478,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 3,
"p0Lost": 0
},
"commands": {
"issued": 5,
"completed": 4,
"rejected": 1,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"recovery": {
"replayDeliveries": 3,
"runnerRestarts": 0,
"harnessRestarts": 0,
"malformedFrames": 0,
"freshBootstraps": 1,
"outcome": "drained",
"reason": "drain_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": false
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "runner.draining",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "runner.draining",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"newWorkAccepted": false
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 5,
"ackedSourceSeq": 4,
"lastControllerCommandSeq": 5,
"reconnectCount": 0,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 1478,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:978d0ca44be61a36",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "runner draining",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:34d91505a30f60fb",
"status": "rejected",
"logicalEffectCount": 0,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "new turns are disabled while draining or backpressured",
"logicalEffectCount": 0,
"status": "rejected"
}
},
"command_phase3_005": {
"commandId": "command_phase3_005",
"controllerSeq": 5,
"commandDigest": "fnv1a64:533318a30fba6d22",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_005",
"controllerSeq": 5,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "runner.drain",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "runner draining",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "rejected",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "new turns are disabled while draining or backpressured",
"logicalEffectCount": 0,
"status": "rejected"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_005",
"controllerSeq": 5,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:05.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_005",
"controllerSeq": 5,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,561 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "duplicate-command",
"connection": {
"state": "stopped",
"connectionCount": 2,
"reconnectCount": 1,
"leaseId": "connection_lease_2e6357a8-6a17-4450-a782-7aa4ca543a9c",
"leaseExpiresAt": "2026-08-07T23:36:39.046Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 9,
"runnerNextSourceSeq": 10,
"coreAckedSourceSeq": 9,
"highestCommittedSourceSeq": 9
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5314,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 1
},
"recovery": {
"replayDeliveries": 8,
"runnerRestarts": 0,
"harnessRestarts": 0,
"malformedFrames": 0,
"freshBootstraps": 1,
"outcome": "recovered",
"reason": "duplicate_command_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "turn.accepted",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "turn.accepted",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"sameSession": true,
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "item.started",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "item.started",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"kind": "assistant_message"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Prove Phase 3 durable recovery."
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 6,
"sourceEventId": "event_runner_phase3_stable_000006",
"eventType": "item.completed",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000006",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.006Z",
"eventType": "item.completed",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Durable recovery completed."
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000006",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 6,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.006Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 7,
"sourceEventId": "event_runner_phase3_stable_000007",
"eventType": "run.result.proposed",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000007",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.007Z",
"eventType": "run.result.proposed",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"schema": "paperclip.prp.result.v1",
"status": "succeeded",
"summary": "Phase 3 durable transport recovered without duplicate effects."
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000007",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 7,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.007Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 8,
"sourceEventId": "event_runner_phase3_stable_000008",
"eventType": "run.terminal",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000008",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.008Z",
"eventType": "run.terminal",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"runTerminalState": "succeeded",
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000008",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 8,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.008Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 9,
"sourceEventId": "event_runner_phase3_stable_000009",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000009",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.009Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000009",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 9,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.009Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 10,
"ackedSourceSeq": 9,
"lastControllerCommandSeq": 4,
"reconnectCount": 1,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 5314,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:d11bf8308f863bad",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [
"WebSocket connection closed: failed to fill whole buffer"
],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,704 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "harness-restart",
"connection": {
"state": "stopped",
"connectionCount": 1,
"reconnectCount": 0,
"leaseId": "connection_lease_6de7f5ec-7f92-4c7c-806a-d262e02b2271",
"leaseExpiresAt": "2026-08-07T23:36:39.644Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 12,
"runnerNextSourceSeq": 13,
"coreAckedSourceSeq": 12,
"highestCommittedSourceSeq": 12
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 6827,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 8,
"p0Lost": 0
},
"commands": {
"issued": 5,
"completed": 5,
"rejected": 0,
"logicalEffects": 5,
"duplicateDeliveries": 0
},
"recovery": {
"replayDeliveries": 11,
"runnerRestarts": 0,
"harnessRestarts": 1,
"malformedFrames": 0,
"freshBootstraps": 1,
"outcome": "recovered",
"reason": "harness_restart_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": false
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "harness.exited",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "harness.exited",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"generation": 1,
"reason": "fault_injection_restart",
"recoverable": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "harness.ready",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "harness.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverKind": "fake",
"generation": 2
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "session.reconciled",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "session.reconciled",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"outcome": "same_session_resumed",
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 6,
"sourceEventId": "event_runner_phase3_stable_000006",
"eventType": "turn.accepted",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000006",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.006Z",
"eventType": "turn.accepted",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"sameSession": true,
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000006",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 6,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.006Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 7,
"sourceEventId": "event_runner_phase3_stable_000007",
"eventType": "item.started",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000007",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.007Z",
"eventType": "item.started",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"kind": "assistant_message"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000007",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 7,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.007Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 8,
"sourceEventId": "event_runner_phase3_stable_000008",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000008",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.008Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Prove Phase 3 durable recovery."
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000008",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 8,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.008Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 9,
"sourceEventId": "event_runner_phase3_stable_000009",
"eventType": "item.completed",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000009",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.009Z",
"eventType": "item.completed",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Durable recovery completed."
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000009",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 9,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.009Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 10,
"sourceEventId": "event_runner_phase3_stable_000010",
"eventType": "run.result.proposed",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000010",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.010Z",
"eventType": "run.result.proposed",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"schema": "paperclip.prp.result.v1",
"status": "succeeded",
"summary": "Phase 3 durable transport recovered without duplicate effects."
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000010",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 10,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.010Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 11,
"sourceEventId": "event_runner_phase3_stable_000011",
"eventType": "run.terminal",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000011",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.011Z",
"eventType": "run.terminal",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"runTerminalState": "succeeded",
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000011",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 11,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.011Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 12,
"sourceEventId": "event_runner_phase3_stable_000012",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000012",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.012Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000012",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 12,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.012Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 13,
"ackedSourceSeq": 12,
"lastControllerCommandSeq": 5,
"reconnectCount": 0,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 6827,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:284c1f5778b929c4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "harness restarted and reconciled",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:34d91505a30f60fb",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_005": {
"commandId": "command_phase3_005",
"controllerSeq": 5,
"commandDigest": "fnv1a64:533318a30fba6d22",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_005",
"controllerSeq": 5,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 2,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "fault.harness_restart",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "harness restarted and reconciled",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "completed",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_005",
"controllerSeq": 5,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:05.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_005",
"controllerSeq": 5,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,603 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "lease-expiry",
"connection": {
"state": "stopped",
"connectionCount": 2,
"reconnectCount": 2,
"leaseId": "connection_lease_42900de0-4a64-47c1-b20c-d14890a9be42",
"leaseExpiresAt": "2026-08-07T23:36:40.312Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 10,
"runnerNextSourceSeq": 11,
"coreAckedSourceSeq": 10,
"highestCommittedSourceSeq": 10
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5314,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 6,
"p0Lost": 0
},
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"recovery": {
"replayDeliveries": 9,
"runnerRestarts": 1,
"harnessRestarts": 0,
"malformedFrames": 0,
"freshBootstraps": 2,
"outcome": "recovered",
"reason": "lease_expiry_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "runner.reconciled",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "runner.reconciled",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"outcome": "same_durable_session_resumed",
"runnerInstanceId": "runner_phase3_stable",
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "turn.accepted",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "turn.accepted",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"sameSession": true,
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "item.started",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "item.started",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"kind": "assistant_message"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 6,
"sourceEventId": "event_runner_phase3_stable_000006",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000006",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.006Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Prove Phase 3 durable recovery."
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000006",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 6,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.006Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 7,
"sourceEventId": "event_runner_phase3_stable_000007",
"eventType": "item.completed",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000007",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.007Z",
"eventType": "item.completed",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Durable recovery completed."
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000007",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 7,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.007Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 8,
"sourceEventId": "event_runner_phase3_stable_000008",
"eventType": "run.result.proposed",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000008",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.008Z",
"eventType": "run.result.proposed",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"schema": "paperclip.prp.result.v1",
"status": "succeeded",
"summary": "Phase 3 durable transport recovered without duplicate effects."
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000008",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 8,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.008Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 9,
"sourceEventId": "event_runner_phase3_stable_000009",
"eventType": "run.terminal",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000009",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.009Z",
"eventType": "run.terminal",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"runTerminalState": "succeeded",
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000009",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 9,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.009Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 10,
"sourceEventId": "event_runner_phase3_stable_000010",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000010",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.010Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000010",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 10,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.010Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 11,
"ackedSourceSeq": 10,
"lastControllerCommandSeq": 4,
"reconnectCount": 2,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 5314,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:d11bf8308f863bad",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [
"WebSocket connection closed: failed to fill whole buffer",
"connection lease expired; a fresh bootstrap may resume this state",
"runner process restored the same durable identity"
],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,561 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "lost-ack",
"connection": {
"state": "stopped",
"connectionCount": 2,
"reconnectCount": 1,
"leaseId": "connection_lease_db423713-98d6-4a86-940b-ec2f0f6cd6e3",
"leaseExpiresAt": "2026-08-07T23:36:38.759Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 9,
"runnerNextSourceSeq": 10,
"coreAckedSourceSeq": 9,
"highestCommittedSourceSeq": 9
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 6023,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 1
},
"recovery": {
"replayDeliveries": 9,
"runnerRestarts": 0,
"harnessRestarts": 0,
"malformedFrames": 0,
"freshBootstraps": 1,
"outcome": "recovered",
"reason": "lost_ack_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 3,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": false
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "turn.accepted",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "turn.accepted",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"sameSession": true,
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "item.started",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "item.started",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"kind": "assistant_message"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Prove Phase 3 durable recovery."
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 6,
"sourceEventId": "event_runner_phase3_stable_000006",
"eventType": "item.completed",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000006",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.006Z",
"eventType": "item.completed",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Durable recovery completed."
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000006",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 6,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.006Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 7,
"sourceEventId": "event_runner_phase3_stable_000007",
"eventType": "run.result.proposed",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000007",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.007Z",
"eventType": "run.result.proposed",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"schema": "paperclip.prp.result.v1",
"status": "succeeded",
"summary": "Phase 3 durable transport recovered without duplicate effects."
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000007",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 7,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.007Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 8,
"sourceEventId": "event_runner_phase3_stable_000008",
"eventType": "run.terminal",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000008",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.008Z",
"eventType": "run.terminal",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"runTerminalState": "succeeded",
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000008",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 8,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.008Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 9,
"sourceEventId": "event_runner_phase3_stable_000009",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000009",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.009Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000009",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 9,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.009Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 10,
"ackedSourceSeq": 9,
"lastControllerCommandSeq": 4,
"reconnectCount": 1,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 6023,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:d11bf8308f863bad",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [
"WebSocket frame write failed: Broken pipe (os error 32)"
],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,561 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "malformed-input",
"connection": {
"state": "stopped",
"connectionCount": 2,
"reconnectCount": 1,
"leaseId": "connection_lease_51b9ac50-5fdc-4f68-a289-6e6e76711bbf",
"leaseExpiresAt": "2026-08-07T23:36:39.957Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 9,
"runnerNextSourceSeq": 10,
"coreAckedSourceSeq": 9,
"highestCommittedSourceSeq": 9
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5314,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"recovery": {
"replayDeliveries": 8,
"runnerRestarts": 0,
"harnessRestarts": 0,
"malformedFrames": 1,
"freshBootstraps": 1,
"outcome": "recovered",
"reason": "malformed_input_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "turn.accepted",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "turn.accepted",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"sameSession": true,
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "item.started",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "item.started",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"kind": "assistant_message"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Prove Phase 3 durable recovery."
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 6,
"sourceEventId": "event_runner_phase3_stable_000006",
"eventType": "item.completed",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000006",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.006Z",
"eventType": "item.completed",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Durable recovery completed."
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000006",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 6,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.006Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 7,
"sourceEventId": "event_runner_phase3_stable_000007",
"eventType": "run.result.proposed",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000007",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.007Z",
"eventType": "run.result.proposed",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"schema": "paperclip.prp.result.v1",
"status": "succeeded",
"summary": "Phase 3 durable transport recovered without duplicate effects."
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000007",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 7,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.007Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 8,
"sourceEventId": "event_runner_phase3_stable_000008",
"eventType": "run.terminal",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000008",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.008Z",
"eventType": "run.terminal",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"runTerminalState": "succeeded",
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000008",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 8,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.008Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 9,
"sourceEventId": "event_runner_phase3_stable_000009",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000009",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.009Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000009",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 9,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.009Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 10,
"ackedSourceSeq": 9,
"lastControllerCommandSeq": 4,
"reconnectCount": 1,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 5314,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:d11bf8308f863bad",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [
"malformed WebSocket JSON: EOF while parsing a value at line 1 column 8"
],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,559 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "none",
"connection": {
"state": "stopped",
"connectionCount": 1,
"reconnectCount": 0,
"leaseId": "connection_lease_469ad2f3-1655-434c-a12b-85718229ea67",
"leaseExpiresAt": "2026-08-07T23:36:38.211Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 9,
"runnerNextSourceSeq": 10,
"coreAckedSourceSeq": 9,
"highestCommittedSourceSeq": 9
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5315,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"recovery": {
"replayDeliveries": 8,
"runnerRestarts": 0,
"harnessRestarts": 0,
"malformedFrames": 0,
"freshBootstraps": 1,
"outcome": "recovered",
"reason": "none_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": false
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "turn.accepted",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "turn.accepted",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"sameSession": true,
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "item.started",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "item.started",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"kind": "assistant_message"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Prove Phase 3 durable recovery."
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 6,
"sourceEventId": "event_runner_phase3_stable_000006",
"eventType": "item.completed",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000006",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.006Z",
"eventType": "item.completed",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Durable recovery completed."
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000006",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 6,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.006Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 7,
"sourceEventId": "event_runner_phase3_stable_000007",
"eventType": "run.result.proposed",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000007",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.007Z",
"eventType": "run.result.proposed",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"schema": "paperclip.prp.result.v1",
"status": "succeeded",
"summary": "Phase 3 durable transport recovered without duplicate effects."
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000007",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 7,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.007Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 8,
"sourceEventId": "event_runner_phase3_stable_000008",
"eventType": "run.terminal",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000008",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.008Z",
"eventType": "run.terminal",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"runTerminalState": "succeeded",
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000008",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 8,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.008Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 9,
"sourceEventId": "event_runner_phase3_stable_000009",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000009",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.009Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000009",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 9,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.009Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 10,
"ackedSourceSeq": 9,
"lastControllerCommandSeq": 4,
"reconnectCount": 0,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 5315,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:d11bf8308f863bad",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,528 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "revoke",
"connection": {
"state": "revoked",
"connectionCount": 1,
"reconnectCount": 0,
"leaseId": "connection_lease_8fe123ed-6a53-42e1-bdc7-315a93568cbd",
"leaseExpiresAt": "2026-08-07T23:36:41.005Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 8,
"runnerNextSourceSeq": 9,
"coreAckedSourceSeq": 8,
"highestCommittedSourceSeq": 8
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5315,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 4,
"p0Lost": 0
},
"commands": {
"issued": 4,
"completed": 3,
"rejected": 1,
"logicalEffects": 3,
"duplicateDeliveries": 0
},
"recovery": {
"replayDeliveries": 4,
"runnerRestarts": 0,
"harnessRestarts": 0,
"malformedFrames": 0,
"freshBootstraps": 1,
"outcome": "revoked",
"reason": "revoke_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": false
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "turn.accepted",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "turn.accepted",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"sameSession": true,
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "item.started",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "item.started",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"kind": "assistant_message"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Prove Phase 3 durable recovery."
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
},
{
"sourceSeq": 6,
"sourceEventId": "event_runner_phase3_stable_000006",
"eventType": "item.completed",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000006",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.006Z",
"eventType": "item.completed",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Durable recovery completed."
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000006",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 6,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.006Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
},
{
"sourceSeq": 7,
"sourceEventId": "event_runner_phase3_stable_000007",
"eventType": "run.result.proposed",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000007",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.007Z",
"eventType": "run.result.proposed",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"schema": "paperclip.prp.result.v1",
"status": "succeeded",
"summary": "Phase 3 durable transport recovered without duplicate effects."
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000007",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 7,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.007Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
},
{
"sourceSeq": 8,
"sourceEventId": "event_runner_phase3_stable_000008",
"eventType": "run.terminal",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000008",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.008Z",
"eventType": "run.terminal",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"runTerminalState": "succeeded",
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000008",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 8,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.008Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 3,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "revoked",
"nextSourceSeq": 9,
"ackedSourceSeq": 8,
"lastControllerCommandSeq": 4,
"reconnectCount": 0,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 5315,
"outbox": [],
"processedCommands": {
"command_after_revoke": {
"commandId": "command_after_revoke",
"controllerSeq": 4,
"commandDigest": "fnv1a64:fd10e64f32894219",
"status": "rejected",
"logicalEffectCount": 0,
"result": {
"commandId": "command_after_revoke",
"controllerSeq": 4,
"detail": "connection capability is revoked; commands have no logical effect",
"logicalEffectCount": 0,
"status": "rejected"
}
},
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [
"connection lease revoked; flushing durable events"
],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_after_revoke",
"controllerSeq": 4,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "must be rejected after revoke"
},
"status": "rejected",
"result": {
"commandId": "command_after_revoke",
"controllerSeq": 4,
"detail": "connection capability is revoked; commands have no logical effect",
"logicalEffectCount": 0,
"status": "rejected"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,601 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "runner-restart",
"connection": {
"state": "stopped",
"connectionCount": 2,
"reconnectCount": 1,
"leaseId": "connection_lease_dcd257e4-0a30-4bb8-8674-0446c617e29f",
"leaseExpiresAt": "2026-08-07T23:36:39.391Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 10,
"runnerNextSourceSeq": 11,
"coreAckedSourceSeq": 10,
"highestCommittedSourceSeq": 10
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 6883,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 6,
"p0Lost": 0
},
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 1
},
"recovery": {
"replayDeliveries": 10,
"runnerRestarts": 1,
"harnessRestarts": 0,
"malformedFrames": 0,
"freshBootstraps": 2,
"outcome": "recovered",
"reason": "runner_restart_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 3,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": false
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "runner.reconciled",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "runner.reconciled",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"outcome": "same_durable_session_resumed",
"runnerInstanceId": "runner_phase3_stable",
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "turn.accepted",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "turn.accepted",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"sameSession": true,
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "item.started",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "item.started",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"kind": "assistant_message"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 6,
"sourceEventId": "event_runner_phase3_stable_000006",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000006",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.006Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Prove Phase 3 durable recovery."
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000006",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 6,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.006Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 7,
"sourceEventId": "event_runner_phase3_stable_000007",
"eventType": "item.completed",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000007",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.007Z",
"eventType": "item.completed",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Durable recovery completed."
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000007",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 7,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.007Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 8,
"sourceEventId": "event_runner_phase3_stable_000008",
"eventType": "run.result.proposed",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000008",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.008Z",
"eventType": "run.result.proposed",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"schema": "paperclip.prp.result.v1",
"status": "succeeded",
"summary": "Phase 3 durable transport recovered without duplicate effects."
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000008",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 8,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.008Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 9,
"sourceEventId": "event_runner_phase3_stable_000009",
"eventType": "run.terminal",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000009",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.009Z",
"eventType": "run.terminal",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"runTerminalState": "succeeded",
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000009",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 9,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.009Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 10,
"sourceEventId": "event_runner_phase3_stable_000010",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000010",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.010Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000010",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 10,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.010Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 11,
"ackedSourceSeq": 10,
"lastControllerCommandSeq": 4,
"reconnectCount": 1,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 6883,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:d11bf8308f863bad",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [
"runner process restored the same durable identity"
],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,561 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "socket-drop",
"connection": {
"state": "stopped",
"connectionCount": 2,
"reconnectCount": 1,
"leaseId": "connection_lease_efd9f53a-cc91-4232-8ec8-1a5d5963dad4",
"leaseExpiresAt": "2026-08-07T23:36:38.467Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 9,
"runnerNextSourceSeq": 10,
"coreAckedSourceSeq": 9,
"highestCommittedSourceSeq": 9
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 5314,
"maxBytes": 65536,
"backpressure": false,
"p0Committed": 5,
"p0Lost": 0
},
"commands": {
"issued": 4,
"completed": 4,
"rejected": 0,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"recovery": {
"replayDeliveries": 8,
"runnerRestarts": 0,
"harnessRestarts": 0,
"malformedFrames": 0,
"freshBootstraps": 1,
"outcome": "recovered",
"reason": "socket_drop_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "turn.accepted",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "turn.accepted",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"sameSession": true,
"turnId": "turn_phase3_stable"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "item.started",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "item.started",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"kind": "assistant_message"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Prove Phase 3 durable recovery."
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 6,
"sourceEventId": "event_runner_phase3_stable_000006",
"eventType": "item.completed",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000006",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.006Z",
"eventType": "item.completed",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"text": "Durable recovery completed."
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000006",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 6,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.006Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 7,
"sourceEventId": "event_runner_phase3_stable_000007",
"eventType": "run.result.proposed",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000007",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.007Z",
"eventType": "run.result.proposed",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"schema": "paperclip.prp.result.v1",
"status": "succeeded",
"summary": "Phase 3 durable transport recovered without duplicate effects."
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000007",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 7,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.007Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 8,
"sourceEventId": "event_runner_phase3_stable_000008",
"eventType": "run.terminal",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000008",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.008Z",
"eventType": "run.terminal",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"reportedWorkDisposition": "done",
"runTerminalState": "succeeded",
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000008",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 8,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.008Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 9,
"sourceEventId": "event_runner_phase3_stable_000009",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000009",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.009Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000009",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 9,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.009Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 10,
"ackedSourceSeq": 9,
"lastControllerCommandSeq": 4,
"reconnectCount": 1,
"maxOutboxBytes": 65536,
"peakOutboxBytes": 5314,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:d11bf8308f863bad",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [
"WebSocket connection closed: failed to fill whole buffer"
],
"backpressure": false,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "turn completed",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
@@ -0,0 +1,443 @@
{
"schema": "paperclip.runner.phase3.trace.v1",
"diagnostics": {
"schema": "paperclip.runner.phase3.diagnostics.v1",
"fault": "storage-pressure",
"connection": {
"state": "stopped",
"connectionCount": 1,
"reconnectCount": 0,
"leaseId": "connection_lease_d78971e6-232f-4e78-955f-9de455589f8c",
"leaseExpiresAt": "2026-08-07T23:36:40.583Z"
},
"identity": {
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable"
},
"cursors": {
"runnerAckedSourceSeq": 5,
"runnerNextSourceSeq": 6,
"coreAckedSourceSeq": 5,
"highestCommittedSourceSeq": 5
},
"outbox": {
"events": 0,
"bytes": 0,
"peakBytes": 2326,
"maxBytes": 16384,
"backpressure": true,
"p0Committed": 3,
"p0Lost": 0
},
"commands": {
"issued": 5,
"completed": 4,
"rejected": 1,
"logicalEffects": 4,
"duplicateDeliveries": 0
},
"recovery": {
"replayDeliveries": 4,
"runnerRestarts": 0,
"harnessRestarts": 0,
"malformedFrames": 0,
"freshBootstraps": 1,
"outcome": "recovered",
"reason": "storage_pressure_completed"
},
"security": {
"bootstrapTicketPersisted": false,
"connectionLeaseTokenPersisted": false,
"secretLeakCount": 0
},
"committedEvents": [
{
"sourceSeq": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"eventType": "workspace.ready",
"priority": 1,
"envelope": {
"envelopeId": "envelope_event_000001",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.001Z",
"eventType": "workspace.ready",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"priority": 1,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000001",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 1,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.001Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 2,
"sourceEventId": "event_runner_phase3_stable_000002",
"eventType": "session.started",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000002",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.002Z",
"eventType": "session.started",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"driverSessionId": "driver_phase3_fake",
"normalizedSessionId": "session_phase3_stable",
"resumed": false
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000002",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 2,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.002Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 3,
"sourceEventId": "event_runner_phase3_stable_000003",
"eventType": "item.delta",
"priority": 2,
"envelope": {
"envelopeId": "envelope_event_000003",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.003Z",
"eventType": "item.delta",
"itemId": "item_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"coalescedCount": 250,
"latest": {
"chunk": "bounded-progress-249",
"secretToken": "[REDACTED]"
}
},
"priority": 2,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000003",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 3,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.003Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 4,
"sourceEventId": "event_runner_phase3_stable_000004",
"eventType": "runner.backpressure",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000004",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.004Z",
"eventType": "runner.backpressure",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"maxOutboxBytes": 16384,
"newTurnsAccepted": false,
"p0ReserveBytes": 8192,
"reason": "outbox_soft_limit"
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000004",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 4,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.004Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 2,
"logicalEffectCount": 1
},
{
"sourceSeq": 5,
"sourceEventId": "event_runner_phase3_stable_000005",
"eventType": "runner.stopped",
"priority": 0,
"envelope": {
"envelopeId": "envelope_event_000005",
"kind": "event",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"emittedAt": "2026-08-07T23:00:00.005Z",
"eventType": "runner.stopped",
"normalizedSessionId": "session_phase3_stable",
"payload": {
"afterDurableFlush": true
},
"priority": 0,
"runId": "run_phase3_stable",
"schema": "paperclip.prp.event.v1",
"schemaVersion": 1,
"sourceEventId": "event_runner_phase3_stable_000005",
"sourceInstanceId": "runner_phase3_stable",
"sourceKind": "runner",
"sourceSeq": 5,
"turnId": "turn_phase3_stable"
},
"protocol": "paperclip.runner",
"runId": "run_phase3_stable",
"runnerInstanceId": "runner_phase3_stable",
"sentAt": "2026-08-07T23:00:00.005Z",
"turnId": "turn_phase3_stable",
"version": 1
},
"deliveryCount": 1,
"logicalEffectCount": 1
}
]
},
"runnerState": {
"schema": "paperclip.runner.phase3.state.v1",
"runnerInstanceId": "runner_phase3_stable",
"environmentLeaseId": "environment_lease_phase3_stable",
"runId": "run_phase3_stable",
"normalizedSessionId": "session_phase3_stable",
"turnId": "turn_phase3_stable",
"itemId": "item_phase3_stable",
"lifecycle": "stopped",
"nextSourceSeq": 6,
"ackedSourceSeq": 5,
"lastControllerCommandSeq": 5,
"reconnectCount": 0,
"maxOutboxBytes": 16384,
"peakOutboxBytes": 2326,
"outbox": [],
"processedCommands": {
"command_phase3_001": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_002": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_003": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"commandDigest": "fnv1a64:778efd982aadc99f",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "storage pressure applied with P2 coalescing",
"logicalEffectCount": 1,
"status": "completed"
}
},
"command_phase3_004": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"commandDigest": "fnv1a64:34d91505a30f60fb",
"status": "rejected",
"logicalEffectCount": 0,
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "new turns are disabled while draining or backpressured",
"logicalEffectCount": 0,
"status": "rejected"
}
},
"command_phase3_005": {
"commandId": "command_phase3_005",
"controllerSeq": 5,
"commandDigest": "fnv1a64:533318a30fba6d22",
"status": "completed",
"logicalEffectCount": 1,
"result": {
"commandId": "command_phase3_005",
"controllerSeq": 5,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
},
"diagnostics": [],
"backpressure": true,
"recoverableFailure": null,
"unrecoverableOutcome": null,
"harnessGeneration": 1,
"stopAfterFlush": true
},
"commands": [
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_001",
"controllerSeq": 1,
"type": "run.prepare",
"issuedAt": "2026-08-07T23:30:01.000Z",
"payload": {
"workspace": "phase-03-durable-fixture"
},
"status": "completed",
"result": {
"commandId": "command_phase3_001",
"controllerSeq": 1,
"detail": "run prepared",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_002",
"controllerSeq": 2,
"type": "session.open",
"issuedAt": "2026-08-07T23:30:02.000Z",
"payload": {
"reuse": "same_session"
},
"status": "completed",
"result": {
"commandId": "command_phase3_002",
"controllerSeq": 2,
"detail": "session bound",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_003",
"controllerSeq": 3,
"type": "fault.storage_pressure",
"issuedAt": "2026-08-07T23:30:03.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_003",
"controllerSeq": 3,
"detail": "storage pressure applied with P2 coalescing",
"logicalEffectCount": 1,
"status": "completed"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_004",
"controllerSeq": 4,
"type": "turn.start",
"issuedAt": "2026-08-07T23:30:04.000Z",
"payload": {
"turnId": "turn_phase3_stable",
"text": "Prove Phase 3 durable recovery."
},
"status": "rejected",
"result": {
"commandId": "command_phase3_004",
"controllerSeq": 4,
"detail": "new turns are disabled while draining or backpressured",
"logicalEffectCount": 0,
"status": "rejected"
}
},
{
"schema": "paperclip.prp.command.v1",
"commandId": "command_phase3_005",
"controllerSeq": 5,
"type": "runner.shutdown",
"issuedAt": "2026-08-07T23:30:05.000Z",
"payload": {},
"status": "completed",
"result": {
"commandId": "command_phase3_005",
"controllerSeq": 5,
"detail": "runner will stop after durable flush",
"logicalEffectCount": 1,
"status": "completed"
}
}
],
"assertions": {
"stableIdentity": true,
"sourceCursorContinuous": true,
"oneLogicalEffectPerAcceptedCommand": true,
"noDuplicateLogicalEvents": true,
"p0Preserved": true,
"boundedStorage": true,
"secretsRedacted": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Loaded 100 of 348 files, more files were not shown because too many files have changed in this diff. Show more