diff --git a/packages/paperclip-runner/devtools/browser/src/App.tsx b/packages/paperclip-runner/devtools/browser/src/App.tsx
index 9d202fc5260..6044c2120d9 100644
--- a/packages/paperclip-runner/devtools/browser/src/App.tsx
+++ b/packages/paperclip-runner/devtools/browser/src/App.tsx
@@ -1,7 +1,19 @@
-import { useMemo, useState } from "react";
+import { useMemo, useRef, useState } from "react";
-import { replayPhase1FixtureText } from "../../../src/tracer/phase1-replay";
+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 {
+ 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 {
@@ -28,12 +40,29 @@ const fixtureOrder = [
"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" },
+];
+
interface BrowserFixture {
key: string;
label: string;
source: string;
}
+type BrowserMode = "live" | "replay";
+type LiveStatus = "idle" | "starting" | "running" | "terminal" | "error";
+
+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;
@@ -55,12 +84,18 @@ function terminalTone(snapshot: SessionSnapshot) {
return "neutral" as const;
}
-function SnapshotSummary({ snapshot }: { snapshot: SessionSnapshot }) {
+function SnapshotSummary({
+ snapshot,
+ label = "Session snapshot",
+}: {
+ snapshot: SessionSnapshot;
+ label?: string;
+}) {
return (
<>
-
Final session snapshot
+
{label}
{snapshot.fixtureName}
@@ -132,7 +167,297 @@ function SnapshotSummary({ snapshot }: { snapshot: SessionSnapshot }) {
);
}
-export function App() {
+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).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).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("happy-path");
+ const [status, setStatus] = useState("idle");
+ const [runId, setRunId] = useState(null);
+ const [snapshot, setSnapshot] = useState(null);
+ const [events, setEvents] = useState([]);
+ const [error, setError] = useState(null);
+ const [diagnostic, setDiagnostic] = useState(null);
+ const [input, setInput] = useState("phase-02-live-trace");
+ const [trace, setTrace] = useState(null);
+ const [parity, setParity] = useState(null);
+ const snapshotRef = useRef(null);
+ const eventsRef = useRef([]);
+
+ 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 (
+
+
+
+ Local runner controls
+
+ Start one Rust runner and one scripted fake harness. No network or model is used.
+
+
+
+ Scenario
+ setScenario(event.target.value as Phase2Scenario)}
+ >
+ {liveScenarios.map((candidate) => (
+
+ {candidate.label}
+
+ ))}
+
+
+ void startRun()}
+ >
+ {status === "starting" ? "Starting…" : "Start local run"}
+
+ void postAction("interrupt")}
+ >
+ Interrupt turn
+
+
+
+
+ Runner status
+
+ {status}
+
+
+
+ {request?.requestKind === "runtime" ? (
+
+
+
{request.type} request
+
{request.prompt}
+
+ {request.type === "permission" ? (
+
+ void postAction("resolve", {
+ requestId: request.requestId,
+ response: { decision: "allow" },
+ })
+ }
+ >
+ Allow
+
+ ) : (
+ <>
+
Response
+
setInput(event.target.value)}
+ />
+
+ void postAction("resolve", {
+ requestId: request.requestId,
+ response: { text: input },
+ })
+ }
+ >
+ Send input
+
+ >
+ )}
+
+ ) : null}
+
+ {trace?.harnessProcessExit ? (
+
+
+
Harness process exit
+ {trace.harnessProcessExit.exitCode ?? "signal"}
+
+
+
Semantic result
+ {trace.result?.reportedWorkDisposition ?? "missing"}
+
+
+ ) : null}
+
+ {parity !== null ? (
+
+ Live and replay reducer output
+
+ {parity ? "Match" : "Mismatch"}
+
+
+ ) : null}
+ {diagnostic ? {diagnostic}
: null}
+ {error ? {error}
: null}
+
+
+
+
+ {snapshot ? (
+
+ ) : (
+
+
Validated live snapshot
+
Start a local run
+
Each incoming event will pass the Phase 1 validator before the reducer applies it.
+
+ )}
+
+
+ );
+}
+
+function StaticReplay() {
const defaultFixture = browserFixtures[0];
if (defaultFixture === undefined) {
throw new Error("No Phase 1 fixtures were bundled");
@@ -155,75 +480,99 @@ export function App() {
setReplay(replayPhase1FixtureText(fixture.source));
}
+ return (
+
+
+
+ Fixture input
+
+ Choose a conformance case or edit the JSON, then validate and replay it.
+
+
+
+ Fixture
+ chooseFixture(event.target.value)}
+ >
+ {browserFixtures.map((fixture) => (
+
+ {fixture.label}
+
+ ))}
+
+ PRP JSON
+
+
+
+
+ {replay.ok ? (
+
+ ) : (
+
+
Validation failed
+
Fixture cannot be replayed
+
+ {replay.issues.map((issue, index) => (
+
+ {issue.path} — {issue.message}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+export function App() {
+ const [mode, setMode] = useState("live");
+
return (
-
-
-
- Fixture input
-
- Choose a conformance case or edit the JSON, then validate and replay it.
-
-
-
- Fixture
- chooseFixture(event.target.value)}
- >
- {browserFixtures.map((fixture) => (
-
- {fixture.label}
-
- ))}
-
- PRP JSON
-
-
+
+ setMode("live")}
+ >
+ Live runner
+
+ setMode("replay")}
+ >
+ Static replay
+
+
-
- {replay.ok ? (
-
- ) : (
-
-
Validation failed
-
Fixture cannot be replayed
-
- {replay.issues.map((issue, index) => (
-
- {issue.path} — {issue.message}
-
- ))}
-
-
- )}
-
-
+ {mode === "live" ? : }
);
}
diff --git a/packages/paperclip-runner/devtools/browser/src/styles.css b/packages/paperclip-runner/devtools/browser/src/styles.css
index 770f35334a7..80f8590be72 100644
--- a/packages/paperclip-runner/devtools/browser/src/styles.css
+++ b/packages/paperclip-runner/devtools/browser/src/styles.css
@@ -60,6 +60,7 @@ body {
}
button,
+input,
select,
textarea {
font: inherit;
@@ -94,6 +95,25 @@ p {
margin-bottom: var(--space-8);
}
+.mode-switch,
+.scenario-actions,
+.live-status,
+.parity-line {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.mode-switch {
+ margin-bottom: var(--space-6);
+}
+
+.mode-switch .ui-button[aria-pressed="false"],
+.scenario-actions .ui-button + .ui-button {
+ background: var(--muted);
+ color: var(--muted-foreground);
+}
+
.page-header > div {
display: grid;
gap: var(--space-2);
@@ -173,8 +193,14 @@ dt {
opacity: 0.88;
}
+.ui-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
.ui-button:focus-visible,
select:focus-visible,
+.request-card input:focus-visible,
.ui-textarea:focus-visible {
outline: 0.125rem solid var(--ring);
outline-offset: 0.125rem;
@@ -217,6 +243,7 @@ dt {
}
select,
+.request-card input,
.ui-textarea {
width: 100%;
border: 0.0625rem solid var(--border);
@@ -230,6 +257,11 @@ select {
padding: var(--space-2) var(--space-3);
}
+.request-card input {
+ min-height: var(--control-height);
+ padding: var(--space-2) var(--space-3);
+}
+
.ui-textarea {
min-height: var(--editor-height);
resize: vertical;
@@ -245,6 +277,53 @@ select {
gap: var(--space-6);
}
+.live-status,
+.parity-line {
+ justify-content: space-between;
+ padding-block: var(--space-2);
+}
+
+.live-status > span,
+.parity-line > span {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+}
+
+.request-card {
+ display: grid;
+ gap: var(--space-3);
+ padding-block: var(--space-4);
+ border-block: 0.0625rem solid var(--border);
+}
+
+.request-card > div {
+ display: grid;
+ gap: var(--space-1);
+}
+
+.process-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-4);
+ margin: 0;
+ padding-block: var(--space-3);
+}
+
+.empty-live-state {
+ display: grid;
+ gap: var(--space-3);
+ padding-block: var(--space-6);
+}
+
+.empty-live-state h2 {
+ font-size: var(--font-size-lg);
+}
+
+.empty-live-state p:last-child {
+ color: var(--muted-foreground);
+}
+
.snapshot-heading {
align-items: flex-start;
}
diff --git a/packages/paperclip-runner/devtools/browser/static-replay.spec.ts b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts
index e21b73c675a..20ebe5d133a 100644
--- a/packages/paperclip-runner/devtools/browser/static-replay.spec.ts
+++ b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts
@@ -4,9 +4,10 @@ import { fileURLToPath } from "node:url";
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
-test("validates and renders the shared happy-path fixture", async ({ page }) => {
+test("validates and renders the shared Phase 1 fixture", async ({ page }) => {
await page.goto("/");
- await expect(page.getByRole("heading", { name: "Static session replay" })).toBeVisible();
+ await page.getByRole("button", { name: "Static replay" }).click();
+ await expect(page.getByRole("heading", { name: "Live local runner" })).toBeVisible();
await expect(page.getByTestId("terminal-badge")).toHaveText("succeeded");
await expect(page.getByTestId("timeline").getByRole("listitem")).toHaveCount(9);
await expect(page.getByTestId("result-summary")).toContainText(
@@ -18,8 +19,9 @@ test("validates and renders the shared happy-path fixture", async ({ page }) =>
});
});
-test("shows duplicate and unsupported-version fixture states", async ({ page }) => {
+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
@@ -28,3 +30,50 @@ test("shows duplicate and unsupported-version fixture states", async ({ page })
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 }) => {
+ 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: resolve(packageRoot, "knowledge/evidence/phase-02-live-complete.png"),
+ fullPage: true,
+ });
+});
+
+test("resolves live permission and input requests", async ({ page }) => {
+ 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 page.screenshot({
+ path: resolve(packageRoot, "knowledge/evidence/phase-02-live-permission.png"),
+ fullPage: true,
+ });
+ await page.getByRole("button", { name: "Allow" }).click();
+ await expect(page.getByTestId("runtime-request")).toContainText("input request");
+ 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 }) => {
+ 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("timeline").getByText("run.terminal")).toHaveCount(1);
+ await page.screenshot({
+ path: resolve(packageRoot, "knowledge/evidence/phase-02-live-interrupted.png"),
+ fullPage: true,
+ });
+});
diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json
index 996853a2b7e..1055597843b 100644
--- a/packages/paperclip-runner/package.json
+++ b/packages/paperclip-runner/package.json
@@ -24,10 +24,11 @@
"typecheck:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
- "test": "pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs",
+ "test": "pnpm run build:phase2-binaries && pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs",
"test:typescript": "vitest run",
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
- "test:browser": "pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
+ "test:browser": "pnpm run build:typescript && pnpm run build:phase2-binaries && pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
+ "build:phase2-binaries": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
"generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs",
"check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check",
"generate:phase1-goldens": "pnpm run build:typescript && node scripts/generate-phase1-goldens.mjs",
@@ -41,9 +42,11 @@
"trace:phase0:rust": "pnpm run build:rust && cargo run --quiet --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin phase0-tracer",
"trace:phase0:typescript": "pnpm run build:typescript && node dist/tracer/phase0.js",
"replay:phase1": "pnpm run build:typescript && node dist/cli/phase1-replay.js",
- "browser:dev": "vite --config vite.config.ts",
+ "trace:phase2": "pnpm run build:typescript && pnpm run build:phase2-binaries && node dist/cli/phase2-mock-core.js",
+ "record:phase2": "pnpm run trace:phase2 -- --quiet --output knowledge/evidence/phase-02-happy-path.json",
+ "browser:dev": "pnpm run build:typescript && pnpm run build:phase2-binaries && vite --config vite.config.ts",
"browser:preview": "vite preview --config vite.config.ts",
- "verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1"
+ "verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1 && pnpm run trace:phase2 -- --quiet"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/duplicate-terminal.json b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/duplicate-terminal.json
new file mode 100644
index 00000000000..fe092947bff
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/duplicate-terminal.json
@@ -0,0 +1,45 @@
+{
+ "schema": "paperclip.fake_harness.script.v1",
+ "name": "duplicate-terminal",
+ "steps": [
+ {
+ "kind": "result",
+ "payload": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "done",
+ "summary": "The duplicate terminal guard accepted one result.",
+ "completionClaim": {
+ "contractRevision": "phase2-contract-v1",
+ "objectiveSatisfied": true,
+ "criteria": [],
+ "remainingWork": []
+ },
+ "evidence": [],
+ "verification": [],
+ "attentionRequests": [],
+ "artifacts": []
+ }
+ },
+ { "kind": "event", "event_type": "turn.completed", "payload": {} },
+ { "kind": "event", "event_type": "session.closed", "payload": {} },
+ {
+ "kind": "terminal",
+ "payload": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded",
+ "reportedWorkDisposition": "done"
+ }
+ },
+ {
+ "kind": "terminal",
+ "payload": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "failed",
+ "runTerminalState": "failed",
+ "reportedWorkDisposition": "yielded"
+ }
+ },
+ { "kind": "exit", "code": 0 }
+ ]
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/error.json b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/error.json
new file mode 100644
index 00000000000..388ffd2995d
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/error.json
@@ -0,0 +1,53 @@
+{
+ "schema": "paperclip.fake_harness.script.v1",
+ "name": "error",
+ "steps": [
+ { "kind": "delay", "milliseconds": 5 },
+ { "kind": "log", "stream": "stderr", "text": "scripted harness failure" },
+ {
+ "kind": "event",
+ "event_type": "item.failed",
+ "item_id": "item_phase2_error",
+ "payload": { "kind": "diagnostic", "message": "The fake harness failed as requested." }
+ },
+ {
+ "kind": "result",
+ "payload": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "yielded",
+ "summary": "The fake harness reported a scripted error.",
+ "completionClaim": {
+ "contractRevision": "phase2-contract-v1",
+ "objectiveSatisfied": false,
+ "criteria": [],
+ "remainingWork": [
+ { "description": "Inspect the scripted failure.", "blocksCompletion": true }
+ ]
+ },
+ "evidence": [],
+ "verification": [
+ { "commandOrCheck": "scripted error scenario", "status": "failed" }
+ ],
+ "attentionRequests": [],
+ "artifacts": [],
+ "continuation": {
+ "kind": "retry",
+ "summary": "Retry with a successful script.",
+ "idempotencyKey": "phase2-error-retry"
+ }
+ }
+ },
+ { "kind": "event", "event_type": "turn.failed", "payload": {} },
+ { "kind": "event", "event_type": "session.failed", "payload": {} },
+ {
+ "kind": "terminal",
+ "payload": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "failed",
+ "runTerminalState": "failed",
+ "reportedWorkDisposition": "yielded"
+ }
+ },
+ { "kind": "exit", "code": 7 }
+ ]
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/happy-path.json b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/happy-path.json
new file mode 100644
index 00000000000..fe1cd39708f
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/happy-path.json
@@ -0,0 +1,90 @@
+{
+ "schema": "paperclip.fake_harness.script.v1",
+ "name": "happy-path",
+ "steps": [
+ {
+ "kind": "event",
+ "event_type": "item.started",
+ "item_id": "item_phase2_command",
+ "payload": { "kind": "command", "text": "pnpm test --filter fake" }
+ },
+ { "kind": "delay", "milliseconds": 15 },
+ { "kind": "log", "stream": "stdout", "text": "prepare workspace" },
+ { "kind": "log", "stream": "stdout", "text": "load scripted driver" },
+ { "kind": "log", "stream": "stdout", "text": "run deterministic check" },
+ { "kind": "log", "stream": "stdout", "text": "write fixture output" },
+ { "kind": "log", "stream": "stdout", "text": "verification passed" },
+ {
+ "kind": "event",
+ "event_type": "item.delta",
+ "item_id": "item_phase2_command",
+ "payload": { "text": "Fake driver output is live. " }
+ },
+ {
+ "kind": "event",
+ "event_type": "item.completed",
+ "item_id": "item_phase2_command",
+ "payload": { "kind": "command", "text": "Fake driver check passed." }
+ },
+ {
+ "kind": "event",
+ "event_type": "item.started",
+ "item_id": "item_phase2_file",
+ "payload": { "kind": "file_change", "text": "phase-02-output.json" }
+ },
+ {
+ "kind": "event",
+ "event_type": "item.completed",
+ "item_id": "item_phase2_file",
+ "payload": { "kind": "file_change", "text": "Wrote phase-02-output.json" }
+ },
+ {
+ "kind": "result",
+ "payload": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "done",
+ "summary": "The live fake-harness run completed successfully.",
+ "completionClaim": {
+ "contractRevision": "phase2-contract-v1",
+ "objectiveSatisfied": true,
+ "criteria": [
+ {
+ "criterionId": "local-runner",
+ "status": "satisfied",
+ "evidenceRefs": ["event:item_phase2_command"]
+ }
+ ],
+ "remainingWork": []
+ },
+ "evidence": [{ "kind": "event", "ref": "item_phase2_command" }],
+ "verification": [
+ { "commandOrCheck": "fake driver conformance", "status": "passed" }
+ ],
+ "attentionRequests": [],
+ "artifacts": [
+ { "kind": "fixture", "ref": "phase-02-output.json", "title": "Live trace" }
+ ]
+ }
+ },
+ {
+ "kind": "event",
+ "event_type": "turn.completed",
+ "payload": {}
+ },
+ {
+ "kind": "event",
+ "event_type": "session.closed",
+ "payload": { "reason": "script_complete" }
+ },
+ {
+ "kind": "terminal",
+ "payload": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded",
+ "reportedWorkDisposition": "done"
+ }
+ },
+ { "kind": "exit", "code": 0 }
+ ]
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/interrupted.json b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/interrupted.json
new file mode 100644
index 00000000000..5ff8f7d6c1f
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/interrupted.json
@@ -0,0 +1,58 @@
+{
+ "schema": "paperclip.fake_harness.script.v1",
+ "name": "interrupted",
+ "steps": [
+ {
+ "kind": "event",
+ "event_type": "item.started",
+ "item_id": "item_phase2_long_command",
+ "payload": { "kind": "command", "text": "fake long-running command" }
+ },
+ { "kind": "log", "stream": "stdout", "text": "waiting for interruption" },
+ { "kind": "await_interrupt" },
+ {
+ "kind": "event",
+ "event_type": "item.failed",
+ "item_id": "item_phase2_long_command",
+ "payload": { "kind": "command", "message": "Interrupted by the operator." }
+ },
+ {
+ "kind": "result",
+ "payload": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "yielded",
+ "summary": "The scripted turn was interrupted.",
+ "completionClaim": {
+ "contractRevision": "phase2-contract-v1",
+ "objectiveSatisfied": false,
+ "criteria": [],
+ "remainingWork": [
+ { "description": "Start a replacement turn.", "blocksCompletion": true }
+ ]
+ },
+ "evidence": [],
+ "verification": [
+ { "commandOrCheck": "turn interruption", "status": "passed" }
+ ],
+ "attentionRequests": [],
+ "artifacts": [],
+ "continuation": {
+ "kind": "same_agent",
+ "summary": "A replacement turn can continue the session.",
+ "idempotencyKey": "phase2-interrupted-continuation"
+ }
+ }
+ },
+ { "kind": "event", "event_type": "session.closed", "payload": {} },
+ {
+ "kind": "terminal",
+ "payload": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "interrupted",
+ "runTerminalState": "cancelled",
+ "reportedWorkDisposition": "yielded"
+ }
+ },
+ { "kind": "exit", "code": 130 }
+ ]
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/linger.json b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/linger.json
new file mode 100644
index 00000000000..9ccf6505a44
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/linger.json
@@ -0,0 +1,15 @@
+{
+ "schema": "paperclip.fake_harness.script.v1",
+ "name": "linger",
+ "steps": [
+ { "kind": "spawn_worker" },
+ {
+ "kind": "event",
+ "event_type": "item.started",
+ "item_id": "item_phase2_linger",
+ "payload": { "kind": "command", "text": "wait for supervisor cleanup" }
+ },
+ { "kind": "await_interrupt" },
+ { "kind": "exit", "code": 130 }
+ ]
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/permission-input.json b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/permission-input.json
new file mode 100644
index 00000000000..5f5fc7371fa
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-02/scripts/permission-input.json
@@ -0,0 +1,83 @@
+{
+ "schema": "paperclip.fake_harness.script.v1",
+ "name": "permission-input",
+ "steps": [
+ {
+ "kind": "request",
+ "request": {
+ "schema": "paperclip.runtime_request.v1",
+ "requestKind": "runtime",
+ "requestId": "request_phase2_permission",
+ "type": "permission",
+ "status": "pending",
+ "prompt": "Allow the fake driver to write its local fixture?",
+ "choices": [
+ { "key": "allow", "label": "Allow" },
+ { "key": "deny", "label": "Deny" }
+ ]
+ }
+ },
+ { "kind": "await_resolution", "request_id": "request_phase2_permission" },
+ {
+ "kind": "request",
+ "request": {
+ "schema": "paperclip.runtime_request.v1",
+ "requestKind": "runtime",
+ "requestId": "request_phase2_input",
+ "type": "input",
+ "status": "pending",
+ "prompt": "Name the local trace.",
+ "choices": []
+ }
+ },
+ { "kind": "await_resolution", "request_id": "request_phase2_input" },
+ {
+ "kind": "event",
+ "event_type": "item.started",
+ "item_id": "item_phase2_permission",
+ "payload": { "kind": "assistant_message", "text": "" }
+ },
+ { "kind": "delay", "milliseconds": 10 },
+ {
+ "kind": "event",
+ "event_type": "item.completed",
+ "item_id": "item_phase2_permission",
+ "payload": {
+ "kind": "assistant_message",
+ "text": "Permission and input were resolved through the local protocol."
+ }
+ },
+ {
+ "kind": "result",
+ "payload": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "done",
+ "summary": "The permission and input flow completed.",
+ "completionClaim": {
+ "contractRevision": "phase2-contract-v1",
+ "objectiveSatisfied": true,
+ "criteria": [],
+ "remainingWork": []
+ },
+ "evidence": [],
+ "verification": [
+ { "commandOrCheck": "permission and input round trip", "status": "passed" }
+ ],
+ "attentionRequests": [],
+ "artifacts": []
+ }
+ },
+ { "kind": "event", "event_type": "turn.completed", "payload": {} },
+ { "kind": "event", "event_type": "session.closed", "payload": {} },
+ {
+ "kind": "terminal",
+ "payload": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded",
+ "reportedWorkDisposition": "done"
+ }
+ },
+ { "kind": "exit", "code": 0 }
+ ]
+}
diff --git a/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml b/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml
index 6f22d575657..e3292759a2f 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml
+++ b/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml
@@ -12,3 +12,11 @@ serde_json.workspace = true
[[bin]]
name = "phase0-tracer"
path = "src/bin/phase0-tracer.rs"
+
+[[bin]]
+name = "paperclip-runnerd"
+path = "src/bin/paperclip-runnerd.rs"
+
+[[bin]]
+name = "fake-harness"
+path = "src/bin/fake-harness.rs"
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-harness.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-harness.rs
new file mode 100644
index 00000000000..9eb83c33d49
--- /dev/null
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-harness.rs
@@ -0,0 +1,40 @@
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use paperclip_runner_core::phase2::{load_fake_harness_script, run_fake_harness, Phase2Error};
+
+fn value(args: &[String], name: &str) -> Result {
+ let index = args
+ .iter()
+ .position(|argument| argument == name)
+ .ok_or_else(|| Phase2Error::invalid(format!("missing required argument {name}")))?;
+ args.get(index + 1)
+ .cloned()
+ .ok_or_else(|| Phase2Error::invalid(format!("missing value for {name}")))
+}
+
+fn run() -> Result {
+ let args = std::env::args().skip(1).collect::>();
+ let script = load_fake_harness_script(&PathBuf::from(value(&args, "--script")?))?;
+ let delay_override_ms = args
+ .iter()
+ .position(|argument| argument == "--delay-ms")
+ .map(|index| {
+ args.get(index + 1)
+ .ok_or_else(|| Phase2Error::invalid("missing value for --delay-ms"))?
+ .parse::()
+ .map_err(|error| Phase2Error::invalid(format!("invalid --delay-ms: {error}")))
+ })
+ .transpose()?;
+ run_fake_harness(script, delay_override_ms)
+}
+
+fn main() -> ExitCode {
+ match run() {
+ Ok(code) => ExitCode::from(u8::try_from(code.clamp(0, 255)).unwrap_or(1)),
+ Err(error) => {
+ eprintln!("fake-harness: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs
new file mode 100644
index 00000000000..33c53fbe93e
--- /dev/null
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs
@@ -0,0 +1,62 @@
+use std::path::PathBuf;
+use std::process::ExitCode;
+use std::time::Duration;
+
+use paperclip_runner_core::phase2::{run_local_runner, Phase2Error, RunnerConfig};
+
+fn value(args: &[String], name: &str) -> Result {
+ let index = args
+ .iter()
+ .position(|argument| argument == name)
+ .ok_or_else(|| Phase2Error::invalid(format!("missing required argument {name}")))?;
+ args.get(index + 1)
+ .cloned()
+ .ok_or_else(|| Phase2Error::invalid(format!("missing value for {name}")))
+}
+
+fn optional_u64(args: &[String], name: &str) -> Result, Phase2Error> {
+ let Some(index) = args.iter().position(|argument| argument == name) else {
+ return Ok(None);
+ };
+ let value = args
+ .get(index + 1)
+ .ok_or_else(|| Phase2Error::invalid(format!("missing value for {name}")))?;
+ value
+ .parse::()
+ .map(Some)
+ .map_err(|error| Phase2Error::invalid(format!("invalid {name}: {error}")))
+}
+
+fn usize_value(args: &[String], name: &str, default: usize) -> Result {
+ optional_u64(args, name)?.map_or(Ok(default), |value| {
+ usize::try_from(value)
+ .map_err(|error| Phase2Error::invalid(format!("invalid {name}: {error}")))
+ })
+}
+
+fn run() -> Result<(), Phase2Error> {
+ let args = std::env::args().skip(1).collect::>();
+ run_local_runner(RunnerConfig {
+ run_id: value(&args, "--run-id")?,
+ normalized_session_id: value(&args, "--session-id")?,
+ runner_instance_id: value(&args, "--runner-id")?,
+ fake_harness_path: PathBuf::from(value(&args, "--fake-harness")?),
+ script_path: PathBuf::from(value(&args, "--script")?),
+ delay_override_ms: optional_u64(&args, "--delay-ms")?,
+ log_max_lines: usize_value(&args, "--log-max-lines", 32)?,
+ log_max_bytes: usize_value(&args, "--log-max-bytes", 16_384)?,
+ shutdown_grace: Duration::from_millis(
+ optional_u64(&args, "--shutdown-grace-ms")?.unwrap_or(100),
+ ),
+ })
+}
+
+fn main() -> ExitCode {
+ match run() {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("paperclip-runnerd: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs
index 0b85caa636d..ff17e2ba528 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)]
pub mod phase1;
+pub mod phase2;
use std::error::Error;
use std::fmt::{self, Display, Formatter};
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/phase2.rs b/packages/paperclip-runner/runner/crates/runner-core/src/phase2.rs
new file mode 100644
index 00000000000..5f65be59ca9
--- /dev/null
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/phase2.rs
@@ -0,0 +1,1225 @@
+use std::collections::{HashMap, VecDeque};
+use std::error::Error;
+use std::fmt::{self, Display, Formatter};
+use std::io::{BufRead, BufReader, Write};
+use std::path::{Path, PathBuf};
+use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
+use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
+use std::thread;
+use std::time::{Duration, Instant};
+
+#[cfg(unix)]
+use std::os::unix::process::CommandExt;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+const RUNNER_STREAM_SCHEMA: &str = "paperclip.runner.stream.v1";
+const RUNNER_COMMAND_SCHEMA: &str = "paperclip.prp.command.v1";
+const HARNESS_COMMAND_SCHEMA: &str = "paperclip.fake_harness.command.v1";
+const HARNESS_MESSAGE_SCHEMA: &str = "paperclip.fake_harness.message.v1";
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Phase2Error(String);
+
+impl Phase2Error {
+ pub fn invalid(message: impl Into) -> Self {
+ Self(message.into())
+ }
+}
+
+impl Display for Phase2Error {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.0)
+ }
+}
+
+impl Error for Phase2Error {}
+
+#[derive(Clone, Debug)]
+pub struct RunnerConfig {
+ pub run_id: String,
+ pub normalized_session_id: String,
+ pub runner_instance_id: String,
+ pub fake_harness_path: PathBuf,
+ pub script_path: PathBuf,
+ pub delay_override_ms: Option,
+ pub log_max_lines: usize,
+ pub log_max_bytes: usize,
+ pub shutdown_grace: Duration,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RunnerCommand {
+ pub schema: String,
+ pub command_id: String,
+ pub controller_seq: u64,
+ #[serde(rename = "type")]
+ pub command_type: String,
+ pub issued_at: String,
+ #[serde(default)]
+ pub payload: Value,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct HarnessCommand {
+ pub schema: String,
+ pub command_id: String,
+ #[serde(rename = "type")]
+ pub command_type: String,
+ #[serde(default)]
+ pub payload: Value,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct HarnessMessage {
+ schema: String,
+ message_seq: u64,
+ #[serde(rename = "type")]
+ message_type: String,
+ #[serde(default)]
+ payload: Value,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FakeHarnessScript {
+ pub schema: String,
+ pub name: String,
+ pub steps: Vec,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum FakeHarnessStep {
+ Delay {
+ milliseconds: u64,
+ },
+ Log {
+ stream: String,
+ text: String,
+ },
+ Event {
+ event_type: String,
+ #[serde(default)]
+ turn_id: Option,
+ #[serde(default)]
+ item_id: Option,
+ #[serde(default)]
+ payload: Value,
+ },
+ Request {
+ request: Value,
+ },
+ AwaitResolution {
+ request_id: String,
+ },
+ AwaitInterrupt,
+ Result {
+ payload: Value,
+ },
+ Terminal {
+ payload: Value,
+ },
+ SpawnWorker,
+ Exit {
+ code: i32,
+ },
+}
+
+#[derive(Debug)]
+enum ProcessOutput {
+ Stdout(String),
+ Stderr(String),
+ StdoutClosed,
+ StderrClosed,
+}
+
+#[derive(Clone, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProcessExitFact {
+ pub exit_code: Option,
+ pub success: bool,
+ pub signal: Option,
+}
+
+#[derive(Clone, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BoundedLogSnapshot {
+ pub lines: Vec,
+ pub retained_bytes: usize,
+ pub dropped_lines: usize,
+}
+
+#[derive(Debug)]
+pub struct BoundedLogBuffer {
+ max_lines: usize,
+ max_bytes: usize,
+ retained_bytes: usize,
+ dropped_lines: usize,
+ lines: VecDeque,
+}
+
+impl BoundedLogBuffer {
+ pub fn new(max_lines: usize, max_bytes: usize) -> Self {
+ Self {
+ max_lines: max_lines.max(1),
+ max_bytes: max_bytes.max(1),
+ retained_bytes: 0,
+ dropped_lines: 0,
+ lines: VecDeque::new(),
+ }
+ }
+
+ pub fn push(&mut self, line: impl Into) {
+ let mut line = line.into();
+ if line.len() > self.max_bytes {
+ let mut end = self.max_bytes;
+ while !line.is_char_boundary(end) {
+ end -= 1;
+ }
+ line.truncate(end);
+ }
+ self.retained_bytes += line.len();
+ self.lines.push_back(line);
+ while self.lines.len() > self.max_lines || self.retained_bytes > self.max_bytes {
+ if let Some(removed) = self.lines.pop_front() {
+ self.retained_bytes = self.retained_bytes.saturating_sub(removed.len());
+ self.dropped_lines += 1;
+ } else {
+ break;
+ }
+ }
+ }
+
+ pub fn snapshot(&self) -> BoundedLogSnapshot {
+ BoundedLogSnapshot {
+ lines: self.lines.iter().cloned().collect(),
+ retained_bytes: self.retained_bytes,
+ dropped_lines: self.dropped_lines,
+ }
+ }
+}
+
+pub struct SupervisedProcess {
+ child: Child,
+ stdin: Option,
+ output: Receiver,
+ process_group_id: u32,
+ shutdown_grace: Duration,
+ finished: bool,
+}
+
+impl SupervisedProcess {
+ pub fn spawn(
+ program: &Path,
+ args: &[String],
+ shutdown_grace: Duration,
+ ) -> Result {
+ let mut command = Command::new(program);
+ command
+ .args(args)
+ .env_clear()
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ if let Some(path) = std::env::var_os("PATH") {
+ command.env("PATH", path);
+ }
+ #[cfg(unix)]
+ command.process_group(0);
+
+ let mut child = command.spawn().map_err(|error| {
+ Phase2Error::invalid(format!(
+ "failed to start supervised process {}: {error}",
+ program.display()
+ ))
+ })?;
+ let process_group_id = child.id();
+ let stdin = child
+ .stdin
+ .take()
+ .ok_or_else(|| Phase2Error::invalid("supervised process stdin was not piped"))?;
+ let stdout = child
+ .stdout
+ .take()
+ .ok_or_else(|| Phase2Error::invalid("supervised process stdout was not piped"))?;
+ let stderr = child
+ .stderr
+ .take()
+ .ok_or_else(|| Phase2Error::invalid("supervised process stderr was not piped"))?;
+ let (sender, output) = mpsc::channel();
+ let stdout_sender = sender.clone();
+ thread::spawn(move || {
+ for line in BufReader::new(stdout).lines() {
+ match line {
+ Ok(line) => {
+ if stdout_sender.send(ProcessOutput::Stdout(line)).is_err() {
+ return;
+ }
+ }
+ Err(error) => {
+ let _ = stdout_sender.send(ProcessOutput::Stderr(format!(
+ "failed to read harness stdout: {error}"
+ )));
+ break;
+ }
+ }
+ }
+ let _ = stdout_sender.send(ProcessOutput::StdoutClosed);
+ });
+ thread::spawn(move || {
+ for line in BufReader::new(stderr).lines() {
+ match line {
+ Ok(line) => {
+ if sender.send(ProcessOutput::Stderr(line)).is_err() {
+ return;
+ }
+ }
+ Err(error) => {
+ let _ = sender.send(ProcessOutput::Stderr(format!(
+ "failed to read harness stderr: {error}"
+ )));
+ break;
+ }
+ }
+ }
+ let _ = sender.send(ProcessOutput::StderrClosed);
+ });
+
+ Ok(Self {
+ child,
+ stdin: Some(stdin),
+ output,
+ process_group_id,
+ shutdown_grace,
+ finished: false,
+ })
+ }
+
+ pub fn id(&self) -> u32 {
+ self.child.id()
+ }
+
+ pub fn send(&mut self, value: &T) -> Result<(), Phase2Error> {
+ let stdin = self
+ .stdin
+ .as_mut()
+ .ok_or_else(|| Phase2Error::invalid("supervised process stdin is closed"))?;
+ serde_json::to_writer(&mut *stdin, value).map_err(|error| {
+ Phase2Error::invalid(format!("command serialization failed: {error}"))
+ })?;
+ stdin
+ .write_all(b"\n")
+ .and_then(|_| stdin.flush())
+ .map_err(|error| {
+ Phase2Error::invalid(format!("failed to write process command: {error}"))
+ })
+ }
+
+ fn recv_timeout(&self, timeout: Duration) -> Result {
+ self.output.recv_timeout(timeout)
+ }
+
+ pub fn receive_stdout_line(&self, timeout: Duration) -> Result, Phase2Error> {
+ let deadline = Instant::now() + timeout;
+ loop {
+ let remaining = deadline.saturating_duration_since(Instant::now());
+ if remaining.is_zero() {
+ return Ok(None);
+ }
+ match self.recv_timeout(remaining) {
+ Ok(ProcessOutput::Stdout(line)) => return Ok(Some(line)),
+ Ok(ProcessOutput::Stderr(_)) | Ok(ProcessOutput::StderrClosed) => {}
+ Ok(ProcessOutput::StdoutClosed) => return Ok(None),
+ Err(RecvTimeoutError::Timeout) => return Ok(None),
+ Err(RecvTimeoutError::Disconnected) => {
+ return Err(Phase2Error::invalid("process output channel closed"));
+ }
+ }
+ }
+ }
+
+ fn try_recv(&self) -> Result {
+ self.output.try_recv()
+ }
+
+ pub fn try_wait(&mut self) -> Result, Phase2Error> {
+ self.child
+ .try_wait()
+ .map(|status| status.map(exit_fact))
+ .map_err(|error| Phase2Error::invalid(format!("failed to inspect process: {error}")))
+ }
+
+ pub fn wait(&mut self) -> Result {
+ let status = self.child.wait().map_err(|error| {
+ Phase2Error::invalid(format!("failed to wait for process: {error}"))
+ })?;
+ self.finished = true;
+ Ok(exit_fact(status))
+ }
+
+ pub fn terminate_group(&mut self) -> Result {
+ self.stdin.take();
+ #[cfg(unix)]
+ signal_process_group(self.process_group_id, "TERM");
+ #[cfg(not(unix))]
+ let _ = self.child.kill();
+
+ let deadline = Instant::now() + self.shutdown_grace;
+ loop {
+ if let Some(fact) = self.try_wait()? {
+ #[cfg(unix)]
+ signal_process_group(self.process_group_id, "KILL");
+ self.finished = true;
+ return Ok(fact);
+ }
+ if Instant::now() >= deadline {
+ break;
+ }
+ thread::sleep(Duration::from_millis(5));
+ }
+
+ #[cfg(unix)]
+ signal_process_group(self.process_group_id, "KILL");
+ #[cfg(not(unix))]
+ let _ = self.child.kill();
+ self.wait()
+ }
+}
+
+impl Drop for SupervisedProcess {
+ fn drop(&mut self) {
+ if !self.finished {
+ let _ = self.terminate_group();
+ }
+ }
+}
+
+#[cfg(unix)]
+fn signal_process_group(process_group_id: u32, signal: &str) {
+ let _ = Command::new("kill")
+ .args([
+ format!("-{signal}"),
+ "--".to_owned(),
+ format!("-{process_group_id}"),
+ ])
+ .env_clear()
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status();
+}
+
+fn exit_fact(status: ExitStatus) -> ProcessExitFact {
+ #[cfg(unix)]
+ {
+ use std::os::unix::process::ExitStatusExt;
+ ProcessExitFact {
+ exit_code: status.code(),
+ success: status.success(),
+ signal: status.signal(),
+ }
+ }
+ #[cfg(not(unix))]
+ {
+ ProcessExitFact {
+ exit_code: status.code(),
+ success: status.success(),
+ signal: None,
+ }
+ }
+}
+
+#[derive(Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+enum RunnerStreamMessage<'a> {
+ Event {
+ event: &'a Value,
+ },
+ CommandReceipt {
+ #[serde(rename = "commandId")]
+ command_id: &'a str,
+ #[serde(rename = "controllerSeq")]
+ controller_seq: u64,
+ status: &'a str,
+ detail: &'a str,
+ },
+ Diagnostic {
+ level: &'a str,
+ message: &'a str,
+ },
+}
+
+struct RunnerState {
+ config: RunnerConfig,
+ source_seq: u64,
+ next_controller_seq: u64,
+ commands: HashMap,
+ harness: Option,
+ logs: BoundedLogBuffer,
+ semantic_result: Option,
+ pending_terminal: Option,
+ terminal_emitted: bool,
+ stdout_closed: bool,
+}
+
+impl RunnerState {
+ fn new(config: RunnerConfig) -> Self {
+ Self {
+ logs: BoundedLogBuffer::new(config.log_max_lines, config.log_max_bytes),
+ config,
+ source_seq: 0,
+ next_controller_seq: 1,
+ commands: HashMap::new(),
+ harness: None,
+ semantic_result: None,
+ pending_terminal: None,
+ terminal_emitted: false,
+ stdout_closed: false,
+ }
+ }
+
+ fn write_stream(&self, message: &RunnerStreamMessage<'_>) -> Result<(), Phase2Error> {
+ let stdout = std::io::stdout();
+ let mut lock = stdout.lock();
+ let envelope = json!({
+ "schema": RUNNER_STREAM_SCHEMA,
+ "message": message,
+ });
+ serde_json::to_writer(&mut lock, &envelope).map_err(|error| {
+ Phase2Error::invalid(format!("stream serialization failed: {error}"))
+ })?;
+ lock.write_all(b"\n")
+ .and_then(|_| lock.flush())
+ .map_err(|error| Phase2Error::invalid(format!("stream write failed: {error}")))
+ }
+
+ fn command_receipt(
+ &self,
+ command: &RunnerCommand,
+ status: &str,
+ detail: &str,
+ ) -> Result<(), Phase2Error> {
+ self.write_stream(&RunnerStreamMessage::CommandReceipt {
+ command_id: &command.command_id,
+ controller_seq: command.controller_seq,
+ status,
+ detail,
+ })
+ }
+
+ fn diagnostic(&self, level: &str, message: &str) -> Result<(), Phase2Error> {
+ self.write_stream(&RunnerStreamMessage::Diagnostic { level, message })
+ }
+
+ fn emit_event(
+ &mut self,
+ event_type: &str,
+ payload: Value,
+ turn_id: Option<&str>,
+ item_id: Option<&str>,
+ ) -> Result<(), Phase2Error> {
+ if self.terminal_emitted {
+ return Ok(());
+ }
+ self.source_seq += 1;
+ let mut event = json!({
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": format!("event_{}_{:03}", self.config.run_id, self.source_seq),
+ "sourceSeq": self.source_seq,
+ "sourceInstanceId": self.config.runner_instance_id,
+ "sourceKind": "runner",
+ "runId": self.config.run_id,
+ "normalizedSessionId": self.config.normalized_session_id,
+ "eventType": event_type,
+ "schemaVersion": 1,
+ "priority": event_priority(event_type),
+ "emittedAt": format!("2026-08-07T21:00:{:02}.{:03}Z", (self.source_seq / 1000) % 60, self.source_seq % 1000),
+ "payload": payload,
+ });
+ if let Some(turn_id) = turn_id {
+ event["turnId"] = Value::String(turn_id.to_owned());
+ }
+ if let Some(item_id) = item_id {
+ event["itemId"] = Value::String(item_id.to_owned());
+ }
+ self.write_stream(&RunnerStreamMessage::Event { event: &event })?;
+ if event_type == "run.terminal" {
+ self.terminal_emitted = true;
+ }
+ Ok(())
+ }
+
+ fn start_harness(&mut self) -> Result<(), Phase2Error> {
+ self.emit_event(
+ "runtime.phase.changed",
+ json!({ "phase": "workspace_preparing" }),
+ None,
+ None,
+ )?;
+ self.emit_event(
+ "workspace.ready",
+ json!({ "workingDirectory": "standalone-fixture" }),
+ None,
+ None,
+ )?;
+ self.emit_event(
+ "harness.starting",
+ json!({ "driverKind": "fake", "transport": "stdio_jsonl" }),
+ None,
+ None,
+ )?;
+ let mut args = vec![
+ "--script".to_owned(),
+ self.config.script_path.display().to_string(),
+ ];
+ if let Some(milliseconds) = self.config.delay_override_ms {
+ args.push("--delay-ms".to_owned());
+ args.push(milliseconds.to_string());
+ }
+ let harness = SupervisedProcess::spawn(
+ &self.config.fake_harness_path,
+ &args,
+ self.config.shutdown_grace,
+ )?;
+ let ready_deadline = Instant::now() + Duration::from_secs(2);
+ loop {
+ let remaining = ready_deadline.saturating_duration_since(Instant::now());
+ if remaining.is_zero() {
+ return Err(Phase2Error::invalid("fake harness did not become ready"));
+ }
+ match harness.recv_timeout(remaining) {
+ Ok(ProcessOutput::Stdout(line)) => {
+ let message = parse_harness_message(&line)?;
+ if message.message_type == "ready" {
+ self.emit_event(
+ "harness.ready",
+ json!({
+ "driverKind": "fake",
+ "driverVersion": message.payload.get("version").cloned().unwrap_or_else(|| json!("1.0.0")),
+ "transport": "stdio_jsonl"
+ }),
+ None,
+ None,
+ )?;
+ break;
+ }
+ return Err(Phase2Error::invalid(
+ "fake harness emitted data before ready",
+ ));
+ }
+ Ok(ProcessOutput::Stderr(line)) => self.logs.push(format!("stderr: {line}")),
+ Ok(ProcessOutput::StdoutClosed) => {
+ return Err(Phase2Error::invalid("fake harness exited before ready"));
+ }
+ Ok(ProcessOutput::StderrClosed) => {}
+ Err(RecvTimeoutError::Timeout) => {
+ return Err(Phase2Error::invalid("fake harness ready timed out"));
+ }
+ Err(RecvTimeoutError::Disconnected) => {
+ return Err(Phase2Error::invalid("fake harness output channel closed"));
+ }
+ }
+ }
+ self.harness = Some(harness);
+ Ok(())
+ }
+
+ fn handle_command(&mut self, command: RunnerCommand) -> Result<(), Phase2Error> {
+ if command.schema != RUNNER_COMMAND_SCHEMA {
+ self.command_receipt(&command, "rejected", "unsupported command schema")?;
+ return Ok(());
+ }
+ let canonical = canonical_json(&serde_json::to_value(&command).map_err(|error| {
+ Phase2Error::invalid(format!("command canonicalization failed: {error}"))
+ })?);
+ if let Some(previous) = self.commands.get(&command.command_id) {
+ if previous == &canonical {
+ self.command_receipt(&command, "duplicate", "equivalent command already applied")?;
+ } else {
+ self.command_receipt(
+ &command,
+ "rejected",
+ "commandId was reused with different data",
+ )?;
+ }
+ return Ok(());
+ }
+ if command.controller_seq != self.next_controller_seq {
+ self.command_receipt(&command, "rejected", "controllerSeq is not contiguous")?;
+ return Ok(());
+ }
+ self.commands.insert(command.command_id.clone(), canonical);
+ self.next_controller_seq += 1;
+
+ match command.command_type.as_str() {
+ "run.prepare" => {
+ if self.harness.is_some() {
+ self.command_receipt(&command, "rejected", "run is already prepared")?;
+ return Ok(());
+ }
+ self.start_harness()?;
+ }
+ "session.open" => {
+ self.emit_event("session.starting", json!({}), None, None)?;
+ self.send_harness_command(&command)?;
+ }
+ "turn.start" => {
+ self.emit_event(
+ "turn.submitted",
+ json!({ "text": command.payload.get("text").cloned().unwrap_or(Value::Null) }),
+ command.payload.get("turnId").and_then(Value::as_str),
+ None,
+ )?;
+ self.send_harness_command(&command)?;
+ }
+ "request.resolve" | "turn.interrupt" | "turn.stop" | "session.close" | "run.cancel" => {
+ self.send_harness_command(&command)?;
+ }
+ unsupported => {
+ self.command_receipt(
+ &command,
+ "rejected",
+ &format!("command {unsupported} is not implemented in Phase 2"),
+ )?;
+ return Ok(());
+ }
+ }
+ self.command_receipt(&command, "accepted", "command applied")
+ }
+
+ fn send_harness_command(&mut self, command: &RunnerCommand) -> Result<(), Phase2Error> {
+ let harness = self
+ .harness
+ .as_mut()
+ .ok_or_else(|| Phase2Error::invalid("run.prepare must start the harness first"))?;
+ harness.send(&HarnessCommand {
+ schema: HARNESS_COMMAND_SCHEMA.to_owned(),
+ command_id: command.command_id.clone(),
+ command_type: command.command_type.clone(),
+ payload: command.payload.clone(),
+ })
+ }
+
+ fn drain_harness_output(&mut self) -> Result<(), Phase2Error> {
+ loop {
+ let output = match self.harness.as_ref().map(SupervisedProcess::try_recv) {
+ Some(Ok(output)) => output,
+ Some(Err(mpsc::TryRecvError::Empty)) | None => return Ok(()),
+ Some(Err(mpsc::TryRecvError::Disconnected)) => {
+ self.stdout_closed = true;
+ return Ok(());
+ }
+ };
+ match output {
+ ProcessOutput::Stdout(line) => self.handle_harness_line(&line)?,
+ ProcessOutput::Stderr(line) => self.logs.push(format!("stderr: {line}")),
+ ProcessOutput::StdoutClosed => self.stdout_closed = true,
+ ProcessOutput::StderrClosed => {}
+ }
+ }
+ }
+
+ fn handle_harness_line(&mut self, line: &str) -> Result<(), Phase2Error> {
+ let message = parse_harness_message(line)?;
+ match message.message_type.as_str() {
+ "event" => {
+ let event_type = message
+ .payload
+ .get("eventType")
+ .and_then(Value::as_str)
+ .ok_or_else(|| Phase2Error::invalid("harness event requires eventType"))?
+ .to_owned();
+ let turn_id = message
+ .payload
+ .get("turnId")
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ let item_id = message
+ .payload
+ .get("itemId")
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ let payload = message
+ .payload
+ .get("payload")
+ .cloned()
+ .unwrap_or_else(|| json!({}));
+ self.emit_event(&event_type, payload, turn_id.as_deref(), item_id.as_deref())?;
+ }
+ "request" => {
+ let request = message
+ .payload
+ .get("request")
+ .cloned()
+ .ok_or_else(|| Phase2Error::invalid("harness request requires request"))?;
+ self.emit_event(
+ "runtime_request.created",
+ json!({ "request": request }),
+ None,
+ None,
+ )?;
+ }
+ "result" => {
+ let result = message
+ .payload
+ .get("result")
+ .cloned()
+ .ok_or_else(|| Phase2Error::invalid("harness result requires result"))?;
+ if self.semantic_result.is_none() {
+ self.semantic_result = Some(result.clone());
+ self.emit_event("run.result.proposed", result, None, None)?;
+ } else {
+ self.emit_event(
+ "harness.diagnostic",
+ json!({ "code": "duplicate_semantic_result_ignored" }),
+ None,
+ None,
+ )?;
+ }
+ }
+ "terminal" => {
+ let terminal =
+ message.payload.get("terminal").cloned().ok_or_else(|| {
+ Phase2Error::invalid("harness terminal requires terminal")
+ })?;
+ if self.pending_terminal.is_none() {
+ self.pending_terminal = Some(terminal);
+ } else {
+ self.emit_event(
+ "harness.diagnostic",
+ json!({ "code": "duplicate_terminal_ignored" }),
+ None,
+ None,
+ )?;
+ }
+ }
+ "log" => {
+ let stream = message
+ .payload
+ .get("stream")
+ .and_then(Value::as_str)
+ .unwrap_or("stdout");
+ let text = message
+ .payload
+ .get("text")
+ .and_then(Value::as_str)
+ .unwrap_or_default();
+ self.logs.push(format!("{stream}: {text}"));
+ }
+ "diagnostic" => {
+ self.logs.push(format!("diagnostic: {}", message.payload));
+ }
+ "ready" => {
+ self.diagnostic("warning", "duplicate harness ready message ignored")?;
+ }
+ unsupported => {
+ return Err(Phase2Error::invalid(format!(
+ "unsupported fake harness message {unsupported} at sequence {}",
+ message.message_seq
+ )));
+ }
+ }
+ Ok(())
+ }
+
+ fn finish_harness(&mut self) -> Result<(), Phase2Error> {
+ let exit = self
+ .harness
+ .as_mut()
+ .ok_or_else(|| Phase2Error::invalid("harness was not started"))?
+ .wait()?;
+ self.finalize_harness(exit)
+ }
+
+ fn terminate_harness(&mut self) -> Result<(), Phase2Error> {
+ let exit = self
+ .harness
+ .as_mut()
+ .ok_or_else(|| Phase2Error::invalid("harness was not started"))?
+ .terminate_group()?;
+ self.finalize_harness(exit)
+ }
+
+ fn finalize_harness(&mut self, exit: ProcessExitFact) -> Result<(), Phase2Error> {
+ self.emit_event(
+ "harness.exited",
+ json!({
+ "processExit": exit,
+ "semanticResultObserved": self.semantic_result.is_some(),
+ "logs": self.logs.snapshot(),
+ }),
+ None,
+ None,
+ )?;
+ let terminal = self.pending_terminal.take().unwrap_or_else(|| {
+ json!({
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "failed",
+ "runTerminalState": "failed",
+ "reportedWorkDisposition": "yielded"
+ })
+ });
+ self.emit_event("run.terminal", terminal, None, None)
+ }
+}
+
+pub fn run_local_runner(config: RunnerConfig) -> Result<(), Phase2Error> {
+ let (command_sender, command_receiver) = mpsc::channel();
+ thread::spawn(move || {
+ let stdin = std::io::stdin();
+ for line in stdin.lock().lines() {
+ let line = match line {
+ Ok(line) => line,
+ Err(_) => break,
+ };
+ if line.trim().is_empty() {
+ continue;
+ }
+ let command = serde_json::from_str::(&line);
+ if command_sender.send(command).is_err() {
+ break;
+ }
+ }
+ });
+
+ let mut state = RunnerState::new(config);
+ loop {
+ match command_receiver.recv_timeout(Duration::from_millis(2)) {
+ Ok(Ok(command)) => state.handle_command(command)?,
+ Ok(Err(error)) => {
+ state.diagnostic("error", &format!("invalid command JSON: {error}"))?
+ }
+ Err(RecvTimeoutError::Timeout) => {}
+ Err(RecvTimeoutError::Disconnected) => {
+ if state.harness.is_none() {
+ return Err(Phase2Error::invalid("controller closed before run.prepare"));
+ }
+ state.terminate_harness()?;
+ return Ok(());
+ }
+ }
+ state.drain_harness_output()?;
+ if state.stdout_closed {
+ state.finish_harness()?;
+ return Ok(());
+ }
+ }
+}
+
+fn parse_harness_message(line: &str) -> Result {
+ let message: HarnessMessage = serde_json::from_str(line).map_err(|error| {
+ Phase2Error::invalid(format!("fake harness emitted invalid JSONL: {error}"))
+ })?;
+ if message.schema != HARNESS_MESSAGE_SCHEMA {
+ return Err(Phase2Error::invalid(
+ "fake harness message schema is unsupported",
+ ));
+ }
+ Ok(message)
+}
+
+fn event_priority(event_type: &str) -> u8 {
+ match event_type {
+ "run.result.proposed"
+ | "run.terminal"
+ | "turn.completed"
+ | "turn.failed"
+ | "turn.interrupted"
+ | "harness.exited" => 0,
+ "item.delta" | "harness.diagnostic" | "runner.diagnostic" => 2,
+ _ => 1,
+ }
+}
+
+fn canonical_json(value: &Value) -> String {
+ match value {
+ Value::Array(values) => format!(
+ "[{}]",
+ values
+ .iter()
+ .map(canonical_json)
+ .collect::>()
+ .join(",")
+ ),
+ Value::Object(values) => {
+ let mut keys = values.keys().collect::>();
+ keys.sort();
+ format!(
+ "{{{}}}",
+ keys.into_iter()
+ .map(|key| format!(
+ "{}:{}",
+ serde_json::to_string(key).expect("JSON object key should serialize"),
+ canonical_json(&values[key])
+ ))
+ .collect::>()
+ .join(",")
+ )
+ }
+ _ => value.to_string(),
+ }
+}
+
+pub fn load_fake_harness_script(path: &Path) -> Result {
+ let input = std::fs::read_to_string(path).map_err(|error| {
+ Phase2Error::invalid(format!(
+ "failed to read fake harness script {}: {error}",
+ path.display()
+ ))
+ })?;
+ let script: FakeHarnessScript = serde_json::from_str(&input).map_err(|error| {
+ Phase2Error::invalid(format!("fake harness script must be valid JSON: {error}"))
+ })?;
+ if script.schema != "paperclip.fake_harness.script.v1" {
+ return Err(Phase2Error::invalid(
+ "fake harness script schema is unsupported",
+ ));
+ }
+ if script.name.trim().is_empty() || script.steps.is_empty() {
+ return Err(Phase2Error::invalid(
+ "fake harness script requires a name and at least one step",
+ ));
+ }
+ Ok(script)
+}
+
+pub fn run_fake_harness(
+ script: FakeHarnessScript,
+ delay_override_ms: Option,
+) -> Result {
+ let (command_sender, command_receiver) = mpsc::channel::();
+ thread::spawn(move || {
+ let stdin = std::io::stdin();
+ for line in stdin.lock().lines().map_while(Result::ok) {
+ if line.trim().is_empty() {
+ continue;
+ }
+ let command = match serde_json::from_str::(&line) {
+ Ok(command) => command,
+ Err(_) => continue,
+ };
+ if command_sender.send(command).is_err() {
+ return;
+ }
+ }
+ });
+
+ let mut output = FakeHarnessOutput::default();
+ output.send(
+ "ready",
+ json!({ "version": "1.0.0", "script": script.name }),
+ )?;
+ receive_harness_command(&command_receiver, "session.open", None)?;
+ output.send_event(
+ "session.started",
+ None,
+ None,
+ json!({ "driverSessionId": "driver_phase2_fake" }),
+ )?;
+ let turn = receive_harness_command(&command_receiver, "turn.start", None)?;
+ let turn_id = turn
+ .payload
+ .get("turnId")
+ .and_then(Value::as_str)
+ .unwrap_or("turn_phase2")
+ .to_owned();
+ output.send_event("turn.started", Some(&turn_id), None, json!({}))?;
+
+ let mut workers = Vec::::new();
+ for step in script.steps {
+ match step {
+ FakeHarnessStep::Delay { milliseconds } => {
+ thread::sleep(Duration::from_millis(
+ delay_override_ms.unwrap_or(milliseconds),
+ ));
+ }
+ FakeHarnessStep::Log { stream, text } => {
+ output.send("log", json!({ "stream": stream, "text": text }))?;
+ }
+ FakeHarnessStep::Event {
+ event_type,
+ turn_id: step_turn_id,
+ item_id,
+ payload,
+ } => {
+ output.send_event(
+ &event_type,
+ step_turn_id.as_deref().or(Some(&turn_id)),
+ item_id.as_deref(),
+ payload,
+ )?;
+ }
+ FakeHarnessStep::Request { request } => {
+ output.send("request", json!({ "request": request }))?;
+ }
+ FakeHarnessStep::AwaitResolution { request_id } => {
+ let command = receive_harness_command(
+ &command_receiver,
+ "request.resolve",
+ Some(&request_id),
+ )?;
+ output.send_event(
+ "runtime_request.resolved",
+ Some(&turn_id),
+ None,
+ json!({
+ "requestId": request_id,
+ "response": command.payload.get("response").cloned().unwrap_or(Value::Null)
+ }),
+ )?;
+ }
+ FakeHarnessStep::AwaitInterrupt => {
+ let command = receive_harness_command(&command_receiver, "turn.interrupt", None)?;
+ output.send_event(
+ "turn.interrupted",
+ Some(&turn_id),
+ None,
+ json!({ "reason": command.payload.get("reason").cloned().unwrap_or_else(|| json!("operator")) }),
+ )?;
+ }
+ FakeHarnessStep::Result { payload } => {
+ output.send("result", json!({ "result": payload }))?;
+ }
+ FakeHarnessStep::Terminal { payload } => {
+ output.send("terminal", json!({ "terminal": payload }))?;
+ }
+ FakeHarnessStep::SpawnWorker => {
+ let worker = Command::new("sh")
+ .args(["-c", "sleep 60"])
+ .env_clear()
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ .map_err(|error| {
+ Phase2Error::invalid(format!("failed to spawn fake worker: {error}"))
+ })?;
+ output.send(
+ "diagnostic",
+ json!({ "workerPid": worker.id(), "purpose": "cleanup_conformance" }),
+ )?;
+ workers.push(worker);
+ }
+ FakeHarnessStep::Exit { code } => {
+ drop(workers);
+ return Ok(code);
+ }
+ }
+ }
+ drop(workers);
+ Ok(0)
+}
+
+fn receive_harness_command(
+ receiver: &Receiver,
+ command_type: &str,
+ request_id: Option<&str>,
+) -> Result {
+ loop {
+ let command = receiver
+ .recv_timeout(Duration::from_secs(10))
+ .map_err(|_| Phase2Error::invalid(format!("timed out waiting for {command_type}")))?;
+ if command.schema != HARNESS_COMMAND_SCHEMA {
+ continue;
+ }
+ if command.command_type != command_type {
+ continue;
+ }
+ if let Some(request_id) = request_id {
+ if command.payload.get("requestId").and_then(Value::as_str) != Some(request_id) {
+ continue;
+ }
+ }
+ return Ok(command);
+ }
+}
+
+#[derive(Default)]
+struct FakeHarnessOutput {
+ sequence: u64,
+}
+
+impl FakeHarnessOutput {
+ fn send(&mut self, message_type: &str, payload: Value) -> Result<(), Phase2Error> {
+ self.sequence += 1;
+ let message = json!({
+ "schema": HARNESS_MESSAGE_SCHEMA,
+ "messageSeq": self.sequence,
+ "type": message_type,
+ "payload": payload,
+ });
+ let stdout = std::io::stdout();
+ let mut lock = stdout.lock();
+ serde_json::to_writer(&mut lock, &message).map_err(|error| {
+ Phase2Error::invalid(format!("harness message serialization failed: {error}"))
+ })?;
+ lock.write_all(b"\n")
+ .and_then(|_| lock.flush())
+ .map_err(|error| Phase2Error::invalid(format!("harness message write failed: {error}")))
+ }
+
+ fn send_event(
+ &mut self,
+ event_type: &str,
+ turn_id: Option<&str>,
+ item_id: Option<&str>,
+ payload: Value,
+ ) -> Result<(), Phase2Error> {
+ self.send(
+ "event",
+ json!({
+ "eventType": event_type,
+ "turnId": turn_id,
+ "itemId": item_id,
+ "payload": payload,
+ }),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bounded_logs_keep_only_the_tail() {
+ let mut logs = BoundedLogBuffer::new(2, 12);
+ logs.push("first");
+ logs.push("second");
+ logs.push("third");
+
+ assert_eq!(logs.snapshot().lines, vec!["second", "third"]);
+ assert_eq!(logs.snapshot().dropped_lines, 1);
+ assert!(logs.snapshot().retained_bytes <= 12);
+ }
+
+ #[test]
+ fn canonical_json_sorts_object_keys_without_reordering_arrays() {
+ assert_eq!(
+ canonical_json(&json!({ "z": [2, 1], "a": { "b": true } })),
+ r#"{"a":{"b":true},"z":[2,1]}"#
+ );
+ }
+
+ #[test]
+ fn all_phase2_scripts_load() {
+ let scripts = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../../protocol/fixtures/phase-02/scripts");
+ for name in [
+ "happy-path",
+ "permission-input",
+ "interrupted",
+ "error",
+ "duplicate-terminal",
+ "linger",
+ ] {
+ let script = load_fake_harness_script(&scripts.join(format!("{name}.json")))
+ .expect("Phase 2 script should load");
+ assert_eq!(script.name, name);
+ }
+ }
+}
diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/phase2_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/phase2_supervisor.rs
new file mode 100644
index 00000000000..2b016953190
--- /dev/null
+++ b/packages/paperclip-runner/runner/crates/runner-core/tests/phase2_supervisor.rs
@@ -0,0 +1,82 @@
+use std::path::PathBuf;
+use std::process::Command;
+use std::process::Stdio;
+use std::time::Duration;
+
+use paperclip_runner_core::phase2::{HarnessCommand, SupervisedProcess};
+use serde_json::{json, Value};
+
+fn process_exists(pid: u64) -> bool {
+ Command::new("kill")
+ .args(["-0", &pid.to_string()])
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .is_ok_and(|status| status.success())
+}
+
+#[test]
+fn process_group_cleanup_stops_harness_and_worker() {
+ let harness = PathBuf::from(env!("CARGO_BIN_EXE_fake-harness"));
+ let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../../../protocol/fixtures/phase-02/scripts/linger.json");
+ let mut process = SupervisedProcess::spawn(
+ &harness,
+ &[
+ "--script".to_owned(),
+ script.display().to_string(),
+ "--delay-ms".to_owned(),
+ "1".to_owned(),
+ ],
+ Duration::from_millis(50),
+ )
+ .expect("fake harness should start");
+ let harness_pid = process.id();
+ process
+ .receive_stdout_line(Duration::from_secs(1))
+ .expect("ready line should be readable")
+ .expect("ready line should exist");
+ process
+ .send(&HarnessCommand {
+ schema: "paperclip.fake_harness.command.v1".to_owned(),
+ command_id: "open".to_owned(),
+ command_type: "session.open".to_owned(),
+ payload: json!({}),
+ })
+ .expect("session.open should send");
+ process
+ .receive_stdout_line(Duration::from_secs(1))
+ .expect("session line should be readable")
+ .expect("session line should exist");
+ process
+ .send(&HarnessCommand {
+ schema: "paperclip.fake_harness.command.v1".to_owned(),
+ command_id: "turn".to_owned(),
+ command_type: "turn.start".to_owned(),
+ payload: json!({ "turnId": "turn_cleanup" }),
+ })
+ .expect("turn.start should send");
+
+ let mut worker_pid = None;
+ for _ in 0..5 {
+ let line = process
+ .receive_stdout_line(Duration::from_secs(1))
+ .expect("harness output should be readable")
+ .expect("harness output should continue");
+ let message: Value = serde_json::from_str(&line).expect("harness output should be JSON");
+ if message["type"] == "diagnostic" {
+ worker_pid = message["payload"]["workerPid"].as_u64();
+ break;
+ }
+ }
+ let worker_pid = worker_pid.expect("linger script should report its worker pid");
+ assert!(process_exists(u64::from(harness_pid)));
+ assert!(process_exists(worker_pid));
+
+ process
+ .terminate_group()
+ .expect("process group cleanup should finish");
+ std::thread::sleep(Duration::from_millis(20));
+ assert!(!process_exists(u64::from(harness_pid)));
+ assert!(!process_exists(worker_pid));
+}
diff --git a/packages/paperclip-runner/scripts/phase2-browser-server.mjs b/packages/paperclip-runner/scripts/phase2-browser-server.mjs
new file mode 100644
index 00000000000..338007f0545
--- /dev/null
+++ b/packages/paperclip-runner/scripts/phase2-browser-server.mjs
@@ -0,0 +1,150 @@
+import { randomUUID } from "node:crypto";
+
+function readJson(request) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ request.on("data", (chunk) => chunks.push(chunk));
+ request.on("end", () => {
+ try {
+ resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString("utf8")));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ request.on("error", reject);
+ });
+}
+
+function json(response, status, value) {
+ response.statusCode = status;
+ response.setHeader("content-type", "application/json; charset=utf-8");
+ response.end(`${JSON.stringify(value)}\n`);
+}
+
+function writeRecord(response, value) {
+ response.write(`${JSON.stringify(value)}\n`);
+}
+
+export function phase2BrowserServerPlugin() {
+ const runs = new Map();
+
+ async function middleware(request, response, next) {
+ const url = new URL(request.url ?? "/", "http://phase2.local");
+ if (!url.pathname.startsWith("/api/phase2/")) {
+ next();
+ return;
+ }
+
+ const runnerModuleUrl = new URL(
+ "../dist/mock-core/phase2-local-runner.js",
+ import.meta.url,
+ ).href;
+ const runner = await import(runnerModuleUrl);
+ if (request.method === "POST" && url.pathname === "/api/phase2/runs") {
+ try {
+ const body = await readJson(request);
+ const id = randomUUID();
+ const history = [];
+ const clients = new Set();
+ const publish = (record) => {
+ history.push(record);
+ for (const client of clients) {
+ writeRecord(client, record);
+ }
+ };
+ const handle = await runner.startPhase2Scenario({
+ scenario: body.scenario,
+ delayMs: 30,
+ onEvent(event) {
+ publish({ kind: "event", event });
+ },
+ onDiagnostic(message) {
+ publish({ kind: "diagnostic", message });
+ },
+ });
+ const entry = { id, handle, history, clients, finished: false, trace: null };
+ runs.set(id, entry);
+ handle.completion.then(
+ (trace) => {
+ entry.trace = trace;
+ entry.finished = true;
+ publish({ kind: "trace", trace });
+ for (const client of clients) {
+ client.end();
+ }
+ clients.clear();
+ },
+ (error) => {
+ entry.finished = true;
+ publish({ kind: "error", message: String(error) });
+ for (const client of clients) {
+ client.end();
+ }
+ clients.clear();
+ },
+ );
+ json(response, 201, { id, metadata: handle.metadata });
+ } catch (error) {
+ json(response, 400, { error: String(error) });
+ }
+ return;
+ }
+
+ const match = url.pathname.match(/^\/api\/phase2\/runs\/([^/]+)(?:\/(events|interrupt|resolve))?$/);
+ if (match === null) {
+ json(response, 404, { error: "Phase 2 route not found" });
+ return;
+ }
+ const entry = runs.get(match[1]);
+ if (entry === undefined) {
+ json(response, 404, { error: "Phase 2 run not found" });
+ return;
+ }
+ const action = match[2];
+ if (request.method === "GET" && action === "events") {
+ response.statusCode = 200;
+ response.setHeader("content-type", "application/x-ndjson; charset=utf-8");
+ response.setHeader("cache-control", "no-store");
+ for (const record of entry.history) {
+ writeRecord(response, record);
+ }
+ if (entry.finished) {
+ response.end();
+ } else {
+ entry.clients.add(response);
+ request.on("close", () => entry.clients.delete(response));
+ }
+ return;
+ }
+ if (request.method === "POST" && action === "interrupt") {
+ try {
+ const receipt = await entry.handle.interrupt("browser_operator");
+ json(response, 200, receipt);
+ } catch (error) {
+ json(response, 409, { error: String(error) });
+ }
+ return;
+ }
+ if (request.method === "POST" && action === "resolve") {
+ try {
+ const body = await readJson(request);
+ const receipt = await entry.handle.resolveRequest(body.requestId, body.response ?? {});
+ json(response, 200, receipt);
+ } catch (error) {
+ json(response, 409, { error: String(error) });
+ }
+ return;
+ }
+ json(response, 405, { error: "Method not allowed" });
+ }
+
+ return {
+ name: "paperclip-runner-phase2-live-server",
+ configureServer(server) {
+ server.middlewares.use(middleware);
+ },
+ configurePreviewServer(server) {
+ server.middlewares.use(middleware);
+ },
+ };
+}
diff --git a/packages/paperclip-runner/src/cli/phase2-mock-core.ts b/packages/paperclip-runner/src/cli/phase2-mock-core.ts
new file mode 100644
index 00000000000..6c23df44f5f
--- /dev/null
+++ b/packages/paperclip-runner/src/cli/phase2-mock-core.ts
@@ -0,0 +1,50 @@
+import { writeFile } from "node:fs/promises";
+
+import {
+ PHASE2_SCENARIOS,
+ phase2TraceAsFixture,
+ runPhase2Scenario,
+ type Phase2Scenario,
+} from "../mock-core/phase2-local-runner.js";
+
+function argument(name: string): string | undefined {
+ const index = process.argv.indexOf(name);
+ return index === -1 ? undefined : process.argv[index + 1];
+}
+
+const scenarioInput = argument("--scenario") ?? "happy-path";
+if (!PHASE2_SCENARIOS.includes(scenarioInput as Phase2Scenario)) {
+ process.stderr.write(
+ `Unknown scenario ${scenarioInput}. Choose ${PHASE2_SCENARIOS.join(", ")}.\n`,
+ );
+ process.exitCode = 1;
+} else {
+ const scenario = scenarioInput as Phase2Scenario;
+ const trace = await runPhase2Scenario({
+ scenario,
+ duplicateTurnCommand: process.argv.includes("--duplicate-turn-command"),
+ onEvent(event) {
+ if (!process.argv.includes("--quiet")) {
+ process.stdout.write(
+ `${String(event.sourceSeq).padStart(2, "0")} ${event.eventType}\n`,
+ );
+ }
+ },
+ });
+ const outputPath = argument("--output");
+ if (outputPath !== undefined) {
+ await writeFile(outputPath, `${JSON.stringify(phase2TraceAsFixture(trace), null, 2)}\n`);
+ }
+ process.stdout.write(
+ `${JSON.stringify({
+ schema: "paperclip.runner.phase2.summary.v1",
+ scenario,
+ eventCount: trace.events.length,
+ terminalCount: trace.events.filter((event) => event.eventType === "run.terminal").length,
+ semanticResult: trace.result?.reportedWorkDisposition ?? null,
+ harnessProcessExit: trace.harnessProcessExit,
+ runnerProcessExit: trace.runnerProcessExit,
+ outputPath: outputPath ?? null,
+ })}\n`,
+ );
+}
diff --git a/packages/paperclip-runner/src/contracts/phase2.ts b/packages/paperclip-runner/src/contracts/phase2.ts
new file mode 100644
index 00000000000..29478157c9b
--- /dev/null
+++ b/packages/paperclip-runner/src/contracts/phase2.ts
@@ -0,0 +1,51 @@
+import type {
+ PrpCapabilities,
+ PrpCommand,
+ PrpEvent,
+ PrpIdentity,
+ PrpRequest,
+ PrpStructuredRunResult,
+} from "../protocol/phase1-contract.js";
+
+export const PHASE2_SCENARIOS = [
+ "happy-path",
+ "permission-input",
+ "interrupted",
+ "error",
+ "duplicate-terminal",
+] as const;
+
+export type Phase2Scenario = (typeof PHASE2_SCENARIOS)[number];
+
+export interface Phase2CommandReceipt {
+ commandId: string;
+ controllerSeq: number;
+ status: "accepted" | "duplicate" | "rejected";
+ detail: string;
+}
+
+export interface Phase2ProcessExitFact {
+ exitCode: number | null;
+ success: boolean;
+ signal: number | null;
+}
+
+export interface Phase2RunMetadata {
+ schema: "paperclip.runner.phase2.metadata.v1";
+ scenario: Phase2Scenario;
+ fixtureName: string;
+ identity: PrpIdentity;
+ capabilities: PrpCapabilities;
+}
+
+export interface Phase2RunTrace extends Omit {
+ schema: "paperclip.runner.phase2.trace.v1";
+ commands: PrpCommand[];
+ commandReceipts: Phase2CommandReceipt[];
+ events: PrpEvent[];
+ requests: PrpRequest[];
+ result: PrpStructuredRunResult | null;
+ harnessProcessExit: Phase2ProcessExitFact | null;
+ runnerProcessExit: { code: number | null; signal: string | null };
+ runnerDiagnostics: string[];
+}
diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts
index 3412abcef45..ce3ed5a2459 100644
--- a/packages/paperclip-runner/src/index.ts
+++ b/packages/paperclip-runner/src/index.ts
@@ -1,11 +1,14 @@
export * from "./contracts/control-plane-port.js";
export * from "./contracts/harness-driver.js";
export * from "./contracts/native-session-backend.js";
+export * from "./contracts/phase2.js";
export * from "./contracts/types.js";
export * from "./mock-core/mock-control-plane-adapter.js";
+export * from "./mock-core/phase2-local-runner.js";
export * from "./protocol/phase0-fixture.js";
export * from "./protocol/phase1-contract.js";
export * from "./protocol/phase1-loader.js";
export * from "./reducer/session-reducer.js";
export * from "./tracer/phase0-runner.js";
export * from "./tracer/phase1-replay.js";
+export * from "./tracer/phase2-live.js";
diff --git a/packages/paperclip-runner/src/mock-core/phase2-local-runner.test.ts b/packages/paperclip-runner/src/mock-core/phase2-local-runner.test.ts
new file mode 100644
index 00000000000..a5b8b154405
--- /dev/null
+++ b/packages/paperclip-runner/src/mock-core/phase2-local-runner.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ applyPhase2LiveEvent,
+ createPhase2LiveSnapshot,
+ phase2SnapshotsMatch,
+ replayPhase2Events,
+} from "../tracer/phase2-live.js";
+import {
+ phase2Internals,
+ phase2TraceAsFixture,
+ runPhase2Scenario,
+ startPhase2Scenario,
+} from "./phase2-local-runner.js";
+
+describe.sequential("Phase 2 local runner and fake harness", () => {
+ it("runs lifecycle, tool, file, structured-result, and process-exit events", async () => {
+ const trace = await runPhase2Scenario({ scenario: "happy-path", delayMs: 1 });
+ const eventTypes = trace.events.map((event) => event.eventType);
+
+ expect(eventTypes).toContain("harness.ready");
+ expect(eventTypes).toContain("item.completed");
+ expect(trace.events.some((event) => event.itemId === "item_phase2_file")).toBe(true);
+ expect(trace.result?.reportedWorkDisposition).toBe("done");
+ expect(trace.harnessProcessExit).toEqual({ exitCode: 0, success: true, signal: null });
+ expect(eventTypes.indexOf("run.result.proposed")).toBeLessThan(
+ eventTypes.indexOf("harness.exited"),
+ );
+ expect(eventTypes.indexOf("harness.exited")).toBeLessThan(
+ eventTypes.indexOf("run.terminal"),
+ );
+
+ const exitEvent = trace.events.find((event) => event.eventType === "harness.exited");
+ const logs = (exitEvent?.payload as Record).logs as {
+ lines: string[];
+ droppedLines: number;
+ retainedBytes: number;
+ };
+ expect(logs.lines).toHaveLength(3);
+ expect(logs.droppedLines).toBe(2);
+ expect(logs.retainedBytes).toBeLessThanOrEqual(256);
+ expect(() => phase2TraceAsFixture(trace)).not.toThrow();
+ });
+
+ it("deduplicates an equivalent turn command before driver side effects", async () => {
+ const trace = await runPhase2Scenario({
+ scenario: "happy-path",
+ delayMs: 5,
+ duplicateTurnCommand: true,
+ });
+
+ expect(trace.commandReceipts.filter((receipt) => receipt.status === "duplicate"))
+ .toHaveLength(1);
+ expect(trace.events.filter((event) => event.eventType === "turn.started")).toHaveLength(1);
+ expect(trace.events.filter((event) => event.eventType === "run.result.proposed"))
+ .toHaveLength(1);
+ });
+
+ it("round-trips permission and input requests before completion", async () => {
+ const trace = await runPhase2Scenario({
+ scenario: "permission-input",
+ delayMs: 1,
+ });
+
+ expect(trace.requests.map((request) => request.type)).toEqual(["permission", "input"]);
+ expect(trace.events.filter((event) => event.eventType === "runtime_request.created"))
+ .toHaveLength(2);
+ expect(trace.events.filter((event) => event.eventType === "runtime_request.resolved"))
+ .toHaveLength(2);
+ expect(trace.result?.summary).toContain("permission and input");
+ });
+
+ it("interrupts one turn and still emits one structured result and terminal", async () => {
+ const trace = await runPhase2Scenario({ scenario: "interrupted", delayMs: 1 });
+
+ expect(trace.events.filter((event) => event.eventType === "turn.interrupted"))
+ .toHaveLength(1);
+ expect(trace.events.filter((event) => event.eventType === "run.result.proposed"))
+ .toHaveLength(1);
+ expect(trace.events.filter((event) => event.eventType === "run.terminal"))
+ .toHaveLength(1);
+ expect(trace.harnessProcessExit?.exitCode).toBe(130);
+ expect(trace.result?.reportedWorkDisposition).toBe("yielded");
+ });
+
+ it("collapses duplicate terminal messages to one terminal event", async () => {
+ const trace = await runPhase2Scenario({
+ scenario: "duplicate-terminal",
+ delayMs: 1,
+ });
+
+ expect(trace.events.filter((event) => event.eventType === "run.terminal"))
+ .toHaveLength(1);
+ expect(
+ trace.events.some(
+ (event) =>
+ event.eventType === "harness.diagnostic" &&
+ (event.payload as Record).code ===
+ "duplicate_terminal_ignored",
+ ),
+ ).toBe(true);
+ });
+
+ it("keeps an error process exit distinct from its semantic result", async () => {
+ const trace = await runPhase2Scenario({ scenario: "error", delayMs: 1 });
+
+ expect(trace.harnessProcessExit).toEqual({ exitCode: 7, success: false, signal: null });
+ expect(trace.result?.summary).toContain("scripted error");
+ expect(
+ trace.events.find((event) => event.eventType === "run.terminal")?.payload,
+ ).toMatchObject({ runTerminalState: "failed" });
+ });
+
+ it("cleans up the harness process group when the controller closes", async () => {
+ const handle = await startPhase2Scenario({
+ scenario: "happy-path",
+ delayMs: 1_000,
+ });
+
+ handle.stop();
+ const trace = await handle.completion;
+
+ expect(trace.runnerProcessExit).toEqual({ code: 0, signal: null });
+ expect(trace.harnessProcessExit?.success).toBe(false);
+ expect(trace.events.filter((event) => event.eventType === "harness.exited"))
+ .toHaveLength(1);
+ expect(trace.events.filter((event) => event.eventType === "run.terminal"))
+ .toHaveLength(1);
+ });
+
+ it("produces the same snapshot for validated live events and replay", async () => {
+ const trace = await runPhase2Scenario({ scenario: "happy-path", delayMs: 1 });
+ let live = createPhase2LiveSnapshot(trace);
+ for (const event of trace.events) {
+ const next = applyPhase2LiveEvent(live, event);
+ expect(next.ok).toBe(true);
+ if (next.ok) {
+ live = next.snapshot;
+ }
+ }
+ const replay = replayPhase2Events(trace, trace.events);
+
+ expect(phase2SnapshotsMatch(live, replay)).toBe(true);
+ });
+
+ it("does not forward arbitrary parent credentials to runner processes", () => {
+ const environment = phase2Internals.runnerEnvironment();
+ expect(environment.PAPERCLIP_API_KEY).toBeUndefined();
+ expect(environment.OPENAI_API_KEY).toBeUndefined();
+ expect(environment.EMAIL_AGENTMAIL_GENERAL_API_KEY).toBeUndefined();
+ });
+});
diff --git a/packages/paperclip-runner/src/mock-core/phase2-local-runner.ts b/packages/paperclip-runner/src/mock-core/phase2-local-runner.ts
new file mode 100644
index 00000000000..145fc84f2b8
--- /dev/null
+++ b/packages/paperclip-runner/src/mock-core/phase2-local-runner.ts
@@ -0,0 +1,568 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import { access } from "node:fs/promises";
+import { delimiter, resolve } from "node:path";
+import { createInterface } from "node:readline";
+import { fileURLToPath } from "node:url";
+
+import type {
+ PrpCommand,
+ PrpEvent,
+ PrpFixture,
+ PrpRequest,
+ PrpStructuredRunResult,
+} from "../protocol/phase1-contract.js";
+import {
+ validatePrpEvent,
+ validatePrpFixture,
+} from "../protocol/phase1-contract.js";
+import {
+ PHASE2_SCENARIOS,
+ type Phase2CommandReceipt,
+ type Phase2ProcessExitFact,
+ type Phase2RunMetadata,
+ type Phase2RunTrace,
+ type Phase2Scenario,
+} from "../contracts/phase2.js";
+
+export {
+ PHASE2_SCENARIOS,
+ type Phase2CommandReceipt,
+ type Phase2ProcessExitFact,
+ type Phase2RunMetadata,
+ type Phase2RunTrace,
+ type Phase2Scenario,
+} from "../contracts/phase2.js";
+
+const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
+const executableSuffix = process.platform === "win32" ? ".exe" : "";
+const runnerBinary = resolve(
+ packageRoot,
+ `runner/target/debug/paperclip-runnerd${executableSuffix}`,
+);
+const fakeHarnessBinary = resolve(
+ packageRoot,
+ `runner/target/debug/fake-harness${executableSuffix}`,
+);
+const scriptRoot = resolve(packageRoot, "protocol/fixtures/phase-02/scripts");
+
+interface RunnerEventMessage {
+ kind: "event";
+ event: unknown;
+}
+
+interface RunnerCommandReceiptMessage {
+ kind: "command_receipt";
+ commandId: string;
+ controllerSeq: number;
+ status: Phase2CommandReceipt["status"];
+ detail: string;
+}
+
+interface RunnerDiagnosticMessage {
+ kind: "diagnostic";
+ level: string;
+ message: string;
+}
+
+type RunnerStreamMessage =
+ | RunnerEventMessage
+ | RunnerCommandReceiptMessage
+ | RunnerDiagnosticMessage;
+
+interface RunnerStreamEnvelope {
+ schema: "paperclip.runner.stream.v1";
+ message: RunnerStreamMessage;
+}
+
+interface ReceiptWaiter {
+ resolve: (receipt: Phase2CommandReceipt) => void;
+ reject: (error: Error) => void;
+ timer: NodeJS.Timeout;
+}
+
+interface EventWaiter {
+ predicate: (event: PrpEvent) => boolean;
+ resolve: (event: PrpEvent) => void;
+ reject: (error: Error) => void;
+ timer: NodeJS.Timeout;
+}
+
+export interface StartPhase2ScenarioOptions {
+ scenario: Phase2Scenario;
+ delayMs?: number;
+ onEvent?: (event: PrpEvent) => void;
+ onDiagnostic?: (message: string) => void;
+}
+
+export interface RunPhase2ScenarioOptions extends StartPhase2ScenarioOptions {
+ duplicateTurnCommand?: boolean;
+}
+
+export interface Phase2RunHandle {
+ metadata: Phase2RunMetadata;
+ completion: Promise;
+ interrupt(reason?: string): Promise;
+ resolveRequest(
+ requestId: string,
+ response: Record,
+ ): Promise;
+ waitForEvent(
+ predicate: (event: PrpEvent) => boolean,
+ timeoutMs?: number,
+ ): Promise;
+ sendDuplicateTurnCommand(): Promise;
+ stop(): void;
+}
+
+function runnerEnvironment(): NodeJS.ProcessEnv {
+ const environment: NodeJS.ProcessEnv = {};
+ for (const key of ["PATH", "SystemRoot", "WINDIR", "PATHEXT"]) {
+ const value = process.env[key];
+ if (value !== undefined) {
+ environment[key] = value;
+ }
+ }
+ return environment;
+}
+
+function metadata(scenario: Phase2Scenario): Phase2RunMetadata {
+ const suffix = scenario.replaceAll("-", "_");
+ return {
+ schema: "paperclip.runner.phase2.metadata.v1",
+ scenario,
+ fixtureName: `Phase 2 ${scenario}`,
+ identity: {
+ schema: "paperclip.prp.identity.v1",
+ companyId: "company_phase2",
+ issueId: `issue_phase2_${suffix}`,
+ runId: `run_phase2_${suffix}`,
+ environmentLeaseId: `lease_phase2_${suffix}`,
+ runnerInstanceId: "runner_phase2_local",
+ normalizedSessionId: `session_phase2_${suffix}`,
+ driverSessionId: "driver_phase2_fake",
+ },
+ capabilities: {
+ schema: "paperclip.prp.capabilities.v1",
+ sessionReusePolicy: "new_per_run",
+ driver: { kind: "fake", version: "1.0.0" },
+ steer: false,
+ interrupt: true,
+ resume: false,
+ runtimeRequests: true,
+ structuredResult: true,
+ typedEvents: true,
+ unsupported: ["steer", "resume"],
+ },
+ };
+}
+
+function processExitFromEvent(event: PrpEvent): Phase2ProcessExitFact | null {
+ if (event.eventType !== "harness.exited") {
+ return null;
+ }
+ const processExit = (event.payload as Record).processExit;
+ if (typeof processExit !== "object" || processExit === null) {
+ return null;
+ }
+ const fact = processExit as Record;
+ return {
+ exitCode: typeof fact.exitCode === "number" ? fact.exitCode : null,
+ success: fact.success === true,
+ signal: typeof fact.signal === "number" ? fact.signal : null,
+ };
+}
+
+class LocalRunnerController implements Phase2RunHandle {
+ readonly metadata: Phase2RunMetadata;
+ readonly completion: Promise;
+
+ #process: ChildProcessWithoutNullStreams;
+ #commands: PrpCommand[] = [];
+ #receipts: Phase2CommandReceipt[] = [];
+ #events: PrpEvent[] = [];
+ #requests: PrpRequest[] = [];
+ #diagnostics: string[] = [];
+ #receiptWaiters = new Map();
+ #eventWaiters: EventWaiter[] = [];
+ #nextControllerSeq = 1;
+ #turnCommand: PrpCommand | null = null;
+ #completionResolve!: (trace: Phase2RunTrace) => void;
+ #completionReject!: (error: Error) => void;
+ #settled = false;
+ #onEvent?: (event: PrpEvent) => void;
+ #onDiagnostic?: (message: string) => void;
+
+ private constructor(options: StartPhase2ScenarioOptions) {
+ this.metadata = metadata(options.scenario);
+ this.#onEvent = options.onEvent;
+ this.#onDiagnostic = options.onDiagnostic;
+ this.completion = new Promise((resolveCompletion, rejectCompletion) => {
+ this.#completionResolve = resolveCompletion;
+ this.#completionReject = rejectCompletion;
+ });
+
+ const scriptPath = resolve(scriptRoot, `${options.scenario}.json`);
+ const args = [
+ "--run-id",
+ this.metadata.identity.runId,
+ "--session-id",
+ this.metadata.identity.normalizedSessionId,
+ "--runner-id",
+ this.metadata.identity.runnerInstanceId,
+ "--fake-harness",
+ fakeHarnessBinary,
+ "--script",
+ scriptPath,
+ "--delay-ms",
+ String(options.delayMs ?? 10),
+ "--log-max-lines",
+ "3",
+ "--log-max-bytes",
+ "256",
+ "--shutdown-grace-ms",
+ "75",
+ ];
+ this.#process = spawn(runnerBinary, args, {
+ cwd: packageRoot,
+ env: runnerEnvironment(),
+ stdio: "pipe",
+ });
+ createInterface({ input: this.#process.stdout }).on("line", (line) => {
+ this.#handleLine(line);
+ });
+ createInterface({ input: this.#process.stderr }).on("line", (line) => {
+ this.#recordDiagnostic(`runner stderr: ${line}`);
+ });
+ this.#process.on("error", (error) => this.#fail(error));
+ this.#process.on("exit", (code, signal) => this.#finish(code, signal));
+ }
+
+ static async start(options: StartPhase2ScenarioOptions): Promise {
+ if (!PHASE2_SCENARIOS.includes(options.scenario)) {
+ throw new Error(`Unsupported Phase 2 scenario: ${options.scenario}`);
+ }
+ await Promise.all([access(runnerBinary), access(fakeHarnessBinary)]).catch(() => {
+ throw new Error(
+ `Phase 2 Rust binaries are missing. Run pnpm --filter @paperclipai/paperclip-runner build:rust first. PATH entries: ${(process.env.PATH ?? "").split(delimiter).length}.`,
+ );
+ });
+ const controller = new LocalRunnerController(options);
+ await controller.#initialize();
+ return controller;
+ }
+
+ async #initialize(): Promise {
+ await this.#sendCommand("run.prepare", { workspace: "standalone-fixture" });
+ await this.#sendCommand("session.open", { reuse: "new_per_run" });
+ this.#turnCommand = this.#makeCommand("turn.start", {
+ turnId: `turn_${this.metadata.identity.runId}`,
+ text: `Run the ${this.metadata.scenario} fake-driver scenario.`,
+ });
+ await this.#writeCommand(this.#turnCommand, true);
+ }
+
+ async interrupt(reason = "operator_requested"): Promise {
+ return this.#sendCommand("turn.interrupt", {
+ turnId: `turn_${this.metadata.identity.runId}`,
+ reason,
+ });
+ }
+
+ async resolveRequest(
+ requestId: string,
+ response: Record,
+ ): Promise {
+ return this.#sendCommand("request.resolve", { requestId, response });
+ }
+
+ waitForEvent(
+ predicate: (event: PrpEvent) => boolean,
+ timeoutMs = 3_000,
+ ): Promise {
+ const existing = this.#events.find(predicate);
+ if (existing !== undefined) {
+ return Promise.resolve(existing);
+ }
+ return new Promise((resolveEvent, rejectEvent) => {
+ const waiter: EventWaiter = {
+ predicate,
+ resolve: resolveEvent,
+ reject: rejectEvent,
+ timer: setTimeout(() => {
+ this.#eventWaiters = this.#eventWaiters.filter(
+ (candidate) => candidate !== waiter,
+ );
+ rejectEvent(new Error("Timed out waiting for a Phase 2 runner event"));
+ }, timeoutMs),
+ };
+ this.#eventWaiters.push(waiter);
+ });
+ }
+
+ async sendDuplicateTurnCommand(): Promise {
+ if (this.#turnCommand === null) {
+ throw new Error("turn.start has not been sent");
+ }
+ return this.#writeCommand(this.#turnCommand, false);
+ }
+
+ stop(): void {
+ if (!this.#settled) {
+ this.#process.stdin.end();
+ }
+ }
+
+ #makeCommand(type: PrpCommand["type"], payload: Record): PrpCommand {
+ const controllerSeq = this.#nextControllerSeq;
+ this.#nextControllerSeq += 1;
+ return {
+ schema: "paperclip.prp.command.v1",
+ commandId: `command_${this.metadata.scenario.replaceAll("-", "_")}_${controllerSeq}`,
+ controllerSeq,
+ type,
+ issuedAt: `2026-08-07T20:45:${String(controllerSeq).padStart(2, "0")}.000Z`,
+ payload,
+ };
+ }
+
+ #sendCommand(
+ type: PrpCommand["type"],
+ payload: Record,
+ ): Promise {
+ return this.#writeCommand(this.#makeCommand(type, payload), true);
+ }
+
+ #writeCommand(
+ command: PrpCommand,
+ recordCommand: boolean,
+ ): Promise {
+ if (recordCommand) {
+ this.#commands.push(structuredClone(command));
+ }
+ return new Promise((resolveReceipt, rejectReceipt) => {
+ const timer = setTimeout(() => {
+ this.#receiptWaiters.delete(command.commandId);
+ rejectReceipt(new Error(`Timed out waiting for ${command.commandId}`));
+ }, 3_000);
+ this.#receiptWaiters.set(command.commandId, {
+ resolve: resolveReceipt,
+ reject: rejectReceipt,
+ timer,
+ });
+ this.#process.stdin.write(`${JSON.stringify(command)}\n`, (error) => {
+ if (error) {
+ clearTimeout(timer);
+ this.#receiptWaiters.delete(command.commandId);
+ rejectReceipt(error);
+ }
+ });
+ });
+ }
+
+ #handleLine(line: string): void {
+ let envelope: RunnerStreamEnvelope;
+ try {
+ envelope = JSON.parse(line) as RunnerStreamEnvelope;
+ } catch (error) {
+ this.#fail(new Error(`Runner emitted invalid JSONL: ${String(error)}`));
+ return;
+ }
+ if (envelope.schema !== "paperclip.runner.stream.v1") {
+ this.#fail(new Error("Runner emitted an unsupported stream envelope"));
+ return;
+ }
+ const message = envelope.message;
+ if (message.kind === "event") {
+ const validation = validatePrpEvent(message.event);
+ if (!validation.ok) {
+ this.#fail(
+ new Error(
+ `Runner event failed Phase 1 validation: ${validation.issues
+ .map((issue) => `${issue.path} ${issue.message}`)
+ .join("; ")}`,
+ ),
+ );
+ return;
+ }
+ this.#events.push(structuredClone(validation.event));
+ if (validation.event.eventType === "runtime_request.created") {
+ const request = (validation.event.payload as Record).request;
+ if (typeof request === "object" && request !== null) {
+ this.#requests.push(structuredClone(request as PrpRequest));
+ }
+ }
+ this.#onEvent?.(validation.event);
+ const resolved = this.#eventWaiters.filter((waiter) => waiter.predicate(validation.event));
+ this.#eventWaiters = this.#eventWaiters.filter(
+ (waiter) => !resolved.includes(waiter),
+ );
+ for (const waiter of resolved) {
+ clearTimeout(waiter.timer);
+ waiter.resolve(validation.event);
+ }
+ return;
+ }
+ if (message.kind === "command_receipt") {
+ const receipt: Phase2CommandReceipt = {
+ commandId: message.commandId,
+ controllerSeq: message.controllerSeq,
+ status: message.status,
+ detail: message.detail,
+ };
+ this.#receipts.push(receipt);
+ const waiter = this.#receiptWaiters.get(receipt.commandId);
+ if (waiter !== undefined) {
+ clearTimeout(waiter.timer);
+ this.#receiptWaiters.delete(receipt.commandId);
+ waiter.resolve(receipt);
+ }
+ return;
+ }
+ this.#recordDiagnostic(`${message.level}: ${message.message}`);
+ }
+
+ #recordDiagnostic(message: string): void {
+ this.#diagnostics.push(message);
+ if (this.#diagnostics.length > 16) {
+ this.#diagnostics.shift();
+ }
+ this.#onDiagnostic?.(message);
+ }
+
+ #finish(code: number | null, signal: NodeJS.Signals | null): void {
+ if (this.#settled) {
+ return;
+ }
+ const resultEvent = this.#events.find((event) => event.eventType === "run.result.proposed");
+ const harnessExitEvent = this.#events.find((event) => event.eventType === "harness.exited");
+ const trace: Phase2RunTrace = {
+ ...this.metadata,
+ schema: "paperclip.runner.phase2.trace.v1",
+ commands: structuredClone(this.#commands),
+ commandReceipts: structuredClone(this.#receipts),
+ events: structuredClone(this.#events),
+ requests: structuredClone(this.#requests),
+ result:
+ resultEvent === undefined
+ ? null
+ : structuredClone(resultEvent.payload as PrpStructuredRunResult),
+ harnessProcessExit:
+ harnessExitEvent === undefined ? null : processExitFromEvent(harnessExitEvent),
+ runnerProcessExit: { code, signal },
+ runnerDiagnostics: [...this.#diagnostics],
+ };
+ const terminalCount = trace.events.filter(
+ (event) => event.eventType === "run.terminal",
+ ).length;
+ if (code !== 0 || terminalCount !== 1) {
+ const error = new Error(
+ `Phase 2 runner exited with code ${String(code)} and ${terminalCount} terminal events`,
+ );
+ this.#settled = true;
+ this.#completionReject(error);
+ return;
+ }
+ this.#settled = true;
+ this.#completionResolve(trace);
+ }
+
+ #fail(error: Error): void {
+ if (this.#settled) {
+ return;
+ }
+ this.#settled = true;
+ for (const waiter of this.#receiptWaiters.values()) {
+ clearTimeout(waiter.timer);
+ waiter.reject(error);
+ }
+ for (const waiter of this.#eventWaiters) {
+ clearTimeout(waiter.timer);
+ waiter.reject(error);
+ }
+ this.#receiptWaiters.clear();
+ this.#eventWaiters = [];
+ this.#completionReject(error);
+ this.#process.stdin.end();
+ }
+}
+
+export async function startPhase2Scenario(
+ options: StartPhase2ScenarioOptions,
+): Promise {
+ return LocalRunnerController.start(options);
+}
+
+export async function runPhase2Scenario(
+ options: RunPhase2ScenarioOptions,
+): Promise {
+ const handle = await startPhase2Scenario(options);
+ if (options.duplicateTurnCommand === true) {
+ const receipt = await handle.sendDuplicateTurnCommand();
+ if (receipt.status !== "duplicate") {
+ handle.stop();
+ throw new Error(`Expected duplicate command receipt, got ${receipt.status}`);
+ }
+ }
+ if (options.scenario === "permission-input") {
+ await handle.waitForEvent(
+ (event) =>
+ event.eventType === "runtime_request.created" &&
+ (event.payload as Record).request !== undefined &&
+ ((event.payload as Record).request as Record)
+ .requestId === "request_phase2_permission",
+ );
+ await handle.resolveRequest("request_phase2_permission", { decision: "allow" });
+ await handle.waitForEvent(
+ (event) =>
+ event.eventType === "runtime_request.created" &&
+ ((event.payload as Record).request as Record)
+ ?.requestId === "request_phase2_input",
+ );
+ await handle.resolveRequest("request_phase2_input", { text: "phase-02-live-trace" });
+ }
+ if (options.scenario === "interrupted") {
+ await handle.waitForEvent(
+ (event) =>
+ event.eventType === "item.started" &&
+ event.itemId === "item_phase2_long_command",
+ );
+ await handle.interrupt();
+ }
+ return handle.completion;
+}
+
+export function phase2TraceAsFixture(trace: Phase2RunTrace): PrpFixture {
+ if (trace.result === null) {
+ throw new Error("A replay fixture requires a structured semantic result");
+ }
+ const fixture: PrpFixture = {
+ schema: "paperclip.prp.fixture.v1",
+ fixtureVersion: 1,
+ protocolVersion: 1,
+ name: trace.fixtureName,
+ description: `Live Phase 2 ${trace.scenario} trace captured from paperclip-runnerd.`,
+ identity: structuredClone(trace.identity),
+ capabilities: structuredClone(trace.capabilities),
+ commands: structuredClone(trace.commands),
+ events: structuredClone(trace.events),
+ requests: structuredClone(trace.requests),
+ result: structuredClone(trace.result),
+ processFacts: {
+ harness: trace.harnessProcessExit,
+ runner: trace.runnerProcessExit,
+ },
+ };
+ const validation = validatePrpFixture(fixture);
+ if (!validation.ok) {
+ throw new Error(
+ `Captured Phase 2 trace is not replayable: ${validation.issues
+ .map((issue) => `${issue.path} ${issue.message}`)
+ .join("; ")}`,
+ );
+ }
+ return validation.fixture;
+}
+
+export const phase2Internals = {
+ runnerEnvironment,
+};
diff --git a/packages/paperclip-runner/src/protocol/phase1-contract.ts b/packages/paperclip-runner/src/protocol/phase1-contract.ts
index ad2431f7c45..aefe9540c33 100644
--- a/packages/paperclip-runner/src/protocol/phase1-contract.ts
+++ b/packages/paperclip-runner/src/protocol/phase1-contract.ts
@@ -85,6 +85,11 @@ if (registeredFixtureValidator === undefined) {
throw new Error("PRP fixture validator was not registered");
}
const fixtureValidator = registeredFixtureValidator as ValidateFunction;
+const registeredEventValidator = ajv.getSchema(eventSchema.$id);
+if (registeredEventValidator === undefined) {
+ throw new Error("PRP event validator was not registered");
+}
+const eventValidator = registeredEventValidator as ValidateFunction;
function asRecord(value: unknown): Record | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -262,6 +267,36 @@ export function parsePrpFixtureText(text: string): ProtocolValidationResult {
}
}
+export type EventValidationResult =
+ | { ok: true; event: PrpEvent; issues: [] }
+ | { ok: false; event: null; issues: ProtocolValidationIssue[] };
+
+export function validatePrpEvent(value: unknown): EventValidationResult {
+ const record = asRecord(value);
+ const schemaVersion = record?.schemaVersion;
+ if (typeof schemaVersion === "number" && schemaVersion !== 1) {
+ return {
+ ok: false,
+ event: null,
+ issues: [
+ {
+ code: "unsupported_required_version",
+ path: "/schemaVersion",
+ message: `event schemaVersion ${schemaVersion} is unsupported; this implementation requires 1`,
+ },
+ ],
+ };
+ }
+ if (!eventValidator(value)) {
+ return {
+ ok: false,
+ event: null,
+ issues: (eventValidator.errors ?? []).map(ajvIssue),
+ };
+ }
+ return { ok: true, event: value, issues: [] };
+}
+
export function negotiateProtocolVersion(
runner: ProtocolVersionRange,
controller: ProtocolVersionRange,
diff --git a/packages/paperclip-runner/src/reducer/session-reducer.ts b/packages/paperclip-runner/src/reducer/session-reducer.ts
index 16b54225309..f8de4c1076d 100644
--- a/packages/paperclip-runner/src/reducer/session-reducer.ts
+++ b/packages/paperclip-runner/src/reducer/session-reducer.ts
@@ -240,11 +240,23 @@ function addGap(snapshot: SessionSnapshot, event: PrpEvent, expected: number): v
}
export function createSessionSnapshot(fixture: PrpFixture): SessionSnapshot {
+ return createSessionSnapshotFromMetadata({
+ fixtureName: fixture.name,
+ identity: fixture.identity,
+ capabilities: fixture.capabilities,
+ });
+}
+
+export function createSessionSnapshotFromMetadata(input: {
+ fixtureName: string;
+ identity: PrpIdentity;
+ capabilities: PrpCapabilities;
+}): SessionSnapshot {
return {
schema: "paperclip.prp.session-snapshot.v1",
- fixtureName: fixture.name,
- identity: structuredClone(fixture.identity),
- capabilities: structuredClone(fixture.capabilities),
+ fixtureName: input.fixtureName,
+ identity: structuredClone(input.identity),
+ capabilities: structuredClone(input.capabilities),
runPhase: "queued",
sessionState: "not_started",
turnState: "not_started",
diff --git a/packages/paperclip-runner/src/tracer/phase2-live.ts b/packages/paperclip-runner/src/tracer/phase2-live.ts
new file mode 100644
index 00000000000..cfd3d6fd75e
--- /dev/null
+++ b/packages/paperclip-runner/src/tracer/phase2-live.ts
@@ -0,0 +1,77 @@
+import type { PrpEvent } from "../protocol/phase1-contract.js";
+import { validatePrpEvent } from "../protocol/phase1-contract.js";
+import {
+ applyPrpEvent,
+ createSessionSnapshotFromMetadata,
+ reduceSessionEvents,
+ type SessionSnapshot,
+} from "../reducer/session-reducer.js";
+import type { Phase2RunMetadata } from "../contracts/phase2.js";
+
+export type Phase2LiveEventResult =
+ | { ok: true; snapshot: SessionSnapshot; event: PrpEvent; issues: [] }
+ | { ok: false; snapshot: SessionSnapshot; event: null; issues: string[] };
+
+export function createPhase2LiveSnapshot(
+ metadata: Phase2RunMetadata,
+): SessionSnapshot {
+ return createSessionSnapshotFromMetadata({
+ fixtureName: metadata.fixtureName,
+ identity: metadata.identity,
+ capabilities: metadata.capabilities,
+ });
+}
+
+export function applyPhase2LiveEvent(
+ current: SessionSnapshot,
+ value: unknown,
+): Phase2LiveEventResult {
+ const validation = validatePrpEvent(value);
+ if (!validation.ok) {
+ return {
+ ok: false,
+ snapshot: current,
+ event: null,
+ issues: validation.issues.map((issue) => `${issue.path} ${issue.message}`),
+ };
+ }
+ if (validation.event.runId !== current.identity.runId) {
+ return {
+ ok: false,
+ snapshot: current,
+ event: null,
+ issues: ["/runId live event must match the active run"],
+ };
+ }
+ if (
+ validation.event.normalizedSessionId !== undefined &&
+ validation.event.normalizedSessionId !== current.identity.normalizedSessionId
+ ) {
+ return {
+ ok: false,
+ snapshot: current,
+ event: null,
+ issues: ["/normalizedSessionId live event must match the active session"],
+ };
+ }
+ return {
+ ok: true,
+ snapshot: applyPrpEvent(current, validation.event),
+ event: validation.event,
+ issues: [],
+ };
+}
+
+export function replayPhase2Events(
+ metadata: Phase2RunMetadata,
+ events: readonly PrpEvent[],
+): SessionSnapshot {
+ return reduceSessionEvents(createPhase2LiveSnapshot(metadata), events);
+}
+
+export function phase2SnapshotsMatch(
+ live: SessionSnapshot,
+ replay: SessionSnapshot,
+): boolean {
+ return JSON.stringify(live) === JSON.stringify(replay);
+}
diff --git a/packages/paperclip-runner/vite.config.ts b/packages/paperclip-runner/vite.config.ts
index 49a825967e7..121347b763f 100644
--- a/packages/paperclip-runner/vite.config.ts
+++ b/packages/paperclip-runner/vite.config.ts
@@ -4,11 +4,13 @@ import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
+import { phase2BrowserServerPlugin } from "./scripts/phase2-browser-server.mjs";
+
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
root: resolve(packageRoot, "devtools/browser"),
- plugins: [react()],
+ plugins: [react(), phase2BrowserServerPlugin()],
build: {
outDir: resolve(packageRoot, "dist-browser"),
emptyOutDir: true,