From 4c3e30cdad559d159adee0e8fda013f90bf74405 Mon Sep 17 00:00:00 2001
From: Dotta
Date: Fri, 21 Aug 2026 15:17:39 -0500
Subject: [PATCH] feat(runner): add OpenCode and durable runner UX
---
Dockerfile | 2 +-
docker/agent-runtime/Dockerfile.opencode | 2 +-
packages/adapter-utils/src/types.ts | 7 +-
.../codex-local/src/ui/build-config.test.ts | 30 +-
.../codex-local/src/ui/build-config.ts | 14 +-
.../devtools/issue-thread/src/App.tsx | 203 +++-
.../issue-thread/src/DevtoolsInspector.tsx | 2 +-
.../devtools/issue-thread/src/ThreadItems.tsx | 147 +++
.../issue-thread/src/issue-thread.css | 157 +++
.../devtools/issue-thread/src/live-client.ts | 25 +-
packages/paperclip-runner/package.json | 3 +-
.../protocol/schemas/command.schema.json | 2 +
.../protocol/schemas/event.schema.json | 45 +-
.../schemas/workspace-diff.schema.json | 49 +
.../workspace-file-reference.schema.json | 26 +
.../src/bin/fake-codex-app-server.rs | 112 ++-
.../runner-core/src/bin/paperclip-runnerd.rs | 40 +-
.../crates/runner-core/src/codex_provider.rs | 104 +-
.../crates/runner-core/src/durable/runner.rs | 338 ++++++-
.../crates/runner-core/src/durable/state.rs | 151 ++-
.../runner-core/src/durable/transport.rs | 22 +-
.../runner-core/src/process_supervisor.rs | 12 +
.../crates/runner-core/src/provider_bridge.rs | 41 +-
.../runner-core/tests/codex_provider.rs | 148 ++-
.../capability-issue-thread-server.mjs | 121 ++-
.../generate-protocol-schema-module.mjs | 2 +
.../spec/paperclip-agent-operation-groups.md | 42 +-
.../src/backends/harness-driver-backend.ts | 20 +-
.../src/backends/native-backend-factory.ts | 49 +
.../src/backends/opencode-native-backend.ts | 29 +
.../src/catalog/operation-groups-doc.test.ts | 4 +-
.../src/catalog/operation-groups-doc.ts | 6 +-
.../paperclip-runner/src/cli/eval-session.ts | 11 +
.../src/cli/opencode-app-server-proxy.ts | 249 +++++
.../src/contracts/harness-driver.ts | 1 +
.../src/contracts/native-execution.test.ts | 54 +-
.../src/contracts/native-execution.ts | 70 +-
.../src/contracts/native-session-backend.ts | 2 +
.../durable-prp-control-plane.ts | 82 +-
.../src/drivers/codex/app-server-transport.ts | 7 +-
.../codex/codex-app-server-driver.test.ts | 46 +-
.../drivers/codex/codex-app-server-driver.ts | 143 ++-
.../src/drivers/opencode/mcp-bridge.test.ts | 141 +++
.../src/drivers/opencode/mcp-bridge.ts | 341 +++++++
.../opencode/opencode-server-driver.test.ts | 157 +++
.../opencode/opencode-server-driver.ts | 890 ++++++++++++++++++
.../src/evals/model-pricing.ts | 4 +-
packages/paperclip-runner/src/index.ts | 5 +
.../src/issue-thread/fixtures.ts | 90 +-
.../src/issue-thread/issue-thread.test.ts | 72 +-
.../src/issue-thread/live-projection.ts | 48 +-
.../src/issue-thread/public-view.ts | 45 +
.../src/issue-thread/types.ts | 17 +-
.../src/live/clean-room-server.test.ts | 47 +-
.../src/live/live-session.test.ts | 126 ++-
.../paperclip-runner/src/live/live-session.ts | 219 ++++-
.../src/live/runnerd-codex-transport.test.ts | 144 ++-
.../src/live/runnerd-codex-transport.ts | 212 ++++-
.../src/live/workspace-diff.test.ts | 46 +
.../src/live/workspace-diff.ts | 219 +++++
.../src/live/workspace-file-reference.test.ts | 42 +
.../src/live/workspace-file-reference.ts | 128 +++
.../src/native-session-runtime.test.ts | 1 +
.../src/native-session-runtime.ts | 41 +-
.../src/protocol/generated/schema-bundle.ts | 332 +++++++
.../generated/standalone-validators.ts | 2 +-
.../protocol/workspace-diff-events.test.ts | 85 ++
.../fake-durable-codex-app-server.mjs | 12 +-
.../test/fixtures/fake-opencode-server.mjs | 143 +++
.../native-session-resumption.test.ts | 1 +
server/src/adapters/registry.test.ts | 13 +
server/src/adapters/registry.ts | 114 ++-
server/src/services/heartbeat.ts | 15 +-
.../native-runtime/native-execution-input.ts | 10 +-
.../native-session-executor.test.ts | 82 +-
.../native-runtime/native-session-executor.ts | 268 +++++-
.../native-runtime/runtime-mode.test.ts | 35 +-
.../services/native-runtime/runtime-mode.ts | 25 +-
ui/src/adapters/codex-local/config-fields.tsx | 177 +++-
.../adapters/paperclip-runner/index.test.ts | 40 +-
ui/src/adapters/paperclip-runner/index.ts | 56 +-
ui/src/components/TaskChatThread.test.tsx | 28 +
ui/src/components/TaskChatThread.tsx | 124 ++-
.../components/task-chat/TaskChatBubble.tsx | 5 +-
.../task-chat/TaskChatLiveRunPill.tsx | 43 +-
.../components/task-chat/TaskChatLiveTail.tsx | 8 +-
.../task-chat/TaskChatRunnerTurn.test.tsx | 56 ++
.../task-chat/TaskChatRunnerTurn.tsx | 103 ++
.../components/task-chat/TaskChatThinking.tsx | 3 +-
.../task-chat/TaskChatThreadView.tsx | 22 +-
.../task-chat/TaskChatTurn.test.tsx | 7 +
ui/src/components/task-chat/TaskChatTurn.tsx | 17 +-
.../components/task-chat/task-chat-model.ts | 14 +-
.../components/task-chat/task-chat-states.ts | 10 +-
.../task-chat/tool-taxonomy.test.ts | 1 +
ui/src/components/task-chat/tool-taxonomy.ts | 2 +-
.../task-chat/transcript-adapter.test.ts | 18 +
.../task-chat/transcript-adapter.ts | 24 +-
ui/src/lib/issue-chat-messages.ts | 2 +
ui/src/pages/IssueDetail.tsx | 16 +-
100 files changed, 7156 insertions(+), 412 deletions(-)
create mode 100644 packages/paperclip-runner/protocol/schemas/workspace-diff.schema.json
create mode 100644 packages/paperclip-runner/protocol/schemas/workspace-file-reference.schema.json
create mode 100644 packages/paperclip-runner/src/backends/native-backend-factory.ts
create mode 100644 packages/paperclip-runner/src/backends/opencode-native-backend.ts
create mode 100644 packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts
create mode 100644 packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts
create mode 100644 packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts
create mode 100644 packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts
create mode 100644 packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts
create mode 100644 packages/paperclip-runner/src/live/workspace-diff.test.ts
create mode 100644 packages/paperclip-runner/src/live/workspace-diff.ts
create mode 100644 packages/paperclip-runner/src/live/workspace-file-reference.test.ts
create mode 100644 packages/paperclip-runner/src/live/workspace-file-reference.ts
create mode 100644 packages/paperclip-runner/src/protocol/workspace-diff-events.test.ts
create mode 100755 packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs
create mode 100644 ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx
create mode 100644 ui/src/components/task-chat/TaskChatRunnerTurn.tsx
diff --git a/Dockerfile b/Dockerfile
index 01976d8d48c..a1c2fb82d95 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -80,7 +80,7 @@ WORKDIR /app
# (the single most expensive layer: four CLI toolchains + apt, per arch) can
# never hit the layer cache and rebuilds on every build.
RUN echo "cli-tools-epoch: ${CLI_TOOLS_CACHE_EPOCH}" \
- && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest \
+ && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai@1.18.17 @google/gemini-cli@latest \
&& apt-get update \
&& apt-get install -y --no-install-recommends openssh-client jq \
&& rm -rf /var/lib/apt/lists/* \
diff --git a/docker/agent-runtime/Dockerfile.opencode b/docker/agent-runtime/Dockerfile.opencode
index f024f0c0077..30a125eeb28 100644
--- a/docker/agent-runtime/Dockerfile.opencode
+++ b/docker/agent-runtime/Dockerfile.opencode
@@ -4,7 +4,7 @@ FROM paperclipai/agent-runtime-base:${BASE_TAG}
USER root
# opencode-ai npm package; binary on PATH is `opencode`.
-RUN npm install -g opencode-ai@latest \
+RUN npm install -g opencode-ai@1.18.17 \
&& { chown -R 1000:1000 /usr/lib/node_modules || true; }
USER 1000:1000
diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts
index be317de5cbd..11a6fcfdeb4 100644
--- a/packages/adapter-utils/src/types.ts
+++ b/packages/adapter-utils/src/types.ts
@@ -519,8 +519,8 @@ export interface ServerAdapterModule {
// ---------------------------------------------------------------------------
export type TranscriptEntry =
- | { kind: "assistant"; ts: string; text: string; delta?: boolean }
- | { kind: "thinking"; ts: string; text: string; delta?: boolean }
+ | { kind: "assistant"; ts: string; text: string; delta?: boolean; channel?: "progress" | "final" | "unknown" }
+ | { kind: "thinking"; ts: string; text: string; delta?: boolean; channel?: "summary" | "detail" | "unknown" }
| { kind: "user"; ts: string; text: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string; invocationId?: string; actionRequestId?: string }
| { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean; delta?: boolean }
@@ -548,6 +548,9 @@ export interface CLIAdapterModule {
export interface CreateConfigValues {
adapterType: string;
+ paperclipRunnerProvider?: "codex" | "opencode";
+ paperclipRunnerLifecycleMode?: "per_turn" | "warm";
+ paperclipRunnerIdleTimeoutMs?: number;
cwd: string;
instructionsFilePath?: string;
promptTemplate: string;
diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts
index afb7326551c..8d314284fe6 100644
--- a/packages/adapters/codex-local/src/ui/build-config.test.ts
+++ b/packages/adapters/codex-local/src/ui/build-config.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { buildCodexLocalConfig } from "./build-config.js";
+import { buildCodexLocalConfig, buildPaperclipRunnerConfig } from "./build-config.js";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
function makeValues(overrides: Partial = {}): CreateConfigValues {
@@ -69,3 +69,31 @@ describe("buildCodexLocalConfig", () => {
expect(config).not.toHaveProperty("model");
});
});
+
+describe("buildPaperclipRunnerConfig", () => {
+ it("preserves the Codex provider default", () => {
+ expect(buildPaperclipRunnerConfig(makeValues())).toMatchObject({
+ provider: "codex",
+ model: "gpt-5.4",
+ lifecycleMode: "per_turn",
+ });
+ });
+
+ it("persists a warm lifecycle and its idle timeout", () => {
+ expect(buildPaperclipRunnerConfig(makeValues({
+ paperclipRunnerLifecycleMode: "warm",
+ paperclipRunnerIdleTimeoutMs: 45_000,
+ }))).toMatchObject({ lifecycleMode: "warm", idleTimeoutMs: 45_000 });
+ });
+
+ it("persists OpenCode and supplies the qualified model when blank", () => {
+ expect(buildPaperclipRunnerConfig(makeValues({
+ adapterType: "paperclip_runner",
+ paperclipRunnerProvider: "opencode",
+ model: "",
+ }))).toMatchObject({
+ provider: "opencode",
+ model: "openrouter/deepseek/deepseek-v4-flash-0731",
+ });
+ });
+});
diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts
index 7c9b98a7b6b..bb09573bc66 100644
--- a/packages/adapters/codex-local/src/ui/build-config.ts
+++ b/packages/adapters/codex-local/src/ui/build-config.ts
@@ -70,5 +70,17 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record | null;
+ if (value && (value.provider === "codex" || value.provider === "opencode") && typeof value.model === "string") {
+ const lifecyclePolicy = value.lifecyclePolicy?.mode === "per_turn"
+ ? { mode: "per_turn" as const, idleTimeoutMs: null }
+ : value.lifecyclePolicy?.mode === "warm" && Number.isSafeInteger(value.lifecyclePolicy.idleTimeoutMs) && Number(value.lifecyclePolicy.idleTimeoutMs) > 0
+ ? { mode: "warm" as const, idleTimeoutMs: Number(value.lifecyclePolicy.idleTimeoutMs) }
+ : { mode: "warm" as const, idleTimeoutMs: 300_000 };
+ return { provider: value.provider, model: value.model, lifecyclePolicy };
+ }
+ } catch {
+ // Invalid preferences fall back to the qualified defaults.
+ }
+ return defaultHarness();
+}
+
+function persistHarnessConfiguration(configuration: CapabilityHarnessConfiguration): void {
+ try {
+ window.localStorage.setItem(CHAT_HARNESS_KEY, JSON.stringify(configuration));
+ } catch {
+ // Provider/model preference is convenient but not authority-bearing.
+ }
}
function readChatHistory(): StoredChatSession[] {
@@ -180,8 +220,9 @@ function settledAnnouncement(snapshot: CapabilityIssueThreadSnapshot, interactio
}
function currentTurnActivity(snapshot: CapabilityIssueThreadSnapshot): string {
+ const harness = snapshot.identity.agentLabel.replace(/^Real /, "");
const turn = snapshot.turns.at(-1);
- if (turn === undefined) return "Dispatching turn to Codex";
+ if (turn === undefined) return `Dispatching turn to ${harness}`;
const activity = [...turn.items].reverse().find((item) =>
item.kind === "tool_activity" || item.kind === "progress_activity" ||
(item.kind === "agent_message" && item.streaming),
@@ -192,8 +233,8 @@ function currentTurnActivity(snapshot: CapabilityIssueThreadSnapshot): string {
: `Paperclip tool completed · ${activity.operationId}`;
}
if (activity?.kind === "progress_activity") return activity.summary;
- if (activity?.kind === "agent_message") return "Receiving Codex response";
- return "Waiting for Codex activity";
+ if (activity?.kind === "agent_message") return `Receiving ${harness} response`;
+ return `Waiting for ${harness} activity`;
}
function useRoute(): CapabilityRoute {
@@ -242,6 +283,9 @@ export function App() {
const [snapshot, setSnapshot] = useState(null);
const [devtools, setDevtools] = useState(null);
const [identity, setIdentity] = useState(null);
+ const initialHarnessRef = useRef(readHarnessConfiguration());
+ const [harness, setHarness] = useState(initialHarnessRef.current);
+ const [activeHarness, setActiveHarness] = useState(null);
const [chatHistory, setChatHistory] = useState(readChatHistory);
const [historicSessionId, setHistoricSessionId] = useState(null);
const liveRoomRef = useRef(null);
@@ -315,10 +359,14 @@ export function App() {
// cannot start, the surface says so rather than rendering a canned thread.
void (async () => {
try {
- const response = await capabilityLiveClient.loadCleanRoom(recallSession("cleanroom"));
+ const response = await capabilityLiveClient.loadCleanRoom(
+ recallSession("cleanroom"),
+ initialHarnessRef.current,
+ );
if (cancelled) return;
rememberSession(response.sessionId, "cleanroom");
setIdentity(response.identity ?? null);
+ setActiveHarness(response.configuration ?? initialHarnessRef.current);
setHistoricSessionId(null);
setSnapshot(response.view);
setSnapshotSurface("chat");
@@ -434,6 +482,7 @@ export function App() {
snapshot,
identity,
updatedAt: snapshot.renderedAt,
+ ...(activeHarness === null ? {} : { configuration: activeHarness }),
};
liveRoomRef.current = record;
setChatHistory((current) => {
@@ -441,7 +490,7 @@ export function App() {
persistChatHistory(next);
return next;
});
- }, [chat, historicSessionId, identity, snapshot?.renderedAt, snapshot?.sessionId]);
+ }, [activeHarness, chat, historicSessionId, identity, snapshot?.renderedAt, snapshot?.sessionId]);
useEffect(() => {
if (snapshot === null) return;
@@ -659,6 +708,7 @@ export function App() {
const adoptCleanRoom = useCallback((next: Awaited>) => {
rememberSession(next.sessionId, "cleanroom");
setIdentity(next.identity ?? null);
+ setActiveHarness(next.configuration ?? harness);
setHistoricSessionId(null);
setSnapshot(next.view);
setDevtools(null);
@@ -666,25 +716,37 @@ export function App() {
setAnnouncement(
`New clean-room chat started on ${next.view.issue.identifier}. The previous session was closed.`,
);
- }, []);
+ }, [harness]);
const newChat = useCallback(() => {
+ const model = harness.model?.trim() ?? "";
+ if (!model) {
+ setActionError("Choose or enter a model before starting a new chat.");
+ return;
+ }
+ if (harness.provider === "opencode" && !model.includes("/")) {
+ setActionError("OpenCode models must use provider/model form.");
+ return;
+ }
+ const configuration = { ...harness, model };
+ persistHarnessConfiguration(configuration);
setConfirmReset(false);
// The room this turn belongs to is about to be retired, so the stream is
// dropped before the request that retires it.
abandonTurn();
setAnnouncement("Starting a new clean-room chat…");
void capabilityLiveClient
- .newCleanRoom(liveRoomRef.current?.sessionId ?? snapshot?.sessionId ?? null)
+ .newCleanRoom(liveRoomRef.current?.sessionId ?? snapshot?.sessionId ?? null, configuration)
.then(adoptCleanRoom)
.catch((cause) => setActionError(describe(cause)));
- }, [abandonTurn, adoptCleanRoom, snapshot]);
+ }, [abandonTurn, adoptCleanRoom, harness, snapshot]);
const selectChatHistory = useCallback((sessionId: string) => {
const live = liveRoomRef.current;
if (live?.sessionId === sessionId) {
setSnapshot(live.snapshot);
setIdentity(live.identity);
+ setActiveHarness(live.configuration ?? null);
setDevtools(null);
setHistoricSessionId(null);
setActionError(null);
@@ -693,19 +755,19 @@ export function App() {
const archived = chatHistory.find((item) => item.sessionId === sessionId);
if (archived === undefined) return;
abandonTurn();
- setSnapshot({
- ...archived.snapshot,
- composer: {
- state: "disabled",
- helper: null,
- reason: "Historical session is read-only",
- pendingInteractionId: null,
- },
- });
- setIdentity(archived.identity);
- setDevtools(null);
- setHistoricSessionId(sessionId);
- setActionError(null);
+ const configuration = archived.configuration ?? defaultHarness();
+ void capabilityLiveClient.loadCleanRoom(sessionId, configuration)
+ .then((restored) => {
+ rememberSession(restored.sessionId, "cleanroom");
+ setSnapshot(restored.view);
+ setIdentity(restored.identity ?? archived.identity);
+ setActiveHarness(restored.configuration ?? configuration);
+ setDevtools(null);
+ setHistoricSessionId(null);
+ setActionError(null);
+ setAnnouncement("Archived chat restored. The runner will resume when you send a message.");
+ })
+ .catch((cause) => setActionError(describe(cause)));
}, [abandonTurn, chatHistory]);
const reset = useCallback(() => {
@@ -829,12 +891,12 @@ export function App() {
{chat
- ? `The clean-room chat could not start a real Codex session: ${error}`
+ ? `The clean-room chat could not start the selected provider: ${error}`
: error}
{chat ? (
- The clean room only runs against real Codex through real runnerd, so it does not fall
+ The clean room only runs against the selected real provider through real runnerd, so it does not fall
back to a fixture or a recording.
) : null}
@@ -859,7 +921,7 @@ export function App() {
{chat
- ? "Starting real runnerd and a real Codex session for a fresh mock tenant…"
+ ? "Starting real runnerd and the selected provider for a fresh mock tenant…"
: "Loading…"}
@@ -917,6 +979,95 @@ export function App() {
onSelectSegment={setSegment}
/>
+ {chat && embeddedEval === null ? (
+
+
+ Provider
+ {
+ const provider = event.target.value as CapabilityHarnessConfiguration["provider"];
+ setHarness((current) => ({
+ ...defaultHarness(provider),
+ lifecyclePolicy: current.lifecyclePolicy,
+ }));
+ }}
+ >
+ Codex
+ OpenCode
+
+
+
+ Execution
+ setHarness((current) => ({
+ ...current,
+ lifecyclePolicy: event.target.value === "per_turn"
+ ? { mode: "per_turn", idleTimeoutMs: null }
+ : { mode: "warm", idleTimeoutMs: 300_000 },
+ }))}
+ >
+ Warm session
+ Turn by turn
+
+
+ {harness.lifecyclePolicy.mode === "warm" ? (
+
+ Idle timeout (seconds)
+ {
+ const seconds = Math.max(1, Number.parseInt(event.target.value || "1", 10));
+ setHarness((current) => ({
+ ...current,
+ lifecyclePolicy: { mode: "warm", idleTimeoutMs: seconds * 1_000 },
+ }));
+ }}
+ />
+
+ ) : null}
+
+ Model
+ setHarness((current) => ({ ...current, model: event.target.value }))}
+ />
+
+ {MODEL_PRESETS[harness.provider].map((model) => )}
+
+
+
+ Start new chat
+
+
+ Active: {activeHarness?.provider ?? "starting"}
+ {activeHarness?.model ? ` · ${activeHarness.model}` : ""}
+ {activeHarness ? ` · ${activeHarness.lifecyclePolicy.mode === "warm" ? `warm ${Math.round(activeHarness.lifecyclePolicy.idleTimeoutMs / 1_000)}s` : "turn by turn"}` : ""}
+
+
+ ) : null}
+
{embeddedEval !== null ? (
← All results
@@ -1039,7 +1190,7 @@ export function App() {
recorded yet.
- Your first message starts a real Codex turn through real runnerd. Codex may
+ Your first message starts a real {snapshot.identity.agentLabel.replace(/^Real /, "")} turn through real runnerd. The agent may
call the semantic tools this session exposes, and every record it creates lands
in the mock control plane only — never a real Paperclip API. Detailed tool,
policy, event, and state evidence stays in the Evidence drawer until you open
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx b/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx
index 876aa106edf..6ebefb491a8 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx
+++ b/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx
@@ -142,7 +142,7 @@ export function DevtoolsInspector({ snapshot, onFork, tab, onTabChange, evalRepo
← Eval suite {evalReport.passed ? "PASS" : evalReport.disposition.replaceAll("_", " ").toUpperCase()} {evalReport.attemptId}
-
Model {evalReport.run.provider}/{evalReport.run.model}
+
Model {evalReport.run.model.startsWith(`${evalReport.run.provider}/`) ? evalReport.run.model : `${evalReport.run.provider}/${evalReport.run.model}`}
Configuration {evalReport.run.configuration}
Session {evalReport.run.sessionId}
Duration {evalReport.run.durationMs} ms
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx b/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx
index 70a68af7a38..dd1b640333a 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx
+++ b/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx
@@ -150,6 +150,147 @@ function ProgressItem({
);
}
+function FileDiff({ diff }: { diff: string | null }) {
+ if (diff === null) return Binary or oversized file; textual diff unavailable.
;
+ return (
+
+ {diff.split("\n").map((line, index) => (
+ {line || " "}{"\n"}
+ ))}
+
+ );
+}
+
+function WorkspaceChanges({
+ item,
+}: {
+ item: Extract;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const [selectedPath, setSelectedPath] = useState(null);
+ const files = item.changeSet.files;
+ const visible = expanded ? files : files.slice(0, 3);
+ const selected = files.find((file) => file.path === selectedPath) ?? null;
+ const stats = (file: (typeof files)[number]) => (
+
+ {file.additions === null ? null : +{file.additions} }
+ {file.deletions === null ? null : -{file.deletions} }
+
+ );
+
+ return (
+ <>
+
+
+ ▣
+
+ {item.changeSet.complete ? "Edited" : "Editing"} {files.length} file{files.length === 1 ? "" : "s"}
+ {item.changeSet.source === "runner_verified" ? "Verified from workspace" : "Reported by harness"}
+
+
+ {item.changeSet.totals.additions === null ? null : +{item.changeSet.totals.additions} }
+ {item.changeSet.totals.deletions === null ? null : -{item.changeSet.totals.deletions} }
+
+ setSelectedPath(files[0]?.path ?? null)}>Review
+
+
+ {visible.map((file) => (
+ setSelectedPath(file.path)}>
+
+ {file.previousPath === null ? file.path : `${file.previousPath} → ${file.path}`}
+ {file.operation.replace("_", " ")}
+
+ {stats(file)}
+
+ ))}
+
+ {files.length > 3 ? (
+ setExpanded((value) => !value)}>
+ {expanded ? "Show fewer files" : `Show ${files.length - 3} more files`} ⌄
+
+ ) : null}
+
+ {selected !== null ? (
+ { if (event.target === event.currentTarget) setSelectedPath(null); }}>
+
+
+ {files.length > 1 ? (
+
+ {files.map((file) => setSelectedPath(file.path)}>{file.path} )}
+
+ ) : null}
+
+
+
+ ) : null}
+ >
+ );
+}
+
+function WorkspaceFileReference({
+ item,
+}: {
+ item: Extract;
+}) {
+ const [open, setOpen] = useState(false);
+ const extension = item.reference.path.includes(".")
+ ? item.reference.path.split(".").at(-1)?.toUpperCase()
+ : "FILE";
+ const presentation = item.reference.presentation === "document"
+ ? "Document"
+ : item.reference.presentation === "code"
+ ? "Code"
+ : item.reference.presentation === "image"
+ ? "Image"
+ : "File";
+ return (
+ <>
+
+ ▤
+
+ {item.reference.displayName}
+ {presentation} · {extension}{item.reference.line === null ? "" : ` · line ${item.reference.line}`}
+
+ setOpen(true)}>Open
+
+ {open ? (
+ { if (event.target === event.currentTarget) setOpen(false); }}>
+
+
+
+ {item.reference.preview === null
+ ?
Preview unavailable. The reference remains recorded with its normalized workspace path.
+ : item.reference.presentation === "document"
+ ?
{item.reference.preview}
+ :
{item.reference.preview} }
+ {item.reference.previewTruncated ?
Preview truncated by the runner.
: null}
+
+
+
+ ) : null}
+ >
+ );
+}
+
function ThreadItemView({
item,
callbacks,
@@ -230,6 +371,12 @@ function ThreadItemView({
case "progress_activity":
return ;
+ case "workspace_changes":
+ return ;
+
+ case "workspace_file_reference":
+ return ;
+
case "denial":
return (
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css
index 505e8b6ecc2..9c9a5ade509 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css
+++ b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css
@@ -700,6 +700,108 @@ textarea {
min-width: 0;
}
+.pit-workspace-card {
+ overflow: hidden;
+ border: 1px solid var(--pit-border);
+ border-radius: var(--pit-radius-lg);
+ background: var(--pit-surface);
+ box-shadow: var(--pit-shadow-sm);
+}
+
+.pit-workspace-head {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: var(--pit-space-3);
+ padding: var(--pit-space-3) var(--pit-space-4);
+ border-bottom: 1px solid var(--pit-border);
+}
+
+.pit-workspace-head strong,
+.pit-workspace-head small,
+.pit-workspace-path small { display: block; }
+.pit-workspace-head small,
+.pit-workspace-path small,
+.pit-workspace-binary { color: var(--pit-muted-foreground); }
+.pit-workspace-icon { font-family: var(--pit-font-symbols); font-size: var(--pit-text-xl); }
+.pit-workspace-total,
+.pit-workspace-stats { display: flex; gap: var(--pit-space-2); font-family: var(--pit-font-mono); }
+[data-stat="add"] { color: var(--pit-success); }
+[data-stat="delete"] { color: var(--pit-danger); }
+
+.pit-workspace-review,
+.pit-workspace-more,
+.pit-workspace-files button,
+.pit-workspace-file-nav button {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.pit-workspace-review {
+ min-height: 2rem;
+ padding: 0 var(--pit-space-3);
+ border: 1px solid var(--pit-border-strong);
+ border-radius: var(--pit-radius-md);
+ background: var(--pit-surface-raised);
+}
+
+.pit-workspace-files button {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--pit-space-4);
+ min-height: 2.75rem;
+ padding: var(--pit-space-2) var(--pit-space-4);
+ text-align: left;
+}
+.pit-workspace-files button:hover,
+.pit-workspace-file-nav button:hover,
+.pit-workspace-file-nav button[data-selected="true"] { background: var(--pit-surface-raised); }
+.pit-workspace-path { min-width: 0; overflow-wrap: anywhere; font-family: var(--pit-font-mono); }
+.pit-workspace-path small { font-family: var(--pit-font-sans); }
+.pit-workspace-more { width: 100%; padding: var(--pit-space-2) var(--pit-space-4); border-top: 1px solid var(--pit-border); text-align: left; }
+
+.pit-dialog.pit-workspace-dialog {
+ width: min(70rem, calc(100vw - 2rem));
+ max-width: 70rem;
+ max-height: calc(100vh - 2rem);
+ padding: 0;
+ gap: 0;
+ overflow: hidden;
+}
+.pit-workspace-dialog > .pit-tool-dialog-head { padding: var(--pit-space-4); }
+.pit-workspace-file-nav { display: flex; max-height: 9rem; overflow: auto; border-bottom: 1px solid var(--pit-border); }
+.pit-workspace-file-nav button { padding: var(--pit-space-2) var(--pit-space-3); white-space: nowrap; font-family: var(--pit-font-mono); }
+.pit-workspace-diff { max-height: min(65vh, 44rem); margin: 0; overflow: auto; padding: var(--pit-space-4); background: var(--pit-surface-sunken); font-family: var(--pit-font-mono); font-size: var(--pit-text-xs); line-height: 1.55; }
+.pit-workspace-diff span { display: block; min-width: max-content; }
+.pit-workspace-diff [data-diff-line="addition"] { color: var(--pit-success); background: var(--pit-success-surface); }
+.pit-workspace-diff [data-diff-line="deletion"] { color: var(--pit-danger); background: var(--pit-danger-surface); }
+.pit-workspace-diff [data-diff-line="hunk"] { color: var(--pit-accent); }
+.pit-workspace-binary { padding: var(--pit-space-4); }
+
+.pit-file-reference-card {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--pit-space-3);
+ padding: var(--pit-space-3);
+ border: 1px solid var(--pit-border);
+ border-radius: var(--pit-radius-lg);
+ background: var(--pit-surface);
+ box-shadow: var(--pit-shadow-sm);
+}
+.pit-file-reference-icon { display: grid; place-items: center; width: 2.75rem; height: 2.75rem; border-radius: var(--pit-radius-md); background: var(--pit-surface-raised); font-family: var(--pit-font-symbols); font-size: var(--pit-text-xl); }
+.pit-file-reference-copy { min-width: 0; }
+.pit-file-reference-copy strong,
+.pit-file-reference-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pit-file-reference-copy small { color: var(--pit-muted-foreground); }
+.pit-dialog.pit-file-reference-dialog { width: min(58rem, calc(100vw - 2rem)); max-width: 58rem; max-height: calc(100vh - 2rem); padding: 0; gap: 0; overflow: hidden; }
+.pit-file-reference-dialog > .pit-tool-dialog-head { padding: var(--pit-space-4); border-bottom: 1px solid var(--pit-border); }
+.pit-file-reference-preview { overflow: auto; padding: var(--pit-space-4); }
+.pit-file-reference-preview > .pit-code { max-height: 65vh; overflow: auto; white-space: pre; }
+
/*
* Chat messages follow the familiar assistant flow: the board's message is a
* compact bubble, while the assistant answer sits directly on the page. The
@@ -2270,7 +2372,62 @@ textarea {
text-transform: uppercase;
}
+.pit-harness-picker {
+ display: flex;
+ align-items: end;
+ gap: var(--pit-space-3);
+ padding: var(--pit-space-2) var(--pit-space-4);
+ border-bottom: 1px solid var(--pit-border);
+ background: var(--pit-surface-sunken);
+}
+
+.pit-harness-picker label {
+ display: grid;
+ gap: var(--pit-space-1);
+ color: var(--pit-muted-foreground);
+ font-size: var(--pit-text-xs);
+}
+
+.pit-harness-picker select,
+.pit-harness-picker input {
+ box-sizing: border-box;
+ min-height: var(--pit-touch-target);
+ padding: 0 var(--pit-space-2);
+ border: 1px solid var(--pit-border-strong);
+ border-radius: var(--pit-radius-sm);
+ background: var(--pit-surface-raised);
+}
+
+.pit-harness-model {
+ flex: 1;
+ max-width: 38rem;
+}
+
+.pit-harness-active {
+ align-self: center;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--pit-faint-foreground);
+ font-family: var(--pit-font-mono);
+ font-size: var(--pit-text-xs);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
@media (max-width: 767px) {
+ .pit-harness-picker {
+ align-items: stretch;
+ flex-wrap: wrap;
+ }
+
+ .pit-harness-model {
+ min-width: 12rem;
+ }
+
+ .pit-harness-active {
+ flex-basis: 100%;
+ }
+
.pit-header-primary {
align-items: flex-start;
}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts b/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts
index dd17739d3bf..c5cc95369cb 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts
+++ b/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts
@@ -41,6 +41,14 @@ export interface CapabilityCleanRoomIdentity {
identifier: string;
}
+export interface CapabilityHarnessConfiguration {
+ provider: "codex" | "opencode";
+ model: string | null;
+ lifecyclePolicy:
+ | { mode: "per_turn"; idleTimeoutMs: null }
+ | { mode: "warm"; idleTimeoutMs: number };
+}
+
export interface CapabilityLiveResponse {
sessionId: string;
view: CapabilityIssueThreadSnapshot;
@@ -49,6 +57,7 @@ export interface CapabilityLiveResponse {
identity?: CapabilityCleanRoomIdentity;
limits?: { maxTurns: number; maxMessageBytes: number };
turns?: number;
+ configuration?: CapabilityHarnessConfiguration;
}
export interface CapabilityToolTestResponse extends CapabilityLiveResponse {
@@ -160,15 +169,21 @@ export const capabilityLiveClient = {
return post("/session", { scenario });
},
/** Reconnect to a clean room, or open one when there is nothing to resume. */
- async loadCleanRoom(sessionId: string | null): Promise
{
- const query = sessionId === null ? "" : `?sessionId=${encodeURIComponent(sessionId)}`;
- const response = await fetch(`${BASE}/cleanroom/session${query}`, { credentials: CREDENTIALS });
+ async loadCleanRoom(sessionId: string | null, configuration: CapabilityHarnessConfiguration): Promise {
+ const query = new URLSearchParams({ provider: configuration.provider });
+ if (sessionId !== null) query.set("sessionId", sessionId);
+ if (configuration.model !== null) query.set("model", configuration.model);
+ query.set("lifecycleMode", configuration.lifecyclePolicy.mode);
+ if (configuration.lifecyclePolicy.mode === "warm") {
+ query.set("idleTimeoutMs", String(configuration.lifecyclePolicy.idleTimeoutMs));
+ }
+ const response = await fetch(`${BASE}/cleanroom/session?${query.toString()}`, { credentials: CREDENTIALS });
if (!response.ok) throw await readError(response, "/cleanroom/session");
return (await response.json()) as CapabilityLiveResponse;
},
/** `New chat`: retire the current room and mint a new mock tenant. */
- newCleanRoom(sessionId: string | null): Promise {
- return post("/cleanroom/session", sessionId === null ? {} : { sessionId });
+ newCleanRoom(sessionId: string | null, configuration: CapabilityHarnessConfiguration): Promise {
+ return post("/cleanroom/session", { ...(sessionId === null ? {} : { sessionId }), ...configuration });
},
/**
* Runs one turn. `onFrame` fires for every interim projection the server
diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json
index 02c23b722dd..608c130fc68 100644
--- a/packages/paperclip-runner/package.json
+++ b/packages/paperclip-runner/package.json
@@ -5,7 +5,8 @@
"license": "MIT",
"type": "module",
"bin": {
- "paperclip-runner-eval-session": "./dist/cli/eval-session.js"
+ "paperclip-runner-eval-session": "./dist/cli/eval-session.js",
+ "paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js"
},
"exports": {
".": {
diff --git a/packages/paperclip-runner/protocol/schemas/command.schema.json b/packages/paperclip-runner/protocol/schemas/command.schema.json
index 532c8c6a77c..108c8292c2b 100644
--- a/packages/paperclip-runner/protocol/schemas/command.schema.json
+++ b/packages/paperclip-runner/protocol/schemas/command.schema.json
@@ -11,6 +11,7 @@
"type": {
"enum": [
"run.prepare",
+ "run.attach",
"session.open",
"turn.start",
"turn.steer",
@@ -23,6 +24,7 @@
"session.close",
"run.cancel",
"runner.drain",
+ "runner.suspend",
"runner.shutdown"
]
},
diff --git a/packages/paperclip-runner/protocol/schemas/event.schema.json b/packages/paperclip-runner/protocol/schemas/event.schema.json
index 3b2067c90f5..8e558a0d05c 100644
--- a/packages/paperclip-runner/protocol/schemas/event.schema.json
+++ b/packages/paperclip-runner/protocol/schemas/event.schema.json
@@ -28,8 +28,8 @@
"itemId": { "type": "string", "minLength": 1, "maxLength": 160 },
"eventType": {
"enum": [
- "runner.connected", "runner.reconnected", "runner.disconnected", "runner.draining", "runner.diagnostic",
- "runtime.phase.changed", "sandbox.metric", "workspace.ready",
+ "runner.connected", "runner.reconnected", "runner.reconciled", "runner.disconnected", "runner.draining", "runner.suspending", "runner.suspended", "runner.stopped", "runner.diagnostic",
+ "runtime.phase.changed", "sandbox.metric", "workspace.ready", "workspace.change.updated", "workspace.diff.recorded", "workspace.file.referenced",
"harness.starting", "harness.ready", "harness.exited", "harness.diagnostic",
"session.starting", "session.started", "session.resuming", "session.resumed", "session.reconciled", "session.closed", "session.failed",
"turn.submitted", "turn.accepted", "turn.started", "turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled",
@@ -37,7 +37,7 @@
"mcp_app.discovered", "mcp_app.resource.resolved", "mcp_app.initializing", "mcp_app.ready", "mcp_app.tool_input", "mcp_app.tool_result", "mcp_app.action.requested", "mcp_app.action.resolved", "mcp_app.host_context.changed", "mcp_app.failed", "mcp_app.teardown",
"runtime_request.created", "runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled",
"interaction.request.proposed", "interaction.request.materialized", "interaction.request.rejected", "interaction.response.progressed", "interaction.response.resolved", "interaction.response.delivered",
- "run.result.proposed", "run.result.accepted", "run.result.rejected",
+ "run.attached", "run.detached", "run.result.proposed", "run.result.accepted", "run.result.rejected",
"attention.request.proposed", "attention.request.routed", "attention.request.resolved", "attention.request.expired", "attention.request.superseded",
"work.assessment.recorded", "issue.status.decision.recorded", "issue.status.decision.applied", "issue.status.decision.rejected", "issue.status.decision.superseded",
"run.terminal"
@@ -47,10 +47,47 @@
"priority": { "enum": [0, 1, 2] },
"emittedAt": { "type": "string", "format": "date-time" },
"observedAt": { "type": "string", "format": "date-time" },
- "payload": { "type": "object", "additionalProperties": true },
+ "payload": {
+ "type": "object",
+ "properties": {
+ "channel": { "enum": ["progress", "final", "summary", "detail", "unknown"] },
+ "providerPhase": { "type": "string" },
+ "providerMethod": { "type": "string" }
+ },
+ "additionalProperties": true
+ },
"debug": { "type": "object", "additionalProperties": true }
},
"allOf": [
+ {
+ "if": { "properties": { "eventType": { "const": "workspace.file.referenced" } } },
+ "then": {
+ "properties": {
+ "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json" }
+ }
+ }
+ },
+ {
+ "if": { "properties": { "eventType": { "enum": ["workspace.change.updated", "workspace.diff.recorded"] } } },
+ "then": {
+ "properties": {
+ "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json" }
+ }
+ }
+ },
+ {
+ "if": { "properties": { "eventType": { "const": "workspace.diff.recorded" } } },
+ "then": {
+ "properties": {
+ "payload": {
+ "allOf": [
+ { "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json" },
+ { "type": "object", "properties": { "complete": { "const": true } } }
+ ]
+ }
+ }
+ }
+ },
{
"if": { "properties": { "eventType": { "const": "mcp_app.tool_input" } } },
"then": {
diff --git a/packages/paperclip-runner/protocol/schemas/workspace-diff.schema.json b/packages/paperclip-runner/protocol/schemas/workspace-diff.schema.json
new file mode 100644
index 00000000000..ebf317dbaee
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/workspace-diff.schema.json
@@ -0,0 +1,49 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json",
+ "title": "Paperclip workspace diff",
+ "type": "object",
+ "required": ["schema", "changeSetId", "revision", "source", "complete", "files", "totals", "patchArtifactRef"],
+ "properties": {
+ "schema": { "const": "paperclip.workspace.diff.v1" },
+ "changeSetId": { "type": "string", "minLength": 1, "maxLength": 200 },
+ "revision": { "type": "integer", "minimum": 1 },
+ "source": { "enum": ["harness_reported", "runner_verified"] },
+ "complete": { "type": "boolean" },
+ "files": {
+ "type": "array",
+ "maxItems": 2000,
+ "items": {
+ "type": "object",
+ "required": ["path", "operation", "previousPath", "additions", "deletions", "binary", "diff"],
+ "properties": {
+ "path": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" },
+ "operation": { "enum": ["create", "modify", "delete", "rename", "mode_change"] },
+ "previousPath": {
+ "oneOf": [
+ { "type": "null" },
+ { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" }
+ ]
+ },
+ "additions": { "type": ["integer", "null"], "minimum": 0 },
+ "deletions": { "type": ["integer", "null"], "minimum": 0 },
+ "binary": { "type": "boolean" },
+ "diff": { "type": ["string", "null"], "maxLength": 262144 }
+ },
+ "additionalProperties": false
+ }
+ },
+ "totals": {
+ "type": "object",
+ "required": ["files", "additions", "deletions"],
+ "properties": {
+ "files": { "type": "integer", "minimum": 0 },
+ "additions": { "type": ["integer", "null"], "minimum": 0 },
+ "deletions": { "type": ["integer", "null"], "minimum": 0 }
+ },
+ "additionalProperties": false
+ },
+ "patchArtifactRef": { "type": ["string", "null"], "maxLength": 2048 }
+ },
+ "additionalProperties": false
+}
diff --git a/packages/paperclip-runner/protocol/schemas/workspace-file-reference.schema.json b/packages/paperclip-runner/protocol/schemas/workspace-file-reference.schema.json
new file mode 100644
index 00000000000..e0dc8555d21
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/workspace-file-reference.schema.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json",
+ "title": "Paperclip workspace file reference",
+ "type": "object",
+ "required": ["schema", "referenceId", "source", "path", "displayName", "mediaType", "presentation", "line", "preview", "previewTruncated", "contentDigest"],
+ "properties": {
+ "schema": { "const": "paperclip.workspace.file_reference.v1" },
+ "referenceId": { "type": "string", "minLength": 1, "maxLength": 240 },
+ "source": { "enum": ["harness_reported", "runner_verified"] },
+ "path": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" },
+ "displayName": { "type": "string", "minLength": 1, "maxLength": 255 },
+ "mediaType": { "type": ["string", "null"], "maxLength": 160 },
+ "presentation": { "enum": ["document", "code", "image", "generic"] },
+ "line": { "type": ["integer", "null"], "minimum": 1 },
+ "preview": { "type": ["string", "null"], "maxLength": 262144 },
+ "previewTruncated": { "type": "boolean" },
+ "contentDigest": {
+ "oneOf": [
+ { "type": "null" },
+ { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }
+ ]
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs
index d38bdcbbc53..54e6304f359 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs
@@ -1,8 +1,16 @@
+use std::collections::BTreeMap;
use std::io::{self, BufRead, Write};
use std::process::ExitCode;
use serde_json::{json, Value};
+#[derive(Clone, Copy, Default)]
+struct Mode {
+ large_event: bool,
+ oversized_event: bool,
+ linger_after_turn_start: bool,
+}
+
fn send(value: Value) -> io::Result<()> {
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &value)?;
@@ -11,6 +19,14 @@ fn send(value: Value) -> io::Result<()> {
}
fn run() -> Result<(), Box> {
+ let args = std::env::args().skip(1).collect::>();
+ let mode = Mode {
+ large_event: args.iter().any(|arg| arg == "--large-event"),
+ oversized_event: args.iter().any(|arg| arg == "--oversized-event"),
+ linger_after_turn_start: args.iter().any(|arg| arg == "--linger-after-turn-start"),
+ };
+ let mut turn_count = 0_u64;
+ let mut pending_tool_turns = BTreeMap::::new();
let stdin = io::stdin();
for line in stdin.lock().lines() {
let request: Value = serde_json::from_str(&line?)?;
@@ -25,7 +41,7 @@ fn run() -> Result<(), Box> {
"initialize" => {
send(json!({"id": id, "result": {"user": {"sessionId": "fake-session"}}}))?
}
- "thread/start" => {
+ "thread/start" | "thread/resume" => {
let tools = request
.pointer("/params/dynamicTools")
.and_then(Value::as_array)
@@ -36,24 +52,102 @@ fn run() -> Result<(), Box> {
}) {
return Err("authorized tool was not registered".into());
}
+ let config = request.pointer("/params/config").and_then(Value::as_object);
+ for key in [
+ "skills.include_instructions",
+ "include_apps_instructions",
+ "include_collaboration_mode_instructions",
+ "features.apps",
+ "features.plugins",
+ "features.multi_agent",
+ "features.memories",
+ "features.image_generation",
+ ] {
+ if config
+ .and_then(|value| value.get(key))
+ .and_then(Value::as_bool)
+ != Some(false)
+ {
+ return Err(format!("skillless thread config omitted {key}=false").into());
+ }
+ }
send(
json!({"id": id, "result": {"thread": {"id": "fake-thread", "sessionId": "fake-session"}}}),
)?;
}
- "turn/start" => {
- send(json!({"id": id, "result": {"turn": {"id": "fake-turn"}}}))?;
+ "thread/read" => {
send(json!({
- "id": "rpc-tool-1", "method": "item/tool/call",
- "params": {"threadId": "fake-thread", "turnId": "fake-turn", "callId": "call-1", "tool": "get_task_context", "arguments": {}}
+ "id": id,
+ "result": {
+ "thread": {
+ "id": "fake-thread",
+ "sessionId": "fake-session",
+ "turns": [],
+ "tokenUsage": {"total": {"inputTokens": 0, "cachedInputTokens": 0, "outputTokens": 0, "reasoningOutputTokens": 0}}
+ }
+ }
+ }))?;
+ }
+ "turn/start" => {
+ turn_count += 1;
+ let turn_id = if turn_count == 1 {
+ "fake-turn".to_owned()
+ } else {
+ format!("fake-turn-{turn_count}")
+ };
+ let call_id = format!("call-{turn_count}");
+ let request_id = format!("rpc-tool-{turn_count}");
+ send(json!({"id": id, "result": {"turn": {"id": turn_id}}}))?;
+ send(json!({
+ "method": "turn/started",
+ "params": {"threadId": "fake-thread", "turn": {"id": turn_id, "status": "inProgress"}}
+ }))?;
+ if mode.large_event || mode.oversized_event {
+ let bytes = if mode.oversized_event {
+ 4 * 1024 * 1024 + 128
+ } else {
+ 1_200_000
+ };
+ send(json!({
+ "method": "item/completed",
+ "params": {
+ "threadId": "fake-thread",
+ "turnId": turn_id,
+ "item": {"id": "large-item", "type": "image", "imageUrl": format!("data:image/png;base64,{}", "A".repeat(bytes))}
+ }
+ }))?;
+ }
+ if mode.linger_after_turn_start {
+ continue;
+ }
+ pending_tool_turns.insert(request_id.clone(), turn_id.clone());
+ send(json!({
+ "id": request_id, "method": "item/tool/call",
+ "params": {"threadId": "fake-thread", "turnId": turn_id, "callId": call_id, "tool": "get_task_context", "arguments": {}}
}))?;
}
_ => {}
}
- if request.get("id").and_then(Value::as_str) == Some("rpc-tool-1")
- && request.get("result").is_some()
- {
+ let completed_turn = request
+ .get("id")
+ .and_then(Value::as_str)
+ .and_then(|request_id| pending_tool_turns.remove(request_id));
+ if let Some(turn_id) = completed_turn.filter(|_| request.get("result").is_some()) {
+ send(json!({
+ "method": "paperclip/runResult",
+ "params": {
+ "threadId": "fake-thread",
+ "turnId": turn_id,
+ "itemId": "semantic-result",
+ "result": {
+ "schema": "paperclip.prp.result.v1",
+ "reportedWorkDisposition": "done",
+ "summary": "Fake provider completed the operation."
+ }
+ }
+ }))?;
send(
- json!({"method": "turn/completed", "params": {"threadId": "fake-thread", "turn": {"id": "fake-turn", "status": "completed"}}}),
+ json!({"method": "turn/completed", "params": {"threadId": "fake-thread", "turn": {"id": turn_id, "status": "completed"}}}),
)?;
}
}
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
index 85e77f20968..fe860ba89e8 100644
--- 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
@@ -65,6 +65,13 @@ fn duration_value(args: &[String], name: &str, default: u64) -> Result Result, LocalRunnerError> {
+ Ok(optional_u64(args, name)?.map(Duration::from_millis))
+}
+
fn repeated_values(args: &[String], name: &str) -> Result, LocalRunnerError> {
let mut values = Vec::new();
let mut index = 0;
@@ -189,6 +196,35 @@ fn run_durable_mode(
args: &[String],
bootstrap_ticket: Option,
) -> Result<(), LocalRunnerError> {
+ let lifecycle_mode = args
+ .iter()
+ .position(|argument| argument == "--lifecycle-mode")
+ .map(|index| {
+ args.get(index + 1)
+ .cloned()
+ .ok_or_else(|| LocalRunnerError::invalid("missing value for --lifecycle-mode"))
+ })
+ .transpose()?
+ .unwrap_or_else(|| "per_turn".to_owned());
+ if lifecycle_mode != "per_turn" && lifecycle_mode != "warm" {
+ return Err(LocalRunnerError::invalid(
+ "--lifecycle-mode must be per_turn or warm",
+ ));
+ }
+ let idle_timeout = optional_duration_value(args, "--idle-timeout-ms")?;
+ if lifecycle_mode == "warm" && idle_timeout.is_none() {
+ return Err(LocalRunnerError::invalid(
+ "warm lifecycle requires --idle-timeout-ms",
+ ));
+ }
+ if lifecycle_mode == "per_turn" && idle_timeout.is_some() {
+ return Err(LocalRunnerError::invalid(
+ "per_turn lifecycle does not accept --idle-timeout-ms",
+ ));
+ }
+ let max_runtime = optional_duration_value(args, "--max-lifetime-ms")?
+ .or(optional_duration_value(args, "--max-runtime-ms")?)
+ .unwrap_or(Duration::MAX);
let config = DurableRunnerConfig {
connect_url: value(args, "--connect-url")?,
state_dir: PathBuf::from(value(args, "--state-dir")?),
@@ -214,7 +250,9 @@ fn run_durable_mode(
p0_reserve_bytes: usize_value(args, "--p0-reserve-bytes", 32 * 1024)?,
max_frame_bytes: usize_value(args, "--max-frame-bytes", 1024 * 1024)?,
reconnect_delay: duration_value(args, "--reconnect-delay-ms", 25)?,
- max_runtime: duration_value(args, "--max-runtime-ms", 15_000)?,
+ max_runtime,
+ lifecycle_mode,
+ idle_timeout,
};
let bootstrap_ticket = bootstrap_ticket
.ok_or_else(|| LocalRunnerError::invalid("runner bootstrap ticket is not available"))?;
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs
index 0e8cf878f0c..c1df769c6db 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs
@@ -9,9 +9,34 @@ use crate::local_runner::LocalRunnerError;
use crate::process_supervisor::SupervisedProcess;
use crate::provider_bridge::{AuthorizedTool, ToolResult};
+pub const CODEX_APP_SERVER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
+
+fn skillless_thread_config() -> Value {
+ json!({
+ "skills.include_instructions": false,
+ "include_apps_instructions": false,
+ "include_collaboration_mode_instructions": false,
+ "features.apps": false,
+ "features.plugins": false,
+ "features.multi_agent": false,
+ "features.memories": false,
+ "features.image_generation": false,
+ })
+}
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum ProviderKind {
+ #[default]
+ Codex,
+ Opencode,
+}
+
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CodexProviderConfig {
+ #[serde(default)]
+ pub kind: ProviderKind,
pub command: PathBuf,
#[serde(default)]
pub args: Vec,
@@ -20,6 +45,19 @@ pub struct CodexProviderConfig {
pub instructions: String,
}
+pub trait Provider {
+ fn kind(&self) -> ProviderKind;
+ fn process_id(&self) -> u32;
+ fn session_identity(&self) -> &str;
+ fn provider_session_id(&self) -> Option<&str>;
+ fn start_turn(&mut self, message: &str, cwd: &str) -> Result;
+ fn interrupt_turn(&mut self, turn_id: &str) -> Result;
+ fn read(&mut self) -> Result;
+ fn poll(&mut self) -> Result, LocalRunnerError>;
+ fn deliver_tool_result(&mut self, result: &ToolResult) -> Result<(), LocalRunnerError>;
+ fn shutdown(&mut self) -> Result<(), LocalRunnerError>;
+}
+
#[derive(Clone, Debug, PartialEq)]
pub enum CodexProviderEvent {
ToolCall {
@@ -31,10 +69,15 @@ pub enum CodexProviderEvent {
method: String,
params: Value,
},
+ SemanticResult {
+ result: Value,
+ item_id: Option,
+ },
Exited,
}
pub struct CodexProvider {
+ kind: ProviderKind,
process: SupervisedProcess,
next_request_id: u64,
thread_id: String,
@@ -53,11 +96,12 @@ impl CodexProvider {
resume_thread_id: Option<&str>,
) -> Result {
let mut provider = Self {
+ kind: config.kind,
process: SupervisedProcess::spawn(
Path::new(&config.command),
&config.args,
Duration::from_secs(2),
- 1024 * 1024,
+ CODEX_APP_SERVER_MAX_FRAME_BYTES,
)?,
next_request_id: 1,
thread_id: String::new(),
@@ -86,6 +130,9 @@ impl CodexProvider {
"cwd": config.cwd,
"model": config.model,
"approvalPolicy": "never",
+ "permissions": "paperclip-runner-workspace-only",
+ "runtimeWorkspaceRoots": [config.cwd],
+ "config": skillless_thread_config(),
"baseInstructions": config.instructions,
"dynamicTools": dynamic_tools,
"experimentalRawEvents": true,
@@ -99,6 +146,9 @@ impl CodexProvider {
"cwd": config.cwd,
"model": config.model,
"approvalPolicy": "never",
+ "permissions": "paperclip-runner-workspace-only",
+ "runtimeWorkspaceRoots": [config.cwd],
+ "config": skillless_thread_config(),
"baseInstructions": config.instructions,
"dynamicTools": dynamic_tools,
"experimentalRawEvents": true,
@@ -115,7 +165,11 @@ impl CodexProvider {
provider.session_id = opened
.pointer("/thread/sessionId")
.and_then(Value::as_str)
- .or_else(|| initialized.pointer("/user/sessionId").and_then(Value::as_str))
+ .or_else(|| {
+ initialized
+ .pointer("/user/sessionId")
+ .and_then(Value::as_str)
+ })
.filter(|value| !value.is_empty())
.map(str::to_owned);
Ok(provider)
@@ -198,6 +252,19 @@ impl CodexProvider {
}));
}
if let Some(method) = message.get("method").and_then(Value::as_str) {
+ if method == "paperclip/runResult" {
+ let params = message.get("params").cloned().unwrap_or(Value::Null);
+ let result = params.get("result").cloned().ok_or_else(|| {
+ LocalRunnerError::invalid("OpenCode semantic result omitted result")
+ })?;
+ return Ok(Some(CodexProviderEvent::SemanticResult {
+ result,
+ item_id: params
+ .get("itemId")
+ .and_then(Value::as_str)
+ .map(str::to_owned),
+ }));
+ }
return Ok(Some(CodexProviderEvent::Notification {
method: method.to_owned(),
params: message.get("params").cloned().unwrap_or(Value::Null),
@@ -256,3 +323,36 @@ impl CodexProvider {
}
}
}
+
+impl Provider for CodexProvider {
+ fn kind(&self) -> ProviderKind {
+ self.kind
+ }
+ fn process_id(&self) -> u32 {
+ CodexProvider::process_id(self)
+ }
+ fn session_identity(&self) -> &str {
+ self.thread_id()
+ }
+ fn provider_session_id(&self) -> Option<&str> {
+ self.session_id()
+ }
+ fn start_turn(&mut self, message: &str, cwd: &str) -> Result {
+ CodexProvider::start_turn(self, message, cwd)
+ }
+ fn interrupt_turn(&mut self, turn_id: &str) -> Result {
+ CodexProvider::interrupt_turn(self, turn_id)
+ }
+ fn read(&mut self) -> Result {
+ self.read_thread()
+ }
+ fn poll(&mut self) -> Result, LocalRunnerError> {
+ CodexProvider::poll(self)
+ }
+ fn deliver_tool_result(&mut self, result: &ToolResult) -> Result<(), LocalRunnerError> {
+ CodexProvider::deliver_tool_result(self, result)
+ }
+ fn shutdown(&mut self) -> Result<(), LocalRunnerError> {
+ CodexProvider::shutdown(self)
+ }
+}
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs
index 18347cdb1df..1adb3826180 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs
@@ -9,6 +9,9 @@ pub fn run_durable_runner(
}
let store = DurableStateStore::new(&config.state_dir)?;
let (mut state, recovered) = store.load_or_create(&config)?;
+ if state.last_activity_at_unix_ms == 0 {
+ state.last_activity_at_unix_ms = current_unix_ms()?;
+ }
if recovered && state.lifecycle == "revoked" {
// Revocation is durable and terminal. Do not normalize, rewrite, start
// a provider, resolve a destination, or consume the fresh bootstrap.
@@ -20,7 +23,13 @@ pub fn run_durable_runner(
state.lifecycle = "ready".to_owned();
state.record_diagnostic("resuming an explicitly suspended durable runner");
}
- let mut provider = start_configured_provider(&mut state)?;
+ let mut provider = match start_configured_provider(&mut state) {
+ Ok(provider) => provider,
+ Err(error) => {
+ persist_provider_failure(&mut state, &store, &error.to_string())?;
+ return Err(error);
+ }
+ };
if recovered {
if let Some(runtime) = provider.as_ref() {
enqueue_event(
@@ -29,10 +38,10 @@ pub fn run_durable_runner(
"provider.started",
0,
json!({
- "provider": "codex",
+ "provider": match runtime.kind() { crate::codex_provider::ProviderKind::Codex => "codex", crate::codex_provider::ProviderKind::Opencode => "opencode" },
"pid": runtime.process_id(),
- "threadId": runtime.thread_id(),
- "sessionId": runtime.session_id(),
+ "threadId": runtime.session_identity(),
+ "sessionId": runtime.provider_session_id(),
"resumed": true,
}),
None,
@@ -70,10 +79,10 @@ pub fn run_durable_runner(
let started = Instant::now();
loop {
- if started.elapsed() > config.max_runtime {
+ if started.elapsed() >= config.max_runtime && !state.active_turn {
state.recoverable_failure = Some("transport_reconnect_deadline_exceeded".to_owned());
state.lifecycle = "recoverable_failure".to_owned();
- state.record_diagnostic("transport reconnect deadline exceeded");
+ state.record_diagnostic("transport reconnect deadline exceeded before a safe rotation could flush");
store.save(&state)?;
return Err(DurableRunnerError::invalid(
"transport reconnect deadline exceeded; durable state is preserved",
@@ -221,10 +230,27 @@ pub fn run_durable_runner(
let mut revoke_deadline: Option = None;
let disconnected = loop {
poll_provider(&mut provider, &mut state, &store, &config)?;
+ if started.elapsed() >= config.max_runtime && !state.active_turn {
+ begin_automatic_suspend(
+ &mut state,
+ &store,
+ &config,
+ &mut provider,
+ "max_lifetime_rotation",
+ )?;
+ }
+ if should_suspend_for_idle(&state)? {
+ begin_automatic_suspend(&mut state, &store, &config, &mut provider, "idle_timeout")?;
+ }
if provider.is_some() {
send_outbox(&mut client, &state, &mut sent_source_seq)?;
}
if (state.stop_after_flush || state.lifecycle == "revoked") && state.outbox.is_empty() {
+ if let Some(mut runtime) = provider.take() {
+ runtime.shutdown().map_err(|error| {
+ DurableRunnerError::invalid(format!("failed to stop provider during durable exit: {error}"))
+ })?;
+ }
state.lifecycle = if state.lifecycle == "revoked" {
"revoked".to_owned()
} else if state.lifecycle == "suspending" {
@@ -292,6 +318,7 @@ pub fn run_durable_runner(
DurableRunnerError::invalid("ACK cursor is required")
})?;
state.apply_ack(acked)?;
+ state.last_activity_at_unix_ms = current_unix_ms()?;
store.save(&state)?;
}
Some("command") => {
@@ -306,6 +333,7 @@ pub fn run_durable_runner(
command,
) {
Ok(processed) => {
+ state.last_activity_at_unix_ms = current_unix_ms()?;
let delivery = client
.send_json(&command_result_envelope(&state, &processed))
.and_then(|()| send_outbox(&mut client, &state, &mut sent_source_seq));
@@ -414,11 +442,11 @@ pub fn run_durable_runner(
fn start_configured_provider(
state: &mut DurableRunnerState,
-) -> Result, DurableRunnerError> {
- let Some(provider_config) = state.codex_provider_config.clone() else {
+) -> Result >, DurableRunnerError> {
+ let Some(provider_config) = state.provider_config.clone() else {
return Ok(None);
};
- let resume_thread_id = state.codex_provider_thread_id.clone();
+ let resume_thread_id = state.provider_session_id.clone();
let tools = state
.provider_tool_bridge
.authorized_tools()
@@ -430,8 +458,8 @@ fn start_configured_provider(
resume_thread_id.as_deref(),
)
.map(|provider| {
- state.codex_provider_thread_id = Some(provider.thread_id().to_owned());
- Some(provider)
+ state.provider_session_id = Some(provider.thread_id().to_owned());
+ Some(Box::new(provider) as Box)
})
.map_err(|error| {
DurableRunnerError::invalid(format!("failed to start Codex provider: {error}"))
@@ -442,7 +470,7 @@ fn process_command_and_provider(
state: &mut DurableRunnerState,
store: &DurableStateStore,
config: &DurableRunnerConfig,
- provider: &mut Option,
+ provider: &mut Option>,
command: &Value,
) -> Result {
let command_id = command
@@ -465,10 +493,10 @@ fn process_command_and_provider(
"provider.started",
0,
json!({
- "provider": "codex",
+ "provider": match runtime.kind() { crate::codex_provider::ProviderKind::Codex => "codex", crate::codex_provider::ProviderKind::Opencode => "opencode" },
"pid": runtime.process_id(),
- "threadId": runtime.thread_id(),
- "sessionId": runtime.session_id(),
+ "threadId": runtime.session_identity(),
+ "sessionId": runtime.provider_session_id(),
"resumed": state.reconnect_count > 0,
}),
None,
@@ -490,7 +518,7 @@ fn process_command_and_provider(
DurableRunnerError::invalid("turn.start payload.text is required")
})?;
let cwd = state
- .codex_provider_config
+ .provider_config
.as_ref()
.map(|value| value.cwd.as_str())
.unwrap_or(".");
@@ -498,6 +526,7 @@ fn process_command_and_provider(
DurableRunnerError::invalid(format!("Codex turn.start failed: {error}"))
})?;
}
+ Some("run.attach") => {}
Some("semantic_tool.result") => {
let result: crate::provider_bridge::ToolResult =
serde_json::from_value(command["payload"].clone()).map_err(|error| {
@@ -536,7 +565,7 @@ fn process_command_and_provider(
.ok_or_else(|| {
DurableRunnerError::invalid("provider.thread.read requires a provider")
})?
- .read_thread()
+ .read()
.map_err(|error| {
DurableRunnerError::invalid(format!("Codex thread/read failed: {error}"))
})?;
@@ -565,7 +594,7 @@ fn process_command_and_provider(
}
fn poll_provider(
- provider: &mut Option,
+ provider: &mut Option>,
state: &mut DurableRunnerState,
store: &DurableStateStore,
config: &DurableRunnerConfig,
@@ -573,16 +602,25 @@ fn poll_provider(
let Some(runtime) = provider.as_mut() else {
return Ok(());
};
- while let Some(event) = runtime
- .poll()
- .map_err(|error| DurableRunnerError::invalid(format!("Codex provider failed: {error}")))?
- {
+ loop {
+ let event = match runtime.poll() {
+ Ok(Some(event)) => event,
+ Ok(None) => break,
+ Err(error) => {
+ let detail = error.to_string();
+ persist_provider_failure(state, store, &detail)?;
+ return Err(DurableRunnerError::invalid(format!(
+ "Codex provider failed: {detail}"
+ )));
+ }
+ };
match event {
crate::codex_provider::CodexProviderEvent::ToolCall {
call_id,
operation_id,
input,
} => {
+ state.last_activity_at_unix_ms = current_unix_ms()?;
let call = state
.provider_tool_bridge
.begin_call(call_id, operation_id, input)
@@ -601,11 +639,12 @@ fn poll_provider(
"callId": call.call_id, "input": call.input,
}
}),
- Some(&config.item_id),
+ Some(&state.item_id.clone()),
)?;
store.save(state)?;
}
crate::codex_provider::CodexProviderEvent::Notification { method, params } => {
+ state.last_activity_at_unix_ms = current_unix_ms()?;
let event_type = if method == "turn/completed" {
"turn.completed"
} else {
@@ -624,29 +663,174 @@ fn poll_provider(
event_type,
priority,
json!({"method": method, "params": params}),
- Some(&config.item_id),
+ Some(&state.item_id.clone()),
+ )?;
+ if method == "turn/completed" {
+ state.active_turn = false;
+ if state.lifecycle_mode == "warm" {
+ state.lifecycle = "warm_idle".to_owned();
+ } else {
+ state.lifecycle = "suspending".to_owned();
+ state.stop_after_flush = true;
+ enqueue_event(
+ state,
+ config,
+ "runner.suspending",
+ 0,
+ json!({ "reason": "per_turn_complete", "resumable": true }),
+ None,
+ )?;
+ }
+ }
+ store.save(state)?;
+ }
+ crate::codex_provider::CodexProviderEvent::SemanticResult {
+ result,
+ item_id,
+ } => {
+ state.last_activity_at_unix_ms = current_unix_ms()?;
+ enqueue_event(
+ state,
+ config,
+ "run.result.proposed",
+ 0,
+ result,
+ item_id.as_deref(),
)?;
store.save(state)?;
}
crate::codex_provider::CodexProviderEvent::Exited => {
- state.recoverable_failure = Some("provider_exited".to_owned());
- state.lifecycle = "recoverable_failure".to_owned();
- store.save(state)?;
- return Err(DurableRunnerError::invalid("Codex provider exited"));
+ persist_provider_failure(state, store, "Codex provider exited unexpectedly")?;
+ return Err(DurableRunnerError::invalid(
+ "native_runner_process_exited: Codex provider exited unexpectedly",
+ ));
}
}
}
Ok(())
}
+fn should_suspend_for_idle(state: &DurableRunnerState) -> Result {
+ should_suspend_for_idle_at(state, current_unix_ms()?)
+}
+
+fn should_suspend_for_idle_at(state: &DurableRunnerState, now_unix_ms: u64) -> Result {
+ if state.lifecycle_mode != "warm"
+ || state.stop_after_flush
+ || !matches!(state.lifecycle.as_str(), "warm_idle" | "waiting_input")
+ {
+ return Ok(false);
+ }
+ let Some(timeout_ms) = state.idle_timeout_ms else {
+ return Ok(false);
+ };
+ Ok(now_unix_ms.saturating_sub(state.last_activity_at_unix_ms) >= timeout_ms)
+}
+
+fn begin_automatic_suspend(
+ state: &mut DurableRunnerState,
+ store: &DurableStateStore,
+ config: &DurableRunnerConfig,
+ provider: &mut Option>,
+ reason: &str,
+) -> Result<(), DurableRunnerError> {
+ if state.stop_after_flush {
+ return Ok(());
+ }
+ if let Some(mut runtime) = provider.take() {
+ runtime.shutdown().map_err(|error| {
+ DurableRunnerError::invalid(format!("failed to checkpoint provider for suspension: {error}"))
+ })?;
+ }
+ state.lifecycle = "suspending".to_owned();
+ state.stop_after_flush = true;
+ enqueue_event(
+ state,
+ config,
+ "runner.suspending",
+ 0,
+ json!({ "reason": reason, "afterDurableFlush": true, "resumable": true }),
+ None,
+ )?;
+ enqueue_event(
+ state,
+ config,
+ "runner.suspended",
+ 0,
+ json!({ "reason": reason, "resumable": true }),
+ None,
+ )?;
+ store.save(state)
+}
+
+fn provider_failure_code(detail: &str) -> &'static str {
+ if detail.contains("stdout frame exceeded") || detail.contains("provider_frame_too_large") {
+ "provider_frame_too_large"
+ } else {
+ "provider_transport_failed"
+ }
+}
+
+fn persist_provider_failure(
+ state: &mut DurableRunnerState,
+ store: &DurableStateStore,
+ detail: &str,
+) -> Result<(), DurableRunnerError> {
+ let code = provider_failure_code(detail);
+ state.recoverable_failure = Some(code.to_owned());
+ state.lifecycle = "recoverable_failure".to_owned();
+ state.record_diagnostic(format!("{code}: {detail}"));
+ store.save(state)
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ use crate::codex_provider::{CodexProviderEvent, Provider, ProviderKind};
+ use crate::local_runner::LocalRunnerError;
+ use crate::provider_bridge::ToolResult;
use std::net::TcpListener;
use std::sync::{mpsc, Mutex};
static ENVIRONMENT_TEST_LOCK: Mutex<()> = Mutex::new(());
+ struct OversizedFrameProvider;
+
+ impl Provider for OversizedFrameProvider {
+ fn kind(&self) -> ProviderKind {
+ ProviderKind::Codex
+ }
+ fn process_id(&self) -> u32 {
+ 1
+ }
+ fn session_identity(&self) -> &str {
+ "test-thread"
+ }
+ fn provider_session_id(&self) -> Option<&str> {
+ Some("test-session")
+ }
+ fn start_turn(&mut self, _message: &str, _cwd: &str) -> Result {
+ unreachable!()
+ }
+ fn interrupt_turn(&mut self, _turn_id: &str) -> Result {
+ unreachable!()
+ }
+ fn read(&mut self) -> Result {
+ unreachable!()
+ }
+ fn poll(&mut self) -> Result, LocalRunnerError> {
+ Err(LocalRunnerError::invalid(
+ "harness stdout frame exceeded 4194304 bytes",
+ ))
+ }
+ fn deliver_tool_result(&mut self, _result: &ToolResult) -> Result<(), LocalRunnerError> {
+ unreachable!()
+ }
+ fn shutdown(&mut self) -> Result<(), LocalRunnerError> {
+ Ok(())
+ }
+ }
+
#[test]
fn usage_counts_remain_observable_while_credential_tokens_are_redacted() {
let sanitized = crate::durable::sanitize_value(&json!({
@@ -657,6 +841,81 @@ mod tests {
assert_eq!(sanitized.pointer("/accessToken"), Some(&json!("[REDACTED]")));
}
+ #[test]
+ fn durable_events_strip_binary_payloads_but_preserve_artifact_metadata() {
+ let sanitized = crate::durable::sanitize_value(&json!({
+ "item": {
+ "id": "image-1",
+ "artifactPath": "/tmp/frog.png",
+ "imageUrl": format!("data:image/png;base64,{}", "A".repeat(1_200_000)),
+ }
+ }));
+ assert_eq!(sanitized.pointer("/item/id"), Some(&json!("image-1")));
+ assert_eq!(sanitized.pointer("/item/artifactPath"), Some(&json!("/tmp/frog.png")));
+ assert_eq!(
+ sanitized.pointer("/item/imageUrl").and_then(Value::as_str),
+ Some("[OMITTED data URL: 1200022 bytes]")
+ );
+ assert!(serde_json::to_vec(&sanitized).unwrap().len() < 1_024);
+ }
+
+ #[test]
+ fn provider_failure_codes_are_stable() {
+ assert_eq!(provider_failure_code("harness stdout frame exceeded 4194304 bytes"), "provider_frame_too_large");
+ assert_eq!(provider_failure_code("invalid JSON-RPC"), "provider_transport_failed");
+ }
+
+ #[test]
+ fn warm_idle_clock_expires_only_safe_inactive_states() {
+ let root = temporary_root("warm-idle-clock");
+ let mut runner_config = config(&root);
+ runner_config.lifecycle_mode = "warm".to_owned();
+ runner_config.idle_timeout = Some(Duration::from_millis(300_000));
+ let store = DurableStateStore::new(&root).unwrap();
+ let (mut state, _) = store.load_or_create(&runner_config).unwrap();
+ state.lifecycle_mode = "warm".to_owned();
+ state.idle_timeout_ms = Some(300_000);
+ state.last_activity_at_unix_ms = 1_000_000;
+
+ for lifecycle in ["warm_idle", "waiting_input"] {
+ state.lifecycle = lifecycle.to_owned();
+ assert!(!should_suspend_for_idle_at(&state, 1_299_999).unwrap());
+ assert!(should_suspend_for_idle_at(&state, 1_300_000).unwrap());
+ }
+ for lifecycle in ["active", "ready", "suspending", "suspended"] {
+ state.lifecycle = lifecycle.to_owned();
+ assert!(!should_suspend_for_idle_at(&state, 2_000_000).unwrap());
+ }
+ state.lifecycle = "warm_idle".to_owned();
+ state.stop_after_flush = true;
+ assert!(!should_suspend_for_idle_at(&state, 2_000_000).unwrap());
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn oversized_provider_frame_persists_a_recoverable_failure() {
+ let root = temporary_root("oversized-provider-frame");
+ let runner_config = config(&root);
+ let store = DurableStateStore::new(&root).unwrap();
+ let (mut state, _) = store.load_or_create(&runner_config).unwrap();
+ let mut provider: Option> = Some(Box::new(OversizedFrameProvider));
+
+ let error = poll_provider(&mut provider, &mut state, &store, &runner_config).unwrap_err();
+ assert!(error.to_string().contains("stdout frame exceeded 4194304 bytes"));
+ let (restored, recovered) = store.load_or_create(&runner_config).unwrap();
+ assert!(recovered);
+ assert_eq!(restored.lifecycle, "recoverable_failure");
+ assert_eq!(
+ restored.recoverable_failure.as_deref(),
+ Some("provider_frame_too_large")
+ );
+ assert!(restored
+ .diagnostics
+ .iter()
+ .any(|entry| entry.contains("provider_frame_too_large")));
+ let _ = fs::remove_dir_all(root);
+ }
+
fn config(root: &Path) -> DurableRunnerConfig {
DurableRunnerConfig {
connect_url: "ws://127.0.0.1:1/durable_runner/connect".to_owned(),
@@ -676,9 +935,32 @@ mod tests {
max_frame_bytes: 1024 * 1024,
reconnect_delay: Duration::from_millis(1),
max_runtime: Duration::from_millis(10),
+ lifecycle_mode: "per_turn".to_owned(),
+ idle_timeout: None,
}
}
+ #[test]
+ fn legacy_codex_provider_fields_deserialize_as_codex() {
+ let root = temporary_root("legacy-provider-state");
+ let runner_config = config(&root);
+ let store = DurableStateStore::new(&root).unwrap();
+ let (state, _) = store.load_or_create(&runner_config).unwrap();
+ let mut value = serde_json::to_value(state).unwrap();
+ let object = value.as_object_mut().unwrap();
+ object.remove("providerConfig");
+ object.remove("providerSessionId");
+ object.insert("codexProviderConfig".to_owned(), json!({
+ "command": "codex", "args": ["app-server"], "cwd": "/tmp",
+ "model": null, "instructions": "legacy"
+ }));
+ object.insert("codexProviderThreadId".to_owned(), json!("thread-legacy"));
+ let restored: DurableRunnerState = serde_json::from_value(value).unwrap();
+ assert_eq!(restored.provider_config.unwrap().kind, crate::codex_provider::ProviderKind::Codex);
+ assert_eq!(restored.provider_session_id.as_deref(), Some("thread-legacy"));
+ let _ = fs::remove_dir_all(root);
+ }
+
fn temporary_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"paperclip-runner-durable_runner-{name}-{}",
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs
index 132ed7a516f..b2de9efe020 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs
@@ -35,6 +35,7 @@ const COMPACTED_COMMAND_FILTER_BYTES: usize = 4096;
const COMPACTED_COMMAND_FILTER_HASHES: usize = 7;
const REVOKE_FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
const TEMP_FILE_ATTEMPTS: usize = 16;
+const MAX_DURABLE_STRING_BYTES: usize = 32 * 1024;
type HmacSha256 = Hmac;
@@ -120,6 +121,16 @@ pub struct DurableRunnerConfig {
pub max_frame_bytes: usize,
pub reconnect_delay: Duration,
pub max_runtime: Duration,
+ pub lifecycle_mode: String,
+ pub idle_timeout: Option,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct AttachmentIdentity {
+ pub run_id: String,
+ pub turn_id: String,
+ pub item_id: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@@ -155,7 +166,17 @@ pub struct DurableRunnerState {
pub normalized_session_id: String,
pub turn_id: String,
pub item_id: String,
+ #[serde(default)]
+ pub previous_attachment_identity: Option,
pub lifecycle: String,
+ #[serde(default = "default_lifecycle_mode")]
+ pub lifecycle_mode: String,
+ #[serde(default)]
+ pub idle_timeout_ms: Option,
+ #[serde(default)]
+ pub last_activity_at_unix_ms: u64,
+ #[serde(default)]
+ pub active_turn: bool,
pub next_source_seq: u64,
pub acked_source_seq: u64,
pub last_controller_command_seq: u64,
@@ -176,10 +197,10 @@ pub struct DurableRunnerState {
pub stop_after_flush: bool,
#[serde(default)]
pub provider_tool_bridge: ProviderToolBridge,
- #[serde(default)]
- pub codex_provider_config: Option,
- #[serde(default)]
- pub codex_provider_thread_id: Option,
+ #[serde(default, alias = "codexProviderConfig")]
+ pub provider_config: Option,
+ #[serde(default, alias = "codexProviderThreadId")]
+ pub provider_session_id: Option,
}
impl DurableRunnerState {
@@ -192,7 +213,12 @@ impl DurableRunnerState {
normalized_session_id: config.normalized_session_id.clone(),
turn_id: config.turn_id.clone(),
item_id: config.item_id.clone(),
+ previous_attachment_identity: None,
lifecycle: "connecting".to_owned(),
+ lifecycle_mode: config.lifecycle_mode.clone(),
+ idle_timeout_ms: config.idle_timeout.and_then(|duration| u64::try_from(duration.as_millis()).ok()),
+ last_activity_at_unix_ms: 0,
+ active_turn: false,
next_source_seq: 1,
acked_source_seq: 0,
last_controller_command_seq: 0,
@@ -210,8 +236,8 @@ impl DurableRunnerState {
harness_generation: 1,
stop_after_flush: false,
provider_tool_bridge: ProviderToolBridge::default(),
- codex_provider_config: None,
- codex_provider_thread_id: None,
+ provider_config: None,
+ provider_session_id: None,
}
}
@@ -240,7 +266,7 @@ impl DurableRunnerState {
Ok(())
}
- fn record_diagnostic(&mut self, message: impl Into) {
+ pub(crate) fn record_diagnostic(&mut self, message: impl Into) {
self.diagnostics.push(redact_text(&message.into()));
if self.diagnostics.len() > MAX_DIAGNOSTICS {
self.diagnostics.remove(0);
@@ -248,6 +274,10 @@ impl DurableRunnerState {
}
}
+fn default_lifecycle_mode() -> String {
+ "per_turn".to_owned()
+}
+
#[derive(Clone, Debug)]
pub struct DurableStateStore {
path: PathBuf,
@@ -597,14 +627,11 @@ fn validate_state_binding(
state.environment_lease_id.as_str(),
config.environment_lease_id.as_str(),
),
- ("runId", state.run_id.as_str(), config.run_id.as_str()),
(
"normalizedSessionId",
state.normalized_session_id.as_str(),
config.normalized_session_id.as_str(),
),
- ("turnId", state.turn_id.as_str(), config.turn_id.as_str()),
- ("itemId", state.item_id.as_str(), config.item_id.as_str()),
] {
if stored != expected {
return Err(DurableRunnerError::invalid(format!(
@@ -612,6 +639,11 @@ fn validate_state_binding(
)));
}
}
+ if state.lifecycle_mode != config.lifecycle_mode {
+ return Err(DurableRunnerError::invalid(
+ "durable lifecycle policy does not match the requested recovery policy",
+ ));
+ }
decode_compacted_command_filter(&state.compacted_command_filter)?;
Ok(())
}
@@ -681,11 +713,30 @@ pub fn sanitize_value(value: &Value) -> Value {
.collect(),
),
Value::Array(values) => Value::Array(values.iter().map(sanitize_value).collect()),
- Value::String(text) => Value::String(redact_text(text)),
+ Value::String(text) => Value::String(sanitize_text_payload(text)),
other => other.clone(),
}
}
+fn sanitize_text_payload(input: &str) -> String {
+ let redacted = redact_text(input);
+ if redacted.starts_with("data:") && redacted.len() > 4 * 1024 {
+ return format!("[OMITTED data URL: {} bytes]", redacted.len());
+ }
+ if redacted.len() <= MAX_DURABLE_STRING_BYTES {
+ return redacted;
+ }
+ let mut boundary = MAX_DURABLE_STRING_BYTES;
+ while !redacted.is_char_boundary(boundary) {
+ boundary -= 1;
+ }
+ format!(
+ "{}\n[TRUNCATED: {} bytes omitted]",
+ &redacted[..boundary],
+ redacted.len() - boundary
+ )
+}
+
fn canonical_json(value: &Value) -> String {
match value {
Value::Object(object) => {
@@ -1145,14 +1196,14 @@ fn execute_command_effect(
"run.prepare provider is invalid: {error}"
))
})?;
- if let Some(existing) = &state.codex_provider_config {
+ if let Some(existing) = &state.provider_config {
if existing != &provider {
return Err(DurableRunnerError::invalid(
"provider configuration changed across a durable session",
));
}
}
- state.codex_provider_config = Some(provider);
+ state.provider_config = Some(provider);
}
state.lifecycle = "ready".to_owned();
enqueue_event(
@@ -1165,6 +1216,58 @@ fn execute_command_effect(
)?;
Ok(("completed".to_owned(), 1, "run prepared".to_owned()))
}
+ "run.attach" => {
+ if state.active_turn || state.lifecycle == "active" || state.lifecycle == "waiting_input" {
+ return Ok((
+ "rejected".to_owned(),
+ 0,
+ "a run cannot attach while another run or turn is active".to_owned(),
+ ));
+ }
+ let required = |name: &str| {
+ payload.get(name).and_then(Value::as_str).filter(|value| !value.is_empty())
+ .map(str::to_owned)
+ .ok_or_else(|| DurableRunnerError::invalid(format!("run.attach payload.{name} is required")))
+ };
+ let run_id = required("runId")?;
+ let turn_id = required("turnId")?;
+ let item_id = required("itemId")?;
+ if let Some(value) = payload.get("authorizedTools") {
+ let tool_set: AuthorizedToolSet = serde_json::from_value(value.clone()).map_err(|error| {
+ DurableRunnerError::invalid(format!("run.attach authorizedTools is invalid: {error}"))
+ })?;
+ state.provider_tool_bridge.attach_run(tool_set).map_err(|error| {
+ DurableRunnerError::invalid(format!("run.attach tool contract rejected: {error}"))
+ })?;
+ } else {
+ state.provider_tool_bridge.attach_existing_run().map_err(|error| {
+ DurableRunnerError::invalid(format!("run.attach tool contract rejected: {error}"))
+ })?;
+ }
+ state.previous_attachment_identity = Some(AttachmentIdentity {
+ run_id: state.run_id.clone(),
+ turn_id: state.turn_id.clone(),
+ item_id: state.item_id.clone(),
+ });
+ state.run_id = run_id;
+ state.turn_id = turn_id;
+ state.item_id = item_id;
+ state.lifecycle = "ready".to_owned();
+ enqueue_event(
+ state,
+ config,
+ "run.attached",
+ 0,
+ json!({
+ "runId": state.run_id,
+ "turnId": state.turn_id,
+ "itemId": state.item_id,
+ "sameSession": true,
+ }),
+ None,
+ )?;
+ Ok(("completed".to_owned(), 1, "run attached to durable session".to_owned()))
+ }
"semantic_tool.result" => {
let result: ToolResult = serde_json::from_value(payload.clone()).map_err(|error| {
DurableRunnerError::invalid(format!("semantic tool result is invalid: {error}"))
@@ -1283,6 +1386,8 @@ fn execute_command_effect(
"new turns are disabled while draining or backpressured".to_owned(),
)),
"turn.start" => {
+ state.active_turn = true;
+ state.lifecycle = "active".to_owned();
let item_id = state.item_id.clone();
enqueue_event(
state,
@@ -1292,7 +1397,7 @@ fn execute_command_effect(
json!({ "turnId": state.turn_id, "sameSession": true }),
None,
)?;
- if state.codex_provider_config.is_some() {
+ if state.provider_config.is_some() {
return Ok((
"completed".to_owned(),
1,
@@ -1349,7 +1454,23 @@ fn execute_command_effect(
}),
None,
)?;
- state.lifecycle = "terminal".to_owned();
+ state.active_turn = false;
+ state.lifecycle = if state.lifecycle_mode == "warm" {
+ "warm_idle".to_owned()
+ } else {
+ "suspending".to_owned()
+ };
+ if state.lifecycle_mode == "per_turn" {
+ state.stop_after_flush = true;
+ enqueue_event(
+ state,
+ config,
+ "runner.suspending",
+ 0,
+ json!({ "reason": "per_turn_complete", "resumable": true }),
+ None,
+ )?;
+ }
Ok(("completed".to_owned(), 1, "turn completed".to_owned()))
}
"turn.interrupt" => Ok((
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs
index 716349b2a42..ccb3292eacd 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs
@@ -696,17 +696,29 @@ fn validate_control_identity(
for (field, expected) in [
("runnerInstanceId", state.runner_instance_id.as_str()),
("environmentLeaseId", state.environment_lease_id.as_str()),
- ("runId", state.run_id.as_str()),
("normalizedSessionId", state.normalized_session_id.as_str()),
- ("turnId", state.turn_id.as_str()),
- ("itemId", state.item_id.as_str()),
] {
- if required_string(value, field)? != expected {
+ let actual = required_string(value, field)?;
+ if actual != expected {
return Err(DurableRunnerError::invalid(format!(
- "control envelope {field} does not match the authenticated session"
+ "control envelope {field} does not match the authenticated session (expected {expected}, received {actual})"
)));
}
}
+ let current_attachment_matches = required_string(value, "runId")? == state.run_id
+ && required_string(value, "turnId")? == state.turn_id
+ && required_string(value, "itemId")? == state.item_id;
+ let prior_ack_matches = value.get("kind").and_then(Value::as_str) == Some("ack")
+ && state.previous_attachment_identity.as_ref().is_some_and(|prior| {
+ value.get("runId").and_then(Value::as_str) == Some(prior.run_id.as_str())
+ && value.get("turnId").and_then(Value::as_str) == Some(prior.turn_id.as_str())
+ && value.get("itemId").and_then(Value::as_str) == Some(prior.item_id.as_str())
+ });
+ if !current_attachment_matches && !prior_ack_matches {
+ return Err(DurableRunnerError::invalid(
+ "control envelope run attachment does not match the authenticated session",
+ ));
+ }
if let Some(connection) = connection {
if required_string(value, "connectionId")? != connection.connection_id
|| required_string(value, "connectionLeaseId")? != connection.lease_id
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs
index 23db4fd7562..a73b4110ee4 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs
@@ -221,6 +221,18 @@ impl SupervisedProcess {
"TMPDIR",
"TEMP",
"TMP",
+ "TZ",
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "NO_PROXY",
+ "ALL_PROXY",
+ "SSL_CERT_DIR",
+ "OPENROUTER_API_KEY",
+ "PAPERCLIP_OPENCODE_COMMAND",
+ "PAPERCLIP_OPENCODE_RUNTIME_DIR",
+ "PAPERCLIP_RUNNER_INSTANCE_ID",
+ "PAPERCLIP_RUN_ID",
+ "PAPERCLIP_NORMALIZED_SESSION_ID",
] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs
index 07844b6524a..1a66f76caf0 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs
@@ -72,6 +72,35 @@ impl Error for ProviderBridgeError {}
impl ProviderToolBridge {
pub fn prepare(&mut self, tool_set: AuthorizedToolSet) -> Result<(), ProviderBridgeError> {
+ self.prepare_internal(tool_set, false)
+ }
+
+ pub fn attach_run(&mut self, tool_set: AuthorizedToolSet) -> Result<(), ProviderBridgeError> {
+ if !self.pending.is_empty() {
+ return Err(ProviderBridgeError::invalid(
+ "cannot attach a new run while provider tool calls are pending",
+ ));
+ }
+ self.prepare_internal(tool_set, true)?;
+ self.completed.clear();
+ Ok(())
+ }
+
+ pub fn attach_existing_run(&mut self) -> Result<(), ProviderBridgeError> {
+ if !self.pending.is_empty() {
+ return Err(ProviderBridgeError::invalid(
+ "cannot attach a new run while provider tool calls are pending",
+ ));
+ }
+ self.completed.clear();
+ Ok(())
+ }
+
+ fn prepare_internal(
+ &mut self,
+ tool_set: AuthorizedToolSet,
+ allow_catalog_change: bool,
+ ) -> Result<(), ProviderBridgeError> {
if tool_set.schema != TOOL_SET_SCHEMA || tool_set.schema_version != 1 {
return Err(ProviderBridgeError::invalid(
"unsupported authorized tool-set contract",
@@ -106,11 +135,13 @@ impl ProviderToolBridge {
));
}
}
- if let Some(existing) = &self.catalog_digest {
- if existing != &tool_set.catalog_digest {
- return Err(ProviderBridgeError::invalid(
- "authorized tool set changed across a durable session",
- ));
+ if !allow_catalog_change {
+ if let Some(existing) = &self.catalog_digest {
+ if existing != &tool_set.catalog_digest {
+ return Err(ProviderBridgeError::invalid(
+ "authorized tool set changed across a durable session",
+ ));
+ }
}
}
self.catalog_digest = Some(tool_set.catalog_digest);
diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs
index f35a9a0ae22..c438149a519 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs
@@ -2,7 +2,7 @@ use std::path::PathBuf;
use std::time::{Duration, Instant};
use paperclip_runner_core::codex_provider::{
- CodexProvider, CodexProviderConfig, CodexProviderEvent,
+ CodexProvider, CodexProviderConfig, CodexProviderEvent, Provider, ProviderKind,
};
use paperclip_runner_core::provider_bridge::{AuthorizedTool, ToolResult};
use serde_json::json;
@@ -11,6 +11,7 @@ use serde_json::json;
fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
let mut provider = CodexProvider::start(
&CodexProviderConfig {
+ kind: ProviderKind::Codex,
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
args: Vec::new(),
cwd: "/tmp".to_owned(),
@@ -56,14 +57,155 @@ fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
}
}
let deadline = Instant::now() + Duration::from_secs(2);
+ let mut saw_semantic_result = false;
loop {
assert!(
Instant::now() < deadline,
"fake Codex did not complete after its tool result"
);
- if matches!(provider.poll().unwrap(), Some(CodexProviderEvent::Notification { method, .. }) if method == "turn/completed")
- {
+ match provider.poll().unwrap() {
+ Some(CodexProviderEvent::SemanticResult { result, item_id }) => {
+ assert_eq!(result["reportedWorkDisposition"], "done");
+ assert_eq!(item_id.as_deref(), Some("semantic-result"));
+ saw_semantic_result = true;
+ }
+ Some(CodexProviderEvent::Notification { method, .. }) if method == "turn/completed" => {
+ assert!(
+ saw_semantic_result,
+ "semantic result must precede terminal completion"
+ );
+ break;
+ }
+ _ => {}
+ }
+ }
+}
+
+#[test]
+fn provider_contract_preserves_the_opencode_tag() {
+ let mut provider = CodexProvider::start(
+ &CodexProviderConfig {
+ kind: ProviderKind::Opencode,
+ command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
+ args: Vec::new(),
+ cwd: "/tmp".to_owned(),
+ model: Some("openrouter/deepseek/deepseek-v4-flash-0731".to_owned()),
+ instructions: "Use the authorized Paperclip tools.".to_owned(),
+ },
+ vec![AuthorizedTool {
+ operation_id: "get_task_context".to_owned(),
+ version: 1,
+ description: "Read task context.".to_owned(),
+ input_schema: json!({"type": "object"}),
+ response_schema: json!({"type": "object"}),
+ }]
+ .into_iter(),
+ None,
+ )
+ .unwrap();
+ assert_eq!(Provider::kind(&provider), ProviderKind::Opencode);
+ Provider::shutdown(&mut provider).unwrap();
+}
+
+#[test]
+fn image_like_prompt_stays_skillless_and_accepts_a_valid_one_point_two_megabyte_event() {
+ let mut provider = CodexProvider::start(
+ &CodexProviderConfig {
+ kind: ProviderKind::Codex,
+ command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
+ args: vec!["--large-event".to_owned()],
+ cwd: "/tmp".to_owned(),
+ model: None,
+ instructions: "Use only the authorized Paperclip tools.".to_owned(),
+ },
+ vec![AuthorizedTool {
+ operation_id: "get_task_context".to_owned(),
+ version: 1,
+ description: "Read task context.".to_owned(),
+ input_schema: json!({"type": "object"}),
+ response_schema: json!({"type": "object"}),
+ }]
+ .into_iter(),
+ None,
+ )
+ .unwrap();
+ provider
+ .start_turn("Create an ASCII frog.", "/tmp")
+ .unwrap();
+ let deadline = Instant::now() + Duration::from_secs(3);
+ let mut saw_large_event = false;
+ let mut saw_authorized_tool = false;
+ loop {
+ assert!(
+ Instant::now() < deadline,
+ "large provider event was not received"
+ );
+ match provider.poll().unwrap() {
+ Some(CodexProviderEvent::Notification { method, params })
+ if method == "item/completed" =>
+ {
+ assert!(
+ params
+ .pointer("/item/imageUrl")
+ .and_then(|value| value.as_str())
+ .unwrap()
+ .len()
+ > 1_100_000
+ );
+ saw_large_event = true;
+ }
+ Some(CodexProviderEvent::ToolCall { operation_id, .. }) => {
+ assert_eq!(operation_id, "get_task_context");
+ saw_authorized_tool = true;
+ }
+ _ => {}
+ }
+ if saw_large_event && saw_authorized_tool {
break;
+ }
+ }
+ provider.shutdown().unwrap();
+}
+
+#[test]
+fn codex_provider_rejects_an_event_above_the_four_megabyte_hard_limit() {
+ let mut provider = CodexProvider::start(
+ &CodexProviderConfig {
+ kind: ProviderKind::Codex,
+ command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
+ args: vec!["--oversized-event".to_owned()],
+ cwd: "/tmp".to_owned(),
+ model: None,
+ instructions: "Use only the authorized Paperclip tools.".to_owned(),
+ },
+ vec![AuthorizedTool {
+ operation_id: "get_task_context".to_owned(),
+ version: 1,
+ description: "Read task context.".to_owned(),
+ input_schema: json!({"type": "object"}),
+ response_schema: json!({"type": "object"}),
+ }]
+ .into_iter(),
+ None,
+ )
+ .unwrap();
+ provider
+ .start_turn("Create an ASCII frog.", "/tmp")
+ .unwrap();
+ let deadline = Instant::now() + Duration::from_secs(3);
+ loop {
+ assert!(
+ Instant::now() < deadline,
+ "oversized provider event did not fail"
+ );
+ match provider.poll() {
+ Err(error) => {
+ assert!(error
+ .to_string()
+ .contains("stdout frame exceeded 4194304 bytes"));
+ break;
+ }
+ _ => {}
}
}
}
diff --git a/packages/paperclip-runner/scripts/capability-issue-thread-server.mjs b/packages/paperclip-runner/scripts/capability-issue-thread-server.mjs
index 857e79b8609..9ba552a689b 100644
--- a/packages/paperclip-runner/scripts/capability-issue-thread-server.mjs
+++ b/packages/paperclip-runner/scripts/capability-issue-thread-server.mjs
@@ -202,6 +202,42 @@ class RouteError extends Error {
}
}
+function harnessConfiguration(source, fallbackModel) {
+ const provider = source.provider === undefined ? "codex" : String(source.provider).trim();
+ if (provider !== "codex" && provider !== "opencode") {
+ throw new RouteError(400, "invalid_provider", "Provider must be codex or opencode.");
+ }
+ const rawModel = source.model === undefined ? fallbackModel : source.model;
+ const model = rawModel === undefined || rawModel === null ? "" : String(rawModel).trim();
+ if (model.length > 256) throw new RouteError(400, "invalid_model", "Model is too long.");
+ if (provider === "opencode" && (!model || !model.includes("/"))) {
+ throw new RouteError(400, "invalid_model", "OpenCode requires a provider/model value.");
+ }
+ const suppliedLifecycle = source.lifecyclePolicy && typeof source.lifecyclePolicy === "object"
+ ? source.lifecyclePolicy
+ : source;
+ const lifecycleMode = suppliedLifecycle.mode === undefined
+ ? source.lifecycleMode === undefined ? "warm" : String(source.lifecycleMode).trim()
+ : String(suppliedLifecycle.mode).trim();
+ if (lifecycleMode !== "per_turn" && lifecycleMode !== "warm") {
+ throw new RouteError(400, "invalid_lifecycle_mode", "Execution mode must be per_turn or warm.");
+ }
+ let lifecyclePolicy;
+ if (lifecycleMode === "per_turn") {
+ lifecyclePolicy = { mode: "per_turn", idleTimeoutMs: null };
+ } else {
+ const rawIdleTimeout = suppliedLifecycle.idleTimeoutMs === undefined
+ ? source.idleTimeoutMs
+ : suppliedLifecycle.idleTimeoutMs;
+ const idleTimeoutMs = rawIdleTimeout === undefined ? 300_000 : Number(rawIdleTimeout);
+ if (!Number.isSafeInteger(idleTimeoutMs) || idleTimeoutMs <= 0) {
+ throw new RouteError(400, "invalid_idle_timeout", "Warm idle timeout must be a positive integer.");
+ }
+ lifecyclePolicy = { mode: "warm", idleTimeoutMs };
+ }
+ return { provider, model: model || null, lifecyclePolicy };
+}
+
export function createCapabilityIssueThreadMiddleware(options = {}) {
const load = options.loadRunner ?? loadCapabilityIssueThreadRunner;
const workingDirectoryRoot = options.scratchRoot ?? scratchRoot();
@@ -269,9 +305,19 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
* out rather than on the way in.
*/
function liveView(runner, entry) {
+ const snapshot = entry.session.snapshot();
+ const provider = snapshot.config.provider ?? "codex";
+ const expectedAgentLabel = provider === "opencode" ? "Real OpenCode" : "Real Codex";
const projected = view(runner, entry);
- if (projected.mode !== "live" || projected.identity.agentLabel !== "Real Codex") {
- throw new RouteError(500, "live_mode_required", "The clean-room path refused a non-live view.");
+ if (
+ projected.mode !== "live"
+ || projected.identity.agentLabel !== expectedAgentLabel
+ ) {
+ throw new RouteError(
+ 500,
+ "provider_identity_mismatch",
+ "The clean-room path refused a view whose provider identity did not match its immutable session.",
+ );
}
return projected;
}
@@ -326,15 +372,12 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
return entry;
}
- /** Retire a session: stop its work, clear its authority, drop its workspace. */
+ /** Archive a session resumably; destructive deletion is intentionally absent. */
async function retire(service, sessionId, reason) {
const entry = sessions.get(sessionId);
if (entry === undefined) return;
- sessions.delete(sessionId);
await service.stop(sessionId, reason).catch(() => undefined);
- if (entry.ownsWorkingDirectory) {
- await rm(entry.workingDirectory, { recursive: true, force: true }).catch(() => undefined);
- }
+ entry.connection = { state: "suspended", attempt: entry.connection.attempt };
}
/**
@@ -351,8 +394,10 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
}
}
- async function createCleanRoomSession(runner, service, capabilityHash) {
- const open = [...sessions.values()].filter((entry) => entry.surface === "cleanroom");
+ async function createCleanRoomSession(runner, service, capabilityHash, configuration) {
+ const open = [...sessions.values()].filter((entry) =>
+ entry.surface === "cleanroom" && entry.session.snapshot().status !== "suspended",
+ );
// Bounded concurrency: the oldest clean room yields rather than refusing a
// new board user, because an abandoned chat is the likelier tenant here.
for (const stale of open.slice(0, Math.max(0, open.length - (MAX_CLEAN_ROOM_SESSIONS - 1)))) {
@@ -367,7 +412,9 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
try {
session = await service.create({
...input,
- ...(options.requestedModel === undefined ? {} : { requestedModel: options.requestedModel }),
+ provider: configuration.provider,
+ lifecyclePolicy: configuration.lifecyclePolicy,
+ ...(configuration.model === null ? {} : { requestedModel: configuration.model }),
});
} catch (error) {
await rm(workingDirectory, { recursive: true, force: true }).catch(() => undefined);
@@ -383,6 +430,7 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
turns: 0,
createdAt: Date.now(),
capabilityHash,
+ configuration,
connection: { state: "connected", attempt: 0 },
};
sessions.set(session.id, entry);
@@ -390,12 +438,31 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
}
function cleanRoomPayload(runner, entry) {
+ const snapshot = entry.session.snapshot();
+ const configuration = {
+ provider: snapshot.config.provider ?? "codex",
+ model: snapshot.config.requestedModel ?? null,
+ lifecyclePolicy: snapshot.config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
+ };
+ if (
+ entry.configuration !== undefined
+ && (entry.configuration.provider !== configuration.provider
+ || entry.configuration.model !== configuration.model
+ || JSON.stringify(entry.configuration.lifecyclePolicy) !== JSON.stringify(configuration.lifecyclePolicy))
+ ) {
+ throw new RouteError(
+ 500,
+ "provider_configuration_mismatch",
+ "The clean-room path refused configuration that differed from its immutable session.",
+ );
+ }
return {
sessionId: entry.session.id,
surface: "cleanroom",
identity: entry.identity,
limits: { maxTurns: MAX_TURNS_PER_SESSION, maxMessageBytes: MAX_MESSAGE_BYTES },
turns: entry.turns,
+ configuration,
view: liveView(runner, entry),
};
}
@@ -542,6 +609,13 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
typeof body.sessionId === "string" ? body.sessionId : url.searchParams.get("sessionId");
const scenario =
typeof body.scenario === "string" ? body.scenario : url.searchParams.get("scenario") ?? "hb-baseline";
+ const requestedHarness = harnessConfiguration({
+ provider: body.provider ?? url.searchParams.get("provider") ?? undefined,
+ model: body.model ?? url.searchParams.get("model") ?? undefined,
+ lifecyclePolicy: body.lifecyclePolicy,
+ lifecycleMode: body.lifecycleMode ?? url.searchParams.get("lifecycleMode") ?? undefined,
+ idleTimeoutMs: body.idleTimeoutMs ?? url.searchParams.get("idleTimeoutMs") ?? undefined,
+ }, options.requestedModel);
/**
* Resolves the caller's own session, or `undefined`.
@@ -585,7 +659,7 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
const capabilityHash = sha256(capability);
const entry =
surface === "cleanroom"
- ? await createCleanRoomSession(runner, service, capabilityHash)
+ ? await createCleanRoomSession(runner, service, capabilityHash, requestedHarness)
: await createSession(runner, service, scenario, workingDirectory, capabilityHash);
if (!reuse) {
response.setHeader("set-cookie", capabilityCookie(request, surface, capability));
@@ -619,7 +693,7 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
if (owned.state === "owned") {
await retire(service, owned.entry.session.id, "new clean-room chat");
}
- const entry = await mint("cleanroom", { rotate: true });
+ const entry = await mint("cleanroom", { rotate: false });
send(response, 201, cleanRoomPayload(runner, entry));
return;
}
@@ -695,6 +769,7 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
fork = await service.create({
seed,
workingDirectory: forkDirectory,
+ provider: snapshot.config.provider ?? "codex",
scenario: snapshot.config.scenario,
capabilities: snapshot.config.capabilities,
explicitClaims: snapshot.config.explicitClaims,
@@ -766,22 +841,29 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
await entry.session.reconnect();
entry.connection = { state: "connected", attempt: 0 };
} else if (route === "reset") {
- // Reset rotates the capability as well as the session: the cookie the
- // caller arrived with must stop working, here and on the replacement.
- const capability = mintCapability();
- const capabilityHash = sha256(capability);
+ // Reset archives the current session and starts another session for
+ // the same browser principal. Keep the capability stable so the
+ // archived session remains selectable and resumable from history.
+ const capabilityHash = entry.capabilityHash;
if (entry.surface === "cleanroom") {
// A clean-room reset is a new tenant, not a rewound one: the seed is
// blank either way, so restoring it would hand back the same mock
// identities the board just saw.
await retire(service, entry.session.id, "clean-room reset");
- const replacement = await createCleanRoomSession(runner, service, capabilityHash);
- response.setHeader("set-cookie", capabilityCookie(request, "cleanroom", capability));
+ const replacement = await createCleanRoomSession(
+ runner,
+ service,
+ capabilityHash,
+ entry.configuration ?? {
+ provider: "codex",
+ model: null,
+ lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
+ },
+ );
send(response, 200, cleanRoomPayload(runner, replacement));
return;
}
const next = await service.reset(entry.session.id);
- sessions.delete(entry.session.id);
const resetEntry = {
...entry,
session: next,
@@ -790,7 +872,6 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
connection: { state: "connected", attempt: 0 },
};
sessions.set(next.id, resetEntry);
- response.setHeader("set-cookie", capabilityCookie(request, "issue", capability));
send(response, 200, payload(runner, resetEntry));
return;
} else if (route === "interaction") {
diff --git a/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs b/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs
index 50f7edc640d..15e080d0dfa 100644
--- a/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs
+++ b/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs
@@ -16,6 +16,8 @@ const schemaNames = [
"identity",
"capabilities",
"command",
+ "workspace-diff",
+ "workspace-file-reference",
"semantic-tool",
"stop-reason",
"terminal",
diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
index 82d9466dfff..acca830eefc 100644
--- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
+++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
-The generated totals are **65 PRP events in 15 event families**, **14 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.
+The generated totals are **71 PRP events in 15 event families**, **16 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.
## Axis 1: PRP v1 event and command families
@@ -16,10 +16,10 @@ PRP records ordered, replayable execution evidence. It is not the model's Paperc
| Family | Purpose | Events | Count |
| --- | --- | --- | ---: |
-| `runner` | Runner connection, drain, and diagnostic lifecycle. | `runner.connected` `runner.reconnected` `runner.disconnected` `runner.draining` `runner.diagnostic` | 5 |
+| `runner` | Runner connection, drain, and diagnostic lifecycle. | `runner.connected` `runner.reconnected` `runner.reconciled` `runner.disconnected` `runner.draining` `runner.suspending` `runner.suspended` `runner.stopped` `runner.diagnostic` | 9 |
| `runtime` | Runner phase transitions. | `runtime.phase.changed` | 1 |
| `sandbox` | Sandbox resource measurements. | `sandbox.metric` | 1 |
-| `workspace` | Workspace readiness. | `workspace.ready` | 1 |
+| `workspace` | Workspace readiness. | `workspace.ready` `workspace.change.updated` `workspace.diff.recorded` `workspace.file.referenced` | 4 |
| `harness` | Provider harness startup, readiness, exit, and diagnostics. | `harness.starting` `harness.ready` `harness.exited` `harness.diagnostic` | 4 |
| `session` | Provider-neutral session open, resume, reconciliation, close, and failure. | `session.starting` `session.started` `session.resuming` `session.resumed` `session.reconciled` `session.closed` `session.failed` | 7 |
| `turn` | Model turn submission through terminal turn disposition. | `turn.submitted` `turn.accepted` `turn.started` `turn.completed` `turn.failed` `turn.interrupted` `turn.cancelled` | 7 |
@@ -27,7 +27,7 @@ PRP records ordered, replayable execution evidence. It is not the model's Paperc
| `mcp_app` | MCP App discovery, initialization, tool, action, host-context, and teardown evidence. | `mcp_app.discovered` `mcp_app.resource.resolved` `mcp_app.initializing` `mcp_app.ready` `mcp_app.tool_input` `mcp_app.tool_result` `mcp_app.action.requested` `mcp_app.action.resolved` `mcp_app.host_context.changed` `mcp_app.failed` `mcp_app.teardown` | 11 |
| `runtime_request` | Runtime permission/input request lifecycle. | `runtime_request.created` `runtime_request.resolved` `runtime_request.expired` `runtime_request.cancelled` | 4 |
| `interaction` | Issue-thread interaction proposal, materialization, response, delivery, and rejection. | `interaction.request.proposed` `interaction.request.materialized` `interaction.request.rejected` `interaction.response.progressed` `interaction.response.resolved` `interaction.response.delivered` | 6 |
-| `run` | Structured result negotiation and terminal run outcome. | `run.result.proposed` `run.result.accepted` `run.result.rejected` `run.terminal` | 4 |
+| `run` | Structured result negotiation and terminal run outcome. | `run.attached` `run.detached` `run.result.proposed` `run.result.accepted` `run.result.rejected` `run.terminal` | 6 |
| `attention` | Continuation and attention routing lifecycle. | `attention.request.proposed` `attention.request.routed` `attention.request.resolved` `attention.request.expired` `attention.request.superseded` | 5 |
| `work` | Recorded work-assessment evidence. | `work.assessment.recorded` | 1 |
| `issue` | Issue-status decision proposal outcome and application evidence. | `issue.status.decision.recorded` `issue.status.decision.applied` `issue.status.decision.rejected` `issue.status.decision.superseded` | 4 |
@@ -36,13 +36,13 @@ PRP records ordered, replayable execution evidence. It is not the model's Paperc
| Family | Purpose | Commands | Count |
| --- | --- | --- | ---: |
-| `run` | Prepare or cancel a run. | `run.prepare` `run.cancel` | 2 |
+| `run` | Prepare or cancel a run. | `run.prepare` `run.attach` `run.cancel` | 3 |
| `session` | Open, snapshot, or close a normalized provider session. | `session.open` `session.snapshot` `session.close` | 3 |
| `turn` | Start, steer, interrupt, or stop a model turn. | `turn.start` `turn.steer` `turn.interrupt` `turn.stop` | 4 |
| `request` | Resolve a pending runtime request. | `request.resolve` | 1 |
| `interaction` | Acknowledge delivery of an interaction response. | `interaction.receipt` | 1 |
| `semantic_tool` | Returns an authorized, correlated Paperclip tool result to the provider through runnerd. | `semantic_tool.result` | 1 |
-| `runner` | Drain or shut down the runner process. | `runner.drain` `runner.shutdown` | 2 |
+| `runner` | Drain or shut down the runner process. | `runner.drain` `runner.suspend` `runner.shutdown` | 3 |
## Axis 2: capability placement
@@ -91,7 +91,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
### Reconciled semantic-operation ledger
-`live_codex` means a live provider dispatcher exists against the deterministic runner/mock port; it does **not** mean a production Paperclip service is bound. Every row remains `realServiceBinding: unbound` until the shared mock/real adapter work lands. `generic_api_request` is test-only and cannot satisfy product coverage.
+`live_codex` means a live provider dispatcher exists. Production binding is tracked separately: `bound` rows are advertised by Paperclip's run-scoped authority over the shared PRP route, while `audit_pending` rows remain unavailable to production agents. `generic_api_request` is test-only and cannot satisfy product coverage.
| Operation | Placement | Claims | Modes / roles | Side effect | Idempotency | Redacts | Mock | Catalogs / current runner | Production / PRP evidence |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
@@ -105,37 +105,37 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `export_company` | `optional_agent_tool` | `portability:export` | `standard` `skill_test` | `admin` | `required` | no | `mock_extension:portability.export` | `scenario` `scenario_mock` | `unbound` company admin/portability item event plus audit record catalog PRP status: `audit_pending` |
| `finish_task` | `always_agent_tool` | none | `standard` `skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
| `generic_api_request` | `optional_agent_tool` | `test:generic_api_request` | `skill_test` | `test_escape_hatch` | `required` | yes | `mock_extension:test.generic_api` | `scenario` + `live` `test_only` | `unbound` test-only; excluded from product PRP evidence catalog PRP status: `audit_pending` |
-| `get_agent` | `optional_agent_tool` | `discovery:agents:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
-| `get_approval` | `optional_agent_tool` | `governance:approvals:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
-| `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
-| `get_task_context` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
-| `get_task_history` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_history` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
+| `get_agent` | `optional_agent_tool` | `discovery:agents:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `agentService.getById (redacted projection)` company equality + redacted actor projection catalog PRP status: `bound` |
+| `get_approval` | `optional_agent_tool` | `governance:approvals:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `approvalService.getById` company equality check catalog PRP status: `bound` |
+| `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `approvalService.getById + issueApprovals` company equality + linked-task projection catalog PRP status: `bound` |
+| `get_task_context` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live` `live_codex` | `PaperclipRunnerToolAuthority + issue/run assignment query` shared PRP route + bound-context authorization test catalog PRP status: `bound` |
+| `get_task_history` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_history` | `scenario` + `live` `live_codex` | `issueComments scoped by bound issue` shared PRP route + bound-context authorization test catalog PRP status: `bound` |
| `get_workspace_runtime` | `optional_agent_tool` | `workspace:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_workspace` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `inspect_operation_result` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `operation_result` | `scenario` `scenario_mock` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
-| `list_agents` | `optional_agent_tool` | `discovery:agents:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:company_actors` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
-| `list_approvals` | `optional_agent_tool` | `governance:approvals:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:company_approvals` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
+| `list_agents` | `optional_agent_tool` | `discovery:agents:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:company_actors` | `scenario` + `live` `live_codex` | `agentService.list (redacted projection)` company-scoped actor projection catalog PRP status: `bound` |
+| `list_approvals` | `optional_agent_tool` | `governance:approvals:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:company_approvals` | `scenario` + `live` `live_codex` | `approvalService.list` company-scoped approval projection catalog PRP status: `bound` |
| `list_cases` | `optional_agent_tool` | `cases:read` | `standard` `skill_test` | `read` | `none` | no | `mock_extension:cases.list` | `scenario` `scenario_mock` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `list_company_skills` | `optional_agent_tool` | `company_skills:read` | `standard` `skill_test` | `read` | `none` | no | `mock_extension:company_skills.list` | `scenario` `scenario_mock` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
-| `list_document_revisions` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
-| `list_documents` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
+| `list_document_revisions` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `scenario` + `live` `live_codex` | `documentService.listIssueDocumentRevisions` bound issue/key revision read catalog PRP status: `bound` |
+| `list_documents` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `scenario` + `live` `live_codex` | `documentService.listIssueDocuments` bound issue document read catalog PRP status: `bound` |
| `list_goals` | `optional_agent_tool` | `discovery:goals:read` | `standard` `skill_test` | `read` | `none` | no | `mock_extension:discovery.goals` | `scenario` `scenario_mock` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard` `skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario` `scenario_mock` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard` `skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario` `scenario_mock` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard` `skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario` `scenario_mock` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard` `skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario` `scenario_mock` | `unbound` company admin/portability item event plus audit record catalog PRP status: `audit_pending` |
-| `read_document` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
+| `read_document` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live` `live_codex` | `documentService.getIssueDocumentByKey` bound issue/key document read catalog PRP status: `bound` |
| `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard` `skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario` `scenario_mock` | `unbound` redacted tool-result item event; secret value never reaches the wire catalog PRP status: `audit_pending` |
| `register_deliverable` | `always_agent_tool` | none | `standard` `planning` `skill_test` | `task_write` | `required` | no | `semantic_command:register_deliverable` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
-| `report_progress` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
+| `report_progress` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live` `live_codex` | `issueService.addComment + heartbeatRuns semanticToolReceipts` real service mutation + idempotent replay test catalog PRP status: `bound` |
| `request_approval` | `optional_agent_tool` | `governance:approvals:request` | `standard` `skill_test` | `governance` | `required` | no | `semantic_command:request_approval` | `scenario` + `live` `live_codex` | `unbound` approval lifecycle plus governed-wait continuation and audit events catalog PRP status: `audit_pending` |
-| `request_human_input` | `always_agent_tool` | none | `standard` `planning` `skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
+| `request_human_input` | `always_agent_tool` | none | `standard` `planning` `skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live` `live_codex` | `issueThreadInteractionService.create` real structured interaction + continuation projection test catalog PRP status: `bound` |
| `request_review` | `always_agent_tool` | none | `standard` `skill_test` | `task_write` | `required` | no | `semantic_command:request_review` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
| `schedule_wake` | `optional_agent_tool` | `control_plane:wakes` | `standard` `skill_test` | `task_write` | `required` | no | inline/no mapping | `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
-| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
+| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live` `live_codex` | `issueService.list` shared PRP route + company scope test catalog PRP status: `bound` |
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard` `skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus company-entity state diff and audit record catalog PRP status: `audit_pending` |
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard` `skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario` `scenario_mock` | `unbound` company admin/portability item event plus audit record catalog PRP status: `audit_pending` |
| `upsert_case` | `optional_agent_tool` | `cases:write` | `standard` `skill_test` | `company_write` | `required` | no | `mock_extension:cases.upsert` | `scenario` `scenario_mock` | `unbound` semantic-operation item event plus company-entity state diff and audit record catalog PRP status: `audit_pending` |
-| `write_document` | `always_agent_tool` | none | `standard` `planning` `skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
+| `write_document` | `always_agent_tool` | none | `standard` `planning` `skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live` `live_codex` | `documentService.upsertIssueDocument + heartbeatRuns semanticToolReceipts` real revision mutation + optimistic concurrency + idempotent replay test catalog PRP status: `bound` |
## Axis 3: the 16 behavioral eval groups
diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts
index b0958932b6b..56891db2949 100644
--- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts
+++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts
@@ -31,7 +31,7 @@ export class HarnessDriverBackend implements NativeSessionBackend {
const descriptor = await this.#driver.descriptor();
return {
kind: "runner",
- name: descriptor.displayName,
+ name: descriptor.kind,
version: descriptor.version,
capabilities: structuredClone(descriptor.capabilities),
};
@@ -86,7 +86,7 @@ export class HarnessDriverBackend implements NativeSessionBackend {
}
class HarnessNativeSession implements NativeSession {
- readonly #input: OpenNativeSessionInput;
+ #input: OpenNativeSessionInput;
readonly #session: HarnessSession;
#terminal: PrpTerminalState | null = null;
@@ -115,6 +115,18 @@ class HarnessNativeSession implements NativeSession {
};
}
+ async attachRun(input: { identity: OpenNativeSessionInput["identity"] }): Promise {
+ if (input.identity.sessionId !== this.#input.identity.sessionId) {
+ throw new Error("native_session_attach_binding_mismatch");
+ }
+ if (this.#session.attachRun === undefined) {
+ throw new Error("native_session_multi_run_unavailable");
+ }
+ await this.#session.attachRun({ runId: input.identity.runId });
+ this.#input = { ...this.#input, identity: structuredClone(input.identity) };
+ this.#terminal = null;
+ }
+
async *events(): AsyncIterable {
for await (const event of this.#session.events()) {
if (event.eventType === "run.terminal") {
@@ -192,6 +204,10 @@ class HarnessNativeSession implements NativeSession {
};
}
+ async usage(): Promise | null> {
+ return this.#session.usage?.() ?? null;
+ }
+
close(input: { reason: string }) {
return this.#session.close(input);
}
diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.ts b/packages/paperclip-runner/src/backends/native-backend-factory.ts
new file mode 100644
index 00000000000..078e952f89a
--- /dev/null
+++ b/packages/paperclip-runner/src/backends/native-backend-factory.ts
@@ -0,0 +1,49 @@
+import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
+import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
+import type { CodexAppServerTransport } from "../drivers/codex/app-server-transport.js";
+import { createCodexNativeSessionBackend } from "./codex-native-backend.js";
+import { createOpenCodeNativeSessionBackend } from "./opencode-native-backend.js";
+
+export interface NativeBackendFactoryOptions {
+ runnerInstanceId?: string;
+ onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise;
+ dynamicTools?: readonly Readonly>[];
+ dynamicToolHandler?: (call: {
+ tool: string;
+ callId: string;
+ threadId: string;
+ turnId: string;
+ arguments: unknown;
+ }) => Promise;
+ codexTransportFactory?: () => CodexAppServerTransport;
+ opencodeRuntimeDirectory?: string;
+ environment?: NodeJS.ProcessEnv;
+ opencodeCommand?: string;
+}
+
+export function createNativeSessionBackend(
+ input: NativeExecutionInputV1,
+ options: NativeBackendFactoryOptions = {},
+): NativeSessionBackend {
+ if (input.provider.kind === "codex") {
+ return createCodexNativeSessionBackend(input, {
+ runnerInstanceId: options.runnerInstanceId,
+ onSpawn: options.onSpawn,
+ dynamicTools: options.dynamicTools,
+ dynamicToolHandler: options.dynamicToolHandler,
+ transportFactory: options.codexTransportFactory,
+ });
+ }
+ if (!options.opencodeRuntimeDirectory) {
+ throw new Error("OpenCode native backend requires an instance runtime directory");
+ }
+ return createOpenCodeNativeSessionBackend(input, {
+ runnerInstanceId: options.runnerInstanceId,
+ onSpawn: options.onSpawn,
+ runtimeDirectory: options.opencodeRuntimeDirectory,
+ environment: options.environment,
+ command: options.opencodeCommand,
+ dynamicTools: options.dynamicTools,
+ dynamicToolHandler: options.dynamicToolHandler,
+ });
+}
diff --git a/packages/paperclip-runner/src/backends/opencode-native-backend.ts b/packages/paperclip-runner/src/backends/opencode-native-backend.ts
new file mode 100644
index 00000000000..7aff1aa15b8
--- /dev/null
+++ b/packages/paperclip-runner/src/backends/opencode-native-backend.ts
@@ -0,0 +1,29 @@
+import { createCodexTaskEnvelope } from "../contracts/codex.js";
+import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
+import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
+import { OpenCodeServerDriver, type OpenCodeServerDriverOptions } from "../drivers/opencode/opencode-server-driver.js";
+import { HarnessDriverBackend } from "./harness-driver-backend.js";
+
+export function createOpenCodeNativeSessionBackend(
+ input: NativeExecutionInputV1,
+ options: Omit,
+): NativeSessionBackend {
+ if (input.provider.kind !== "opencode" || !input.provider.model) {
+ throw new Error("OpenCode native backend requires a persisted OpenCode provider/model selection");
+ }
+ return new HarnessDriverBackend(new OpenCodeServerDriver({
+ ...options,
+ model: input.provider.model,
+ taskEnvelope: createCodexTaskEnvelope({
+ objective: input.completionContract.contract.objective,
+ contractRevision: input.completionContract.contract.revision,
+ criteria: input.completionContract.contract.criteria,
+ constraints: [
+ "Work only inside the supplied working directory.",
+ "Do not discover or invoke skills.",
+ "Do not call a control-plane API.",
+ "Return one semantic completion result through paperclip_finish or paperclip_block.",
+ ],
+ }),
+ }));
+}
diff --git a/packages/paperclip-runner/src/catalog/operation-groups-doc.test.ts b/packages/paperclip-runner/src/catalog/operation-groups-doc.test.ts
index bd189fe2702..8f06eecfedf 100644
--- a/packages/paperclip-runner/src/catalog/operation-groups-doc.test.ts
+++ b/packages/paperclip-runner/src/catalog/operation-groups-doc.test.ts
@@ -68,8 +68,8 @@ describe("canonical operation-groups specification", () => {
expect(input.source.behaviorGroups).toHaveLength(16);
expect(input.scenarios).toHaveLength(106);
expect(new Set(input.scenarios.map((scenario) => scenario.id)).size).toBe(106);
- expect(input.eventTypes).toHaveLength(65);
- expect(input.commandTypes).toHaveLength(14);
+ expect(input.eventTypes).toHaveLength(74);
+ expect(input.commandTypes).toHaveLength(16);
});
it("keeps the checked-in Markdown byte-identical to the generated contract", () => {
diff --git a/packages/paperclip-runner/src/catalog/operation-groups-doc.ts b/packages/paperclip-runner/src/catalog/operation-groups-doc.ts
index 30a3e6404f6..847346e8668 100644
--- a/packages/paperclip-runner/src/catalog/operation-groups-doc.ts
+++ b/packages/paperclip-runner/src/catalog/operation-groups-doc.ts
@@ -99,8 +99,8 @@ export interface OperationGroupsInput {
catalogIndexSource: string;
}
-const EXPECTED_EVENT_COUNT = 65;
-const EXPECTED_COMMAND_COUNT = 14;
+const EXPECTED_EVENT_COUNT = 74;
+const EXPECTED_COMMAND_COUNT = 16;
const EXPECTED_SCENARIO_COUNT = 106;
const EXPECTED_BEHAVIOR_GROUP_COUNT = 16;
@@ -282,7 +282,7 @@ export function renderOperationGroupsDocument(input: OperationGroupsInput): stri
"",
"This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.",
"",
- "The generated totals are **65 PRP events in 15 event families**, **14 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.",
+ "The generated totals are **71 PRP events in 15 event families**, **16 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.",
"",
"## Axis 1: PRP v1 event and command families",
"",
diff --git a/packages/paperclip-runner/src/cli/eval-session.ts b/packages/paperclip-runner/src/cli/eval-session.ts
index f6e5c40396d..137ee84a2ad 100644
--- a/packages/paperclip-runner/src/cli/eval-session.ts
+++ b/packages/paperclip-runner/src/cli/eval-session.ts
@@ -21,6 +21,9 @@ interface EvalSessionRequest {
attemptId: string;
prompt: string;
model: string;
+ provider?: "codex" | "opencode";
+ driver?: "codex_app_server" | "opencode_server";
+ opencodeVersion?: string;
runnerd: { path: string; sha256: string };
limits: { turnTimeoutMs: number; maxAgentTurns: number; maxEstimatedCostNanodollars: number };
session: CreateCapabilityLiveSessionInput;
@@ -61,6 +64,8 @@ try {
attemptId: request.attemptId,
prompt: request.prompt,
model: request.model,
+ provider: request.provider ?? "codex",
+ opencodeVersion: request.opencodeVersion,
runnerBinaryPath: runnerdPath,
seed: request.session.seed ?? {},
actorId: request.session.actorId ?? "",
@@ -92,6 +97,9 @@ try {
build: PAPERCLIP_RUNNER_BUILD_METADATA,
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
requestedModel: request.model,
+ provider: request.provider ?? "codex",
+ driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : "codex_app_server"),
+ providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : null,
turn,
snapshot,
devtools: projectCapabilityDevtools(snapshot),
@@ -121,6 +129,9 @@ try {
build: PAPERCLIP_RUNNER_BUILD_METADATA,
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
requestedModel: request.model,
+ provider: request.provider ?? "codex",
+ driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : "codex_app_server"),
+ providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : null,
timing: { startedAt: evalStartedAt, finishedAt: new Date().toISOString(), durationMs: Date.now() - evalStartedAtMs },
}, null, 2)}\n`);
process.exitCode = 2;
diff --git a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts
new file mode 100644
index 00000000000..e93e6eb3af5
--- /dev/null
+++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts
@@ -0,0 +1,249 @@
+#!/usr/bin/env node
+import { createInterface } from "node:readline";
+import { resolve } from "node:path";
+
+import type { HarnessSession, PersistedHarnessSession } from "../contracts/harness-driver.js";
+import { createCodexTaskEnvelope } from "../contracts/codex.js";
+import { OpenCodeServerDriver } from "../drivers/opencode/opencode-server-driver.js";
+
+type RpcMessage = { id?: string | number; method?: string; params?: unknown; result?: unknown; error?: unknown };
+
+const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
+const pending = new Map();
+let nextServerRequestId = 1;
+let driver: OpenCodeServerDriver | null = null;
+let session: HarnessSession | null = null;
+let eventPump: Promise | null = null;
+let cwd = "";
+let activeTurnId: string | null = null;
+const announcedTurnIds = new Set();
+
+function send(value: unknown): void {
+ process.stdout.write(`${JSON.stringify(value)}\n`);
+}
+
+function requestController(method: string, params: unknown): Promise {
+ const id = `opencode-${nextServerRequestId++}`;
+ send({ id, method, params });
+ return new Promise((resolveValue, reject) => pending.set(id, { resolve: resolveValue, reject }));
+}
+
+function record(value: unknown): Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {};
+}
+
+function text(value: unknown, fallback = ""): string {
+ return typeof value === "string" ? value : fallback;
+}
+
+function runtimeDirectory(): string {
+ const configured = process.env.PAPERCLIP_OPENCODE_RUNTIME_DIR?.trim();
+ if (!configured) throw new Error("PAPERCLIP_OPENCODE_RUNTIME_DIR is required");
+ return resolve(configured);
+}
+
+async function open(params: Record, resume: boolean): Promise> {
+ if (session) return threadResponse(session.ids().providerSessionId ?? session.ids().driverSessionId);
+ cwd = resolve(text(params.cwd, process.cwd()));
+ const model = text(params.model);
+ if (!model.includes("/")) throw new Error("OpenCode proxy requires model in provider/model form");
+ const dynamicTools = Array.isArray(params.dynamicTools) ? params.dynamicTools.map(record) : [];
+ driver = new OpenCodeServerDriver({
+ model,
+ command: process.env.PAPERCLIP_OPENCODE_COMMAND?.trim() || "opencode",
+ runtimeDirectory: runtimeDirectory(),
+ environment: process.env,
+ runnerInstanceId: process.env.PAPERCLIP_RUNNER_INSTANCE_ID ?? "paperclip-runnerd-opencode",
+ taskEnvelope: createCodexTaskEnvelope({
+ objective: "Complete the provider turn supplied by Paperclip Runner.",
+ constraints: [
+ text(params.baseInstructions, "Complete only the supplied task."),
+ "Work only inside the supplied working directory.",
+ "Use Paperclip MCP tools for semantic operations.",
+ ],
+ }),
+ dynamicTools,
+ dynamicToolHandler: async (call) => requestController("item/tool/call", {
+ threadId: session?.ids().driverSessionId ?? "opening",
+ turnId: call.turnId,
+ callId: call.callId,
+ tool: call.tool,
+ arguments: call.arguments,
+ }),
+ onDiagnostic: (message) => process.stderr.write(`[opencode] ${message}\n`),
+ });
+ if (resume) {
+ const threadId = text(params.threadId);
+ const snapshot: PersistedHarnessSession = {
+ driverKind: "opencode_server",
+ driverSessionId: threadId,
+ providerSessionId: threadId,
+ runId: process.env.PAPERCLIP_RUN_ID ?? "runnerd-run",
+ normalizedSessionId: process.env.PAPERCLIP_NORMALIZED_SESSION_ID ?? threadId,
+ activeTurnId: null,
+ lastSourceSequence: 0,
+ };
+ const recovered = await driver.recoverSession(snapshot);
+ if (!recovered.recovered || !recovered.session) throw new Error(recovered.reason ?? "OpenCode recovery failed");
+ session = recovered.session;
+ } else {
+ session = await driver.openSession({
+ runId: process.env.PAPERCLIP_RUN_ID ?? "runnerd-run",
+ normalizedSessionId: process.env.PAPERCLIP_NORMALIZED_SESSION_ID ?? `runnerd-${Date.now()}`,
+ workingDirectory: cwd,
+ });
+ }
+ eventPump = pumpEvents(session);
+ return threadResponse(session.ids().providerSessionId ?? session.ids().driverSessionId);
+}
+
+function threadResponse(id: string): Record {
+ return { thread: { id, sessionId: id, cwd }, model: process.env.PAPERCLIP_OPENCODE_MODEL ?? null };
+}
+
+function announceTurnStarted(opened: HarnessSession, turnId: string): void {
+ if (announcedTurnIds.has(turnId)) return;
+ announcedTurnIds.add(turnId);
+ send({
+ method: "turn/started",
+ params: {
+ threadId: opened.ids().driverSessionId,
+ turnId,
+ turn: { id: turnId, status: "inProgress" },
+ },
+ });
+}
+
+async function pumpEvents(opened: HarnessSession): Promise {
+ for await (const event of opened.events()) {
+ const payload = record(event.payload);
+ if (event.eventType === "turn.started") {
+ if (typeof event.turnId === "string") announceTurnStarted(opened, event.turnId);
+ } else if (event.eventType === "item.started" || event.eventType === "item.delta" || event.eventType === "item.completed") {
+ const assistantText = payload.kind === "text" && typeof payload.text === "string"
+ ? payload.text
+ : null;
+ const method = event.eventType === "item.delta" && assistantText !== null
+ ? "item/agentMessage/delta"
+ : event.eventType.replace(".", "/");
+ const providerItem = record(payload.item);
+ const item = assistantText === null
+ ? payload.item ?? { id: event.itemId, type: payload.kind, text: payload.text }
+ : { ...providerItem, id: event.itemId, type: "agentMessage", text: assistantText };
+ send({
+ method,
+ params: {
+ threadId: opened.ids().driverSessionId,
+ turnId: event.turnId,
+ itemId: event.itemId,
+ ...payload,
+ item,
+ ...(method === "item/agentMessage/delta" ? { delta: assistantText } : {}),
+ },
+ });
+ if (payload.kind === "usage") {
+ const usage = record(payload.usage);
+ const cache = record(usage.cache);
+ send({ method: "thread/tokenUsage/updated", params: {
+ threadId: opened.ids().driverSessionId,
+ turnId: event.turnId,
+ tokenUsage: { total: {
+ inputTokens: usage.inputTokens ?? usage.input ?? 0,
+ outputTokens: usage.outputTokens ?? usage.output ?? 0,
+ cachedInputTokens: usage.cachedInputTokens ?? cache.read ?? 0,
+ reasoningTokens: usage.reasoningTokens ?? usage.reasoning ?? 0,
+ costUsd: usage.costUsd ?? usage.cost ?? null,
+ } },
+ } });
+ }
+ } else if (event.eventType === "run.result.proposed") {
+ send({
+ method: "paperclip/runResult",
+ params: {
+ threadId: opened.ids().driverSessionId,
+ turnId: event.turnId,
+ itemId: event.itemId ?? "semantic-result",
+ result: payload,
+ },
+ });
+ } else if (["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) {
+ const status = event.eventType.slice("turn.".length);
+ send({ method: "turn/completed", params: { threadId: opened.ids().driverSessionId, turnId: event.turnId, turn: { id: event.turnId, status } } });
+ activeTurnId = null;
+ } else if (event.eventType === "harness.diagnostic") {
+ send({ method: "warning", params: { threadId: opened.ids().driverSessionId, message: payload.message ?? payload.code } });
+ }
+ }
+}
+
+async function handle(message: RpcMessage): Promise {
+ if (message.method === undefined && message.id !== undefined) {
+ const waiter = pending.get(String(message.id));
+ if (!waiter) return;
+ pending.delete(String(message.id));
+ if (message.error !== undefined) waiter.reject(new Error(JSON.stringify(message.error)));
+ else {
+ const contentItems = record(message.result).contentItems;
+ waiter.resolve(Array.isArray(contentItems) ? contentItems[0] ?? message.result : message.result);
+ }
+ return;
+ }
+ if (!message.method || message.id === undefined) return;
+ const params = record(message.params);
+ let result: unknown;
+ switch (message.method) {
+ case "initialize":
+ result = { user: { sessionId: "opencode" }, serverInfo: { name: "opencode", version: "1.18.17" } };
+ break;
+ case "thread/start": result = await open(params, false); break;
+ case "thread/resume": result = await open(params, true); break;
+ case "turn/start": {
+ if (!session) throw new Error("OpenCode thread is not open");
+ const inputItems = Array.isArray(params.input) ? params.input.map(record) : [];
+ const messageText = inputItems.map((entry) => text(entry.text)).filter(Boolean).join("\n");
+ const turn = await session.startTurn({ message: { role: "user", text: messageText } });
+ activeTurnId = turn.turnId;
+ // OpenCode normally publishes session/turn startup over SSE, but a fast
+ // completion can make the synchronous prompt response the only place the
+ // authoritative turn id is observed. Emit the normalized notification
+ // after this request's JSON-RPC response: runnerd's request reader ignores
+ // notifications that arrive before the matching response. Clearing an
+ // earlier SSE announcement guarantees one post-response signal, while
+ // announceTurnStarted still deduplicates any later SSE echo.
+ queueMicrotask(() => {
+ announcedTurnIds.delete(turn.turnId);
+ announceTurnStarted(session!, turn.turnId);
+ });
+ result = { turn: { id: turn.turnId, status: "inProgress" } };
+ break;
+ }
+ case "turn/interrupt":
+ if (!session?.interrupt) throw new Error("OpenCode interruption is unavailable");
+ await session.interrupt({ turnId: text(params.turnId, activeTurnId ?? "") });
+ result = true;
+ break;
+ case "thread/read":
+ if (!session) throw new Error("OpenCode thread is not open");
+ result = { thread: { id: session.ids().driverSessionId, cwd, turns: activeTurnId ? [{ id: activeTurnId, status: "inProgress" }] : [] }, transcript: await session.read?.() };
+ break;
+ default: throw new Error(`Unsupported OpenCode proxy method ${message.method}`);
+ }
+ send({ id: message.id, result });
+}
+
+input.on("line", (line) => {
+ if (!line.trim()) return;
+ let message: RpcMessage;
+ try { message = JSON.parse(line) as RpcMessage; }
+ catch (error) { process.stderr.write(`Invalid JSON-RPC input: ${String(error)}\n`); return; }
+ void handle(message).catch((error) => send({ id: message.id, error: { code: -32000, message: error instanceof Error ? error.message : String(error) } }));
+});
+
+async function shutdown(): Promise {
+ await session?.close({ reason: "proxy_shutdown", force: true }).catch(() => {});
+ await eventPump?.catch(() => {});
+ process.exit(0);
+}
+
+process.on("SIGTERM", () => { void shutdown(); });
+process.on("SIGINT", () => { void shutdown(); });
diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts
index b7b8ee8d3fe..e2b904464be 100644
--- a/packages/paperclip-runner/src/contracts/harness-driver.ts
+++ b/packages/paperclip-runner/src/contracts/harness-driver.ts
@@ -328,6 +328,7 @@ export interface HarnessSession {
displayId?: string | null;
};
events(): AsyncIterable;
+ attachRun?(input: { runId: string }): Promise | void;
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
steer?(input: { turnId: string; message: NativeUserMessage }): Promise;
interrupt?(input: { turnId?: string; reason?: string }): Promise;
diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts
index 2bf28c01669..9765bc5d8ee 100644
--- a/packages/paperclip-runner/src/contracts/native-execution.test.ts
+++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts
@@ -19,7 +19,13 @@ const input: NativeExecutionInputV1 = {
workMode: "standard",
},
workspace: { cwd: "/safe/workspace", repoUrl: null, repoRef: null, branchName: null },
- session: { normalizedSessionId: null, driverKind: "codex_app_server", protocolVersion: 1 },
+ session: {
+ normalizedSessionId: null,
+ driverKind: "codex_app_server",
+ protocolVersion: 1,
+ lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
+ },
+ provider: { kind: "codex", model: null },
completionContract: {
id: "contract-1",
sha256: "abc123",
@@ -61,4 +67,50 @@ describe("NativeExecutionInputV1", () => {
workspace: { ...input.workspace, env: { PAPERCLIP_API_KEY: "canary" } },
})).toThrow("unknown field env");
});
+
+ it("accepts a persisted OpenCode driver/model pair and rejects mismatches", () => {
+ const opencode = parseNativeExecutionInput({
+ ...input,
+ session: { ...input.session, driverKind: "opencode_server" },
+ provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
+ });
+ expect(opencode.provider).toEqual({
+ kind: "opencode",
+ model: "openrouter/deepseek/deepseek-v4-flash-0731",
+ });
+ expect(() => parseNativeExecutionInput({
+ ...input,
+ session: { ...input.session, driverKind: "opencode_server" },
+ provider: { kind: "codex", model: null },
+ })).toThrow("does not match");
+ });
+
+ it("deserializes pre-provider Codex state as Codex", () => {
+ const legacy = structuredClone(input) as Record;
+ delete legacy.provider;
+ expect(parseNativeExecutionInput(legacy).provider).toEqual({ kind: "codex", model: null });
+ });
+
+ it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => {
+ const legacy = structuredClone(input) as Record;
+ delete (legacy.session as Record).lifecyclePolicy;
+ expect(parseNativeExecutionInput(legacy).session.lifecyclePolicy).toEqual({
+ mode: "per_turn",
+ idleTimeoutMs: null,
+ });
+ expect(parseNativeExecutionInput({
+ ...input,
+ session: {
+ ...input.session,
+ lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
+ },
+ }).session.lifecyclePolicy).toEqual({ mode: "warm", idleTimeoutMs: 300_000 });
+ expect(() => parseNativeExecutionInput({
+ ...input,
+ session: {
+ ...input.session,
+ lifecyclePolicy: { mode: "warm", idleTimeoutMs: 0 },
+ },
+ })).toThrow("positive integer");
+ });
});
diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts
index f2c35e2c5ba..b3adb3a6c7f 100644
--- a/packages/paperclip-runner/src/contracts/native-execution.ts
+++ b/packages/paperclip-runner/src/contracts/native-execution.ts
@@ -23,6 +23,10 @@ export interface NativeCredentialBindingRef {
displayName: string | null;
}
+export type NativeSessionLifecyclePolicy =
+ | { mode: "per_turn"; idleTimeoutMs: null }
+ | { mode: "warm"; idleTimeoutMs: number };
+
export interface NativeExecutionInputV1 {
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA;
binding: {
@@ -48,8 +52,13 @@ export interface NativeExecutionInputV1 {
};
session: {
normalizedSessionId: string | null;
- driverKind: "codex_app_server";
+ driverKind: "codex_app_server" | "opencode_server";
protocolVersion: 1;
+ lifecyclePolicy: NativeSessionLifecyclePolicy;
+ };
+ provider: {
+ kind: "codex" | "opencode";
+ model: string | null;
};
completionContract: {
id: string;
@@ -131,6 +140,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
"task",
"workspace",
"session",
+ "provider",
"completionContract",
"interactionResponses",
"credentialBindings",
@@ -146,15 +156,55 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
const workspace = record(input.workspace, "input.workspace");
exactKeys(workspace, ["cwd", "repoUrl", "repoRef", "branchName"], "input.workspace");
const session = record(input.session, "input.session");
- exactKeys(session, ["normalizedSessionId", "driverKind", "protocolVersion"], "input.session");
+ exactKeys(session, ["normalizedSessionId", "driverKind", "protocolVersion", "lifecyclePolicy"], "input.session");
const completionContract = record(input.completionContract, "input.completionContract");
exactKeys(completionContract, ["id", "sha256", "schemaVersion", "contract"], "input.completionContract");
const contract = record(completionContract.contract, "input.completionContract.contract");
exactKeys(contract, ["revision", "objective", "criteria"], "input.completionContract.contract");
if (task.workMode !== "standard") throw new NativeExecutionInputError("input.task.workMode must be standard");
- if (session.driverKind !== "codex_app_server" || session.protocolVersion !== 1) {
- throw new NativeExecutionInputError("input.session must select codex_app_server protocol version 1");
+ if (
+ (session.driverKind !== "codex_app_server" && session.driverKind !== "opencode_server")
+ || session.protocolVersion !== 1
+ ) {
+ throw new NativeExecutionInputError("input.session must select a supported protocol version 1 driver");
+ }
+ const lifecyclePolicyValue = session.lifecyclePolicy === undefined
+ ? { mode: "per_turn", idleTimeoutMs: null }
+ : record(session.lifecyclePolicy, "input.session.lifecyclePolicy");
+ exactKeys(lifecyclePolicyValue, ["mode", "idleTimeoutMs"], "input.session.lifecyclePolicy");
+ let lifecyclePolicy: NativeSessionLifecyclePolicy;
+ if (lifecyclePolicyValue.mode === "per_turn") {
+ if (lifecyclePolicyValue.idleTimeoutMs !== null) {
+ throw new NativeExecutionInputError("input.session.lifecyclePolicy.idleTimeoutMs must be null for per_turn");
+ }
+ lifecyclePolicy = { mode: "per_turn", idleTimeoutMs: null };
+ } else if (lifecyclePolicyValue.mode === "warm") {
+ if (!Number.isSafeInteger(lifecyclePolicyValue.idleTimeoutMs) || Number(lifecyclePolicyValue.idleTimeoutMs) <= 0) {
+ throw new NativeExecutionInputError("input.session.lifecyclePolicy.idleTimeoutMs must be a positive integer for warm");
+ }
+ lifecyclePolicy = { mode: "warm", idleTimeoutMs: Number(lifecyclePolicyValue.idleTimeoutMs) };
+ } else {
+ throw new NativeExecutionInputError("input.session.lifecyclePolicy.mode must be per_turn or warm");
+ }
+ const provider = input.provider === undefined
+ ? { kind: "codex", model: null }
+ : record(input.provider, "input.provider");
+ exactKeys(provider, ["kind", "model"], "input.provider");
+ if (provider.kind !== "codex" && provider.kind !== "opencode") {
+ throw new NativeExecutionInputError("input.provider.kind must be codex or opencode");
+ }
+ if (
+ (provider.kind === "codex" && session.driverKind !== "codex_app_server")
+ || (provider.kind === "opencode" && session.driverKind !== "opencode_server")
+ ) {
+ throw new NativeExecutionInputError("input.provider.kind does not match input.session.driverKind");
+ }
+ const providerModel = provider.model === null || provider.model === undefined
+ ? null
+ : text(provider.model, "input.provider.model");
+ if (provider.kind === "opencode" && (providerModel === null || !providerModel.includes("/"))) {
+ throw new NativeExecutionInputError("input.provider.model is required for opencode in provider/model form");
}
if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) {
throw new NativeExecutionInputError("input.completionContract.contract.criteria must not be empty");
@@ -206,7 +256,10 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
identifier: text(task.identifier, "input.task.identifier"),
title: text(task.title, "input.task.title"),
description: nullableText(task.description, "input.task.description"),
- prompt: text(task.prompt, "input.task.prompt"),
+ // `prompt` was added after native run inputs were already persisted.
+ // Recovery must retain those runs, so derive the same bounded model
+ // prompt from their task fields instead of making them unrecoverable.
+ prompt: text(task.prompt ?? task.description ?? task.title, "input.task.prompt"),
workMode: "standard",
},
workspace: {
@@ -217,8 +270,13 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
},
session: {
normalizedSessionId: nullableText(session.normalizedSessionId, "input.session.normalizedSessionId"),
- driverKind: "codex_app_server",
+ driverKind: session.driverKind,
protocolVersion: 1,
+ lifecyclePolicy,
+ },
+ provider: {
+ kind: provider.kind,
+ model: providerModel,
},
completionContract: {
id: text(completionContract.id, "input.completionContract.id"),
diff --git a/packages/paperclip-runner/src/contracts/native-session-backend.ts b/packages/paperclip-runner/src/contracts/native-session-backend.ts
index ee598ecbf56..683b5d30ead 100644
--- a/packages/paperclip-runner/src/contracts/native-session-backend.ts
+++ b/packages/paperclip-runner/src/contracts/native-session-backend.ts
@@ -49,6 +49,7 @@ export interface NativeSessionRecoveryResult {
export interface NativeSession {
identity(): NativeRunIdentity;
capabilities(): Promise;
+ attachRun?(input: { identity: NativeRunIdentity }): Promise;
events(input?: { afterCursor?: string | null }): AsyncIterable;
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
steer?(input: { turnId: string; message: NativeUserMessage }): Promise;
@@ -59,6 +60,7 @@ export interface NativeSession {
terminal: PrpTerminalState;
turnId: string | null;
} | null>;
+ usage?(): Promise | null>;
snapshot(): Promise;
close(input: { reason: string }): Promise;
}
diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts
index 9c81df9a9f4..45456d7d4ad 100644
--- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts
+++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts
@@ -700,6 +700,17 @@ export class DurablePrpControlPlane {
}
}
+ /** Forces a resumable re-authentication after an immutable run attachment rotates. */
+ disconnectActiveRunner(): void {
+ const connections = [...this.#connections];
+ this.#connections.clear();
+ for (const connection of connections) connection.close();
+ }
+
+ activeRunnerConnectionCount(): number {
+ return [...this.#connections].filter((connection) => connection.secureChannel !== null).length;
+ }
+
issueBootstrapTicket(ttlMs = 5_000): string {
const ticket = `bootstrap_${randomUUID()}`;
const material = credentialMaterial(ticket);
@@ -1239,10 +1250,31 @@ export class DurablePrpControlPlane {
? status
: "failed";
command.result = structuredClone(result);
+ if (command.status === "completed" && command.type === "run.attach") {
+ this.#applyRunAttachment(command.payload);
+ }
this.store.save();
this.#sendNextCommand(connection);
}
+ #applyRunAttachment(payload: Record): void {
+ const runId = payload.runId;
+ const turnId = payload.turnId;
+ const itemId = payload.itemId;
+ if (typeof runId !== "string" || typeof turnId !== "string" || typeof itemId !== "string") {
+ throw new Error("completed run.attach omitted its identity binding");
+ }
+ const next = { ...this.identity, runId, turnId, itemId };
+ Object.assign(this.identity, next);
+ this.store.state.identity = structuredClone(next);
+ for (const ticket of Object.values(this.store.state.tickets)) {
+ ticket.identity = structuredClone(next);
+ }
+ for (const lease of Object.values(this.store.state.leases)) {
+ lease.identity = structuredClone(next);
+ }
+ }
+
#event(connection: MockWebSocketConnection, envelope: Record): void {
const event = envelope.payload as Record | undefined;
const sourceSeq = event?.sourceSeq;
@@ -1299,7 +1331,8 @@ export class DurablePrpControlPlane {
) {
const call = { callId: semantic.callId, operationId: semantic.operationId, input: semantic.input };
void this.#onSemanticToolInput(call).then((result) => {
- this.queueCommand("semantic_tool.result", { ...call, result }, `command_tool_${call.callId}`, true);
+ const runScope = createHash("sha256").update(this.identity.runId).digest("hex").slice(0, 12);
+ this.queueCommand("semantic_tool.result", { ...call, result }, `command_tool_${runScope}_${call.callId}`, true);
});
}
@@ -1370,6 +1403,10 @@ export function spawnRunner(options: {
maxOutboxBytes: number;
p0ReserveBytes: number;
maxRuntimeMs?: number;
+ maxLifetimeMs?: number;
+ lifecyclePolicy?:
+ | { mode: "per_turn"; idleTimeoutMs: null }
+ | { mode: "warm"; idleTimeoutMs: number };
runnerBinaryPath?: string;
environment?: NodeJS.ProcessEnv;
}): RunnerProcessHandle {
@@ -1404,9 +1441,18 @@ export function spawnRunner(options: {
String(options.p0ReserveBytes),
"--reconnect-delay-ms",
"20",
- "--max-runtime-ms",
- String(options.maxRuntimeMs ?? 10_000),
];
+ if (options.maxLifetimeMs !== undefined) {
+ args.push("--max-lifetime-ms", String(options.maxLifetimeMs));
+ } else if (options.maxRuntimeMs !== undefined) {
+ args.push("--max-runtime-ms", String(options.maxRuntimeMs));
+ }
+ if (options.lifecyclePolicy !== undefined) {
+ args.push("--lifecycle-mode", options.lifecyclePolicy.mode);
+ if (options.lifecyclePolicy.mode === "warm") {
+ args.push("--idle-timeout-ms", String(options.lifecyclePolicy.idleTimeoutMs));
+ }
+ }
const child = spawn(options.runnerBinaryPath ?? runnerBinary, args, {
cwd: packageRoot,
env: runnerEnvironment(options.ticket, options.environment),
@@ -1643,6 +1689,10 @@ export interface DurableEvalSessionInput {
explicitClaims: string[];
turnTimeoutMs: number;
toolExposure?: "eager" | "lazy";
+ provider?: "codex" | "opencode";
+ opencodeVersion?: string;
+ opencodeCommand?: string;
+ opencodeProxyPath?: string;
}
export type DurableEvalInfrastructureFailureClass =
@@ -1773,6 +1823,9 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
"-c", "shell_environment_policy.inherit=\"none\"",
"app-server",
];
+ const providerKind = input.provider ?? "codex";
+ const opencodeProxyPath = input.opencodeProxyPath
+ ?? fileURLToPath(new URL("../cli/opencode-app-server-proxy.js", import.meta.url));
core.queueCommand("run.prepare", {
authorizedTools: {
schema: "paperclip.runner.authorized-tools.v1", schemaVersion: 1,
@@ -1780,7 +1833,9 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
operations: exposedOperations,
},
provider: {
- command: process.env.PAPERCLIP_CODEX_COMMAND ?? "codex", args: providerArgs,
+ kind: providerKind,
+ command: providerKind === "opencode" ? process.execPath : process.env.PAPERCLIP_CODEX_COMMAND ?? "codex",
+ args: providerKind === "opencode" ? [opencodeProxyPath] : providerArgs,
cwd: tmpdir(), model: input.model,
instructions: "You are a Paperclip agent. Use only the supplied Paperclip tools for company state. Follow the user request, then reply concisely. Do not use shell or filesystem tools.",
},
@@ -1793,6 +1848,16 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
ticket: core.issueBootstrapTicket(), maxOutboxBytes: 256 * 1024,
p0ReserveBytes: 64 * 1024, maxRuntimeMs: input.turnTimeoutMs + 30_000,
runnerBinaryPath: input.runnerBinaryPath,
+ environment: providerKind === "opencode"
+ ? {
+ ...process.env,
+ PAPERCLIP_OPENCODE_COMMAND: input.opencodeCommand ?? "opencode",
+ PAPERCLIP_OPENCODE_RUNTIME_DIR: resolve(root, "opencode"),
+ PAPERCLIP_RUNNER_INSTANCE_ID: identity.runnerInstanceId,
+ PAPERCLIP_RUN_ID: identity.runId,
+ PAPERCLIP_NORMALIZED_SESSION_ID: identity.normalizedSessionId,
+ }
+ : process.env,
});
try {
const deadline = Date.now() + input.turnTimeoutMs;
@@ -1838,10 +1903,14 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
}
let evidenceIndex = 1;
let assistantText = "";
+ let semanticResult: Record | null = null;
let usage: Record = {};
const projectedCalls = new Map();
for (const record of records) {
const payload = record.payload as Record | undefined;
+ if (record.eventType === "run.result.proposed" && payload && semanticResult === null) {
+ semanticResult = structuredClone(payload);
+ }
const semantic = payload?.semantic_tool as Record | undefined;
if (semantic?.phase === "input" || semantic?.phase === "result") {
const wrapperInput = semantic.input as Record | undefined;
@@ -1914,10 +1983,11 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
snapshot: {
schema: "paperclip.capability.live-session.v1", revision: authority.snapshot().revision,
sessionId: identity.normalizedSessionId, providerThreadId: "withheld", providerSessionId: null,
- providerModel: { id: input.model, provider: "openai" }, status: "idle", activeTurnId: null,
+ providerModel: { id: input.model, provider: providerKind === "opencode" ? input.model.split("/", 1)[0] : "openai" }, status: "idle", activeTurnId: null,
+ semanticResult,
createdAt: now, updatedAt: now,
authority: { active: true, runId: identity.runId, companyId, actorId: input.actorId, taskId: input.taskId, sessionId: identity.normalizedSessionId, scenarioId: input.attemptId, capabilities: input.capabilities, explicitClaims: input.explicitClaims },
- config: { workingDirectory: tmpdir(), requestedModel: input.model, scenario: { id: input.attemptId, claims: input.capabilities }, capabilities: input.capabilities, explicitClaims: input.explicitClaims, seedState: initialState, turnTimeoutMs: input.turnTimeoutMs },
+ config: { workingDirectory: tmpdir(), requestedModel: input.model, provider: providerKind, driver: providerKind === "opencode" ? "opencode_server" : "codex_app_server", providerVersion: providerKind === "opencode" ? input.opencodeVersion ?? "1.18.17" : null, scenario: { id: input.attemptId, claims: input.capabilities }, capabilities: input.capabilities, explicitClaims: input.explicitClaims, seedState: initialState, turnTimeoutMs: input.turnTimeoutMs },
mockState: finalState,
transcript: [
{ id: "transcript-user", role: "user", text: input.prompt, turnId: identity.turnId, at: now },
diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts
index 6bb6fcd21bb..d2c6de49513 100644
--- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts
+++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts
@@ -20,6 +20,11 @@ export interface CodexAppServerTransport {
setServerRequestHandler(handler: CodexServerRequestHandler): void;
close(): Promise;
processInfo?(): CodexTransportProcessInfo;
+ attachRun?(input: {
+ runId: string;
+ turnId: string;
+ itemId: string;
+ }): Promise;
}
export interface CodexTransportProcessInfo {
@@ -193,7 +198,7 @@ export function redactCodexDiagnostic(message: string): string {
.replace(/([?&](?:api[_-]?key|token|secret|password)=)[^\s]+/gi, "$1[REDACTED]")
.replace(/(["'](?:api[_-]?key|token|secret|password|authorization)["']\s*:\s*["'])[^"']+/gi, "$1[REDACTED]")
.replace(/(api[_-]?key|token|secret|password)\s*[=:]\s*[^\s,;]+/gi, "$1=[REDACTED]")
- .replace(/(PAPERCLIP_API_KEY|OPENAI_API_KEY)=[^\s]+/g, "$1=[REDACTED]");
+ .replace(/(PAPERCLIP_API_KEY|OPENAI_API_KEY|OPENROUTER_API_KEY)=[^\s]+/g, "$1=[REDACTED]");
}
function proxyContainsCredentials(value: string): boolean {
diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts
index ce04ebf4a7f..bf14237a11e 100644
--- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts
+++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts
@@ -357,6 +357,7 @@ describe("Codex app-server Codex driver", () => {
transport.push("item/started", { threadId: "thread-1", turnId: turn.turnId, item: { id: "cmd-1", type: "commandExecution", command: "printf hello" } });
transport.push("item/commandExecution/outputDelta", { threadId: "thread-1", turnId: turn.turnId, itemId: "cmd-1", delta: "hello" });
transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "file-1", type: "fileChange", changes: [{ path: "hello.txt" }] } });
+ transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "reference-1", type: "agentMessage", text: "Open [hello.txt](hello.txt)." } });
const requestResolution = transport.invoke({
id: "request-1",
method: "item/tool/requestUserInput",
@@ -379,9 +380,15 @@ describe("Codex app-server Codex driver", () => {
expect(events.filter((event) => event.eventType === "run.result.proposed")).toHaveLength(1);
expect(events.filter((event) => event.eventType === "turn.completed")).toHaveLength(1);
expect(events.filter((event) => event.eventType === "run.terminal")).toHaveLength(0);
+ expect(events.find((event) => event.eventType === "workspace.change.updated")?.payload)
+ .toMatchObject({ schema: "paperclip.workspace.diff.v1", complete: true, totals: { files: 1 } });
+ expect(events.find((event) => event.eventType === "workspace.diff.recorded")?.payload)
+ .toMatchObject({ schema: "paperclip.workspace.diff.v1", complete: true });
+ expect(events.find((event) => event.eventType === "workspace.file.referenced")?.payload)
+ .toMatchObject({ schema: "paperclip.workspace.file_reference.v1", path: "hello.txt" });
expect(events.map((event) => event.eventType)).toEqual(expect.arrayContaining([
"session.started", "turn.started", "item.started", "item.delta", "item.completed", "run.result.proposed", "turn.completed",
- "runtime_request.created", "runtime_request.resolved",
+ "runtime_request.created", "runtime_request.resolved", "workspace.change.updated", "workspace.diff.recorded",
]));
const capabilities: PrpCapabilities = {
@@ -407,6 +414,40 @@ describe("Codex app-server Codex driver", () => {
expect(await session.usage?.()).toMatchObject({ modelContextWindow: 128000 });
});
+ it("normalizes provider message and reasoning phases onto every streamed item event", async () => {
+ const transport = new FakeCodexTransport();
+ const session = await makeDriver([transport]).openSession({
+ runId: "run-stream-channels",
+ normalizedSessionId: "normalized-stream-channels",
+ workingDirectory: "/workspace",
+ });
+ const turn = await session.startTurn({ message: { role: "user", text: "Stream progress." } });
+ transport.push("turn/started", { threadId: "thread-1", turn: { id: turn.turnId, status: "inProgress" } });
+ transport.push("item/started", { threadId: "thread-1", turnId: turn.turnId, item: { id: "progress-1", type: "agentMessage", phase: "commentary", text: "" } });
+ transport.push("item/agentMessage/delta", { threadId: "thread-1", turnId: turn.turnId, itemId: "progress-1", delta: "Running it now." });
+ transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "progress-1", type: "agentMessage", phase: "commentary", text: "Running it now." } });
+ transport.push("item/started", { threadId: "thread-1", turnId: turn.turnId, item: { id: "reason-1", type: "reasoning", summary: [] } });
+ transport.push("item/reasoning/summaryTextDelta", { threadId: "thread-1", turnId: turn.turnId, itemId: "reason-1", delta: "Summary" });
+ transport.push("item/reasoning/textDelta", { threadId: "thread-1", turnId: turn.turnId, itemId: "reason-1", delta: "Detail" });
+ transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "reason-1", type: "reasoning", summary: [{ text: "Summary" }] } });
+ transport.push("item/started", { threadId: "thread-1", turnId: turn.turnId, item: { id: "final-1", type: "agentMessage", phase: "final_answer", text: "" } });
+ transport.push("item/agentMessage/delta", { threadId: "thread-1", turnId: turn.turnId, itemId: "final-1", delta: JSON.stringify(result) });
+ transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "final-1", type: "agentMessage", phase: "final_answer", text: JSON.stringify(result) } });
+ transport.push("turn/completed", { threadId: "thread-1", turn: { id: turn.turnId, status: "completed", items: [] } });
+
+ const events = await collectUntilTerminal(session.events());
+ const pick = (kind: string, channel: string) => events.find((event) =>
+ event.payload.kind === kind && event.payload.channel === channel);
+ expect(pick("agentMessage", "progress")?.payload).toMatchObject({ providerPhase: "commentary" });
+ expect(events.find((event) => event.eventType === "item.delta" && event.payload.channel === "progress")?.payload)
+ .toMatchObject({ providerMethod: "item/agentMessage/delta", text: "Running it now." });
+ expect(events.find((event) => event.eventType === "item.delta" && event.payload.channel === "summary")?.payload)
+ .toMatchObject({ providerMethod: "item/reasoning/summaryTextDelta" });
+ expect(events.find((event) => event.eventType === "item.delta" && event.payload.channel === "detail")?.payload)
+ .toMatchObject({ providerMethod: "item/reasoning/textDelta" });
+ expect(pick("agentMessage", "final")?.payload).toMatchObject({ providerPhase: "final_answer" });
+ });
+
it("captures an exact skillless model/environment snapshot with credentials absent", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({ runId: "run-context", normalizedSessionId: "normalized-context", workingDirectory: "/workspace" });
@@ -437,6 +478,7 @@ describe("Codex app-server Codex driver", () => {
"skills.include_instructions": false,
include_apps_instructions: false,
include_collaboration_mode_instructions: false,
+ "features.image_generation": false,
},
permissions: "paperclip-runner-workspace-only",
runtimeWorkspaceRoots: ["/workspace"],
@@ -459,6 +501,8 @@ describe("Codex app-server Codex driver", () => {
"permissions.paperclip-runner-workspace-only.network.enabled=false",
'shell_environment_policy.inherit="none"',
expect.stringContaining('shell_environment_policy.set={PATH="/bin",LANG="C.UTF-8"}'),
+ "--disable",
+ "image_generation",
]));
expect(appServerArgs.find((arg) => arg.startsWith("permissions."))).toContain(
'"/isolated/home"="none"',
diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.ts
index ecd9a95ae9b..08f9e9e8aaf 100644
--- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.ts
+++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.ts
@@ -1,3 +1,4 @@
+import { randomUUID } from "node:crypto";
import { parse, relative, resolve } from "node:path";
import type {
@@ -41,6 +42,7 @@ import {
type PrpStructuredRunResult,
} from "../../protocol/replay-contract.js";
import type { NativeUserMessage } from "../../contracts/types.js";
+import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js";
import {
ProcessCodexAppServerTransport,
createSanitizedCodexEnvironment,
@@ -246,6 +248,7 @@ const SKILLLESS_BASE_CONFIG = {
"features.plugins": false,
"features.multi_agent": false,
"features.memories": false,
+ "features.image_generation": false,
} as const;
function commandEnvironment(source: NodeJS.ProcessEnv = process.env): Record {
@@ -298,6 +301,8 @@ export function createIsolatedCodexAppServerArgs(
...(commandEnv.length > 0
? ["-c", `shell_environment_policy.set={${commandEnv}}`]
: []),
+ "--disable",
+ "image_generation",
"app-server",
];
}
@@ -644,7 +649,7 @@ export class CodexAppServerDriver implements HarnessDriver {
class CodexHarnessSession implements HarnessSession {
readonly #transport: CodexAppServerTransport;
- readonly #runId: string;
+ #runId: string;
readonly #normalizedSessionId: string;
readonly #opened: OpenedCodexThread;
readonly #taskEnvelope: CodexTaskEnvelope;
@@ -669,6 +674,9 @@ class CodexHarnessSession implements HarnessSession {
#terminal = false;
#turnStarted = false;
readonly #terminalTurns = new Map();
+ readonly #workspaceChangesByTurn = new Map>();
+ readonly #emittedFileReferences = new Set();
+ readonly #itemChannels = new Map();
readonly #pendingRuntimeRequests = new Map();
readonly #lineage = new Map();
#goal: HarnessThreadGoal | null = null;
@@ -699,6 +707,7 @@ class CodexHarnessSession implements HarnessSession {
}) {
this.#transport = input.transport;
this.#runId = input.runId;
+ this.#sourceSequence = 0;
this.#normalizedSessionId = input.normalizedSessionId;
this.#opened = input.opened;
this.#taskEnvelope = input.taskEnvelope;
@@ -772,6 +781,30 @@ class CodexHarnessSession implements HarnessSession {
};
}
+ async attachRun(input: { runId: string }): Promise {
+ if (this.#activeTurnId !== null || this.#turnStartPending || this.#pendingRuntimeRequests.size > 0) {
+ throw new Error("codex_run_attach_busy");
+ }
+ if (!input.runId) throw new Error("codex_run_attach_invalid");
+ await this.#transport.attachRun?.({
+ runId: input.runId,
+ turnId: `turn_attachment_${randomUUID().replaceAll("-", "")}`,
+ itemId: `item_attachment_${randomUUID().replaceAll("-", "")}`,
+ });
+ this.#runId = input.runId;
+ this.#result = null;
+ this.#resultFingerprint = null;
+ this.#resultCallId = null;
+ this.#resultTurnId = null;
+ this.#terminal = false;
+ this.#terminalTurns.clear();
+ this.#turnStarted = false;
+ this.#protocolFailed = false;
+ this.#protocolFailureCode = null;
+ this.#protocolFailureMessage = null;
+ this.#emit("run.attached", { runId: input.runId, sameSession: true });
+ }
+
contextSnapshot(): CodexModelContextSnapshot {
return structuredClone(this.#opened.context);
}
@@ -1247,8 +1280,12 @@ class CodexHarnessSession implements HarnessSession {
}
if (notification.method === "item/started") {
if (!this.#notificationNamesActiveTurn(turnId, "item start")) return;
+ const channel = this.#channelForStartedItem(item);
+ if (itemId) this.#itemChannels.set(itemId, channel);
this.#emit("item.started", boundedPayload({
kind: text(item.type, "unknown"),
+ channel,
+ providerPhase: text(item.phase) || undefined,
text: itemText(item),
item,
}), { turnId, itemId });
@@ -1257,11 +1294,29 @@ class CodexHarnessSession implements HarnessSession {
if (notification.method === "item/completed") {
if (!this.#notificationNamesActiveTurn(turnId, "item completion")) return;
if (!this.#captureResultFromItem(item, turnId)) return;
+ const channel = itemId
+ ? this.#itemChannels.get(itemId) ?? this.#channelForStartedItem(item)
+ : this.#channelForStartedItem(item);
this.#emit("item.completed", boundedPayload({
kind: text(item.type, "unknown"),
+ channel,
+ providerPhase: text(item.phase) || undefined,
text: itemText(item),
item,
}), { turnId, itemId });
+ if (text(item.type) === "agentMessage") {
+ for (const reference of paperclipWorkspaceFileReferencesFromText(
+ this.#opened.context.workingDirectory,
+ text(item.text),
+ turnId,
+ )) {
+ if (this.#emittedFileReferences.has(reference.referenceId)) continue;
+ this.#emittedFileReferences.add(reference.referenceId);
+ this.#emit("workspace.file.referenced", { ...reference }, { turnId, itemId: reference.referenceId });
+ }
+ }
+ if (itemId) this.#itemChannels.delete(itemId);
+ if (text(item.type) === "fileChange") this.#recordWorkspaceChanges(turnId, item.changes, true);
return;
}
const deltaKinds: Record = {
@@ -1278,11 +1333,22 @@ class CodexHarnessSession implements HarnessSession {
const deltaKind = deltaKinds[notification.method];
if (deltaKind !== undefined) {
if (!this.#notificationNamesActiveTurn(turnId, "item update")) return;
+ const methodChannel = this.#channelForDelta(notification.method);
+ const channel = methodChannel !== "unknown"
+ ? methodChannel
+ : itemId
+ ? this.#itemChannels.get(itemId) ?? "unknown"
+ : "unknown";
this.#emit("item.delta", boundedPayload({
kind: deltaKind,
+ channel,
+ providerMethod: notification.method,
text: text(params.delta, text(params.patch, text(params.output))),
update: params,
}), { turnId, itemId: itemId || `${turnId}:${deltaKind}` });
+ if (notification.method === "item/fileChange/patchUpdated") {
+ this.#recordWorkspaceChanges(turnId, params.changes, false);
+ }
return;
}
if (notification.method === "thread/tokenUsage/updated") {
@@ -1296,6 +1362,74 @@ class CodexHarnessSession implements HarnessSession {
}
}
+ #channelForStartedItem(item: Record): "progress" | "final" | "summary" | "detail" | "unknown" {
+ const type = text(item.type);
+ const phase = text(item.phase).toLowerCase();
+ if (type === "agentMessage") {
+ if (phase === "commentary") return "progress";
+ if (phase === "final_answer") return "final";
+ return "unknown";
+ }
+ if (type === "reasoning") return "summary";
+ return "unknown";
+ }
+
+ #channelForDelta(method: string): "progress" | "final" | "summary" | "detail" | "unknown" {
+ if (method === "item/reasoning/summaryTextDelta") return "summary";
+ if (method === "item/reasoning/textDelta") return "detail";
+ return "unknown";
+ }
+
+ #recordWorkspaceChanges(turnId: string, value: unknown, complete: boolean): void {
+ const changes = Array.isArray(value) ? value : [];
+ const files = changes.slice(0, 2_000).flatMap((candidate): Record[] => {
+ const change = record(candidate);
+ const path = text(change.path).replaceAll("\\", "/");
+ if (!path || path.startsWith("/") || path.split("/").includes("..")) return [];
+ const kind = change.kind;
+ const kindRecord = record(kind);
+ const kindText = text(kind, text(kindRecord.type, Object.keys(kindRecord)[0] ?? "update"));
+ const update = record(kindRecord.update ?? change.update);
+ const previousPath = text(update.move_path, text(update.movePath)) || null;
+ const operation = previousPath
+ ? "rename"
+ : kindText.toLowerCase().includes("add")
+ ? "create"
+ : kindText.toLowerCase().includes("delete")
+ ? "delete"
+ : "modify";
+ const diff = text(change.diff).slice(0, 262_144) || null;
+ const diffLines = diff?.split("\n") ?? [];
+ return [{
+ path,
+ operation,
+ previousPath,
+ additions: diff === null ? null : diffLines.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
+ deletions: diff === null ? null : diffLines.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
+ binary: diff === null,
+ diff,
+ }];
+ });
+ if (files.length === 0) return;
+ const unknown = files.some((file) => file.additions === null || file.deletions === null);
+ const payload = {
+ schema: "paperclip.workspace.diff.v1",
+ changeSetId: `${turnId}:workspace`,
+ revision: Number(record(this.#workspaceChangesByTurn.get(turnId)).revision ?? 0) + 1,
+ source: "harness_reported",
+ complete,
+ files,
+ totals: {
+ files: files.length,
+ additions: unknown ? null : files.reduce((sum, file) => sum + Number(file.additions), 0),
+ deletions: unknown ? null : files.reduce((sum, file) => sum + Number(file.deletions), 0),
+ },
+ patchArtifactRef: null,
+ };
+ this.#workspaceChangesByTurn.set(turnId, payload);
+ this.#emit("workspace.change.updated", payload, { turnId, itemId: `${turnId}:workspace` });
+ }
+
async #handleServerRequest(request: CodexRpcServerRequest): Promise> {
if (request.method === "item/tool/call") {
const tool = text(request.params.tool);
@@ -1516,6 +1650,13 @@ class CodexHarnessSession implements HarnessSession {
}
}
this.#terminalTurns.set(turnId, this.#terminalFingerprint(turn));
+ const workspace = this.#workspaceChangesByTurn.get(turnId);
+ if (workspace !== undefined) {
+ this.#emit("workspace.diff.recorded", { ...workspace, complete: true }, {
+ turnId,
+ itemId: `${turnId}:workspace`,
+ });
+ }
const eventType =
status === "failed"
? "turn.failed"
diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts
new file mode 100644
index 00000000000..99c5984646d
--- /dev/null
+++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts
@@ -0,0 +1,141 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
+
+const bridges: OpenCodeMcpBridge[] = [];
+
+afterEach(async () => {
+ await Promise.all(bridges.splice(0).map((bridge) => bridge.close()));
+});
+
+async function rpc(
+ bridge: OpenCodeMcpBridge,
+ body: Record,
+ secret = bridge.secret,
+) {
+ return fetch(bridge.url, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${secret}`,
+ "Content-Type": "application/json",
+ Accept: "application/json, text/event-stream",
+ },
+ body: JSON.stringify({ jsonrpc: "2.0", ...body }),
+ });
+}
+
+describe("OpenCode MCP bridge", () => {
+ it("binds to loopback, requires authentication, and exposes only its closed catalog", async () => {
+ const bridge = await startOpenCodeMcpBridge({
+ secret: "session-secret",
+ tools: [{ name: "documents.read", description: "Read", inputSchema: { type: "object" } }],
+ handler: async () => ({ ok: true }),
+ });
+ bridges.push(bridge);
+ expect(new URL(bridge.url).hostname).toBe("127.0.0.1");
+ expect((await rpc(bridge, { id: 1, method: "tools/list" }, "wrong")).status).toBe(401);
+ const listed = await rpc(bridge, { id: 2, method: "tools/list" });
+ expect(await listed.json()).toMatchObject({
+ result: { tools: [
+ { name: "documents.read" },
+ { name: "paperclip_finish" },
+ { name: "paperclip_block" },
+ ] },
+ });
+ });
+
+ it("maps prefixed names and replays identical duplicate calls exactly once", async () => {
+ const handler = vi.fn(async () => ({ value: 7 }));
+ const bridge = await startOpenCodeMcpBridge({
+ tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
+ handler,
+ });
+ bridges.push(bridge);
+ const request = { id: "call-1", method: "tools/call", params: { name: "paperclip_documents.read", arguments: { id: "doc" } } };
+ expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } });
+ expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } });
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "documents.read", callId: "call-1", arguments: { id: "doc" } }));
+ });
+
+ it("fails conflicting duplicate identities and handler errors closed", async () => {
+ const bridge = await startOpenCodeMcpBridge({
+ tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
+ handler: async () => { throw new Error("denied"); },
+ });
+ bridges.push(bridge);
+ const failed = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "a" } } });
+ expect(await failed.json()).toMatchObject({ result: { isError: true, content: [{ text: "denied" }] } });
+ const conflict = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "b" } } });
+ expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } });
+ });
+
+ it("normalizes all supported OpenCode MCP prefixes", () => {
+ expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read");
+ expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read");
+ expect(canonicalOpenCodeMcpToolName("paperclip.documents.read")).toBe("documents.read");
+ });
+
+ it("validates inputs before dispatch", async () => {
+ const handler = vi.fn(async () => ({ ok: true }));
+ const bridge = await startOpenCodeMcpBridge({
+ tools: [{
+ name: "documents.read",
+ inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } }, additionalProperties: false },
+ }],
+ handler,
+ });
+ bridges.push(bridge);
+ const response = await rpc(bridge, {
+ id: "invalid",
+ method: "tools/call",
+ params: { name: "documents.read", arguments: { id: 9 } },
+ });
+ expect(await response.json()).toMatchObject({ result: { isError: true } });
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("times out and cancels in-flight calls", async () => {
+ const observed = vi.fn();
+ const bridge = await startOpenCodeMcpBridge({
+ tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
+ timeoutMs: 20,
+ handler: ({ signal }) => new Promise((_resolve, reject) => {
+ signal.addEventListener("abort", () => { observed(); reject(new Error("aborted")); }, { once: true });
+ }),
+ });
+ bridges.push(bridge);
+ const response = await rpc(bridge, {
+ id: "slow-call",
+ method: "tools/call",
+ params: { name: "documents.read", arguments: {} },
+ });
+ expect(await response.json()).toMatchObject({ result: { isError: true, content: [{ text: "Paperclip tool call timed out" }] } });
+ expect(observed).toHaveBeenCalledOnce();
+ });
+
+ it("honors MCP cancellation notifications", async () => {
+ const started = vi.fn();
+ const bridge = await startOpenCodeMcpBridge({
+ tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
+ handler: ({ signal }) => new Promise((_resolve, reject) => {
+ started();
+ signal.addEventListener("abort", () => reject(new Error("handler aborted")), { once: true });
+ }),
+ });
+ bridges.push(bridge);
+ const pending = rpc(bridge, {
+ id: "cancel-me",
+ method: "tools/call",
+ params: { name: "documents.read", arguments: {} },
+ });
+ await vi.waitFor(() => expect(started).toHaveBeenCalledOnce());
+ expect((await rpc(bridge, {
+ method: "notifications/cancelled",
+ params: { requestId: "cancel-me", reason: "turn stopped" },
+ })).status).toBe(202);
+ expect(await (await pending).json()).toMatchObject({
+ result: { isError: true, content: [{ text: "Paperclip tool call cancelled" }] },
+ });
+ });
+});
diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts
new file mode 100644
index 00000000000..7dee014e7ca
--- /dev/null
+++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts
@@ -0,0 +1,341 @@
+import { randomBytes, timingSafeEqual } from "node:crypto";
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
+
+import {
+ CODEX_BLOCK_RESULT_OUTPUT_SCHEMA,
+ CODEX_BLOCK_TOOL_NAME,
+ CODEX_COMPLETION_TOOL_NAME,
+ CODEX_RESULT_OUTPUT_SCHEMA,
+} from "../../contracts/codex.js";
+
+export interface OpenCodeMcpToolDefinition {
+ name: string;
+ description?: string;
+ inputSchema: Record;
+}
+
+export interface OpenCodeMcpCall {
+ tool: string;
+ callId: string;
+ arguments: unknown;
+ signal: AbortSignal;
+}
+
+export interface OpenCodeMcpBridgeOptions {
+ tools?: readonly Readonly>[];
+ handler: (call: OpenCodeMcpCall) => Promise;
+ timeoutMs?: number;
+ maxBodyBytes?: number;
+ secret?: string;
+}
+
+export interface OpenCodeMcpBridge {
+ url: string;
+ secret: string;
+ close(): Promise;
+}
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+const DEFAULT_MAX_BODY_BYTES = 1_048_576;
+
+export function canonicalOpenCodeMcpToolName(name: string): string {
+ const trimmed = name.trim();
+ if (trimmed === CODEX_COMPLETION_TOOL_NAME || trimmed === CODEX_BLOCK_TOOL_NAME) return trimmed;
+ for (const prefix of ["paperclip__", "paperclip_", "paperclip."]) {
+ if (trimmed.startsWith(prefix)) return trimmed.slice(prefix.length);
+ }
+ return trimmed;
+}
+
+export async function startOpenCodeMcpBridge(
+ options: OpenCodeMcpBridgeOptions,
+): Promise {
+ const secret = options.secret ?? randomBytes(32).toString("base64url");
+ const tools = normalizeTools(options.tools ?? []);
+ const validators = compileValidators(tools);
+ const calls = new Map }>();
+ const controllers = new Map();
+ const server = createServer((request, response) => {
+ void handleRequest(request, response, {
+ secret,
+ tools,
+ validators,
+ calls,
+ controllers,
+ handler: options.handler,
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
+ maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
+ });
+ });
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ server.off("error", reject);
+ resolve();
+ });
+ });
+ const address = server.address();
+ if (address === null || typeof address === "string") {
+ await closeServer(server);
+ throw new Error("OpenCode MCP bridge failed to bind a loopback port");
+ }
+ return {
+ url: `http://127.0.0.1:${address.port}/mcp`,
+ secret,
+ close: async () => {
+ for (const controller of controllers.values()) controller.abort();
+ await closeServer(server);
+ },
+ };
+}
+
+function normalizeTools(input: readonly Readonly>[]): OpenCodeMcpToolDefinition[] {
+ const tools: OpenCodeMcpToolDefinition[] = [];
+ const seen = new Set();
+ for (const raw of input) {
+ const name = typeof raw.name === "string" ? canonicalOpenCodeMcpToolName(raw.name) : "";
+ if (!name || seen.has(name)) continue;
+ const inputSchema = isRecord(raw.inputSchema) ? structuredClone(raw.inputSchema) : { type: "object" };
+ tools.push({
+ name,
+ ...(typeof raw.description === "string" ? { description: raw.description } : {}),
+ inputSchema,
+ });
+ seen.add(name);
+ }
+ for (const terminal of [
+ { name: CODEX_COMPLETION_TOOL_NAME, description: "Return the semantic completion result.", inputSchema: CODEX_RESULT_OUTPUT_SCHEMA },
+ { name: CODEX_BLOCK_TOOL_NAME, description: "Return the semantic blocked result.", inputSchema: CODEX_BLOCK_RESULT_OUTPUT_SCHEMA },
+ ]) {
+ if (!seen.has(terminal.name)) tools.push(terminal);
+ }
+ return tools;
+}
+
+function compileValidators(tools: readonly OpenCodeMcpToolDefinition[]): Map {
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
+ return new Map(tools.map((tool) => [tool.name, ajv.compile(tool.inputSchema)]));
+}
+
+async function handleRequest(
+ request: IncomingMessage,
+ response: ServerResponse,
+ context: {
+ secret: string;
+ tools: OpenCodeMcpToolDefinition[];
+ validators: Map;
+ calls: Map }>;
+ controllers: Map;
+ handler: OpenCodeMcpBridgeOptions["handler"];
+ timeoutMs: number;
+ maxBodyBytes: number;
+ },
+): Promise {
+ setSecurityHeaders(response);
+ if (!authorized(request.headers.authorization, context.secret)) {
+ response.statusCode = 401;
+ response.end();
+ return;
+ }
+ if (request.method === "GET") {
+ response.statusCode = 405;
+ response.setHeader("Allow", "POST, DELETE");
+ response.end();
+ return;
+ }
+ if (request.method === "DELETE") {
+ response.statusCode = 204;
+ response.end();
+ return;
+ }
+ if (request.method !== "POST" || request.url !== "/mcp") {
+ response.statusCode = 404;
+ response.end();
+ return;
+ }
+ let message: Record;
+ try {
+ message = parseMessage(await readBody(request, context.maxBodyBytes));
+ } catch (error) {
+ writeRpc(response, null, undefined, rpcError(-32700, safeError(error)));
+ return;
+ }
+ const id = message.id ?? null;
+ const method = typeof message.method === "string" ? message.method : "";
+ if (method === "notifications/cancelled") {
+ const params = isRecord(message.params) ? message.params : {};
+ const requestId = params.requestId;
+ if (typeof requestId === "string" || typeof requestId === "number") {
+ context.controllers.get(String(requestId))?.abort();
+ }
+ response.statusCode = 202;
+ response.end();
+ return;
+ }
+ if (method === "notifications/initialized") {
+ response.statusCode = 202;
+ response.end();
+ return;
+ }
+ if (method === "initialize") {
+ response.setHeader("Mcp-Session-Id", randomBytes(16).toString("hex"));
+ writeRpc(response, id, {
+ protocolVersion: "2025-03-26",
+ capabilities: { tools: { listChanged: false } },
+ serverInfo: { name: "paperclip-runner", version: "1" },
+ });
+ return;
+ }
+ if (method === "ping") {
+ writeRpc(response, id, {});
+ return;
+ }
+ if (method === "tools/list") {
+ writeRpc(response, id, { tools: context.tools });
+ return;
+ }
+ if (method !== "tools/call") {
+ writeRpc(response, id, undefined, rpcError(-32601, "Method not found"));
+ return;
+ }
+ const params = isRecord(message.params) ? message.params : {};
+ const rawName = typeof params.name === "string" ? params.name : "";
+ const tool = canonicalOpenCodeMcpToolName(rawName);
+ if (!context.tools.some((entry) => entry.name === tool)) {
+ writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] });
+ return;
+ }
+ const callId = typeof id === "string" || typeof id === "number" ? String(id) : randomBytes(12).toString("hex");
+ const args = params.arguments ?? {};
+ const validator = context.validators.get(tool);
+ if (validator === undefined || !validator(args)) {
+ const detail = validator?.errors?.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`).join("; ");
+ writeRpc(response, id, {
+ isError: true,
+ content: [{ type: "text", text: `Invalid tool input${detail ? `: ${detail}` : "."}` }],
+ });
+ return;
+ }
+ const fingerprint = canonicalJson({ tool, args });
+ const existing = context.calls.get(callId);
+ if (existing && existing.fingerprint !== fingerprint) {
+ writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Duplicate call identity conflict." }] });
+ return;
+ }
+ const controller = existing === undefined ? new AbortController() : undefined;
+ const execution = existing?.promise ?? withCancellationAndTimeout(
+ context.handler({ tool, callId, arguments: structuredClone(args), signal: controller!.signal }),
+ controller!,
+ context.timeoutMs,
+ );
+ if (!existing) {
+ if (context.calls.size >= 2_048) context.calls.delete(context.calls.keys().next().value!);
+ context.calls.set(callId, { fingerprint, promise: execution });
+ context.controllers.set(callId, controller!);
+ void execution.finally(() => context.controllers.delete(callId)).catch(() => undefined);
+ }
+ try {
+ const result = await execution;
+ writeRpc(response, id, { content: [{ type: "text", text: safeJson(result) }] });
+ } catch (error) {
+ writeRpc(response, id, { isError: true, content: [{ type: "text", text: safeError(error) }] });
+ }
+}
+
+function authorized(value: string | undefined, secret: string): boolean {
+ if (!value?.startsWith("Bearer ")) return false;
+ const supplied = Buffer.from(value.slice(7));
+ const expected = Buffer.from(secret);
+ return supplied.length === expected.length && timingSafeEqual(supplied, expected);
+}
+
+function readBody(request: IncomingMessage, maxBytes: number): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ let size = 0;
+ request.on("data", (chunk: Buffer) => {
+ size += chunk.length;
+ if (size > maxBytes) {
+ reject(new Error("MCP request exceeded the retained payload limit"));
+ request.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
+ request.on("error", reject);
+ });
+}
+
+function parseMessage(body: string): Record {
+ const parsed: unknown = JSON.parse(body);
+ if (!isRecord(parsed) || parsed.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC request");
+ return parsed;
+}
+
+function writeRpc(response: ServerResponse, id: unknown, result?: unknown, error?: unknown): void {
+ response.statusCode = 200;
+ response.setHeader("Content-Type", "application/json");
+ response.end(JSON.stringify({ jsonrpc: "2.0", id, ...(error === undefined ? { result } : { error }) }));
+}
+
+function rpcError(code: number, message: string) {
+ return { code, message };
+}
+
+function setSecurityHeaders(response: ServerResponse): void {
+ response.setHeader("Cache-Control", "no-store");
+ response.setHeader("X-Content-Type-Options", "nosniff");
+}
+
+function closeServer(server: Server): Promise {
+ return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
+}
+
+function withCancellationAndTimeout(
+ promise: Promise,
+ controller: AbortController,
+ timeoutMs: number,
+): Promise {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const finish = (callback: () => void) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ controller.signal.removeEventListener("abort", onAbort);
+ callback();
+ };
+ const onAbort = () => finish(() => reject(new Error("Paperclip tool call cancelled")));
+ const timer = setTimeout(() => {
+ finish(() => reject(new Error("Paperclip tool call timed out")));
+ controller.abort();
+ }, timeoutMs);
+ timer.unref?.();
+ controller.signal.addEventListener("abort", onAbort, { once: true });
+ promise.then(
+ (value) => finish(() => resolve(value)),
+ (error) => finish(() => reject(error)),
+ );
+ });
+}
+
+function safeError(error: unknown): string {
+ return (error instanceof Error ? error.message : String(error)).slice(0, 2_000);
+}
+
+function safeJson(value: unknown): string {
+ const serialized = JSON.stringify(value);
+ return (serialized ?? "null").slice(0, 64 * 1024);
+}
+
+function canonicalJson(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
+ if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
+ return JSON.stringify(value) ?? "undefined";
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts
new file mode 100644
index 00000000000..60a00f33331
--- /dev/null
+++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts
@@ -0,0 +1,157 @@
+import { chmod, mkdtemp, readFile, rm, stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { OpenCodeServerDriver, openCodeServerDriverInternals } from "./opencode-server-driver.js";
+
+const roots: string[] = [];
+const fixture = resolve("test/fixtures/fake-opencode-server.mjs");
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
+});
+
+describe("OpenCodeServerDriver", () => {
+ it("starts an authenticated isolated server, creates a session, streams usage, aborts, and cleans up", async () => {
+ await chmod(fixture, 0o755);
+ const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
+ const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
+ roots.push(root, workspace);
+ const spawns: Array<{ pid: number }> = [];
+ const diagnostics: string[] = [];
+ const driver = new OpenCodeServerDriver({
+ model: "openrouter/deepseek/deepseek-v4-flash-0731",
+ runtimeDirectory: root,
+ command: fixture,
+ environment: {
+ PATH: process.env.PATH,
+ OPENROUTER_API_KEY: "test-openrouter-key",
+ PAPERCLIP_API_KEY: "must-not-leak",
+ UNRELATED_SECRET: "must-not-leak",
+ },
+ onSpawn: async (meta) => { spawns.push(meta); },
+ onDiagnostic: (message) => diagnostics.push(message),
+ });
+ const session = await driver.openSession({ runId: "run-1", normalizedSessionId: "normalized/1", workingDirectory: workspace });
+ expect(session.ids()).toMatchObject({ providerSessionId: "ses_fake_1" });
+ expect(spawns).toHaveLength(1);
+ const turn = await session.startTurn({ message: { role: "user", text: "finish" } });
+ const events = [];
+ for await (const event of session.events()) events.push(event);
+ expect(events.map((event) => event.eventType)).toContain("turn.completed");
+ expect(events).toContainEqual(expect.objectContaining({ eventType: "run.result.proposed" }));
+ expect(events.filter((event) => event.eventType === "item.delta")).toHaveLength(1);
+ expect(events.find((event) => event.eventType === "item.delta")?.payload.text).toBe("done [guide](guide.md)");
+ expect(events.find((event) => event.eventType === "workspace.change.updated")?.payload)
+ .toMatchObject({ schema: "paperclip.workspace.diff.v1", source: "harness_reported", totals: { files: 1 } });
+ expect(events.find((event) => event.eventType === "workspace.diff.recorded")?.payload)
+ .toMatchObject({ schema: "paperclip.workspace.diff.v1", complete: true });
+ expect(events.find((event) => event.eventType === "workspace.file.referenced")?.payload)
+ .toMatchObject({ schema: "paperclip.workspace.file_reference.v1", path: "guide.md" });
+ expect(JSON.stringify(events)).not.toContain("submitted prompt must not become assistant text");
+ expect(await session.usage()).toMatchObject({
+ input: 3,
+ output: 2,
+ costUsd: 0.001,
+ provider: "openrouter",
+ driverVersion: "1.18.17",
+ });
+ await session.interrupt?.({ turnId: turn.turnId });
+ const snapshot = await session.snapshot();
+ expect(snapshot.driverKind).toBe("opencode_server");
+ await session.close({ reason: "test" });
+ const recovered = await driver.recoverSession?.(snapshot);
+ expect(recovered).toMatchObject({ recovered: true });
+ await expect(recovered?.session?.read?.()).resolves.toMatchObject({ sessionId: "ses_fake_1", messages: [] });
+ await recovered?.session?.close({ reason: "recovery-test" });
+
+ const sessionRoot = join(root, "normalized_1");
+ expect((await stat(sessionRoot)).mode & 0o777).toBe(0o700);
+ expect((await stat(join(sessionRoot, "config", "opencode", "opencode.json"))).mode & 0o777).toBe(0o600);
+ const environment = JSON.parse(await readFile(join(sessionRoot, "data", "fake-environment.json"), "utf8"));
+ const mcpEvidence = JSON.parse(await readFile(join(sessionRoot, "data", "fake-mcp-evidence.json"), "utf8"));
+ expect(environment.keys).toContain("OPENROUTER_API_KEY");
+ expect(environment.keys).not.toContain("PAPERCLIP_API_KEY");
+ expect(environment.keys).not.toContain("UNRELATED_SECRET");
+ expect(environment.projectConfigDisabled).toBe("true");
+ expect(mcpEvidence.tools).toEqual(expect.arrayContaining(["paperclip_finish", "paperclip_block"]));
+ const config = await readFile(join(sessionRoot, "config", "opencode", "opencode.json"), "utf8");
+ expect(config).toContain("openrouter/deepseek/deepseek-v4-flash-0731");
+ expect(config).toContain('"external_directory": "deny"');
+ expect(config).not.toContain("test-openrouter-key");
+ expect(diagnostics.join("\n")).not.toContain("test-openrouter-key");
+ expect(diagnostics.join("\n")).toContain("[REDACTED]");
+ });
+
+ it("validates provider/model form", async () => {
+ const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
+ roots.push(root);
+ const driver = new OpenCodeServerDriver({ model: "bad", runtimeDirectory: root });
+ await expect(driver.validateConfig?.({ model: "bad" })).resolves.toMatchObject({ ok: false });
+ await expect(driver.validateConfig?.({ model: "openrouter/model" })).resolves.toMatchObject({ ok: true });
+ });
+
+ it("normalizes a structured block result", async () => {
+ await chmod(fixture, 0o755);
+ const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
+ const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
+ roots.push(root, workspace);
+ const driver = new OpenCodeServerDriver({
+ model: "openrouter/deepseek/deepseek-v4-flash-0731",
+ runtimeDirectory: root,
+ command: fixture,
+ environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
+ });
+ const session = await driver.openSession({ runId: "run-block", normalizedSessionId: "block", workingDirectory: workspace });
+ await session.startTurn({ message: { role: "user", text: "block-result" } });
+ const events = [];
+ for await (const event of session.events()) events.push(event);
+ expect(events.find((event) => event.eventType === "run.result.proposed")?.payload)
+ .toMatchObject({ reportedWorkDisposition: "blocked", blocker: { reasonCode: "test_dependency" } });
+ await session.close({ reason: "test" });
+ });
+
+ it("rejects malformed and oversized SSE frames", async () => {
+ const stream = (value: string) => new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(value));
+ controller.close();
+ },
+ });
+ const consume = async (value: string) => {
+ for await (const _event of openCodeServerDriverInternals.parseSse(stream(value))) {
+ // Consume the complete stream so parse failures are observed.
+ }
+ };
+ await expect(consume("data: {bad json}\n\n")).rejects.toThrow();
+ await expect(consume(`data: ${"x".repeat(1_048_577)}`)).rejects.toThrow("payload limit");
+ });
+
+ it("restarts OpenCode when session startup fails transiently", async () => {
+ await chmod(fixture, 0o755);
+ const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
+ const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
+ roots.push(root, workspace);
+ let failSessionCreate = true;
+ const spawns: number[] = [];
+ const driver = new OpenCodeServerDriver({
+ model: "openrouter/deepseek/deepseek-v4-flash-0731",
+ runtimeDirectory: root,
+ command: fixture,
+ environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
+ fetch: async (input, init) => {
+ if (failSessionCreate && String(input).endsWith("/session") && init?.method === "POST") {
+ failSessionCreate = false;
+ throw new Error("transient session initialization failure");
+ }
+ return fetch(input, init);
+ },
+ onSpawn: async ({ pid }) => { spawns.push(pid); },
+ });
+ const session = await driver.openSession({ runId: "run-retry", normalizedSessionId: "retry", workingDirectory: workspace });
+ expect(session.ids().providerSessionId).toBe("ses_fake_1");
+ expect(spawns).toHaveLength(2);
+ await session.close({ reason: "test" });
+ });
+});
diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts
new file mode 100644
index 00000000000..e9c15d22181
--- /dev/null
+++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts
@@ -0,0 +1,890 @@
+import { spawn, type ChildProcess } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { createServer } from "node:net";
+import { dirname, join, resolve } from "node:path";
+
+import {
+ CODEX_BLOCK_TOOL_NAME,
+ CODEX_COMPLETION_TOOL_NAME,
+ CODEX_SKILLLESS_BASE_INSTRUCTIONS,
+ createCodexTaskEnvelope,
+ type CodexTaskEnvelope,
+} from "../../contracts/codex.js";
+import type {
+ HarnessDriver,
+ HarnessDriverConfigValidation,
+ HarnessDriverDescriptor,
+ HarnessSession,
+ HarnessSessionRecoveryResult,
+ HarnessTranscriptSnapshot,
+ OpenHarnessSessionInput,
+ PersistedHarnessSession,
+} from "../../contracts/harness-driver.js";
+import type { NativeSessionCapabilities, NativeUserMessage } from "../../contracts/types.js";
+import type { PrpEvent, PrpStructuredRunResult } from "../../protocol/replay-contract.js";
+import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js";
+import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js";
+import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
+
+export const OPENCODE_SERVER_DRIVER_KIND = "opencode_server" as const;
+export const QUALIFIED_OPENCODE_VERSION = "1.18.17" as const;
+export const QUALIFIED_OPENCODE_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731" as const;
+
+type DynamicToolHandler = (call: {
+ tool: string;
+ callId: string;
+ threadId: string;
+ turnId: string;
+ arguments: unknown;
+}) => Promise;
+
+export interface OpenCodeServerDriverOptions {
+ model: string;
+ taskEnvelope?: CodexTaskEnvelope;
+ runnerInstanceId?: string;
+ command?: string;
+ runtimeDirectory: string;
+ environment?: NodeJS.ProcessEnv;
+ dynamicTools?: readonly Readonly>[];
+ dynamicToolHandler?: DynamicToolHandler;
+ onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise;
+ onDiagnostic?: (message: string) => void;
+ fetch?: typeof globalThis.fetch;
+ now?: () => Date;
+}
+
+interface OpenCodeRuntime {
+ baseUrl: string;
+ authHeader: string;
+ version: string;
+ process: ChildProcess;
+ bridge: OpenCodeMcpBridge;
+ sensitiveValues: readonly string[];
+ close(): Promise;
+}
+
+const CAPABILITIES: NativeSessionCapabilities = {
+ resume: true,
+ typedEvents: true,
+ steering: false,
+ interruption: true,
+ structuredResult: true,
+ read: true,
+ reconciliation: true,
+ usage: true,
+ dynamicTools: true,
+ runtimeRequestResolution: false,
+ goals: false,
+ threadLineage: false,
+ unsupported: ["steering", "runtimeRequestResolution", "goals", "threadLineage"],
+};
+
+export class OpenCodeServerDriver implements HarnessDriver {
+ readonly #options: OpenCodeServerDriverOptions;
+
+ constructor(options: OpenCodeServerDriverOptions) {
+ this.#options = options;
+ }
+
+ async descriptor(): Promise {
+ return {
+ kind: OPENCODE_SERVER_DRIVER_KIND,
+ displayName: "OpenCode server",
+ version: QUALIFIED_OPENCODE_VERSION,
+ protocolVersion: "http+sse/v1",
+ capabilities: structuredClone(CAPABILITIES),
+ };
+ }
+
+ async validateConfig(config: unknown): Promise {
+ const candidate = isRecord(config) ? config : {};
+ const issues = [];
+ const model = typeof candidate.model === "string" ? candidate.model.trim() : "";
+ if (!validModel(model)) issues.push({ path: "model", code: "invalid_model", message: "OpenCode model must use provider/model form." });
+ const command = typeof candidate.command === "string" ? candidate.command.trim() : "opencode";
+ if (!command) issues.push({ path: "command", code: "invalid_command", message: "OpenCode command cannot be empty." });
+ return issues.length === 0
+ ? { ok: true, config: { model, command }, issues: [] }
+ : { ok: false, config: null, issues };
+ }
+
+ async openSession(input: OpenHarnessSessionInput): Promise {
+ return this.#open(input, null);
+ }
+
+ async recoverSession(snapshot: PersistedHarnessSession): Promise {
+ if (
+ snapshot.driverKind !== OPENCODE_SERVER_DRIVER_KIND
+ || !snapshot.runId
+ || !snapshot.normalizedSessionId
+ || !snapshot.driverSessionId
+ ) return { recovered: false, reason: "persisted OpenCode session identity is incomplete" };
+ try {
+ const session = await this.#open({
+ runId: snapshot.runId,
+ normalizedSessionId: snapshot.normalizedSessionId,
+ workingDirectory: await this.#readWorkspace(snapshot.normalizedSessionId),
+ }, snapshot);
+ return { recovered: true, session };
+ } catch (error) {
+ return { recovered: false, reason: redact(String(error), [this.#options.environment?.OPENROUTER_API_KEY]) };
+ }
+ }
+
+ async #readWorkspace(normalizedSessionId: string): Promise {
+ const raw = await readFile(join(sessionRoot(this.#options.runtimeDirectory, normalizedSessionId), "workspace"), "utf8");
+ return raw.trim();
+ }
+
+ async #open(input: OpenHarnessSessionInput, snapshot: PersistedHarnessSession | null): Promise {
+ const cwd = validateWorkspace(input.workingDirectory);
+ const root = sessionRoot(this.#options.runtimeDirectory, input.normalizedSessionId);
+ await mkdir(root, { recursive: true, mode: 0o700 });
+ await writeFile(join(root, "workspace"), `${cwd}\n`, { mode: 0o600 });
+ let lastError: unknown = null;
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
+ let session: OpenCodeHarnessSession | null = null;
+ let runtime: OpenCodeRuntime | null = null;
+ try {
+ runtime = await startRuntime({
+ options: this.#options,
+ root,
+ cwd,
+ dispatch: (call) => {
+ if (session === null) throw new Error("OpenCode session is not ready for tool calls");
+ return session.dispatchTool(call);
+ },
+ });
+ const fetcher = this.#options.fetch ?? globalThis.fetch;
+ let providerSessionId = snapshot?.providerSessionId ?? snapshot?.driverSessionId ?? null;
+ if (providerSessionId !== null) {
+ const existing = await api(fetcher, runtime, `/session/${encodeURIComponent(providerSessionId)}`);
+ if (!isRecord(existing) || text(existing.id) !== providerSessionId) throw new Error("OpenCode resumed a different session");
+ } else {
+ const created = await api(fetcher, runtime, "/session", {
+ method: "POST",
+ body: JSON.stringify({ title: `Paperclip ${input.runId}` }),
+ });
+ providerSessionId = text(record(created).id);
+ if (!providerSessionId) throw new Error("OpenCode session creation omitted its id");
+ }
+ session = new OpenCodeHarnessSession({
+ runtime,
+ fetcher,
+ runId: input.runId,
+ normalizedSessionId: input.normalizedSessionId,
+ providerSessionId,
+ workingDirectory: cwd,
+ runnerInstanceId: this.#options.runnerInstanceId ?? `paperclip-opencode-${input.runId}`,
+ model: this.#options.model,
+ taskEnvelope: this.#options.taskEnvelope ?? createCodexTaskEnvelope({ objective: "Complete the supplied task." }),
+ dynamicToolHandler: this.#options.dynamicToolHandler,
+ snapshot,
+ now: this.#options.now ?? (() => new Date()),
+ });
+ session.startEventPump();
+ return session;
+ } catch (error) {
+ lastError = error;
+ await runtime?.close();
+ if (attempt === 3 || !retryableOpenCodeStartupError(error)) throw error;
+ this.#options.onDiagnostic?.(`OpenCode session startup attempt ${attempt} failed; retrying.`);
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 100 * attempt));
+ }
+ }
+ throw lastError;
+ }
+}
+
+class OpenCodeHarnessSession implements HarnessSession {
+ readonly #runtime: OpenCodeRuntime;
+ readonly #fetch: typeof globalThis.fetch;
+ #runId: string;
+ readonly #normalizedSessionId: string;
+ readonly #providerSessionId: string;
+ readonly #workingDirectory: string;
+ readonly #runnerInstanceId: string;
+ readonly #model: string;
+ readonly #taskEnvelope: CodexTaskEnvelope;
+ readonly #dynamicToolHandler?: DynamicToolHandler;
+ readonly #now: () => Date;
+ readonly #events = new AsyncQueue();
+ readonly #transcript: PrpEvent[] = [];
+ readonly #terminalTurns = new Map();
+ readonly #seenProviderEvents = new Set();
+ readonly #messageRoles = new Map();
+ readonly #pendingMessageParts = new Map>>();
+ readonly #partText = new Map();
+ readonly #workspaceChangesByTurn = new Map>();
+ readonly #emittedFileReferences = new Set();
+ #sourceSequence: number;
+ #activeTurnId: string | null;
+ #result: PrpStructuredRunResult | null;
+ #resultFingerprint: string | null;
+ #resultCallId: string | null;
+ #resultTurnId: string | null;
+ #usage: Record | null = null;
+ #closed = false;
+ #abort = new AbortController();
+
+ constructor(input: {
+ runtime: OpenCodeRuntime;
+ fetcher: typeof globalThis.fetch;
+ runId: string;
+ normalizedSessionId: string;
+ providerSessionId: string;
+ workingDirectory: string;
+ runnerInstanceId: string;
+ model: string;
+ taskEnvelope: CodexTaskEnvelope;
+ dynamicToolHandler?: DynamicToolHandler;
+ snapshot: PersistedHarnessSession | null;
+ now: () => Date;
+ }) {
+ this.#runtime = input.runtime;
+ this.#fetch = input.fetcher;
+ this.#runId = input.runId;
+ this.#sourceSequence = 0;
+ this.#normalizedSessionId = input.normalizedSessionId;
+ this.#providerSessionId = input.providerSessionId;
+ this.#workingDirectory = input.workingDirectory;
+ this.#runnerInstanceId = input.runnerInstanceId;
+ this.#model = input.model;
+ this.#taskEnvelope = input.taskEnvelope;
+ this.#dynamicToolHandler = input.dynamicToolHandler;
+ this.#now = input.now;
+ this.#sourceSequence = input.snapshot?.lastSourceSequence ?? 0;
+ this.#activeTurnId = input.snapshot?.activeTurnId ?? null;
+ const restored = input.snapshot?.semanticResult ?? null;
+ this.#result = restored?.result ?? null;
+ this.#resultFingerprint = restored?.fingerprint ?? null;
+ this.#resultCallId = restored?.callId ?? null;
+ this.#resultTurnId = restored?.turnId ?? null;
+ for (const terminal of input.snapshot?.terminalTurns ?? []) this.#terminalTurns.set(terminal.turnId, terminal.fingerprint);
+ this.#emit(input.snapshot ? "session.resumed" : "session.started", {
+ driverSessionId: input.providerSessionId,
+ providerSessionId: input.providerSessionId,
+ context: {
+ protocolVersion: "http+sse/v1",
+ opencodeVersion: input.runtime.version,
+ model: input.model,
+ modelProvider: input.model.split("/", 1)[0],
+ workingDirectory: input.workingDirectory,
+ environmentKeys: sanitizedEnvironmentKeys(),
+ },
+ });
+ this.#emit("item.completed", {
+ kind: "model",
+ text: `${input.model} (OpenCode ${input.runtime.version})`,
+ model: { name: input.model, provider: input.model.split("/", 1)[0], opencodeVersion: input.runtime.version },
+ }, { itemId: `${input.providerSessionId}:model` });
+ }
+
+ ids() {
+ return { driverSessionId: this.#providerSessionId, providerSessionId: this.#providerSessionId, displayId: this.#providerSessionId };
+ }
+
+ attachRun(input: { runId: string }): void {
+ if (this.#activeTurnId !== null) throw new Error("opencode_run_attach_busy");
+ if (!input.runId) throw new Error("opencode_run_attach_invalid");
+ this.#runId = input.runId;
+ this.#result = null;
+ this.#resultFingerprint = null;
+ this.#resultCallId = null;
+ this.#resultTurnId = null;
+ this.#terminalTurns.clear();
+ this.#emit("run.attached", { runId: input.runId, sameSession: true });
+ }
+
+ events(): AsyncIterable { return this.#events; }
+
+ startEventPump(): void { void this.#pumpEvents(); }
+
+ async startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }> {
+ if (this.#activeTurnId !== null) throw new Error("OpenCode session already has an active turn");
+ const turnId = `turn-${randomBytes(12).toString("hex")}`;
+ this.#activeTurnId = turnId;
+ this.#emit("turn.submitted", { envelopeSchema: this.#taskEnvelope.schema, text: input.message.text });
+ this.#emit("turn.accepted", { turnId }, { turnId });
+ this.#emit("turn.started", { status: "inProgress" }, { turnId });
+ const [providerID, ...modelParts] = this.#model.split("/");
+ const modelID = modelParts.join("/");
+ const prompt = JSON.stringify({ task: this.#taskEnvelope, message: input.message.text });
+ await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/prompt_async`, {
+ method: "POST",
+ body: JSON.stringify({
+ // OpenCode 1.18's PromptPayload carries the provider and model at the
+ // top level. Older examples used a nested `model` object; 1.18
+ // silently ignores that shape and falls back to the configured model,
+ // which can turn `openrouter/deepseek/...` into a duplicated provider
+ // lookup. Keep Paperclip's persisted provider/model form, but adapt it
+ // at this HTTP boundary.
+ providerID,
+ modelID,
+ system: CODEX_SKILLLESS_BASE_INSTRUCTIONS,
+ parts: [{ type: "text", text: prompt }],
+ }),
+ });
+ return { turnId };
+ }
+
+ async interrupt(input: { turnId?: string; reason?: string }): Promise {
+ if (input.turnId && this.#activeTurnId && input.turnId !== this.#activeTurnId) throw new Error("stale OpenCode turn");
+ await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/abort`, { method: "POST" });
+ }
+
+ async read(): Promise> {
+ const messages = await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/message`);
+ return { sessionId: this.#providerSessionId, messages };
+ }
+
+ async reconcile(): Promise> {
+ const status = await api(this.#fetch, this.#runtime, "/session/status");
+ return { sessionId: this.#providerSessionId, status: record(status)[this.#providerSessionId] ?? null };
+ }
+
+ async usage(): Promise | null> { return this.#usage === null ? null : structuredClone(this.#usage); }
+
+ async transcript(): Promise {
+ return {
+ schema: "paperclip-runner/harness-transcript/v1",
+ complete: true,
+ eventCount: this.#transcript.length,
+ events: structuredClone(this.#transcript),
+ omissionReason: null,
+ };
+ }
+
+ async snapshot(): Promise {
+ return {
+ driverKind: OPENCODE_SERVER_DRIVER_KIND,
+ driverSessionId: this.#providerSessionId,
+ providerSessionId: this.#providerSessionId,
+ runId: this.#runId,
+ normalizedSessionId: this.#normalizedSessionId,
+ activeTurnId: this.#activeTurnId,
+ semanticResult: this.#result && this.#resultFingerprint && this.#resultTurnId
+ ? { result: structuredClone(this.#result), fingerprint: this.#resultFingerprint, callId: this.#resultCallId, turnId: this.#resultTurnId }
+ : null,
+ terminalTurns: [...this.#terminalTurns].map(([turnId, fingerprint]) => ({ turnId, fingerprint })),
+ lastSourceSequence: this.#sourceSequence,
+ };
+ }
+
+ async close(): Promise {
+ if (this.#closed) return;
+ this.#closed = true;
+ this.#abort.abort();
+ this.#events.close();
+ await this.#runtime.close();
+ }
+
+ async dispatchTool(call: { tool: string; callId: string; arguments: unknown }): Promise {
+ const tool = canonicalOpenCodeMcpToolName(call.tool);
+ const turnId = this.#activeTurnId;
+ if (turnId === null) throw new Error("OpenCode tool call is not bound to an active turn");
+ this.#emit("item.started", {
+ kind: "dynamicToolCall",
+ item: { type: "tool_call", id: call.callId, name: tool, arguments: call.arguments },
+ }, { turnId, itemId: call.callId });
+ if (tool === CODEX_COMPLETION_TOOL_NAME || tool === CODEX_BLOCK_TOOL_NAME) {
+ const validation = validatePrpStructuredRunResult(call.arguments);
+ if (!validation.ok) throw new Error("Invalid semantic result");
+ if (
+ (tool === CODEX_BLOCK_TOOL_NAME && validation.result.reportedWorkDisposition !== "blocked")
+ || (tool === CODEX_COMPLETION_TOOL_NAME && validation.result.reportedWorkDisposition === "blocked")
+ ) throw new Error("Semantic result disposition does not match the terminal tool");
+ if (validation.result.completionClaim.contractRevision !== this.#taskEnvelope.completionContract.revision) {
+ throw new Error("Semantic result completion contract revision does not match");
+ }
+ const fingerprint = canonicalJson(validation.result);
+ if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint) throw new Error("A different semantic result was already committed");
+ if (!this.#resultFingerprint) {
+ this.#result = structuredClone(validation.result);
+ this.#resultFingerprint = fingerprint;
+ this.#resultCallId = call.callId;
+ this.#resultTurnId = turnId;
+ this.#emit("run.result.proposed", validation.result, { turnId, itemId: call.callId });
+ }
+ this.#emit("item.completed", {
+ kind: "dynamicToolCall",
+ item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result: "Semantic completion accepted." },
+ }, { turnId, itemId: call.callId });
+ return { accepted: true };
+ }
+ if (!this.#dynamicToolHandler) throw new Error("Unsupported Paperclip operation");
+ try {
+ const result = await this.#dynamicToolHandler({
+ tool,
+ callId: call.callId,
+ threadId: this.#providerSessionId,
+ turnId,
+ arguments: call.arguments,
+ });
+ this.#emit("item.completed", {
+ kind: "dynamicToolCall",
+ item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result },
+ }, { turnId, itemId: call.callId });
+ return result;
+ } catch (error) {
+ this.#emit("item.completed", {
+ kind: "dynamicToolCall",
+ item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, error: redact(String(error)), is_error: true },
+ }, { turnId, itemId: call.callId });
+ throw error;
+ }
+ }
+
+ async #pumpEvents(): Promise {
+ let attempts = 0;
+ while (!this.#closed && !this.#abort.signal.aborted) {
+ try {
+ const response = await this.#fetch(`${this.#runtime.baseUrl}/event`, {
+ headers: { Authorization: this.#runtime.authHeader, Accept: "text/event-stream" },
+ signal: this.#abort.signal,
+ });
+ if (!response.ok || !response.body) throw new Error(`OpenCode event stream returned HTTP ${response.status}`);
+ for await (const event of parseSse(response.body)) {
+ if (this.#closed) return;
+ this.#mapProviderEvent(event);
+ }
+ throw new Error("OpenCode event stream closed before the session became terminal");
+ } catch (error) {
+ if (this.#closed || this.#abort.signal.aborted) return;
+ attempts += 1;
+ if (attempts > 3) {
+ this.#emit("harness.diagnostic", { code: "opencode_sse_failed", message: redact(String(error), this.#runtime.sensitiveValues) });
+ this.#events.fail(error);
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 50 * attempts));
+ }
+ }
+ }
+
+ #mapProviderEvent(value: unknown): void {
+ const event = record(value);
+ const properties = record(event.properties);
+ const type = text(event.type);
+ const eventId = text(event.id, canonicalJson(value));
+ if (this.#seenProviderEvents.has(eventId)) return;
+ this.#seenProviderEvents.add(eventId);
+ if (this.#seenProviderEvents.size > 10_000) this.#seenProviderEvents.delete(this.#seenProviderEvents.values().next().value!);
+ const sessionId = text(properties.sessionID, text(record(properties.info).sessionID));
+ if (sessionId && sessionId !== this.#providerSessionId) return;
+ const turnId = this.#activeTurnId;
+ if (type === "message.part.updated" && turnId) {
+ const part = record(properties.part);
+ const messageId = text(part.messageID, text(part.messageId));
+ if (!messageId) return;
+ const role = this.#messageRoles.get(messageId);
+ if (role === "assistant") this.#emitAssistantPart(part, turnId);
+ else if (role === undefined) {
+ const pending = this.#pendingMessageParts.get(messageId) ?? [];
+ if (pending.length < 100) pending.push(part);
+ this.#pendingMessageParts.set(messageId, pending);
+ }
+ return;
+ }
+ if (type === "message.updated" && turnId) {
+ const info = record(properties.info);
+ const messageId = text(info.id, text(info.messageID, text(info.messageId)));
+ const role = text(info.role);
+ if (messageId && role) {
+ this.#messageRoles.set(messageId, role);
+ const pending = this.#pendingMessageParts.get(messageId) ?? [];
+ this.#pendingMessageParts.delete(messageId);
+ if (role === "assistant") for (const part of pending) this.#emitAssistantPart(part, turnId);
+ }
+ const tokens = record(info.tokens);
+ if (Object.keys(tokens).length > 0 || typeof info.cost === "number") {
+ this.#usage = bounded({
+ ...tokens,
+ costUsd: info.cost ?? null,
+ model: this.#model,
+ provider: this.#model.split("/", 1)[0],
+ driverVersion: this.#runtime.version,
+ });
+ this.#emit("item.completed", { kind: "usage", usage: this.#usage }, { turnId, itemId: `${turnId}:usage` });
+ }
+ return;
+ }
+ if ((type === "session.idle" || (type === "session.status" && text(record(properties.status).type) === "idle")) && turnId) {
+ const fingerprint = canonicalJson({ status: "completed", semanticResult: this.#resultFingerprint });
+ this.#terminalTurns.set(turnId, fingerprint);
+ const workspace = this.#workspaceChangesByTurn.get(turnId);
+ if (workspace !== undefined) this.#emit("workspace.diff.recorded", { ...workspace, complete: true }, { turnId, itemId: `${turnId}:workspace` });
+ this.#emit("turn.completed", { status: "completed" }, { turnId });
+ this.#activeTurnId = null;
+ this.#events.close();
+ return;
+ }
+ if (type === "session.error" && turnId) {
+ this.#emit("turn.failed", { status: "failed", error: bounded(properties.error ?? properties) }, { turnId });
+ this.#terminalTurns.set(turnId, canonicalJson({ status: "failed" }));
+ this.#activeTurnId = null;
+ this.#events.close();
+ }
+ }
+
+ #emitAssistantPart(part: Record, turnId: string): void {
+ const partId = text(part.id, `${turnId}:part`);
+ const partType = text(part.type, "unknown");
+ if (partType === "patch") {
+ const paths = Array.isArray(part.files) ? part.files : [];
+ const files = paths.slice(0, 2_000).flatMap((value): Record[] => {
+ const path = text(value).replaceAll("\\", "/");
+ if (!path || path.startsWith("/") || path.split("/").includes("..")) return [];
+ return [{ path, operation: "modify", previousPath: null, additions: null, deletions: null, binary: false, diff: null }];
+ });
+ if (files.length > 0) {
+ const previous = this.#workspaceChangesByTurn.get(turnId);
+ const payload = {
+ schema: "paperclip.workspace.diff.v1",
+ changeSetId: `${turnId}:workspace`,
+ revision: Number(record(previous).revision ?? 0) + 1,
+ source: "harness_reported",
+ complete: false,
+ files,
+ totals: { files: files.length, additions: null, deletions: null },
+ patchArtifactRef: null,
+ };
+ this.#workspaceChangesByTurn.set(turnId, payload);
+ this.#emit("workspace.change.updated", payload, { turnId, itemId: `${turnId}:workspace` });
+ }
+ }
+ const content = text(part.text, text(part.output));
+ const previous = this.#partText.get(partId) ?? "";
+ this.#partText.set(partId, content);
+ if (partType === "text" && content.length > 0) {
+ for (const reference of paperclipWorkspaceFileReferencesFromText(
+ this.#workingDirectory,
+ content,
+ turnId,
+ )) {
+ if (this.#emittedFileReferences.has(reference.referenceId)) continue;
+ this.#emittedFileReferences.add(reference.referenceId);
+ this.#emit("workspace.file.referenced", { ...reference }, { turnId, itemId: reference.referenceId });
+ }
+ }
+ const delta = content.startsWith(previous) ? content.slice(previous.length) : content;
+ if (!delta) return;
+ this.#emit("item.delta", { kind: partType, text: delta, item: bounded(part) }, { turnId, itemId: partId });
+ }
+
+ #emit(eventType: PrpEvent["eventType"], payload: Record, refs: { turnId?: string; itemId?: string } = {}): void {
+ const sourceSeq = ++this.#sourceSequence;
+ const event: PrpEvent = {
+ schema: "paperclip.prp.event.v1",
+ sourceEventId: `${this.#runnerInstanceId}:${this.#runId}:${sourceSeq}`,
+ sourceSeq,
+ sourceInstanceId: this.#runnerInstanceId,
+ sourceKind: "runner",
+ runId: this.#runId,
+ normalizedSessionId: this.#normalizedSessionId,
+ ...(refs.turnId ? { turnId: refs.turnId } : {}),
+ ...(refs.itemId ? { itemId: refs.itemId } : {}),
+ eventType,
+ schemaVersion: 1,
+ priority: eventType === "run.result.proposed" ? 0 : 1,
+ emittedAt: this.#now().toISOString(),
+ payload,
+ };
+ this.#transcript.push(structuredClone(event));
+ this.#events.push(event);
+ }
+}
+
+async function startRuntime(input: {
+ options: OpenCodeServerDriverOptions;
+ root: string;
+ cwd: string;
+ dispatch: (call: { tool: string; callId: string; arguments: unknown }) => Promise;
+}): Promise {
+ const bridge = await startOpenCodeMcpBridge({
+ tools: input.options.dynamicTools,
+ handler: input.dispatch,
+ });
+ const port = await reservePort();
+ const password = randomBytes(32).toString("base64url");
+ const username = "paperclip";
+ const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
+ const configHome = join(input.root, "config");
+ const dataHome = join(input.root, "data");
+ const cacheHome = join(input.root, "cache");
+ const isolatedHome = join(input.root, "home");
+ await Promise.all([
+ mkdir(join(configHome, "opencode"), { recursive: true, mode: 0o700 }),
+ mkdir(dataHome, { recursive: true, mode: 0o700 }),
+ mkdir(cacheHome, { recursive: true, mode: 0o700 }),
+ mkdir(isolatedHome, { recursive: true, mode: 0o700 }),
+ ]);
+ const config = {
+ $schema: "https://opencode.ai/config.json",
+ model: input.options.model,
+ small_model: input.options.model,
+ share: "disabled",
+ instructions: [],
+ plugin: [],
+ permission: { "*": "allow", external_directory: "deny" },
+ mcp: {
+ paperclip: {
+ type: "remote",
+ url: bridge.url,
+ enabled: true,
+ oauth: false,
+ headers: { Authorization: `Bearer ${bridge.secret}` },
+ timeout: 30_000,
+ },
+ },
+ };
+ await writeFile(join(configHome, "opencode", "opencode.json"), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
+ const environment = sanitizedEnvironment(input.options.environment ?? process.env, {
+ HOME: isolatedHome,
+ XDG_CONFIG_HOME: configHome,
+ XDG_DATA_HOME: dataHome,
+ XDG_CACHE_HOME: cacheHome,
+ OPENCODE_DISABLE_PROJECT_CONFIG: "true",
+ OPENCODE_SERVER_USERNAME: username,
+ OPENCODE_SERVER_PASSWORD: password,
+ });
+ const child = spawn(input.options.command ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
+ cwd: input.cwd,
+ env: environment,
+ stdio: ["ignore", "ignore", "pipe"],
+ detached: globalThis.process.platform !== "win32",
+ });
+ let diagnostics = "";
+ child.stderr?.on("data", (chunk) => {
+ diagnostics = `${diagnostics}${String(chunk)}`.slice(-8_192);
+ input.options.onDiagnostic?.(redact(String(chunk), [password, input.options.environment?.OPENROUTER_API_KEY]));
+ });
+ try {
+ await new Promise((resolve, reject) => {
+ child.once("spawn", resolve);
+ child.once("error", reject);
+ });
+ if (child.pid) await input.options.onSpawn?.({
+ pid: child.pid,
+ processGroupId: globalThis.process.platform === "win32" ? null : child.pid,
+ startedAt: new Date().toISOString(),
+ });
+ const baseUrl = `http://127.0.0.1:${port}`;
+ const health = await waitForHealth(baseUrl, authHeader, input.options.fetch ?? globalThis.fetch, child, diagnostics);
+ const version = text(record(health).version);
+ if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("OpenCode health response omitted a semantic version");
+ const qualifiedComparison = compareVersion(version, QUALIFIED_OPENCODE_VERSION);
+ if (qualifiedComparison < 0) throw new Error(`OpenCode ${version} is older than required ${QUALIFIED_OPENCODE_VERSION}`);
+ if (qualifiedComparison > 0) input.options.onDiagnostic?.(`OpenCode ${version} is newer than qualified ${QUALIFIED_OPENCODE_VERSION}.`);
+ return {
+ baseUrl,
+ authHeader,
+ version,
+ process: child,
+ bridge,
+ sensitiveValues: [password, input.options.environment?.OPENROUTER_API_KEY].filter((value): value is string => Boolean(value)),
+ close: async () => {
+ await bridge.close().catch(() => {});
+ if (child.exitCode === null && child.signalCode === null && child.pid) {
+ try {
+ if (globalThis.process.platform === "win32") child.kill("SIGTERM");
+ else globalThis.process.kill(-child.pid, "SIGTERM");
+ } catch { child.kill("SIGTERM"); }
+ }
+ await waitForExit(child, 2_000);
+ if (child.exitCode === null && child.signalCode === null && child.pid) {
+ try {
+ if (globalThis.process.platform === "win32") child.kill("SIGKILL");
+ else globalThis.process.kill(-child.pid, "SIGKILL");
+ } catch { child.kill("SIGKILL"); }
+ }
+ },
+ };
+ } catch (error) {
+ await bridge.close().catch(() => {});
+ child.kill("SIGKILL");
+ throw error;
+ }
+}
+
+async function api(fetcher: typeof globalThis.fetch, runtime: OpenCodeRuntime, path: string, init: RequestInit = {}): Promise {
+ const timeout = AbortSignal.timeout(20_000);
+ const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
+ let response: Response;
+ try {
+ response = await fetcher(`${runtime.baseUrl}${path}`, {
+ ...init,
+ signal,
+ headers: { Authorization: runtime.authHeader, "Content-Type": "application/json", ...init.headers },
+ });
+ } catch (error) {
+ throw new Error(`OpenCode API ${path} request failed: ${redact(String(error), runtime.sensitiveValues)}`);
+ }
+ if (!response.ok) throw new Error(`OpenCode API ${path} returned HTTP ${response.status}: ${redact(await response.text(), runtime.sensitiveValues)}`);
+ if (response.status === 204) return null;
+ return response.json();
+}
+
+function retryableOpenCodeStartupError(error: unknown): boolean {
+ const message = String(error);
+ return message.includes("request failed")
+ || message.includes("did not become healthy")
+ || message.includes("exited during startup")
+ || message.includes("HTTP 408")
+ || message.includes("HTTP 425")
+ || message.includes("HTTP 429")
+ || /HTTP 5\d\d/.test(message);
+}
+
+async function waitForHealth(baseUrl: string, authHeader: string, fetcher: typeof globalThis.fetch, process: ChildProcess, diagnostics: string): Promise {
+ const deadline = Date.now() + 10_000;
+ while (Date.now() < deadline) {
+ if (process.exitCode !== null || process.signalCode !== null) throw new Error(`OpenCode server exited during startup: ${redact(diagnostics)}`);
+ try {
+ const response = await fetcher(`${baseUrl}/global/health`, {
+ headers: { Authorization: authHeader },
+ signal: AbortSignal.timeout(1_000),
+ });
+ if (response.ok) return response.json();
+ } catch { /* server is still starting */ }
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ throw new Error("OpenCode server did not become healthy within 10 seconds");
+}
+
+async function reservePort(): Promise {
+ const server = createServer();
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => resolve());
+ });
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Unable to reserve OpenCode port");
+ const port = address.port;
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
+ return port;
+}
+
+async function* parseSse(stream: ReadableStream): AsyncIterable {
+ const decoder = new TextDecoder();
+ let buffer = "";
+ for await (const chunk of stream as unknown as AsyncIterable) {
+ buffer += decoder.decode(chunk, { stream: true });
+ if (buffer.length > 1_048_576) throw new Error("OpenCode SSE event exceeded the retained payload limit");
+ let boundary: number;
+ while ((boundary = buffer.indexOf("\n\n")) >= 0) {
+ const frame = buffer.slice(0, boundary).replaceAll("\r", "");
+ buffer = buffer.slice(boundary + 2);
+ const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
+ if (!data || data === "[DONE]") continue;
+ yield JSON.parse(data);
+ }
+ }
+}
+
+function sanitizedEnvironment(source: NodeJS.ProcessEnv, overrides: Record): NodeJS.ProcessEnv {
+ const allowed = ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "OPENROUTER_API_KEY"];
+ const result: NodeJS.ProcessEnv = {};
+ for (const key of allowed) if (source[key] !== undefined) result[key] = source[key];
+ return { ...result, ...overrides };
+}
+
+function sanitizedEnvironmentKeys(): string[] {
+ return ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "OPENROUTER_API_KEY"];
+}
+
+function sessionRoot(runtimeDirectory: string, normalizedSessionId: string): string {
+ const safe = normalizedSessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
+ if (!safe || safe === "." || safe === "..") throw new Error("Invalid normalized OpenCode session id");
+ return join(resolve(runtimeDirectory), safe);
+}
+
+function validateWorkspace(value: string): string {
+ const cwd = resolve(value);
+ if (!value.trim() || cwd === dirname(cwd)) throw new Error("OpenCode working directory must not be a filesystem root");
+ return cwd;
+}
+
+function validModel(value: string): boolean {
+ const slash = value.indexOf("/");
+ return slash > 0 && slash < value.length - 1;
+}
+
+function compareVersion(left: string, right: string): number {
+ const a = left.split(".").map(Number);
+ const b = right.split(".").map(Number);
+ for (let index = 0; index < 3; index += 1) {
+ if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0);
+ }
+ return 0;
+}
+
+function bounded(value: unknown): Record {
+ const serialized = JSON.stringify(value);
+ if (!serialized || serialized.length > 64 * 1024) return { omitted: true, reason: "payload_limit" };
+ const parsed = JSON.parse(serialized) as unknown;
+ return isRecord(parsed) ? parsed : { value: parsed };
+}
+
+function record(value: unknown): Record { return isRecord(value) ? value : {}; }
+function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }
+function text(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; }
+function redact(value: string, sensitiveValues: readonly (string | undefined)[] = []): string {
+ let redacted = value;
+ for (const sensitive of sensitiveValues) {
+ if (sensitive && sensitive.length >= 4) redacted = redacted.split(sensitive).join("[REDACTED]");
+ }
+ return redacted.replace(/(OPENROUTER_API_KEY|authorization|password|token|secret)\s*[:=]\s*[^\s,}\]]+/gi, "$1=[REDACTED]").slice(0, 8_192);
+}
+function canonicalJson(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
+ if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
+ return JSON.stringify(value) ?? "undefined";
+}
+
+async function waitForExit(process: ChildProcess, timeoutMs: number): Promise {
+ if (process.exitCode !== null || process.signalCode !== null) return;
+ await new Promise((resolve) => {
+ const timer = setTimeout(resolve, timeoutMs);
+ timer.unref?.();
+ process.once("exit", () => { clearTimeout(timer); resolve(); });
+ });
+}
+
+export const openCodeServerDriverInternals = { parseSse };
+
+class AsyncQueue implements AsyncIterable {
+ #items: T[] = [];
+ #waiters: Array<{ resolve: (result: IteratorResult) => void; reject: (error: unknown) => void }> = [];
+ #closed = false;
+ #error: unknown = null;
+ push(item: T): void {
+ if (this.#closed) return;
+ const waiter = this.#waiters.shift();
+ if (waiter) waiter.resolve({ done: false, value: item });
+ else this.#items.push(item);
+ }
+ close(): void {
+ if (this.#closed) return;
+ this.#closed = true;
+ for (const waiter of this.#waiters.splice(0)) waiter.resolve({ done: true, value: undefined });
+ }
+ fail(error: unknown): void {
+ if (this.#closed) return;
+ this.#closed = true;
+ this.#error = error;
+ for (const waiter of this.#waiters.splice(0)) waiter.reject(error);
+ }
+ [Symbol.asyncIterator](): AsyncIterator {
+ return {
+ next: () => {
+ const item = this.#items.shift();
+ if (item !== undefined) return Promise.resolve({ done: false, value: item });
+ if (this.#error !== null) return Promise.reject(this.#error);
+ if (this.#closed) return Promise.resolve({ done: true, value: undefined });
+ return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }));
+ },
+ };
+ }
+}
diff --git a/packages/paperclip-runner/src/evals/model-pricing.ts b/packages/paperclip-runner/src/evals/model-pricing.ts
index c680be4077b..aed2c696ce1 100644
--- a/packages/paperclip-runner/src/evals/model-pricing.ts
+++ b/packages/paperclip-runner/src/evals/model-pricing.ts
@@ -1,4 +1,4 @@
-export const MODEL_PRICING_VERSION = "openai-api-2026-08-20" as const;
+export const MODEL_PRICING_VERSION = "provider-list-prices-2026-08-21" as const;
interface TokenRatesUsdPerMillion {
input: number;
@@ -14,6 +14,8 @@ const RATES: Readonly> = Object.freeze({
"gpt-5.6-sol": { input: 5, cachedInput: 0.5, output: 30 },
"gpt-5.6-terra": { input: 2, cachedInput: 0.2, output: 12 },
"gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, output: 1.2 },
+ // OpenRouter routed list price published for DeepSeek V4 Flash 0731.
+ "openrouter/deepseek/deepseek-v4-flash-0731": { input: 0.065, cachedInput: 0.02, output: 0.18 },
});
export interface EstimatedModelCost {
diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts
index 02ba8077cd7..a26ab04757b 100644
--- a/packages/paperclip-runner/src/index.ts
+++ b/packages/paperclip-runner/src/index.ts
@@ -8,6 +8,8 @@ export * from "./contracts/codex.js";
export * from "./contracts/types.js";
export * from "./backends/harness-driver-backend.js";
export * from "./backends/codex-native-backend.js";
+export * from "./backends/opencode-native-backend.js";
+export * from "./backends/native-backend-factory.js";
export * from "./native-session-runtime.js";
export {
DurablePrpControlPlane,
@@ -15,12 +17,15 @@ export {
} from "./control-plane/durable-prp-control-plane.js";
export * from "./drivers/codex/app-server-transport.js";
export * from "./drivers/codex/codex-app-server-driver.js";
+export * from "./drivers/opencode/opencode-server-driver.js";
+export * from "./drivers/opencode/mcp-bridge.js";
export {
createRunnerdCodexTransport,
defaultCapabilityRunnerdBinary,
type RunnerdCodexTransport,
type RunnerdCodexTransportOptions,
} from "./live/runnerd-codex-transport.js";
+export * from "./live/workspace-file-reference.js";
export * from "./protocol/replay-contract.js";
export * from "./protocol/replay-loader.js";
export * from "./protocol/semantic-tool-receipts.js";
diff --git a/packages/paperclip-runner/src/issue-thread/fixtures.ts b/packages/paperclip-runner/src/issue-thread/fixtures.ts
index 2008df78b81..b55dd87859e 100644
--- a/packages/paperclip-runner/src/issue-thread/fixtures.ts
+++ b/packages/paperclip-runner/src/issue-thread/fixtures.ts
@@ -23,6 +23,8 @@ export const CAPABILITY_UI_SHOT_SLUGS = [
"denial-optional-tool",
"document-revision",
"deliverable-registered",
+ "workspace-file-changes",
+ "workspace-file-reference",
"disposition-terminal",
"debug-panel-open",
"reconnect-banner",
@@ -954,6 +956,87 @@ const BUILDERS: Record CapabilityIssueThreadSnapshot
return snapshot;
},
+ "workspace-file-changes": () => {
+ const snapshot = baseline();
+ const turn = lastTurn(snapshot);
+ turn.items.push({
+ kind: "workspace_changes",
+ id: "item-workspace-turn-3",
+ at: at(4, 18),
+ changeSet: {
+ schema: "paperclip.workspace.diff.v1",
+ changeSetId: "turn-3:workspace",
+ revision: 1,
+ source: "runner_verified",
+ complete: true,
+ files: [
+ {
+ path: "packages/runner/src/workspace-events.ts",
+ operation: "create",
+ previousPath: null,
+ additions: 12,
+ deletions: 0,
+ binary: false,
+ diff: "diff --git a/packages/runner/src/workspace-events.ts b/packages/runner/src/workspace-events.ts\n--- /dev/null\n+++ b/packages/runner/src/workspace-events.ts\n@@ -1,0 +1,12 @@\n+export const WORKSPACE_DIFF_SCHEMA = \\\"paperclip.workspace.diff.v1\\\";\n+\n+export function recordWorkspaceDiff() {\n+ return { complete: true };\n+}\n",
+ },
+ {
+ path: "packages/runner/src/index.ts",
+ operation: "modify",
+ previousPath: null,
+ additions: 2,
+ deletions: 1,
+ binary: false,
+ diff: "diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts\n--- a/packages/runner/src/index.ts\n+++ b/packages/runner/src/index.ts\n@@ -4,1 +4,2 @@\n-export * from './events';\n+export * from './events';\n+export * from './workspace-events';\n",
+ },
+ {
+ path: "packages/runner/src/legacy-diff.ts",
+ operation: "delete",
+ previousPath: null,
+ additions: 0,
+ deletions: 8,
+ binary: false,
+ diff: "diff --git a/packages/runner/src/legacy-diff.ts b/packages/runner/src/legacy-diff.ts\n--- a/packages/runner/src/legacy-diff.ts\n+++ /dev/null\n@@ -1,8 +1,0 @@\n-export function legacyDiff() {\n- return null;\n-}\n",
+ },
+ {
+ path: "packages/runner/test/workspace-events.test.ts",
+ operation: "create",
+ previousPath: null,
+ additions: 16,
+ deletions: 0,
+ binary: false,
+ diff: "diff --git a/packages/runner/test/workspace-events.test.ts b/packages/runner/test/workspace-events.test.ts\n--- /dev/null\n+++ b/packages/runner/test/workspace-events.test.ts\n@@ -1,0 +1,16 @@\n+import { test } from 'vitest';\n+\n+test('records workspace changes', () => {\n+ // deterministic fixture\n+});\n",
+ },
+ ],
+ totals: { files: 4, additions: 30, deletions: 9 },
+ patchArtifactRef: null,
+ },
+ });
+ return snapshot;
+ },
+
+ "workspace-file-reference": () => {
+ const snapshot = baseline();
+ lastTurn(snapshot).items.push({
+ kind: "workspace_file_reference",
+ id: "item-file-reference-turn-3",
+ at: at(4, 16),
+ reference: {
+ schema: "paperclip.workspace.file_reference.v1",
+ referenceId: "turn-3:file:runner-plan",
+ source: "runner_verified",
+ path: "docs/paperclip-runner-protocol.md",
+ displayName: "paperclip-runner-protocol.md",
+ mediaType: "text/markdown",
+ presentation: "document",
+ line: null,
+ preview: "# Paperclip Runner Protocol\n\nThe runner projects provider activity into durable, provider-neutral events.\n\n## Workspace references\n\nA local Markdown link becomes a `workspace.file.referenced` event only after the runner verifies that the target stays inside the authorized workspace.\n",
+ previewTruncated: false,
+ contentDigest: "sha256:2e7d2c03a9507ae265ecf5b5356885a53393a2029d241e48b76e557868953c97",
+ },
+ });
+ return snapshot;
+ },
+
"disposition-terminal": () => {
const snapshot = baseline();
const turn = lastTurn(snapshot);
@@ -1046,7 +1129,12 @@ export function capabilityIssueThreadFixture(
slug: string = "thread-baseline",
fixtureProfile: string = CAPABILITY_DEFAULT_FIXTURE_PROFILE,
): CapabilityIssueThreadSnapshot {
- const build = BUILDERS[slug as CapabilityUiShotSlug] ?? BUILDERS["thread-baseline"];
+ const effectiveSlug = slug === "thread-baseline" && fixtureProfile === "wc-workspace-changes"
+ ? "workspace-file-changes"
+ : slug === "thread-baseline" && fixtureProfile === "fr-file-reference"
+ ? "workspace-file-reference"
+ : slug;
+ const build = BUILDERS[effectiveSlug as CapabilityUiShotSlug] ?? BUILDERS["thread-baseline"];
const snapshot = build();
snapshot.issue.fixtureProfile = fixtureProfile;
return snapshot;
diff --git a/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts b/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts
index a4022cc50fc..497f6b821d7 100644
--- a/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts
+++ b/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts
@@ -22,6 +22,8 @@ const REQUIRED_ITEM_KINDS: Array = [
"agent_message",
"durable_comment",
"tool_activity",
+ "workspace_changes",
+ "workspace_file_reference",
"interaction",
"document",
"deliverable",
@@ -59,9 +61,9 @@ function credentialLike(value: unknown): string[] {
}
describe("Capability issue-thread fixtures", () => {
- it("exposes exactly the twelve screenshot slugs from the UX contract §10.2", () => {
- expect(CAPABILITY_UI_SHOT_SLUGS).toHaveLength(12);
- expect(new Set(CAPABILITY_UI_SHOT_SLUGS).size).toBe(12);
+ it("exposes every screenshot slug exactly once", () => {
+ expect(CAPABILITY_UI_SHOT_SLUGS).toHaveLength(14);
+ expect(new Set(CAPABILITY_UI_SHOT_SLUGS).size).toBe(14);
});
it("builds a schema-valid snapshot for every slug", () => {
@@ -164,6 +166,34 @@ describe("Capability issue-thread fixtures", () => {
expect(credentialLike(capabilityIssueThreadFixture(slug)), slug).toEqual([]);
}
});
+
+ it("includes a reviewable workspace-change scenario", () => {
+ const snapshot = capabilityIssueThreadFixture("workspace-file-changes");
+ const item = allItems(snapshot).find((candidate) => candidate.kind === "workspace_changes");
+ expect(item?.kind).toBe("workspace_changes");
+ if (item?.kind !== "workspace_changes") return;
+ expect(item.changeSet.files).toHaveLength(4);
+ expect(item.changeSet.source).toBe("runner_verified");
+ expect(item.changeSet.files[0]?.diff).toContain("diff --git");
+ expect(allItems(capabilityIssueThreadFixture("thread-baseline", "wc-workspace-changes")))
+ .toEqual(allItems(snapshot));
+ });
+
+ it("includes a verified individual-file reference scenario", () => {
+ const snapshot = capabilityIssueThreadFixture("workspace-file-reference");
+ const item = allItems(snapshot).find((candidate) => candidate.kind === "workspace_file_reference");
+ expect(item?.kind).toBe("workspace_file_reference");
+ if (item?.kind !== "workspace_file_reference") return;
+ expect(item.reference).toMatchObject({
+ schema: "paperclip.workspace.file_reference.v1",
+ source: "runner_verified",
+ path: "docs/paperclip-runner-protocol.md",
+ presentation: "document",
+ });
+ expect(item.reference.preview).toContain("# Paperclip Runner Protocol");
+ expect(allItems(capabilityIssueThreadFixture("thread-baseline", "fr-file-reference")))
+ .toEqual(allItems(snapshot));
+ });
});
describe("Capability live projection", () => {
@@ -345,7 +375,22 @@ describe("Capability live projection", () => {
}
it("projects mock records into the same view contract the fixtures use", async () => {
- const view = projectCapabilityIssueThread({ snapshot: await liveSnapshot() });
+ const snapshot = await liveSnapshot();
+ snapshot.workspaceDiffs = [{
+ turnId: "turn-1",
+ at: "2026-08-09T09:00:35.000Z",
+ diff: {
+ schema: "paperclip.workspace.diff.v1",
+ changeSetId: "turn-1:workspace",
+ revision: 1,
+ source: "runner_verified",
+ complete: true,
+ files: [{ path: "src/index.ts", operation: "modify", previousPath: null, additions: 1, deletions: 1, binary: false, diff: "-old\n+new\n" }],
+ totals: { files: 1, additions: 1, deletions: 1 },
+ patchArtifactRef: null,
+ },
+ }];
+ const view = projectCapabilityIssueThread({ snapshot });
expect(view.schema).toBe(CAPABILITY_ISSUE_THREAD_VIEW_SCHEMA);
expect(view.mode).toBe("live");
@@ -357,6 +402,10 @@ describe("Capability live projection", () => {
expect(items.filter((item) => item.kind === "user_message")).toHaveLength(1);
expect(items.some((item) => item.kind === "agent_message")).toBe(true);
expect(items.some((item) => item.kind === "tool_activity")).toBe(true);
+ expect(items.find((item) => item.kind === "workspace_changes")).toMatchObject({
+ kind: "workspace_changes",
+ changeSet: { source: "runner_verified", totals: { files: 1 } },
+ });
const progress = items.find((item) => item.kind === "progress_activity");
expect(progress).toMatchObject({
@@ -380,6 +429,21 @@ describe("Capability live projection", () => {
}
});
+ it("uses the persisted harness provider for live agent identity", async () => {
+ const snapshot = await liveSnapshot();
+ snapshot.config.provider = "opencode";
+ snapshot.config.driver = "opencode_server";
+ snapshot.status = "running";
+ snapshot.activeTurnId = "turn-1";
+ const view = projectCapabilityIssueThread({ snapshot });
+
+ expect(view.identity.agentLabel).toBe("Real OpenCode");
+ expect(allItems(view).find((item) => item.kind === "agent_message"))
+ .toMatchObject({ author: "Real OpenCode" });
+ expect(view.composer.helper).toBe("OpenCode is working — send to steer, or stop the turn.");
+ expect(JSON.stringify(view)).not.toContain("Codex");
+ });
+
it("hides the synthetic interaction-result message from the thread", async () => {
const view = projectCapabilityIssueThread({ snapshot: await liveSnapshot() });
const bodies = allItems(view)
diff --git a/packages/paperclip-runner/src/issue-thread/live-projection.ts b/packages/paperclip-runner/src/issue-thread/live-projection.ts
index b356acfdbce..f831771d7cb 100644
--- a/packages/paperclip-runner/src/issue-thread/live-projection.ts
+++ b/packages/paperclip-runner/src/issue-thread/live-projection.ts
@@ -514,6 +514,7 @@ function composerModel(
mode: CapabilityThreadMode,
pendingInteractionId: string | null,
terminal: boolean,
+ providerName: string,
): CapabilityComposerModel {
if (mode === "replay") {
return { state: "disabled", helper: null, reason: "Replay is read-only", pendingInteractionId: null };
@@ -534,6 +535,16 @@ function composerModel(
pendingInteractionId: null,
};
}
+ if (snapshot.status === "suspended" || snapshot.status === "suspending") {
+ return {
+ state: snapshot.status === "suspending" ? "reconnecting" : "ready",
+ helper: snapshot.status === "suspended"
+ ? "Runner suspended — your next message will restore this provider session."
+ : "Suspending runner after durable flush…",
+ reason: null,
+ pendingInteractionId,
+ };
+ }
if (terminal) {
return {
state: "ready",
@@ -556,7 +567,7 @@ function composerModel(
if (snapshot.activeTurnId !== null || snapshot.status === "running") {
return {
state: "streaming",
- helper: "Codex is working — send to steer, or stop the turn.",
+ helper: `${providerName} is working — send to steer, or stop the turn.`,
reason: null,
pendingInteractionId: null,
};
@@ -707,6 +718,11 @@ export function projectCapabilityIssueThread(
): CapabilityIssueThreadSnapshot {
const { snapshot } = input;
const mode = input.mode ?? "live";
+ const agentLabel = mode === "live"
+ ? snapshot.config.provider === "opencode" || snapshot.config.driver === "opencode_server"
+ ? "Real OpenCode"
+ : "Real Codex"
+ : mode === "replay" ? "Replay" : "Fake agent";
const connection = input.connection ?? { state: "connected", attempt: 0 };
const state = parseMockState(snapshot.mockState);
const calls = collectCalls(snapshot.evidence);
@@ -752,7 +768,7 @@ export function projectCapabilityIssueThread(
kind: "agent_message",
id: entry.id,
at: entry.at,
- author: mode === "live" ? "Real Codex" : "Fake agent",
+ author: agentLabel,
body: entry.text,
streaming: snapshot.activeTurnId !== null && entry.turnId === snapshot.activeTurnId,
});
@@ -803,6 +819,7 @@ export function projectCapabilityIssueThread(
}
for (const group of progressGroups.values()) {
const copy = PROGRESS_EVENTS[group.event];
+ const providerName = agentLabel.replace(/^Real /, "");
const running = snapshot.activeTurnId === group.turnId && !group.event.endsWith("_completed");
turnFor(group.turnId, group.at).items.push({
kind: "progress_activity",
@@ -810,13 +827,31 @@ export function projectCapabilityIssueThread(
at: group.at,
activity: copy.activity,
status: running ? "running" : "complete",
- label: copy.label,
- summary: running ? copy.running : copy.complete,
+ label: copy.label.replaceAll("Codex", providerName),
+ summary: (running ? copy.running : copy.complete).replaceAll("Codex", providerName),
eventCount: group.count,
details: group.details,
});
}
+ for (const entry of snapshot.workspaceDiffs ?? []) {
+ turnFor(entry.turnId, entry.at).items.push({
+ kind: "workspace_changes",
+ id: `item-workspace-${entry.diff.changeSetId}`,
+ at: entry.at,
+ changeSet: structuredClone(entry.diff),
+ });
+ }
+
+ for (const entry of snapshot.workspaceFileReferences ?? []) {
+ turnFor(entry.turnId, entry.at).items.push({
+ kind: "workspace_file_reference",
+ id: `item-file-reference-${entry.reference.referenceId}`,
+ at: entry.at,
+ reference: structuredClone(entry.reference),
+ });
+ }
+
// The transcript, provider progress, and semantic calls are collected from
// separate durable record sets. Put them back into time order for a chat-like
// flow while relying on stable sort order for records sharing a timestamp.
@@ -841,9 +876,9 @@ export function projectCapabilityIssueThread(
sessionId: snapshot.sessionId,
mode,
identity: {
- agentLabel: mode === "live" ? "Real Codex" : mode === "replay" ? "Replay" : "Fake agent",
+ agentLabel,
runnerLabel: mode === "live" ? "Real runnerd" : "In-process runner",
- runnerAttached: snapshot.status === "idle" || snapshot.status === "running",
+ runnerAttached: ["idle", "warm_idle", "waiting_input", "running"].includes(snapshot.status),
controlPlaneLabel: "Mock Paperclip",
controlPlaneTooltip: "All issue records are mock. No real Paperclip API is reachable.",
replaySource: input.replaySource ?? null,
@@ -866,6 +901,7 @@ export function projectCapabilityIssueThread(
mode,
pendingInteraction?.id ?? null,
terminal,
+ agentLabel.replace(/^Real /, ""),
),
evidence: evidenceModel(snapshot, state, calls, input),
connection,
diff --git a/packages/paperclip-runner/src/issue-thread/public-view.ts b/packages/paperclip-runner/src/issue-thread/public-view.ts
index 108eca845bd..50772c698e1 100644
--- a/packages/paperclip-runner/src/issue-thread/public-view.ts
+++ b/packages/paperclip-runner/src/issue-thread/public-view.ts
@@ -298,6 +298,51 @@ function publicItem(item: CapabilityThreadItem, aliases: Aliases): CapabilityThr
eventCount: item.eventCount,
details: item.details,
};
+ case "workspace_changes":
+ return {
+ kind: "workspace_changes",
+ id,
+ at: item.at,
+ changeSet: {
+ schema: item.changeSet.schema,
+ changeSetId: aliases.text(item.changeSet.changeSetId),
+ revision: item.changeSet.revision,
+ source: item.changeSet.source,
+ complete: item.changeSet.complete,
+ files: item.changeSet.files.map((file) => ({
+ path: clamp(file.path, 1024),
+ operation: file.operation,
+ previousPath: file.previousPath === null ? null : clamp(file.previousPath, 1024),
+ additions: file.additions,
+ deletions: file.deletions,
+ binary: file.binary,
+ diff: file.diff === null ? null : clamp(file.diff, 262_144),
+ })),
+ totals: { ...item.changeSet.totals },
+ patchArtifactRef: item.changeSet.patchArtifactRef === null
+ ? null
+ : clamp(item.changeSet.patchArtifactRef, 2048),
+ },
+ };
+ case "workspace_file_reference":
+ return {
+ kind: "workspace_file_reference",
+ id,
+ at: item.at,
+ reference: {
+ schema: item.reference.schema,
+ referenceId: aliases.text(item.reference.referenceId),
+ source: item.reference.source,
+ path: clamp(item.reference.path, 1024),
+ displayName: clamp(item.reference.displayName, 255),
+ mediaType: item.reference.mediaType === null ? null : clamp(item.reference.mediaType, 160),
+ presentation: item.reference.presentation,
+ line: item.reference.line,
+ preview: item.reference.preview === null ? null : clamp(item.reference.preview, 262_144),
+ previewTruncated: item.reference.previewTruncated,
+ contentDigest: item.reference.contentDigest,
+ },
+ };
case "interaction":
return {
kind: "interaction",
diff --git a/packages/paperclip-runner/src/issue-thread/types.ts b/packages/paperclip-runner/src/issue-thread/types.ts
index 98da740875c..d3ae57c522c 100644
--- a/packages/paperclip-runner/src/issue-thread/types.ts
+++ b/packages/paperclip-runner/src/issue-thread/types.ts
@@ -14,6 +14,9 @@ import type {
CapabilityJsonValue,
CapabilityTaskStatus,
} from "../mock-core/capability-control-plane-types.js";
+import type { PaperclipWorkspaceDiff } from "../live/workspace-diff.js";
+import type { PaperclipWorkspaceFileReference } from "../live/workspace-file-reference.js";
+export type { CapabilityJsonValue } from "../mock-core/capability-control-plane-types.js";
import type { CapabilitySemanticOperationId } from "../semantic-tools/types.js";
export const CAPABILITY_ISSUE_THREAD_VIEW_SCHEMA = "paperclip.capability.issue-thread-view.v1" as const;
@@ -34,7 +37,7 @@ export const CAPABILITY_DISPOSITION_LABELS: Readonly;
}
+ | {
+ kind: "workspace_changes";
+ id: string;
+ at: string;
+ changeSet: PaperclipWorkspaceDiff;
+ }
+ | {
+ kind: "workspace_file_reference";
+ id: string;
+ at: string;
+ reference: PaperclipWorkspaceFileReference;
+ }
| ({ kind: "interaction"; id: string; at: string } & CapabilityThreadInteractionCard)
| {
kind: "document";
diff --git a/packages/paperclip-runner/src/live/clean-room-server.test.ts b/packages/paperclip-runner/src/live/clean-room-server.test.ts
index 60db283fc08..5f2f4b7205d 100644
--- a/packages/paperclip-runner/src/live/clean-room-server.test.ts
+++ b/packages/paperclip-runner/src/live/clean-room-server.test.ts
@@ -367,6 +367,11 @@ interface CleanRoomPayload {
identity: { token: string; companyId: string; actorId: string; taskId: string; identifier: string };
limits: { maxTurns: number; maxMessageBytes: number };
view: CapabilityIssueThreadSnapshot;
+ configuration: {
+ provider: "codex" | "opencode";
+ model: string | null;
+ lifecyclePolicy: { mode: "per_turn"; idleTimeoutMs: null } | { mode: "warm"; idleTimeoutMs: number };
+ };
}
function items(view: CapabilityIssueThreadSnapshot): CapabilityThreadItem[] {
@@ -620,6 +625,20 @@ describe("Capability clean-room chat server", () => {
expect(opened.view.evidence.traceability).toEqual([]);
});
+ it("creates an OpenCode room with the requested provider/model pinned", async () => {
+ const model = "openrouter/deepseek/deepseek-v4-flash-0731";
+ const opened = await call(
+ `/api/capability/ui/cleanroom/session?provider=opencode&model=${encodeURIComponent(model)}`,
+ );
+
+ expect(opened.configuration).toEqual({
+ provider: "opencode",
+ model,
+ lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
+ });
+ expect(opened.view.identity.agentLabel).toBe("Real OpenCode");
+ });
+
it("recreates a removed run scratch root before starting a later chat", async () => {
const opened = await call("/api/capability/ui/cleanroom/session");
@@ -858,7 +877,7 @@ describe("Capability clean-room chat server", () => {
expect(retried.sessionId).toBe(opened.sessionId);
});
- it("reset and new chat both rotate identities and retire the prior authority", async () => {
+ it("reset and new chat mint new identities while keeping prior chats resumable", async () => {
const opened = await call("/api/capability/ui/cleanroom/session");
await call("/api/capability/ui/message", {
sessionId: opened.sessionId,
@@ -874,7 +893,7 @@ describe("Capability clean-room chat server", () => {
expect(await status("/api/capability/ui/message", {
sessionId: opened.sessionId,
message: "still there?",
- })).toBe(404);
+ })).toBe(200);
const fresh = await call("/api/capability/ui/cleanroom/session", { sessionId: reset.sessionId });
expect(fresh.sessionId).not.toBe(reset.sessionId);
@@ -883,7 +902,7 @@ describe("Capability clean-room chat server", () => {
expect(await status("/api/capability/ui/message", {
sessionId: reset.sessionId,
message: "still there?",
- })).toBe(404);
+ })).toBe(200);
});
it("bounds turns per chat with a named reason instead of an opaque failure", async () => {
@@ -1153,7 +1172,7 @@ describe("Capability clean-room chat server", () => {
expect(await status(`/api/capability/ui/session?sessionId=${aliceIssue.sessionId}`, undefined, alice)).toBe(200);
});
- it("rotates the capability on reset and new chat and revokes the old one", async () => {
+ it("keeps the capability stable when reset and new chat archive resumable sessions", async () => {
const alice = new Jar();
const opened = await call("/api/capability/ui/cleanroom/session", undefined, undefined, alice);
const first = alice.value("paperclip_capability_chat");
@@ -1165,22 +1184,19 @@ describe("Capability clean-room chat server", () => {
const afterReset = await call("/api/capability/ui/reset", { sessionId: opened.sessionId }, undefined, alice);
const second = alice.value("paperclip_capability_chat");
- expect(second).not.toBe(first);
+ expect(second).toBe(first);
expect(afterReset.sessionId).not.toBe(opened.sessionId);
- // The old cookie is dead against the replacement session as well as the
- // retired one, so a stolen cookie does not survive a reset.
- expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterReset.sessionId}`, undefined, stale)).toBe(404);
- expect(await status("/api/capability/ui/message", { sessionId: afterReset.sessionId, message: "hi" }, stale)).toBe(404);
+ expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${opened.sessionId}`, undefined, stale)).toBe(200);
+ expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterReset.sessionId}`, undefined, stale)).toBe(200);
const afterNewChat = await call("/api/capability/ui/cleanroom/session", { sessionId: afterReset.sessionId }, undefined, alice);
const third = alice.value("paperclip_capability_chat");
- expect(third).not.toBe(second);
+ expect(third).toBe(second);
const previous = new Jar();
previous.capture(new Response(null, {
headers: { "set-cookie": `paperclip_capability_chat=${second}` },
}));
- expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterNewChat.sessionId}`, undefined, previous)).toBe(404);
- expect(await status("/api/capability/ui/reset", { sessionId: afterNewChat.sessionId }, previous)).toBe(404);
+ expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterNewChat.sessionId}`, undefined, previous)).toBe(200);
expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterNewChat.sessionId}`, undefined, alice)).toBe(200);
});
@@ -1208,7 +1224,7 @@ describe("Capability clean-room chat server", () => {
}
});
- it("rotates the issue-surface capability on reset", async () => {
+ it("keeps the issue-surface capability stable so reset archives remain resumable", async () => {
const alice = new Jar();
const opened = await call("/api/capability/ui/session?scenario=hb-baseline", undefined, undefined, alice);
const first = alice.value("paperclip_capability_issue");
@@ -1218,8 +1234,9 @@ describe("Capability clean-room chat server", () => {
}));
const reset = await call("/api/capability/ui/reset", { sessionId: opened.sessionId }, undefined, alice);
- expect(alice.value("paperclip_capability_issue")).not.toBe(first);
- expect(await status(`/api/capability/ui/session?sessionId=${reset.sessionId}`, undefined, stale)).toBe(404);
+ expect(alice.value("paperclip_capability_issue")).toBe(first);
+ expect(await status(`/api/capability/ui/session?sessionId=${opened.sessionId}`, undefined, stale)).toBe(200);
+ expect(await status(`/api/capability/ui/session?sessionId=${reset.sessionId}`, undefined, stale)).toBe(200);
expect(await status(`/api/capability/ui/session?sessionId=${reset.sessionId}`, undefined, alice)).toBe(200);
});
});
diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts
index a05df5cf484..03cb027d95f 100644
--- a/packages/paperclip-runner/src/live/live-session.test.ts
+++ b/packages/paperclip-runner/src/live/live-session.test.ts
@@ -57,6 +57,7 @@ interface FakeProviderState {
transports: FakeCapabilityCodexTransport[];
turns: Map;
holdAfterTool: boolean;
+ onTurnStart?: () => Promise;
}
class FakeCapabilityCodexTransport implements CodexAppServerTransport {
@@ -114,6 +115,7 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
this.state.turns.set(turnId, "inProgress");
const input = Array.isArray(params.input) ? params.input[0] as Record : {};
const message = String(input.text ?? "");
+ await this.state.onTurnStart?.();
void this.#runTurn(turnId, message);
return { turn: { id: turnId, status: "inProgress" } };
}
@@ -213,6 +215,8 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
});
this.state.lastToolResult = String((result.contentItems as Array>)[0]?.text);
assistantText = "I created a durable question and will use its typed result when resumed.";
+ } else if (message.includes("reference file")) {
+ assistantText = "Open [guide.md](guide.md) for the complete notes.";
} else if (message.includes("long")) {
return;
} else {
@@ -288,6 +292,111 @@ function providerState(): FakeProviderState {
}
describe("Capability live runnerd and Codex session", () => {
+ it("turns an assistant local-file link into a verified dynamic-chat file card record", async () => {
+ const root = await mkdtemp(join(tmpdir(), "paperclip-live-file-reference-"));
+ await writeFile(join(root, "guide.md"), "# Dynamic guide\n\nVerified by the runner.\n");
+ const state = providerState();
+ const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state) });
+ const session = await service.create({ workingDirectory: root });
+
+ const result = await session.sendMessage("reference file");
+ expect(result.snapshot.workspaceFileReferences).toHaveLength(1);
+ expect(result.snapshot.workspaceFileReferences?.[0]?.reference).toMatchObject({
+ schema: "paperclip.workspace.file_reference.v1",
+ source: "runner_verified",
+ path: "guide.md",
+ preview: "# Dynamic guide\n\nVerified by the runner.\n",
+ });
+ await service.shutdown(session.id);
+ });
+
+ it("records runner-verified file changes for a dynamic chat turn", async () => {
+ const root = await mkdtemp(join(tmpdir(), "paperclip-live-workspace-"));
+ await writeFile(join(root, "existing.txt"), "before\n");
+ const state = providerState();
+ state.onTurnStart = async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ await writeFile(join(root, "existing.txt"), "after\n");
+ await writeFile(join(root, "created.ts"), "export const created = true;\n");
+ };
+ const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state) });
+ const session = await service.create({ workingDirectory: root });
+
+ const result = await session.sendMessage("edit the workspace");
+ expect(result.snapshot.workspaceDiffs).toHaveLength(1);
+ expect(result.snapshot.workspaceDiffs?.[0]).toMatchObject({
+ turnId: "turn-1",
+ diff: {
+ schema: "paperclip.workspace.diff.v1",
+ source: "runner_verified",
+ complete: true,
+ totals: { files: 2 },
+ },
+ });
+ expect(result.snapshot.workspaceDiffs?.[0]?.diff.files.map((file) => file.path))
+ .toEqual(["created.ts", "existing.txt"]);
+ await service.shutdown(session.id);
+ });
+
+ it("suspends after every per-turn response and restores the same provider session", async () => {
+ const state = providerState();
+ const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state) });
+ const session = await service.create({ lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } });
+
+ await session.sendMessage("first response");
+ expect(session.snapshot()).toMatchObject({ status: "suspended", providerThreadId: state.threadId });
+ expect(state.transports[0]?.processInfo().exited).toBe(true);
+
+ await session.sendMessage("restored response");
+ expect(state.transports).toHaveLength(2);
+ expect(session.snapshot()).toMatchObject({ status: "suspended", providerThreadId: state.threadId });
+ await service.shutdown(session.id);
+ });
+
+ it("keeps a warm runner through active work and suspends it only after idle expiry", async () => {
+ const state = providerState();
+ const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state) });
+ const session = await service.create({ lifecyclePolicy: { mode: "warm", idleTimeoutMs: 25 } });
+
+ await session.sendMessage("warm response");
+ expect(session.snapshot().status).toBe("warm_idle");
+ expect(state.transports[0]?.processInfo().exited).toBe(false);
+ await vi.waitFor(() => expect(session.snapshot().status).toBe("suspended"), { timeout: 500 });
+ expect(state.transports[0]?.processInfo().exited).toBe(true);
+
+ const active = session.sendMessage("start a long turn");
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(session.snapshot().status).toBe("running");
+ await session.interrupt("test cleanup");
+ await expect(active).resolves.toMatchObject({ status: "interrupted" });
+ await service.shutdown(session.id);
+ });
+
+ it("persists the selected provider and passes it to the live runner transport", async () => {
+ const state = providerState();
+ const providers: Array = [];
+ const baseFactory = fakeTransportFactory(state);
+ const service = new CapabilityLiveSessionService({
+ transportFactory: (options) => {
+ providers.push(options.provider);
+ return baseFactory(options);
+ },
+ });
+ const session = await service.create({
+ provider: "opencode",
+ requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
+ });
+
+ expect(providers).toEqual(["opencode"]);
+ expect(session.snapshot().config).toMatchObject({
+ provider: "opencode",
+ driver: "opencode_server",
+ providerVersion: "1.18.17",
+ requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
+ });
+ await service.shutdown(session.id);
+ });
+
it("opens lazy sessions with core and discovery gateways instead of optional schemas", async () => {
const state = providerState();
const claim = "discovery:agents:read";
@@ -382,7 +491,7 @@ describe("Capability live runnerd and Codex session", () => {
await firstService.shutdown(first.id);
});
- it("stops active work and reset rotates authority while restoring clean mock state", async () => {
+ it("stops active work and reset archives the prior authority while restoring clean mock state", async () => {
const state = providerState();
const store = new InMemoryCapabilityLiveSessionStore();
const service = new CapabilityLiveSessionService({
@@ -392,6 +501,8 @@ describe("Capability live runnerd and Codex session", () => {
const session = await service.create({
runId: "run-live-reset",
sessionId: "session-live-reset",
+ provider: "opencode",
+ requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
});
const longTurn = session.sendMessage("Start a long turn.");
await new Promise((resolve) => setTimeout(resolve, 1));
@@ -404,13 +515,18 @@ describe("Capability live runnerd and Codex session", () => {
expect(replacement.snapshot().authority.runId).not.toBe("run-live-reset");
expect(replacement.mockState().comments).toHaveLength(0);
expect(replacement.snapshot().authority.active).toBe(true);
- expect(session.snapshot().authority.active).toBe(false);
- expect((await store.load(session.id))).toBeNull();
+ expect(replacement.snapshot().config).toMatchObject({
+ provider: "opencode",
+ driver: "opencode_server",
+ requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
+ });
+ expect(session.snapshot().authority.active).toBe(true);
+ expect((await store.load(session.id))?.status).toBe("suspended");
expect(state.transports[0]?.processInfo().exited).toBe(true);
const stopped = await service.stop(replacement.id, "bounded test stop");
- expect(stopped.authority.active).toBe(false);
+ expect(stopped.authority.active).toBe(true);
expect(stopped.process?.runnerExited).toBe(true);
- expect((await store.load(replacement.id))?.status).toBe("closed");
+ expect((await store.load(replacement.id))?.status).toBe("suspended");
});
it("durably resumes a distinct attempt, preserves partial usage, and deduplicates the semantic effect", async () => {
diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts
index 8cc8f0e8c35..4a44e59f0ac 100644
--- a/packages/paperclip-runner/src/live/live-session.ts
+++ b/packages/paperclip-runner/src/live/live-session.ts
@@ -36,6 +36,15 @@ import {
type CapabilityRunnerdCodexTransportOptions,
type CapabilityRunnerdProcessEvidence,
} from "./runnerd-codex-transport.js";
+import {
+ capturePaperclipWorkspace,
+ diffPaperclipWorkspace,
+ type PaperclipWorkspaceDiff,
+} from "./workspace-diff.js";
+import {
+ discoverPaperclipWorkspaceFileReferences,
+ type PaperclipWorkspaceFileReference,
+} from "./workspace-file-reference.js";
const LIVE_SESSION_SCHEMA = "paperclip.capability.live-session.v1" as const;
const LIVE_BASE_INSTRUCTIONS = [
@@ -51,7 +60,12 @@ const INVOKE_DISCOVERED_TOOL = "invoke_discovered_capability";
export type CapabilityLiveSessionStatus =
| "starting"
+ | "restoring"
| "idle"
+ | "warm_idle"
+ | "waiting_input"
+ | "suspending"
+ | "suspended"
| "running"
| "stopping"
| "closed"
@@ -94,11 +108,18 @@ export interface CapabilityLiveSessionConfigSnapshot {
workingDirectory: string;
/** Exact model requested by the caller; omitted only for legacy interactive sessions. */
requestedModel?: string;
+ /** Safe harness identity used by server-side projections and eval metadata. */
+ provider?: "codex" | "opencode";
+ driver?: "codex_app_server" | "opencode_server";
+ providerVersion?: string | null;
scenario: CapabilitySemanticScenarioPolicy;
capabilities: string[];
explicitClaims: string[];
seedState: string;
turnTimeoutMs: number;
+ lifecyclePolicy?:
+ | { mode: "per_turn"; idleTimeoutMs: null }
+ | { mode: "warm"; idleTimeoutMs: number };
toolExposure?: CapabilityToolExposureMode;
}
@@ -143,6 +164,18 @@ export interface CapabilityLiveStateRevision {
state: string;
}
+export interface CapabilityLiveWorkspaceDiffEntry {
+ turnId: string;
+ at: string;
+ diff: PaperclipWorkspaceDiff;
+}
+
+export interface CapabilityLiveWorkspaceFileReferenceEntry {
+ turnId: string;
+ at: string;
+ reference: PaperclipWorkspaceFileReference;
+}
+
export interface RecordCapabilityLiveUsageInput {
receiptId: string;
providerResponseId?: string | null;
@@ -164,6 +197,8 @@ export interface CapabilityLiveSessionSnapshot {
providerSessionId: string | null;
/** Safe provider identity retained for reproducible eval bundle declarations. */
providerModel?: { id: string; provider: string };
+ /** Validated structured provider result, retained separately from assistant prose. */
+ semanticResult?: Record | null;
status: CapabilityLiveSessionStatus;
activeTurnId: string | null;
createdAt: string;
@@ -188,12 +223,17 @@ export interface CapabilityLiveSessionSnapshot {
usageLedger?: CapabilityLiveUsageReceipt[];
/** Bounded mock-control-plane snapshots for DevTools time travel and diffs. */
stateHistory?: CapabilityLiveStateRevision[];
+ /** Complete, bounded per-turn workspace changes verified by the runner. */
+ workspaceDiffs?: CapabilityLiveWorkspaceDiffEntry[];
+ /** Assistant-authored local links verified and normalized by the runner. */
+ workspaceFileReferences?: CapabilityLiveWorkspaceFileReferenceEntry[];
loadedOperationIds?: string[];
}
export interface CreateCapabilityLiveSessionInput {
seed?: CapabilityFixtureSeed | CapabilityFixtureState;
workingDirectory?: string;
+ provider?: "codex" | "opencode";
requestedModel?: string;
scenario?: CapabilitySemanticScenarioPolicy;
capabilities?: string[];
@@ -205,6 +245,7 @@ export interface CreateCapabilityLiveSessionInput {
sessionId?: string;
attemptId?: string;
turnTimeoutMs?: number;
+ lifecyclePolicy?: CapabilityLiveSessionConfigSnapshot["lifecyclePolicy"];
toolExposure?: CapabilityToolExposureMode;
}
@@ -401,6 +442,55 @@ export function assertCapabilityLiveSessionSnapshot(
if (authority.sessionId !== snapshot.sessionId) {
throw new Error("capability_live_checkpoint_binding_mismatch");
}
+ const config = record(snapshot.config);
+ const persistedProvider = config.provider;
+ if (
+ persistedProvider !== undefined &&
+ persistedProvider !== "codex" &&
+ persistedProvider !== "opencode"
+ ) {
+ throw new Error("capability_live_checkpoint_corrupt: invalid config.provider");
+ }
+ const provider = persistedProvider ?? "codex";
+ const expectedDriver = provider === "opencode" ? "opencode_server" : "codex_app_server";
+ if (config.driver !== undefined && config.driver !== expectedDriver) {
+ throw new Error("capability_live_checkpoint_corrupt: provider/driver mismatch");
+ }
+ if (persistedProvider !== undefined && config.driver === undefined) {
+ throw new Error("capability_live_checkpoint_corrupt: missing config.driver");
+ }
+ if (config.requestedModel !== undefined) {
+ const requestedModel = text(config.requestedModel);
+ if (requestedModel.trim().length === 0) {
+ throw new Error("capability_live_checkpoint_corrupt: invalid config.requestedModel");
+ }
+ if (provider === "opencode" && !requestedModel.includes("/")) {
+ throw new Error("capability_live_checkpoint_corrupt: invalid OpenCode model");
+ }
+ } else if (provider === "opencode") {
+ throw new Error("capability_live_checkpoint_corrupt: missing OpenCode model");
+ }
+ if (
+ config.providerVersion !== undefined &&
+ config.providerVersion !== null &&
+ (typeof config.providerVersion !== "string" || config.providerVersion.trim().length === 0)
+ ) {
+ throw new Error("capability_live_checkpoint_corrupt: invalid config.providerVersion");
+ }
+ if (config.lifecyclePolicy !== undefined) {
+ const lifecyclePolicy = record(config.lifecyclePolicy);
+ if (lifecyclePolicy.mode === "per_turn") {
+ if (lifecyclePolicy.idleTimeoutMs !== null) {
+ throw new Error("capability_live_checkpoint_corrupt: per-turn idle timeout must be null");
+ }
+ } else if (
+ lifecyclePolicy.mode !== "warm"
+ || !Number.isSafeInteger(lifecyclePolicy.idleTimeoutMs)
+ || Number(lifecyclePolicy.idleTimeoutMs) <= 0
+ ) {
+ throw new Error("capability_live_checkpoint_corrupt: invalid lifecycle policy");
+ }
+ }
if (typeof snapshot.mockState !== "string") {
throw new Error("capability_live_checkpoint_corrupt: invalid mockState");
}
@@ -613,6 +703,9 @@ export class CapabilityLiveSessionService {
authority,
config: {
workingDirectory: input.workingDirectory ?? process.cwd(),
+ provider: input.provider ?? "codex",
+ driver: input.provider === "opencode" ? "opencode_server" : "codex_app_server",
+ providerVersion: input.provider === "opencode" ? "1.18.17" : null,
...(input.requestedModel === undefined
? {}
: { requestedModel: requireNonEmpty(input.requestedModel, "requested_model") }),
@@ -621,6 +714,7 @@ export class CapabilityLiveSessionService {
explicitClaims: authority.explicitClaims,
seedState,
turnTimeoutMs: input.turnTimeoutMs ?? 120_000,
+ lifecyclePolicy: input.lifecyclePolicy ?? { mode: "warm", idleTimeoutMs: 300_000 },
toolExposure: input.toolExposure ?? "eager",
},
store: this.#store,
@@ -745,33 +839,35 @@ export class CapabilityLiveSessionService {
async reset(sessionId: string): Promise {
const session = await this.restore(sessionId);
const config = session.snapshot().config;
- await session.shutdown("reset");
- this.#sessions.delete(sessionId);
- await this.#store.delete(sessionId);
+ // Reset starts a distinct governed chat, but the prior chat is an archive,
+ // not disposable state. Suspending preserves its provider checkpoint and
+ // authority binding so selecting it from history can restore it safely.
+ await session.suspend("reset archived prior session");
const seedPort = CapabilityMockControlPlaneAdapter.restore(config.seedState);
return this.create({
seed: seedPort.snapshot(),
workingDirectory: config.workingDirectory,
+ provider: config.provider ?? "codex",
+ ...(config.requestedModel === undefined ? {} : { requestedModel: config.requestedModel }),
scenario: config.scenario,
capabilities: config.capabilities,
explicitClaims: config.explicitClaims,
turnTimeoutMs: config.turnTimeoutMs,
+ lifecyclePolicy: config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
toolExposure: config.toolExposure ?? "eager",
});
}
async stop(sessionId: string, reason = "operator stopped session"): Promise {
const session = await this.restore(sessionId);
- await session.shutdown(reason);
- this.#sessions.delete(sessionId);
+ await session.suspend(reason);
return session.snapshot();
}
async shutdown(sessionId: string, reason = "service shutdown"): Promise {
const session = this.#sessions.get(sessionId);
if (session === undefined) return;
- await session.shutdown(reason);
- this.#sessions.delete(sessionId);
+ await session.suspend(reason);
}
}
@@ -807,6 +903,8 @@ export class CapabilityLiveSession {
readonly #terminalTurns: CapabilityLiveTurnTerminalFact[];
readonly #usageLedger: CapabilityLiveUsageReceipt[];
readonly #stateHistory: CapabilityLiveStateRevision[];
+ readonly #workspaceDiffs: CapabilityLiveWorkspaceDiffEntry[];
+ readonly #workspaceFileReferences: CapabilityLiveWorkspaceFileReferenceEntry[];
#currentAttemptId: string;
#transport: CodexAppServerTransport | null = null;
#processEvidence: CapabilityRunnerdProcessEvidence | null = null;
@@ -826,6 +924,7 @@ export class CapabilityLiveSession {
#turnEventCount = 0;
readonly #rawUsageByTurn = new Map();
#latestCumulativeUsage: Record = {};
+ #idleTimer: ReturnType | null = null;
readonly #loadedOperationIds = new Set();
private constructor(options: OpenSessionOptions & { snapshot?: CapabilityLiveSessionSnapshot }) {
@@ -864,6 +963,8 @@ export class CapabilityLiveSession {
operationId: "session.open",
state: this.#port.serialize(),
}]);
+ this.#workspaceDiffs = structuredClone(options.snapshot?.workspaceDiffs ?? []);
+ this.#workspaceFileReferences = structuredClone(options.snapshot?.workspaceFileReferences ?? []);
for (const operationId of options.snapshot?.loadedOperationIds ?? []) this.#loadedOperationIds.add(operationId);
this.#providerThreadId = options.snapshot?.providerThreadId ?? "";
this.#providerSessionId = options.snapshot?.providerSessionId ?? null;
@@ -1033,6 +1134,8 @@ export class CapabilityLiveSession {
terminalTurns: structuredClone(this.#terminalTurns),
usageLedger: structuredClone(this.#usageLedger),
stateHistory: structuredClone(this.#stateHistory),
+ workspaceDiffs: structuredClone(this.#workspaceDiffs),
+ workspaceFileReferences: structuredClone(this.#workspaceFileReferences),
loadedOperationIds: [...this.#loadedOperationIds].sort(),
};
}
@@ -1093,6 +1196,9 @@ export class CapabilityLiveSession {
async sendMessage(message: string): Promise {
const value = message.trim();
if (value.length === 0) throw new Error("Capability live messages cannot be empty");
+ if (this.#status === "suspended" || this.#transport === null) {
+ await this.#connect(true);
+ }
if (this.#transport === null || this.#status === "closed" || this.#status === "failed") {
throw new Error("Capability live session is not connected");
}
@@ -1100,13 +1206,23 @@ export class CapabilityLiveSession {
throw new Error("Capability live session already has an active turn");
}
this.#status = "running";
+ this.#clearIdleTimer();
this.#turnEventCount = 0;
+ // Start the baseline before admitting provider work, but do not make turn
+ // interruption wait for a potentially large workspace walk. The provider
+ // turn id is bound first; the terminal path waits for this baseline before
+ // comparing the post-turn workspace.
+ const workspaceBeforePromise = capturePaperclipWorkspace(this.#config.workingDirectory);
// Held by id, not by position: a provider can start streaming before
// `turn/start` resolves, in which case the assistant draft is already the
// last transcript entry and the user message would never learn its turn.
const userEntryId = this.#appendTranscript("user", value, null);
let response: Record;
const terminal = this.#armTurnWaiter();
+ // Workspace capture can outlive a deliberately tiny test timeout. Attach
+ // a rejection observer immediately; the authoritative await below still
+ // propagates the same error to the caller.
+ void terminal.catch(() => undefined);
try {
response = await this.#transport.request("turn/start", {
threadId: this.#providerThreadId,
@@ -1136,13 +1252,77 @@ export class CapabilityLiveSession {
const draft =
draftId === null ? undefined : this.#transcript.find((entry) => entry.id === draftId);
if (draft !== undefined && draft.turnId === null) draft.turnId = turnId;
+ const workspaceBefore = await workspaceBeforePromise;
await this.#persist();
const result = await terminal;
+ const workspaceFileReferences = await discoverPaperclipWorkspaceFileReferences(
+ this.#config.workingDirectory,
+ result.assistantText,
+ result.turnId,
+ );
+ for (const reference of workspaceFileReferences) {
+ this.#workspaceFileReferences.push({
+ turnId: result.turnId,
+ at: this.#now().toISOString(),
+ reference: {
+ ...reference,
+ preview: reference.preview === null ? null : redactCodexDiagnostic(reference.preview),
+ },
+ });
+ }
+ const workspaceAfter = await capturePaperclipWorkspace(this.#config.workingDirectory);
+ const workspaceDiff = diffPaperclipWorkspace(
+ workspaceBefore,
+ workspaceAfter,
+ `${this.id}:${result.turnId}:workspace`,
+ );
+ if (workspaceDiff !== null) {
+ this.#workspaceDiffs.push({
+ turnId: result.turnId,
+ at: this.#now().toISOString(),
+ diff: {
+ ...workspaceDiff,
+ files: workspaceDiff.files.map((file) => ({
+ ...file,
+ diff: file.diff === null ? null : redactCodexDiagnostic(file.diff),
+ })),
+ },
+ });
+ }
await this.#captureTurnUsage(result.turnId);
await this.#persist();
+ await this.#afterTurnSettled();
return { ...result, snapshot: this.snapshot() };
}
+ async #afterTurnSettled(): Promise {
+ const policy = this.#config.lifecyclePolicy ?? { mode: "per_turn" as const, idleTimeoutMs: null };
+ if (policy.mode === "per_turn") {
+ await this.suspend("per-turn lifecycle completed");
+ return;
+ }
+ this.#status = this.pendingInteractions().length > 0 ? "waiting_input" : "warm_idle";
+ this.#armIdleTimer(policy.idleTimeoutMs);
+ await this.#persist();
+ }
+
+ #clearIdleTimer(): void {
+ if (this.#idleTimer !== null) clearTimeout(this.#idleTimer);
+ this.#idleTimer = null;
+ }
+
+ #armIdleTimer(timeoutMs: number): void {
+ this.#clearIdleTimer();
+ this.#idleTimer = setTimeout(() => {
+ this.#idleTimer = null;
+ void this.suspend("warm session idle timeout").catch((error) => {
+ this.#appendEvidence("diagnostic", this.#activeTurnId, {
+ message: redactCodexDiagnostic(String(error)),
+ });
+ });
+ }, timeoutMs);
+ }
+
async #captureTurnUsage(turnId: string): Promise {
if (this.#transport === null) throw new Error("capability_live_usage_transport_missing");
// Codex can emit rawResponse/completed immediately after turn/completed.
@@ -1294,6 +1474,7 @@ export class CapabilityLiveSession {
const result = await terminal;
if (result.turnId !== turnId) throw new Error("capability_live_provider_session_drift: terminal turn mismatch");
await this.#persist();
+ await this.#afterTurnSettled();
return { ...result, snapshot: this.snapshot() };
}
@@ -1354,7 +1535,25 @@ export class CapabilityLiveSession {
await this.#connect(true);
}
+ async suspend(reason = "session suspended"): Promise {
+ if (this.#status === "closed" || this.#status === "failed" || this.#status === "suspended") return;
+ this.#clearIdleTimer();
+ this.#status = "suspending";
+ await this.#persist();
+ await this.#disconnect(reason);
+ this.#status = "suspended";
+ this.#appendEvidence("cleanup", null, {
+ reason,
+ authorityCleared: false,
+ mockStopped: false,
+ processExited: this.#processEvidence?.runnerExited ?? true,
+ resumable: true,
+ });
+ await this.#persist();
+ }
+
async shutdown(reason: string): Promise {
+ this.#clearIdleTimer();
if (this.#activeTurnId !== null) {
try {
await this.interrupt(reason);
@@ -1378,10 +1577,12 @@ export class CapabilityLiveSession {
}
async #connect(resume: boolean): Promise {
- this.#status = "starting";
+ this.#status = resume ? "restoring" : "starting";
const identityDigest = createHash("sha256").update(this.id).digest("hex").slice(0, 20);
const transportBundle = this.#transportFactory({
...this.#transportOptions,
+ provider: this.#config.provider ?? this.#transportOptions.provider ?? "codex",
+ lifecyclePolicy: this.#config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
stateDirectory: resolve(this.#config.workingDirectory, ".paperclip-runner-prp", identityDigest),
prpIdentity: {
runnerInstanceId: `runner_lab_${identityDigest}`,
@@ -1504,7 +1705,7 @@ export class CapabilityLiveSession {
sessionId: this.id,
providerThreadId: this.#providerThreadId,
providerSessionId: this.#providerSessionId,
- mode: "live_codex",
+ mode: `live_${this.#config.provider ?? "codex"}`,
runner: "paperclip-runnerd",
requestedModel: this.#config.requestedModel ?? null,
controlPlane: "mock",
diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts
index 3ce5c355821..7a663f1da67 100644
--- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts
+++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts
@@ -1,5 +1,6 @@
+import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
-import { resolve } from "node:path";
+import { join, resolve } from "node:path";
import { expect, it } from "vitest";
@@ -84,3 +85,144 @@ it("runs the lab provider boundary through authenticated durable PRP", async ()
}
expect(bundle.evidence()).toMatchObject({ runnerExited: true, runnerExitCode: 0 });
}, 30_000);
+
+it("attaches a second governed run to one warm runner and provider process", async () => {
+ const bundle = createCapabilityRunnerdCodexTransport({
+ runnerBinary: defaultCapabilityRunnerdBinary(),
+ codexCommand: fakeCodex,
+ codexArgs: [],
+ lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 },
+ });
+ bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
+ try {
+ await bundle.transport.request("initialize", {});
+ await bundle.transport.request("thread/start", {
+ cwd: tmpdir(),
+ dynamicTools: [{
+ name: "get_task_context",
+ description: "Read the active task.",
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+ }],
+ });
+ const runnerPid = bundle.evidence().runnerPid;
+ const providerPid = bundle.evidence().codexPid;
+ const notifications = bundle.transport.notifications()[Symbol.asyncIterator]();
+ const waitForCompletion = async (label: string) => {
+ const deadline = Date.now() + 5_000;
+ while (Date.now() < deadline) {
+ const next = await Promise.race([
+ notifications.next(),
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} notification timeout`)), 1_000)),
+ ]);
+ if (next.value?.method === "turn/completed") return;
+ }
+ throw new Error(`${label} completion timeout`);
+ };
+ await bundle.transport.request("turn/start", { input: [{ type: "text", text: "first run" }] });
+ await waitForCompletion("first run");
+
+ await bundle.transport.attachRun?.({
+ runId: "run-warm-second",
+ turnId: "turn-binding-second",
+ itemId: "item-binding-second",
+ });
+ await bundle.transport.request("turn/start", { input: [{ type: "text", text: "second run" }] });
+ await waitForCompletion("second run");
+
+ expect(bundle.evidence()).toMatchObject({ runnerPid, codexPid: providerPid, runnerExited: false });
+ } finally {
+ await bundle.transport.close();
+ }
+}, 30_000);
+
+it("cold-restores a suspended provider session under a new run binding", async () => {
+ const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-cold-attach-"));
+ const baseIdentity = {
+ runnerInstanceId: "runner-cold-attach",
+ environmentLeaseId: "lease-cold-attach",
+ runId: "run-cold-first",
+ normalizedSessionId: "session-cold-attach",
+ turnId: "turn-cold-first",
+ itemId: "item-cold-first",
+ };
+ const options = {
+ runnerBinary: defaultCapabilityRunnerdBinary(),
+ codexCommand: fakeCodex,
+ codexArgs: [],
+ stateDirectory,
+ lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 },
+ };
+ const dynamicTools = [{
+ name: "get_task_context",
+ description: "Read the active task.",
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+ }];
+ const first = createCapabilityRunnerdCodexTransport({ ...options, prpIdentity: baseIdentity });
+ first.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
+ try {
+ await first.transport.request("thread/start", { cwd: tmpdir(), dynamicTools });
+ await first.transport.request("turn/start", { input: [{ type: "text", text: "first process" }] });
+ for await (const event of first.transport.notifications()) {
+ if (event.method === "turn/completed") break;
+ }
+ } finally {
+ await first.transport.close();
+ }
+
+ const restored = createCapabilityRunnerdCodexTransport({
+ ...options,
+ prpIdentity: {
+ ...baseIdentity,
+ runId: "run-cold-restored",
+ turnId: "turn-cold-restored",
+ itemId: "item-cold-restored",
+ },
+ });
+ restored.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
+ try {
+ const read = await restored.transport.request("thread/read", {});
+ expect(read.thread).toMatchObject({ id: "fake-thread" });
+ await restored.transport.request("turn/start", { input: [{ type: "text", text: "restored process" }] });
+ for await (const event of restored.transport.notifications()) {
+ if (event.method === "turn/completed") break;
+ }
+ expect(restored.evidence()).toMatchObject({ runnerExited: false, codexPid: expect.any(Number) });
+ } finally {
+ await restored.transport.close();
+ await rm(stateDirectory, { recursive: true, force: true });
+ }
+}, 30_000);
+
+it("rejects the notification stream promptly when runnerd exits after accepting a turn", async () => {
+ const bundle = createCapabilityRunnerdCodexTransport({
+ runnerBinary: defaultCapabilityRunnerdBinary(),
+ codexCommand: fakeCodex,
+ codexArgs: ["--linger-after-turn-start"],
+ });
+ bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
+ try {
+ await bundle.transport.request("initialize", {});
+ await bundle.transport.request("thread/start", {
+ cwd: tmpdir(),
+ dynamicTools: [{
+ name: "get_task_context",
+ description: "Read the active task.",
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
+ }],
+ });
+ await bundle.transport.request("turn/start", {
+ input: [{ type: "text", text: "Wait for another instruction." }],
+ });
+ const notifications = bundle.transport.notifications()[Symbol.asyncIterator]();
+ expect((await notifications.next()).value?.method).toBe("turn/started");
+ const runnerPid = bundle.evidence().runnerPid;
+ expect(runnerPid).not.toBeNull();
+ process.kill(runnerPid!, "SIGKILL");
+ await expect(Promise.race([
+ notifications.next(),
+ new Promise((_, reject) => setTimeout(() => reject(new Error("notification stream hung")), 2_000)),
+ ])).rejects.toThrow("native_runner_process_exited");
+ } finally {
+ await bundle.transport.close();
+ }
+}, 30_000);
diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts
index 9183459d58b..0e242fca016 100644
--- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts
+++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
-import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -13,6 +13,7 @@ import type {
} from "../drivers/codex/app-server-transport.js";
import { createSanitizedCodexEnvironment } from "../drivers/codex/app-server-transport.js";
import { createIsolatedCodexAppServerArgs } from "../drivers/codex/codex-app-server-driver.js";
+import type { DurableRecoveryIdentity } from "../contracts/durable-recovery.js";
import {
DurablePrpControlPlane,
durableRecoveryInternals,
@@ -26,6 +27,22 @@ const executableSuffix = process.platform === "win32" ? ".exe" : "";
const MAX_NOTIFICATION_COUNT = 2_048;
const MAX_NOTIFICATION_BYTES = 4 * 1024 * 1024;
+function recoveredControlPlaneIdentity(
+ directory: string,
+ desired: DurableRecoveryIdentity,
+): DurableRecoveryIdentity {
+ const stored = record(JSON.parse(readFileSync(resolve(directory, "mock-core-state.json"), "utf8")));
+ const identity = record(stored.identity) as unknown as DurableRecoveryIdentity;
+ if (
+ identity.runnerInstanceId !== desired.runnerInstanceId
+ || identity.environmentLeaseId !== desired.environmentLeaseId
+ || identity.normalizedSessionId !== desired.normalizedSessionId
+ ) {
+ throw new Error("PRP recovery identity does not match the durable session binding");
+ }
+ return structuredClone(identity);
+}
+
export interface CapabilityRunnerdProcessEvidence {
runnerPid: number | null;
runnerProcessGroupId: number | null;
@@ -50,6 +67,11 @@ export interface CapabilityRunnerdCodexTransportOptions {
onDiagnostic?: (message: string) => void;
onEvidence?: (evidence: Readonly) => void;
stateDirectory?: string;
+ lifecyclePolicy?:
+ | { mode: "per_turn"; idleTimeoutMs: null }
+ | { mode: "warm"; idleTimeoutMs: number };
+ /** Current run's authority catalog, used when a suspended session is rebound. */
+ resumeDynamicTools?: readonly Readonly>[];
prpIdentity?: {
runnerInstanceId: string;
environmentLeaseId: string;
@@ -93,16 +115,20 @@ export function unwrapRunnerdProviderNotification(input: unknown): Record {
#values: Array<{ value: CodexRpcNotification; bytes: number }> = [];
- #waiters: Array<(value: IteratorResult) => void> = [];
+ #waiters: Array<{
+ resolve: (value: IteratorResult) => void;
+ reject: (error: Error) => void;
+ }> = [];
#bytes = 0;
#closed = false;
+ #error: Error | null = null;
push(value: CodexRpcNotification): void {
if (this.#closed) return;
const bytes = Buffer.byteLength(JSON.stringify(value));
const waiter = this.#waiters.shift();
if (waiter !== undefined) {
- waiter({ value, done: false });
+ waiter.resolve({ value, done: false });
return;
}
if (this.#values.length >= MAX_NOTIFICATION_COUNT || this.#bytes + bytes > MAX_NOTIFICATION_BYTES) {
@@ -112,12 +138,16 @@ class NotificationQueue implements AsyncIterable {
this.#bytes += bytes;
}
- close(): void {
+ close(error?: Error): void {
if (this.#closed) return;
this.#closed = true;
+ this.#error = error ?? null;
this.#values = [];
this.#bytes = 0;
- for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
+ for (const waiter of this.#waiters.splice(0)) {
+ if (this.#error !== null) waiter.reject(this.#error);
+ else waiter.resolve({ value: undefined, done: true });
+ }
}
[Symbol.asyncIterator](): AsyncIterator {
@@ -128,8 +158,9 @@ class NotificationQueue implements AsyncIterable {
this.#bytes -= queued.bytes;
return { value: queued.value, done: false };
}
+ if (this.#error !== null) throw this.#error;
if (this.#closed) return { value: undefined, done: true };
- return new Promise((resolveValue) => this.#waiters.push(resolveValue));
+ return new Promise((resolveValue, reject) => this.#waiters.push({ resolve: resolveValue, reject }));
},
};
}
@@ -145,6 +176,22 @@ function commandDigest(value: unknown): string {
return `sha256:${createHash("sha256").update(durableRecoveryInternals.canonicalJson(value)).digest("hex")}`;
}
+function authorizedToolSet(tools: readonly Readonly>[]): Record {
+ const operations = tools.map((tool) => ({
+ operationId: String(tool.name ?? ""),
+ version: 1,
+ description: String(tool.description ?? ""),
+ inputSchema: record(tool.inputSchema),
+ responseSchema: {},
+ }));
+ return {
+ schema: "paperclip.runner.authorized-tools.v1",
+ schemaVersion: 1,
+ catalogDigest: commandDigest(operations),
+ operations,
+ };
+}
+
function unwrapToolResponse(response: Record): unknown {
const items = Array.isArray(response.contentItems) ? response.contentItems : [];
const value = record(items[0]).text;
@@ -173,12 +220,17 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
#threadId = "";
#sessionId: string | null = null;
#turnId = "";
+ #authorizedTools: Record | null = null;
#closed = false;
+ #failure: Error | null = null;
#controlPlaneRelease: (() => Promise | void) | null = null;
constructor(readonly options: CapabilityRunnerdCodexTransportOptions) {
this.#ownsRoot = options.stateDirectory === undefined;
this.#root = options.stateDirectory ?? mkdtempSync(resolve(tmpdir(), "paperclip-runner-lab-prp-"));
+ if (options.resumeDynamicTools !== undefined) {
+ this.#authorizedTools = authorizedToolSet(options.resumeDynamicTools);
+ }
mkdirSync(this.#root, { recursive: true, mode: 0o700 });
this.#evidence = {
runnerPid: null,
@@ -198,6 +250,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
async request(method: string, params: Record): Promise> {
if (this.#closed) throw new Error("PRP Codex transport is closed");
+ this.#throwIfFailed();
if (method === "initialize") return { user: {} };
if (method === "thread/start") return this.#start(params);
if (method === "turn/start") return this.#startTurn(params);
@@ -234,6 +287,34 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
};
}
+ async attachRun(input: { runId: string; turnId: string; itemId: string }): Promise {
+ if (this.#closed || this.#core === null) throw new Error("PRP provider session is not attachable");
+ await this.#command("run.attach", {
+ runId: input.runId,
+ turnId: input.turnId,
+ itemId: input.itemId,
+ ...(this.#authorizedTools === null ? {} : { authorizedTools: this.#authorizedTools }),
+ });
+ const core = this.#core;
+ const attachDeadline = Date.now() + 10_000;
+ let attachmentDrained = false;
+ while (core !== null && Date.now() < attachDeadline) {
+ const durable = record(JSON.parse(readFileSync(resolve(this.#root, "runner", "runner-state.json"), "utf8")));
+ const attached = core.store.state.committedEvents.find((event) =>
+ event.eventType === "run.attached"
+ && record(record(event.envelope.payload).payload).runId === input.runId
+ );
+ if (attached && Number(durable.ackedSourceSeq ?? 0) >= attached.sourceSeq) {
+ attachmentDrained = true;
+ break;
+ }
+ this.#throwIfFailed();
+ await new Promise((resolveWait) => setTimeout(resolveWait, 10));
+ }
+ if (!attachmentDrained) throw new Error("runnerd did not durably acknowledge the run attachment");
+ this.#turnId = input.turnId;
+ }
+
async close(): Promise {
if (this.#closed) return;
this.#closed = true;
@@ -272,13 +353,6 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
itemId: `item_lab_${token}`,
};
const dynamicTools = Array.isArray(params.dynamicTools) ? params.dynamicTools.map(record) : [];
- const operations = dynamicTools.map((tool) => ({
- operationId: String(tool.name ?? ""),
- version: 1,
- description: String(tool.description ?? ""),
- inputSchema: record(tool.inputSchema),
- responseSchema: {},
- }));
const core = new DurablePrpControlPlane({
stateDirectory: resolve(this.#root, "control-plane"),
identity,
@@ -300,13 +374,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
const provider = this.options.provider ?? "codex";
const opencodeProxyPath = this.options.opencodeProxyPath
?? fileURLToPath(new URL("../cli/opencode-app-server-proxy.js", import.meta.url));
+ this.#authorizedTools = authorizedToolSet(dynamicTools);
core.queueCommand("run.prepare", {
- authorizedTools: {
- schema: "paperclip.runner.authorized-tools.v1",
- schemaVersion: 1,
- catalogDigest: commandDigest(operations),
- operations,
- },
+ authorizedTools: this.#authorizedTools,
provider: {
kind: provider,
command: provider === "opencode" ? process.execPath : this.options.codexCommand ?? "codex",
@@ -330,9 +400,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
maxOutboxBytes: 256 * 1024,
p0ReserveBytes: 64 * 1024,
maxRuntimeMs: 60 * 60 * 1_000,
+ lifecyclePolicy: this.options.lifecyclePolicy,
runnerBinaryPath: this.options.runnerBinary ?? defaultCapabilityRunnerdBinary(),
environment: provider === "opencode"
? {
+ ...process.env,
...this.options.environment,
PAPERCLIP_OPENCODE_COMMAND: this.options.opencodeCommand ?? "opencode",
PAPERCLIP_OPENCODE_RUNTIME_DIR: this.options.opencodeRuntimeDirectory ?? resolve(this.#root, "opencode"),
@@ -343,24 +415,36 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
: this.options.environment,
});
this.#handle = handle;
+ this.#watchRunner(handle);
this.#evidence.runnerPid = handle.child.pid ?? null;
this.#evidence.runnerProcessGroupId = null;
this.#publish();
- this.#pump = setInterval(() => this.#pumpEvents(), 5);
+ this.#pump = setInterval(() => this.#pumpEventsSafely(), 5);
await this.#waitCommand("run.prepare");
await this.#waitCommand("session.open");
await this.#waitForProviderIdentity();
this.#diagnostic("runnerd authenticated to the durable PRP control plane");
- return { thread: { id: this.#threadId, sessionId: this.#sessionId, model: params.model, modelProvider: "openai" } };
+ return {
+ thread: {
+ id: this.#threadId,
+ sessionId: this.#sessionId,
+ model: params.model,
+ modelProvider: provider === "opencode" && typeof params.model === "string"
+ ? params.model.split("/", 1)[0]
+ : "openai",
+ },
+ };
}
async #resume(): Promise {
- const identity = this.options.prpIdentity;
- if (identity === undefined || !existsSync(resolve(this.#root, "runner", "runner-state.json"))) {
+ const desiredIdentity = this.options.prpIdentity;
+ if (desiredIdentity === undefined || !existsSync(resolve(this.#root, "runner", "runner-state.json"))) {
throw new Error("PRP provider resume state is unavailable");
}
+ const controlPlaneDirectory = resolve(this.#root, "control-plane");
+ const identity = recoveredControlPlaneIdentity(controlPlaneDirectory, desiredIdentity);
const core = new DurablePrpControlPlane({
- stateDirectory: resolve(this.#root, "control-plane"),
+ stateDirectory: controlPlaneDirectory,
identity,
onSemanticToolInput: async (call) => unwrapToolResponse(await this.#handler({
id: call.callId,
@@ -390,14 +474,39 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
maxOutboxBytes: 256 * 1024,
p0ReserveBytes: 64 * 1024,
maxRuntimeMs: 60 * 60 * 1_000,
+ lifecyclePolicy: this.options.lifecyclePolicy,
runnerBinaryPath: this.options.runnerBinary ?? defaultCapabilityRunnerdBinary(),
});
this.#handle = handle;
+ this.#watchRunner(handle);
this.#evidence.runnerPid = handle.child.pid ?? null;
this.#evidence.runnerProcessGroupId = null;
this.#publish();
- this.#pump = setInterval(() => this.#pumpEvents(), 5);
+ this.#pump = setInterval(() => this.#pumpEventsSafely(), 5);
await this.#waitForProviderIdentity();
+ if (
+ identity.runId !== desiredIdentity.runId
+ || identity.turnId !== desiredIdentity.turnId
+ || identity.itemId !== desiredIdentity.itemId
+ ) {
+ const priorEvidenceSeq = core.store.state.ackedSourceSeq;
+ const drainDeadline = Date.now() + 10_000;
+ while (Date.now() < drainDeadline) {
+ const durable = record(JSON.parse(readFileSync(resolve(this.#root, "runner", "runner-state.json"), "utf8")));
+ if (Number(durable.ackedSourceSeq ?? 0) >= priorEvidenceSeq) break;
+ this.#throwIfFailed();
+ await new Promise((resolveWait) => setTimeout(resolveWait, 10));
+ }
+ const drained = record(JSON.parse(readFileSync(resolve(this.#root, "runner", "runner-state.json"), "utf8")));
+ if (Number(drained.ackedSourceSeq ?? 0) < priorEvidenceSeq) {
+ throw new Error("runnerd did not drain prior-run evidence before attachment");
+ }
+ await this.attachRun({
+ runId: desiredIdentity.runId,
+ turnId: desiredIdentity.turnId,
+ itemId: desiredIdentity.itemId,
+ });
+ }
this.#diagnostic("runnerd restored its durable PRP session and provider thread");
}
@@ -412,6 +521,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
// do not expose the temporary transport identity to the strict driver.
const deadline = Date.now() + 30_000;
while (this.#turnId === pendingTurnId && Date.now() < deadline) {
+ this.#throwIfFailed();
this.#pumpEvents();
if (this.#turnId !== pendingTurnId) break;
if (this.#handle?.child.exitCode !== null) throw new Error("runnerd exited before provider turn startup");
@@ -437,6 +547,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
await this.#waitCommand(type, commandId);
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
+ this.#throwIfFailed();
const event = core.store.state.committedEvents.find((candidate) => {
if (candidate.eventType !== "provider.rpc_result") return false;
return record(record(candidate.envelope.payload).payload).commandId === commandId;
@@ -451,6 +562,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
async #waitForProviderIdentity(): Promise {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
+ this.#throwIfFailed();
this.#pumpEvents();
if (this.#threadId.length > 0 && this.#evidence.codexPid !== null) return;
if (this.#handle?.child.exitCode !== null) throw new Error("runnerd exited before provider startup");
@@ -462,6 +574,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
async #waitCommand(type: string, commandId?: string): Promise {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
+ this.#throwIfFailed();
const command = this.#core?.store.state.commands.find((candidate) =>
commandId === undefined ? candidate.type === type : candidate.commandId === commandId
);
@@ -507,6 +620,55 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
}
}
+ #pumpEventsSafely(): void {
+ try {
+ this.#pumpEvents();
+ } catch (error) {
+ this.#failTransport(
+ new Error(`provider_transport_failed: ${error instanceof Error ? error.message : String(error)}`),
+ );
+ }
+ }
+
+ #watchRunner(handle: RunnerProcessHandle): void {
+ void handle.completion.then(
+ (result) => {
+ this.#evidence.runnerExited = true;
+ this.#evidence.runnerExitCode = result.code;
+ this.#evidence.runnerSignal = result.signal as NodeJS.Signals | null;
+ const detail = result.stderr.trim() || result.stdout.trim();
+ if (detail) this.#diagnostic(detail.slice(-4_096));
+ this.#publish();
+ if (this.#closed || this.#handle !== handle) return;
+ const code = /provider_frame_too_large|stdout frame exceeded/i.test(detail)
+ ? "provider_frame_too_large"
+ : "native_runner_process_exited";
+ this.#failTransport(new Error(
+ `${code}: runnerd exited unexpectedly${result.code === null ? "" : ` with code ${result.code}`}${detail ? `: ${detail.slice(-1_000)}` : ""}`,
+ ));
+ },
+ (error) => {
+ if (this.#closed || this.#handle !== handle) return;
+ this.#failTransport(new Error(
+ `native_runner_process_exited: runnerd process failed: ${error instanceof Error ? error.message : String(error)}`,
+ ));
+ },
+ );
+ }
+
+ #failTransport(error: Error): void {
+ if (this.#failure !== null || this.#closed) return;
+ this.#failure = error;
+ if (this.#pump !== null) clearInterval(this.#pump);
+ this.#pump = null;
+ this.#diagnostic(error.message);
+ this.#queue.close(error);
+ }
+
+ #throwIfFailed(): void {
+ if (this.#failure !== null) throw this.#failure;
+ }
+
#diagnostic(message: string): void {
this.#evidence.diagnostics.push(message);
if (this.#evidence.diagnostics.length > 64) this.#evidence.diagnostics.shift();
diff --git a/packages/paperclip-runner/src/live/workspace-diff.test.ts b/packages/paperclip-runner/src/live/workspace-diff.test.ts
new file mode 100644
index 00000000000..59aad5325fd
--- /dev/null
+++ b/packages/paperclip-runner/src/live/workspace-diff.test.ts
@@ -0,0 +1,46 @@
+import { mkdtemp, mkdir, rename, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { capturePaperclipWorkspace, diffPaperclipWorkspace } from "./workspace-diff.js";
+
+describe("runner-verified workspace diffs", () => {
+ it("captures create, modify, delete, and rename without runner-owned noise", async () => {
+ const root = await mkdtemp(join(tmpdir(), "paperclip-workspace-diff-"));
+ await mkdir(join(root, ".paperclip-runner-prp"));
+ await writeFile(join(root, ".paperclip-runner-prp", "state.json"), "before");
+ await writeFile(join(root, "modify.txt"), "old\n");
+ await writeFile(join(root, "delete.txt"), "gone\n");
+ await writeFile(join(root, "rename.txt"), "same\n");
+ const before = await capturePaperclipWorkspace(root);
+
+ await writeFile(join(root, ".paperclip-runner-prp", "state.json"), "after");
+ await writeFile(join(root, "modify.txt"), "new\n");
+ await writeFile(join(root, "create.txt"), "created\n");
+ await writeFile(join(root, "delete.txt"), "");
+ await rename(join(root, "rename.txt"), join(root, "renamed.txt"));
+ const after = await capturePaperclipWorkspace(root);
+ const diff = diffPaperclipWorkspace(before, after, "turn-1:workspace");
+
+ expect(diff?.schema).toBe("paperclip.workspace.diff.v1");
+ expect(diff?.source).toBe("runner_verified");
+ expect(diff?.files.map((file) => [file.path, file.operation])).toEqual([
+ ["create.txt", "create"],
+ ["delete.txt", "modify"],
+ ["modify.txt", "modify"],
+ ["renamed.txt", "rename"],
+ ]);
+ expect(diff?.files.find((file) => file.path === "renamed.txt")?.previousPath).toBe("rename.txt");
+ expect(JSON.stringify(diff)).not.toContain(".paperclip-runner-prp");
+ });
+
+ it("returns no record when the workspace did not change", async () => {
+ const root = await mkdtemp(join(tmpdir(), "paperclip-workspace-stable-"));
+ await writeFile(join(root, "stable.txt"), "same\n");
+ const before = await capturePaperclipWorkspace(root);
+ const after = await capturePaperclipWorkspace(root);
+ expect(diffPaperclipWorkspace(before, after, "turn-1:workspace")).toBeNull();
+ });
+});
diff --git a/packages/paperclip-runner/src/live/workspace-diff.ts b/packages/paperclip-runner/src/live/workspace-diff.ts
new file mode 100644
index 00000000000..09bcf5fb935
--- /dev/null
+++ b/packages/paperclip-runner/src/live/workspace-diff.ts
@@ -0,0 +1,219 @@
+import { createHash } from "node:crypto";
+import { readdir, readFile } from "node:fs/promises";
+import { join, relative, sep } from "node:path";
+
+export const PAPERCLIP_WORKSPACE_DIFF_SCHEMA = "paperclip.workspace.diff.v1" as const;
+
+export type PaperclipWorkspaceChangeSource = "harness_reported" | "runner_verified";
+export type PaperclipWorkspaceFileOperation =
+ | "create"
+ | "modify"
+ | "delete"
+ | "rename"
+ | "mode_change";
+
+export interface PaperclipWorkspaceFileChange {
+ path: string;
+ operation: PaperclipWorkspaceFileOperation;
+ previousPath: string | null;
+ additions: number | null;
+ deletions: number | null;
+ binary: boolean;
+ diff: string | null;
+}
+
+export interface PaperclipWorkspaceDiff {
+ schema: typeof PAPERCLIP_WORKSPACE_DIFF_SCHEMA;
+ changeSetId: string;
+ revision: number;
+ source: PaperclipWorkspaceChangeSource;
+ complete: boolean;
+ files: PaperclipWorkspaceFileChange[];
+ totals: { files: number; additions: number | null; deletions: number | null };
+ patchArtifactRef: string | null;
+}
+
+interface WorkspaceFileSnapshot {
+ hash: string;
+ content: Buffer | null;
+ binary: boolean;
+}
+
+export type PaperclipWorkspaceSnapshot = Map;
+
+const IGNORED_DIRECTORY_NAMES = new Set([
+ ".git",
+ ".paperclip-runner-prp",
+ ".paperclip-runner",
+ ".next",
+ ".turbo",
+ "build",
+ "coverage",
+ "dist",
+ "node_modules",
+ "target",
+]);
+const MAX_FILES = 2_000;
+const MAX_DIRECTORIES = 500;
+const MAX_FILE_BYTES = 2 * 1024 * 1024;
+const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
+const MAX_DIFF_CHARS_PER_FILE = 128 * 1024;
+
+function normalizedRelativePath(root: string, absolutePath: string): string {
+ return relative(root, absolutePath).split(sep).join("/");
+}
+
+function appearsBinary(content: Buffer): boolean {
+ return content.subarray(0, Math.min(content.length, 8_192)).includes(0);
+}
+
+/**
+ * Captures only the authorized workspace. Runner-owned state and common
+ * dependency/VCS directories are excluded, symlinks are never followed, and
+ * hard limits make this safe to run around an interactive turn.
+ */
+export async function capturePaperclipWorkspace(root: string): Promise {
+ const result: PaperclipWorkspaceSnapshot = new Map();
+ let retainedBytes = 0;
+ let visitedDirectories = 0;
+
+ async function visit(directory: string): Promise {
+ if (result.size >= MAX_FILES || retainedBytes >= MAX_TOTAL_BYTES || visitedDirectories >= MAX_DIRECTORIES) return;
+ visitedDirectories += 1;
+ let entries;
+ try {
+ entries = await readdir(directory, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ entries.sort((left, right) => left.name.localeCompare(right.name));
+ for (const entry of entries) {
+ if (result.size >= MAX_FILES || retainedBytes >= MAX_TOTAL_BYTES) return;
+ if (IGNORED_DIRECTORY_NAMES.has(entry.name)) continue;
+ const absolutePath = join(directory, entry.name);
+ if (entry.isDirectory()) {
+ await visit(absolutePath);
+ continue;
+ }
+ if (!entry.isFile()) continue;
+ let bytes: Buffer;
+ try {
+ bytes = await readFile(absolutePath);
+ } catch {
+ continue;
+ }
+ const binary = appearsBinary(bytes);
+ const retain = bytes.length <= MAX_FILE_BYTES && retainedBytes + bytes.length <= MAX_TOTAL_BYTES;
+ const content = retain ? bytes : null;
+ if (content !== null) retainedBytes += content.length;
+ result.set(normalizedRelativePath(root, absolutePath), {
+ hash: createHash("sha256").update(bytes).digest("hex"),
+ content,
+ binary: binary || content === null,
+ });
+ }
+ }
+
+ await visit(root);
+ return result;
+}
+
+function lines(content: Buffer | null): string[] {
+ if (content === null || content.length === 0) return [];
+ const value = content.toString("utf8");
+ const result = value.split("\n");
+ if (result.at(-1) === "") result.pop();
+ return result;
+}
+
+function unifiedDiff(path: string, before: Buffer | null, after: Buffer | null): {
+ diff: string;
+ additions: number;
+ deletions: number;
+} {
+ const oldLines = lines(before);
+ const newLines = lines(after);
+ let prefix = 0;
+ while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1;
+ let suffix = 0;
+ while (
+ suffix < oldLines.length - prefix &&
+ suffix < newLines.length - prefix &&
+ oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
+ ) suffix += 1;
+ const removed = oldLines.slice(prefix, oldLines.length - suffix);
+ const added = newLines.slice(prefix, newLines.length - suffix);
+ const oldStart = Math.min(prefix + 1, Math.max(1, oldLines.length));
+ const newStart = Math.min(prefix + 1, Math.max(1, newLines.length));
+ const header = [
+ `diff --git a/${path} b/${path}`,
+ `--- ${before === null ? "/dev/null" : `a/${path}`}`,
+ `+++ ${after === null ? "/dev/null" : `b/${path}`}`,
+ `@@ -${oldStart},${removed.length} +${newStart},${added.length} @@`,
+ ];
+ const patch = [...header, ...removed.map((line) => `-${line}`), ...added.map((line) => `+${line}`)].join("\n");
+ return {
+ diff: patch.length <= MAX_DIFF_CHARS_PER_FILE
+ ? `${patch}\n`
+ : `${patch.slice(0, MAX_DIFF_CHARS_PER_FILE)}\n… diff truncated by runner …\n`,
+ additions: added.length,
+ deletions: removed.length,
+ };
+}
+
+export function diffPaperclipWorkspace(
+ before: PaperclipWorkspaceSnapshot,
+ after: PaperclipWorkspaceSnapshot,
+ changeSetId: string,
+): PaperclipWorkspaceDiff | null {
+ const files: PaperclipWorkspaceFileChange[] = [];
+ const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
+ for (const path of paths) {
+ const prior = before.get(path);
+ const current = after.get(path);
+ if (prior?.hash === current?.hash) continue;
+ const operation = prior === undefined ? "create" : current === undefined ? "delete" : "modify";
+ const binary = prior?.binary === true || current?.binary === true;
+ const stats = binary
+ ? { diff: null, additions: null, deletions: null }
+ : unifiedDiff(path, prior?.content ?? null, current?.content ?? null);
+ files.push({ path, operation, previousPath: null, binary, ...stats });
+ }
+ if (files.length === 0) return null;
+
+ // Exact-content delete/create pairs are represented as a rename. This keeps
+ // the protocol semantic even when the harness only used shell commands.
+ const deletedByHash = new Map();
+ for (const file of files) {
+ if (file.operation === "delete") deletedByHash.set(before.get(file.path)!.hash, file);
+ }
+ for (const file of [...files]) {
+ if (file.operation !== "create") continue;
+ const deleted = deletedByHash.get(after.get(file.path)!.hash);
+ if (deleted === undefined) continue;
+ const deletedIndex = files.indexOf(deleted);
+ if (deletedIndex >= 0) files.splice(deletedIndex, 1);
+ file.operation = "rename";
+ file.previousPath = deleted.path;
+ file.additions = 0;
+ file.deletions = 0;
+ file.diff = `similarity index 100%\nrename from ${deleted.path}\nrename to ${file.path}\n`;
+ deletedByHash.delete(after.get(file.path)!.hash);
+ }
+
+ const hasUnknownStats = files.some((file) => file.additions === null || file.deletions === null);
+ return {
+ schema: PAPERCLIP_WORKSPACE_DIFF_SCHEMA,
+ changeSetId,
+ revision: 1,
+ source: "runner_verified",
+ complete: true,
+ files,
+ totals: {
+ files: files.length,
+ additions: hasUnknownStats ? null : files.reduce((sum, file) => sum + (file.additions ?? 0), 0),
+ deletions: hasUnknownStats ? null : files.reduce((sum, file) => sum + (file.deletions ?? 0), 0),
+ },
+ patchArtifactRef: null,
+ };
+}
diff --git a/packages/paperclip-runner/src/live/workspace-file-reference.test.ts b/packages/paperclip-runner/src/live/workspace-file-reference.test.ts
new file mode 100644
index 00000000000..f10d2c632e6
--- /dev/null
+++ b/packages/paperclip-runner/src/live/workspace-file-reference.test.ts
@@ -0,0 +1,42 @@
+import { mkdtemp, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ discoverPaperclipWorkspaceFileReferences,
+ paperclipWorkspaceFileReferencesFromText,
+} from "./workspace-file-reference.js";
+
+describe("workspace file references", () => {
+ it("normalizes Markdown links and verifies a bounded preview", async () => {
+ const root = await mkdtemp(join(tmpdir(), "paperclip-file-reference-"));
+ await writeFile(join(root, "guide.md"), "# Guide\n\nSafe content.\n");
+ const references = await discoverPaperclipWorkspaceFileReferences(
+ root,
+ `Read [the guide](${join(root, "guide.md")}:2).`,
+ "turn-1",
+ );
+ expect(references).toHaveLength(1);
+ expect(references[0]).toMatchObject({
+ schema: "paperclip.workspace.file_reference.v1",
+ source: "runner_verified",
+ path: "guide.md",
+ displayName: "the guide",
+ presentation: "document",
+ line: 2,
+ preview: "# Guide\n\nSafe content.\n",
+ });
+ expect(references[0]?.contentDigest).toMatch(/^sha256:[a-f0-9]{64}$/);
+ });
+
+ it("rejects external URLs and paths outside the authorized workspace", () => {
+ const references = paperclipWorkspaceFileReferencesFromText(
+ "/workspace",
+ "[external](https://example.com/file.md) [outside](/etc/passwd) [safe](docs/safe.md)",
+ "turn-1",
+ );
+ expect(references.map((reference) => reference.path)).toEqual(["docs/safe.md"]);
+ });
+});
diff --git a/packages/paperclip-runner/src/live/workspace-file-reference.ts b/packages/paperclip-runner/src/live/workspace-file-reference.ts
new file mode 100644
index 00000000000..b1f8b77ed23
--- /dev/null
+++ b/packages/paperclip-runner/src/live/workspace-file-reference.ts
@@ -0,0 +1,128 @@
+import { createHash } from "node:crypto";
+import { readFile, stat } from "node:fs/promises";
+import { basename, extname, isAbsolute, relative, resolve, sep } from "node:path";
+
+export const PAPERCLIP_WORKSPACE_FILE_REFERENCE_SCHEMA = "paperclip.workspace.file_reference.v1" as const;
+
+export interface PaperclipWorkspaceFileReference {
+ schema: typeof PAPERCLIP_WORKSPACE_FILE_REFERENCE_SCHEMA;
+ referenceId: string;
+ source: "harness_reported" | "runner_verified";
+ path: string;
+ displayName: string;
+ mediaType: string | null;
+ presentation: "document" | "code" | "image" | "generic";
+ line: number | null;
+ preview: string | null;
+ previewTruncated: boolean;
+ contentDigest: string | null;
+}
+
+const MAX_REFERENCES = 32;
+const MAX_PREVIEW_BYTES = 256 * 1024;
+const MAX_READ_BYTES = 1024 * 1024;
+
+function media(path: string): Pick {
+ const extension = extname(path).toLowerCase();
+ if ([".md", ".mdx", ".txt", ".rst"].includes(extension)) {
+ return { mediaType: extension === ".md" || extension === ".mdx" ? "text/markdown" : "text/plain", presentation: "document" };
+ }
+ if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"].includes(extension)) {
+ const imageType = extension === ".jpg" ? "jpeg" : extension.slice(1);
+ return { mediaType: `image/${imageType}`, presentation: "image" };
+ }
+ if ([".ts", ".tsx", ".js", ".jsx", ".json", ".rs", ".py", ".go", ".java", ".css", ".html", ".yaml", ".yml", ".toml", ".sh"].includes(extension)) {
+ return { mediaType: "text/plain", presentation: "code" };
+ }
+ return { mediaType: null, presentation: "generic" };
+}
+
+function localTarget(rawTarget: string): { target: string; line: number | null } | null {
+ let target = rawTarget.trim();
+ if (target.startsWith("<") && target.endsWith(">")) target = target.slice(1, -1);
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) && !target.startsWith("file:")) return null;
+ if (target.startsWith("file://")) {
+ try { target = decodeURIComponent(new URL(target).pathname); } catch { return null; }
+ } else {
+ try { target = decodeURIComponent(target); } catch { return null; }
+ }
+ let line: number | null = null;
+ const hashLine = target.match(/#L(\d+)(?:C\d+)?$/i);
+ if (hashLine) {
+ line = Number(hashLine[1]);
+ target = target.slice(0, hashLine.index);
+ } else {
+ const suffixLine = target.match(/:(\d+)$/);
+ if (suffixLine) {
+ line = Number(suffixLine[1]);
+ target = target.slice(0, suffixLine.index);
+ }
+ }
+ return target.length === 0 ? null : { target, line };
+}
+
+export function paperclipWorkspaceFileReferencesFromText(
+ workspace: string,
+ assistantText: string,
+ turnId: string,
+ source: PaperclipWorkspaceFileReference["source"] = "harness_reported",
+): PaperclipWorkspaceFileReference[] {
+ const root = resolve(workspace);
+ const references: PaperclipWorkspaceFileReference[] = [];
+ const seen = new Set();
+ const links = assistantText.matchAll(/\[([^\]]+)]\((<[^>]+>|[^)\s]+)(?:\s+["'][^"']*["'])?\)/g);
+ for (const match of links) {
+ if (references.length >= MAX_REFERENCES) break;
+ const parsed = localTarget(match[2] ?? "");
+ if (parsed === null) continue;
+ const absolutePath = resolve(root, parsed.target);
+ const relativePath = relative(root, absolutePath).split(sep).join("/");
+ if (!relativePath || relativePath.startsWith("../") || isAbsolute(relativePath)) continue;
+ const key = `${relativePath}:${parsed.line ?? ""}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const identity = createHash("sha256").update(`${turnId}\0${key}`).digest("hex").slice(0, 24);
+ references.push({
+ schema: PAPERCLIP_WORKSPACE_FILE_REFERENCE_SCHEMA,
+ referenceId: `${turnId}:file:${identity}`,
+ source,
+ path: relativePath,
+ displayName: (match[1]?.trim() || basename(relativePath)).slice(0, 255),
+ ...media(relativePath),
+ line: parsed.line,
+ preview: null,
+ previewTruncated: false,
+ contentDigest: null,
+ });
+ }
+ return references;
+}
+
+export async function discoverPaperclipWorkspaceFileReferences(
+ workspace: string,
+ assistantText: string,
+ turnId: string,
+): Promise {
+ const references = paperclipWorkspaceFileReferencesFromText(workspace, assistantText, turnId, "runner_verified");
+ const verified: PaperclipWorkspaceFileReference[] = [];
+ for (const reference of references) {
+ const absolutePath = resolve(workspace, reference.path);
+ let info;
+ try { info = await stat(absolutePath); } catch { continue; }
+ if (!info.isFile()) continue;
+ if (info.size > MAX_READ_BYTES) {
+ verified.push({ ...reference, previewTruncated: true });
+ continue;
+ }
+ let bytes: Buffer;
+ try { bytes = await readFile(absolutePath); } catch { continue; }
+ const binary = bytes.subarray(0, Math.min(bytes.length, 8_192)).includes(0);
+ verified.push({
+ ...reference,
+ preview: binary ? null : bytes.subarray(0, MAX_PREVIEW_BYTES).toString("utf8"),
+ previewTruncated: bytes.length > MAX_PREVIEW_BYTES,
+ contentDigest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
+ });
+ }
+ return verified;
+}
diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts
index 0109e397386..8a5a3edf217 100644
--- a/packages/paperclip-runner/src/native-session-runtime.test.ts
+++ b/packages/paperclip-runner/src/native-session-runtime.test.ts
@@ -63,6 +63,7 @@ const input: NativeExecutionInputV1 = {
},
workspace: { cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
session: { normalizedSessionId: identity.sessionId, driverKind: "codex_app_server", protocolVersion: 1 },
+ provider: { kind: "codex", model: null },
completionContract: {
id: "contract-recovery",
sha256: "contract-recovery-sha",
diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts
index f9767952505..b99e826c3de 100644
--- a/packages/paperclip-runner/src/native-session-runtime.ts
+++ b/packages/paperclip-runner/src/native-session-runtime.ts
@@ -4,6 +4,7 @@ import type { ControlPlanePort } from "./contracts/control-plane-port.js";
import type { NativeExecutionInputV1, NativeSessionExecutionResult } from "./contracts/native-execution.js";
import { buildNativeModelEnvelope, parseNativeExecutionInput } from "./contracts/native-execution.js";
import type { NativeSession, NativeSessionBackend } from "./contracts/native-session-backend.js";
+import type { PersistedNativeSession } from "./contracts/native-session-backend.js";
import type { PrpEvent, PrpTerminalState } from "./protocol/replay-contract.js";
export interface ExecuteNativeSessionOptions {
@@ -14,6 +15,10 @@ export interface ExecuteNativeSessionOptions {
controlPlaneInstanceId: string;
timeoutMs?: number;
onSession?: (session: NativeSession | null) => void;
+ existingSession?: NativeSession;
+ persistedSession?: PersistedNativeSession | null;
+ keepSessionOpen?: boolean;
+ onCheckpoint?: (snapshot: PersistedNativeSession) => Promise | void;
}
function isTurnTerminal(event: PrpEvent): boolean {
@@ -62,7 +67,9 @@ async function consumeTurn(session: NativeSession, controlPlane: ControlPlanePor
export async function executeNativeSession(options: ExecuteNativeSessionOptions): Promise {
const input = parseNativeExecutionInput(options.input);
const descriptor = await options.backend.descriptor();
- const persistedSession = await options.controlPlane.loadSessionCheckpoint?.() ?? null;
+ const persistedSession = options.existingSession
+ ? null
+ : options.persistedSession ?? await options.controlPlane.loadSessionCheckpoint?.() ?? null;
const normalizedSessionId = persistedSession?.identity.sessionId
?? input.session.normalizedSessionId
?? randomUUID();
@@ -97,7 +104,14 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
) throw new Error("native_session_checkpoint_binding_mismatch");
let recovered = false;
let session: NativeSession;
- if (persistedSession) {
+ if (options.existingSession) {
+ if (options.existingSession.attachRun === undefined) {
+ throw new Error("native_session_multi_run_unavailable");
+ }
+ await options.existingSession.attachRun({ identity });
+ session = options.existingSession;
+ recovered = true;
+ } else if (persistedSession) {
if (!options.backend.recoverSession) {
throw new Error("native_session_recovery_unavailable: refusing to open a second provider session");
}
@@ -116,12 +130,13 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
options.onSession?.(session);
try {
const checkpoint = async () => {
- if (options.controlPlane.checkpointSession) {
- await options.controlPlane.checkpointSession(await session.snapshot());
- }
+ const snapshot = await session.snapshot();
+ await options.controlPlane.checkpointSession?.(snapshot);
+ await options.onCheckpoint?.(snapshot);
};
const recoveredSnapshot = await session.snapshot();
await options.controlPlane.checkpointSession?.(recoveredSnapshot);
+ await options.onCheckpoint?.(recoveredSnapshot);
let consumed = { event: null as PrpEvent | null, eventCount: 0, highestContiguousSourceSeq: 0 };
const completionSnapshot = recoveredSnapshot.semanticResult && recoveredSnapshot.terminal
@@ -225,6 +240,12 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
semanticResult: completed.result,
terminal,
});
+ await options.onCheckpoint?.({
+ ...snapshot,
+ semanticResult: completed.result,
+ terminal,
+ });
+ const usage = await session.usage?.() ?? null;
return {
result: completed.result,
terminal,
@@ -232,14 +253,16 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
normalizedSessionId,
providerSessionId: snapshot.providerSessionId ?? null,
driverKind: descriptor.name,
- driverVersion: descriptor.version,
+ driverVersion: typeof usage?.driverVersion === "string" ? usage.driverVersion : descriptor.version,
nativeEventCount: consumed.eventCount,
highestContiguousSourceSeq: consumed.highestContiguousSourceSeq,
- usage: null,
+ usage,
};
} finally {
- options.onSession?.(null);
- await session.close({ reason: "native session execution complete" });
+ if (!options.keepSessionOpen) {
+ options.onSession?.(null);
+ await session.close({ reason: "native session execution complete" });
+ }
}
}
diff --git a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts
index f49e541add5..3e66e5a0c42 100644
--- a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts
+++ b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts
@@ -227,6 +227,7 @@ export const commandSchema = {
"type": {
"enum": [
"run.prepare",
+ "run.attach",
"session.open",
"turn.start",
"turn.steer",
@@ -239,6 +240,7 @@ export const commandSchema = {
"session.close",
"run.cancel",
"runner.drain",
+ "runner.suspend",
"runner.shutdown"
]
},
@@ -288,6 +290,245 @@ export const commandSchema = {
"additionalProperties": true
} as const;
+export const workspaceDiffSchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json",
+ "title": "Paperclip workspace diff",
+ "type": "object",
+ "required": [
+ "schema",
+ "changeSetId",
+ "revision",
+ "source",
+ "complete",
+ "files",
+ "totals",
+ "patchArtifactRef"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.workspace.diff.v1"
+ },
+ "changeSetId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 200
+ },
+ "revision": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "source": {
+ "enum": [
+ "harness_reported",
+ "runner_verified"
+ ]
+ },
+ "complete": {
+ "type": "boolean"
+ },
+ "files": {
+ "type": "array",
+ "maxItems": 2000,
+ "items": {
+ "type": "object",
+ "required": [
+ "path",
+ "operation",
+ "previousPath",
+ "additions",
+ "deletions",
+ "binary",
+ "diff"
+ ],
+ "properties": {
+ "path": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"
+ },
+ "operation": {
+ "enum": [
+ "create",
+ "modify",
+ "delete",
+ "rename",
+ "mode_change"
+ ]
+ },
+ "previousPath": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"
+ }
+ ]
+ },
+ "additions": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0
+ },
+ "deletions": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0
+ },
+ "binary": {
+ "type": "boolean"
+ },
+ "diff": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 262144
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "totals": {
+ "type": "object",
+ "required": [
+ "files",
+ "additions",
+ "deletions"
+ ],
+ "properties": {
+ "files": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "additions": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0
+ },
+ "deletions": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0
+ }
+ },
+ "additionalProperties": false
+ },
+ "patchArtifactRef": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 2048
+ }
+ },
+ "additionalProperties": false
+} as const;
+
+export const workspaceFileReferenceSchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json",
+ "title": "Paperclip workspace file reference",
+ "type": "object",
+ "required": [
+ "schema",
+ "referenceId",
+ "source",
+ "path",
+ "displayName",
+ "mediaType",
+ "presentation",
+ "line",
+ "preview",
+ "previewTruncated",
+ "contentDigest"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.workspace.file_reference.v1"
+ },
+ "referenceId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 240
+ },
+ "source": {
+ "enum": [
+ "harness_reported",
+ "runner_verified"
+ ]
+ },
+ "path": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"
+ },
+ "displayName": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "mediaType": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 160
+ },
+ "presentation": {
+ "enum": [
+ "document",
+ "code",
+ "image",
+ "generic"
+ ]
+ },
+ "line": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1
+ },
+ "preview": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 262144
+ },
+ "previewTruncated": {
+ "type": "boolean"
+ },
+ "contentDigest": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+} as const;
+
export const semanticToolSchema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json",
@@ -1171,12 +1412,19 @@ export const eventSchema = {
"enum": [
"runner.connected",
"runner.reconnected",
+ "runner.reconciled",
"runner.disconnected",
"runner.draining",
+ "runner.suspending",
+ "runner.suspended",
+ "runner.stopped",
"runner.diagnostic",
"runtime.phase.changed",
"sandbox.metric",
"workspace.ready",
+ "workspace.change.updated",
+ "workspace.diff.recorded",
+ "workspace.file.referenced",
"harness.starting",
"harness.ready",
"harness.exited",
@@ -1220,6 +1468,8 @@ export const eventSchema = {
"interaction.response.progressed",
"interaction.response.resolved",
"interaction.response.delivered",
+ "run.attached",
+ "run.detached",
"run.result.proposed",
"run.result.accepted",
"run.result.rejected",
@@ -1256,6 +1506,23 @@ export const eventSchema = {
},
"payload": {
"type": "object",
+ "properties": {
+ "channel": {
+ "enum": [
+ "progress",
+ "final",
+ "summary",
+ "detail",
+ "unknown"
+ ]
+ },
+ "providerPhase": {
+ "type": "string"
+ },
+ "providerMethod": {
+ "type": "string"
+ }
+ },
"additionalProperties": true
},
"debug": {
@@ -1264,6 +1531,69 @@ export const eventSchema = {
}
},
"allOf": [
+ {
+ "if": {
+ "properties": {
+ "eventType": {
+ "const": "workspace.file.referenced"
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "payload": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json"
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "eventType": {
+ "enum": [
+ "workspace.change.updated",
+ "workspace.diff.recorded"
+ ]
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "payload": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json"
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "eventType": {
+ "const": "workspace.diff.recorded"
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "payload": {
+ "allOf": [
+ {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "complete": {
+ "const": true
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ },
{
"if": {
"properties": {
@@ -1441,6 +1771,8 @@ export const prpSchemaBundle = {
"identity": identitySchema,
"capabilities": capabilitiesSchema,
"command": commandSchema,
+ "workspace-diff": workspaceDiffSchema,
+ "workspace-file-reference": workspaceFileReferenceSchema,
"semantic-tool": semanticToolSchema,
"stop-reason": stopReasonSchema,
"terminal": terminalSchema,
diff --git a/packages/paperclip-runner/src/protocol/generated/standalone-validators.ts b/packages/paperclip-runner/src/protocol/generated/standalone-validators.ts
index b53de6aa92a..5a8fcde25d9 100644
--- a/packages/paperclip-runner/src/protocol/generated/standalone-validators.ts
+++ b/packages/paperclip-runner/src/protocol/generated/standalone-validators.ts
@@ -4,4 +4,4 @@
import equalModule from "ajv/dist/runtime/equal.js";
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
-"use strict";export const fixtureValidator = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"const":1},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},"commands":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"}},"events":{"type":"array","minItems":1,"items":{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","run.cancel","runner.drain","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema70 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/request.schema.json","title":"PRP request","oneOf":[{"type":"object","required":["schema","requestKind","requestId","type","status","prompt"],"properties":{"schema":{"const":"paperclip.runtime_request.v1"},"requestKind":{"const":"runtime"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"enum":["permission","input"]},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"choices":{"type":"array","items":{"type":"object","required":["key","label"],"properties":{"key":{"type":"string","minLength":1},"label":{"type":"string","minLength":1}},"additionalProperties":true}}},"additionalProperties":true},{"type":"object","required":["schema","requestKind","requestId","kind","blocking","payload"],"properties":{"schema":{"const":"paperclip.interaction_request.v1"},"requestKind":{"const":"issue_thread"},"requestId":{"type":"string","minLength":1,"maxLength":160},"kind":{"enum":["request_confirmation","request_checkbox_confirmation","request_item_verdicts","ask_user_questions","suggest_tasks"]},"blocking":{"type":"boolean"},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true}]};const schema69 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/result.schema.json","title":"PRP structured run result","type":"object","required":["schema","reportedWorkDisposition","summary","completionClaim","evidence","verification","attentionRequests","artifacts"],"properties":{"schema":{"const":"paperclip.run_result.v1"},"reportedWorkDisposition":{"enum":["done","blocked","needs_review","yielded"]},"summary":{"type":"string","minLength":1,"maxLength":12000},"completionClaim":{"type":"object","required":["contractRevision","objectiveSatisfied","criteria","remainingWork"],"properties":{"contractRevision":{"type":"string","minLength":1},"objectiveSatisfied":{"type":"boolean"},"criteria":{"type":"array","items":{"type":"object","required":["criterionId","status","evidenceRefs"],"properties":{"criterionId":{"type":"string","minLength":1},"status":{"enum":["satisfied","not_satisfied","unknown"]},"evidenceRefs":{"type":"array","items":{"type":"string"}},"explanation":{"type":"string"}},"additionalProperties":true}},"remainingWork":{"type":"array","items":{"type":"object","required":["description","blocksCompletion"],"properties":{"description":{"type":"string","minLength":1},"blocksCompletion":{"type":"boolean"}},"additionalProperties":true}}},"additionalProperties":true},"evidence":{"type":"array","items":{"type":"object"}},"verification":{"type":"array","items":{"type":"object","required":["commandOrCheck","status"],"properties":{"commandOrCheck":{"type":"string","minLength":1},"status":{"enum":["passed","failed","not_run"]},"detail":{"type":"string"},"artifactRef":{"type":"string"}},"additionalProperties":true}},"blocker":{"type":"object","required":["reasonCode","owner","unblockAction","scope"],"properties":{"reasonCode":{"type":"string","minLength":1},"owner":{"type":"object","additionalProperties":true},"unblockAction":{"type":"string","minLength":1},"scope":{"enum":["current_track","task_wide"]}},"additionalProperties":true},"attentionRequests":{"type":"array","items":{"type":"object"}},"artifacts":{"type":"array","items":{"type":"object","required":["kind","ref"],"properties":{"kind":{"type":"string","minLength":1},"ref":{"type":"string","minLength":1},"title":{"type":"string"}},"additionalProperties":true}},"continuation":{"type":"object","required":["kind","summary","idempotencyKey"],"properties":{"kind":{"enum":["same_agent","retry","delegated_issue","response_wake","monitor"]},"summary":{"type":"string","minLength":1},"idempotencyKey":{"type":"string","minLength":1}},"additionalProperties":true}},"allOf":[{"if":{"properties":{"reportedWorkDisposition":{"const":"blocked"}}},"then":{"required":["blocker"]}},{"if":{"properties":{"reportedWorkDisposition":{"const":"yielded"}}},"then":{"required":["continuation"]}}],"additionalProperties":true};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const schema32 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/identity.schema.json","title":"PRP identity graph","type":"object","required":["schema","companyId","issueId","runId","environmentLeaseId","runnerInstanceId","normalizedSessionId","driverSessionId"],"properties":{"schema":{"const":"paperclip.prp.identity.v1"},"companyId":{"$ref":"#/$defs/stableId"},"issueId":{"$ref":"#/$defs/stableId"},"runId":{"$ref":"#/$defs/stableId"},"environmentLeaseId":{"$ref":"#/$defs/stableId"},"runnerInstanceId":{"$ref":"#/$defs/stableId"},"normalizedSessionId":{"$ref":"#/$defs/stableId"},"driverSessionId":{"$ref":"#/$defs/stableId"},"providerSessionId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true,"$defs":{"stableId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"}}};const schema33 = {"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate21(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/identity.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate21.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.companyId === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "companyId"},message:"must have required property '"+"companyId"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.issueId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "issueId"},message:"must have required property '"+"issueId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.runId === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.environmentLeaseId === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "environmentLeaseId"},message:"must have required property '"+"environmentLeaseId"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.runnerInstanceId === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runnerInstanceId"},message:"must have required property '"+"runnerInstanceId"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.normalizedSessionId === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.driverSessionId === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "driverSessionId"},message:"must have required property '"+"driverSessionId"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.schema !== undefined){if("paperclip.prp.identity.v1" !== data.schema){const err8 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.identity.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.companyId !== undefined){let data1 = data.companyId;if(typeof data1 === "string"){if(func1(data1) > 160){const err9 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data1) < 1){const err10 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data1)){const err11 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/companyId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.issueId !== undefined){let data2 = data.issueId;if(typeof data2 === "string"){if(func1(data2) > 160){const err13 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data2) < 1){const err14 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(!pattern4.test(data2)){const err15 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/issueId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.runId !== undefined){let data3 = data.runId;if(typeof data3 === "string"){if(func1(data3) > 160){const err17 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(func1(data3) < 1){const err18 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(!pattern4.test(data3)){const err19 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}else {const err20 = {instancePath:instancePath+"/runId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.environmentLeaseId !== undefined){let data4 = data.environmentLeaseId;if(typeof data4 === "string"){if(func1(data4) > 160){const err21 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data4) < 1){const err22 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data4)){const err23 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/environmentLeaseId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.runnerInstanceId !== undefined){let data5 = data.runnerInstanceId;if(typeof data5 === "string"){if(func1(data5) > 160){const err25 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(func1(data5) < 1){const err26 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(!pattern4.test(data5)){const err27 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}else {const err28 = {instancePath:instancePath+"/runnerInstanceId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data.normalizedSessionId !== undefined){let data6 = data.normalizedSessionId;if(typeof data6 === "string"){if(func1(data6) > 160){const err29 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(func1(data6) < 1){const err30 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(!pattern4.test(data6)){const err31 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}else {const err32 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}if(data.driverSessionId !== undefined){let data7 = data.driverSessionId;if(typeof data7 === "string"){if(func1(data7) > 160){const err33 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(func1(data7) < 1){const err34 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if(!pattern4.test(data7)){const err35 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}else {const err36 = {instancePath:instancePath+"/driverSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.providerSessionId !== undefined){let data8 = data.providerSessionId;if(typeof data8 === "string"){if(func1(data8) > 160){const err37 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(func1(data8) < 1){const err38 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(!pattern4.test(data8)){const err39 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}else {const err40 = {instancePath:instancePath+"/providerSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}}else {const err41 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}validate21.errors = vErrors;return errors === 0;}validate21.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema43 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/event.schema.json","title":"PRP native event","type":"object","required":["schema","sourceEventId","sourceSeq","sourceInstanceId","sourceKind","runId","eventType","schemaVersion","priority","emittedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.event.v1"},"sourceEventId":{"type":"string","minLength":1,"maxLength":160},"sourceSeq":{"type":"integer","minimum":1},"sourceInstanceId":{"type":"string","minLength":1,"maxLength":160},"sourceKind":{"enum":["runner","control_plane"]},"runId":{"type":"string","minLength":1,"maxLength":160},"normalizedSessionId":{"type":"string","minLength":1,"maxLength":160},"turnId":{"type":"string","minLength":1,"maxLength":160},"itemId":{"type":"string","minLength":1,"maxLength":160},"eventType":{"enum":["runner.connected","runner.reconnected","runner.disconnected","runner.draining","runner.diagnostic","runtime.phase.changed","sandbox.metric","workspace.ready","harness.starting","harness.ready","harness.exited","harness.diagnostic","session.starting","session.started","session.resuming","session.resumed","session.reconciled","session.closed","session.failed","turn.submitted","turn.accepted","turn.started","turn.completed","turn.failed","turn.interrupted","turn.cancelled","item.started","item.delta","item.completed","item.failed","mcp_app.discovered","mcp_app.resource.resolved","mcp_app.initializing","mcp_app.ready","mcp_app.tool_input","mcp_app.tool_result","mcp_app.action.requested","mcp_app.action.resolved","mcp_app.host_context.changed","mcp_app.failed","mcp_app.teardown","runtime_request.created","runtime_request.resolved","runtime_request.expired","runtime_request.cancelled","interaction.request.proposed","interaction.request.materialized","interaction.request.rejected","interaction.response.progressed","interaction.response.resolved","interaction.response.delivered","run.result.proposed","run.result.accepted","run.result.rejected","attention.request.proposed","attention.request.routed","attention.request.resolved","attention.request.expired","attention.request.superseded","work.assessment.recorded","issue.status.decision.recorded","issue.status.decision.applied","issue.status.decision.rejected","issue.status.decision.superseded","run.terminal"]},"schemaVersion":{"const":1},"priority":{"enum":[0,1,2]},"emittedAt":{"type":"string","format":"date-time"},"observedAt":{"type":"string","format":"date-time"},"payload":{"type":"object","additionalProperties":true},"debug":{"type":"object","additionalProperties":true}},"allOf":[{"if":{"properties":{"eventType":{"const":"mcp_app.tool_input"}}},"then":{"properties":{"payload":{"type":"object","properties":{"semantic_tool":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json"},{"type":"object","properties":{"phase":{"const":"input"}}}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"const":"mcp_app.tool_result"}}},"then":{"properties":{"payload":{"type":"object","properties":{"semantic_tool":{"allOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json"},{"type":"object","properties":{"phase":{"const":"result"}}}]}},"additionalProperties":true}}}},{"if":{"properties":{"eventType":{"const":"run.terminal"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/terminal.schema.json"}}}},{"if":{"properties":{"eventType":{"const":"run.result.proposed"}}},"then":{"properties":{"payload":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}}}}],"additionalProperties":true};const schema44 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json","title":"PRP provider-neutral semantic tool envelope","type":"object","required":["schema","schemaVersion","phase","operationId","callId","correlation","idempotencyKey","content"],"properties":{"schema":{"const":"paperclip.prp.semantic_tool.v1"},"schemaVersion":{"const":1},"phase":{"enum":["input","result"]},"operationId":{"$ref":"#/$defs/stableId"},"callId":{"$ref":"#/$defs/stableId"},"correlation":{"type":"object","required":["runId","normalizedSessionId","turnId","itemId"],"properties":{"runId":{"$ref":"#/$defs/stableId"},"normalizedSessionId":{"$ref":"#/$defs/stableId"},"turnId":{"$ref":"#/$defs/stableId"},"itemId":{"$ref":"#/$defs/stableId"},"requestId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true},"idempotencyKey":{"type":["string","null"],"minLength":1,"maxLength":240},"content":{"$ref":"#/$defs/safeContent"},"outcome":{"enum":["succeeded","denied","conflict","duplicate","unavailable","failed"]},"code":{"$ref":"#/$defs/stableCode"},"retryable":{"type":"boolean"},"authorizationBoundary":{"enum":["company","actor","active_task","grant","governed_action","lock","revision"]},"operationReceiptId":{"$ref":"#/$defs/stableId"},"auditReceiptId":{"$ref":"#/$defs/stableId"},"currentRevision":{"oneOf":[{"type":"integer","minimum":0},{"$ref":"#/$defs/stableId"}]},"duplicateOfReceiptId":{"$ref":"#/$defs/stableId"},"artifactRefs":{"type":"array","items":{"allOf":[{"$ref":"#/$defs/safeReference"},{"type":"object","properties":{"kind":{"enum":["artifact","work_product"]}}}]},"uniqueItems":true},"targets":{"type":"array","items":{"$ref":"#/$defs/immutableTarget"},"uniqueItems":true},"causalRefs":{"type":"array","items":{"$ref":"#/$defs/safeReference"},"uniqueItems":true}},"allOf":[{"if":{"properties":{"phase":{"const":"result"}}},"then":{"required":["outcome","code","retryable","authorizationBoundary","operationReceiptId"]}}],"additionalProperties":true,"$defs":{"stableId":{"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"stableCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"},"safeContent":{"type":"object","required":["digest","redactionDisposition","references"],"properties":{"digest":{"type":"string","pattern":"^sha256:[a-f0-9]{64}$"},"redactionDisposition":{"enum":["digest_only","allowlisted_references","redacted"]},"references":{"type":"array","items":{"$ref":"#/$defs/safeReference"},"uniqueItems":true}},"additionalProperties":true},"safeReference":{"type":"object","required":["kind","id"],"properties":{"kind":{"enum":["task","document_revision","interaction","approval","decision","artifact","work_product","wake","monitor","audit","operation"]},"id":{"$ref":"#/$defs/stableId"}},"additionalProperties":true},"immutableTarget":{"type":"object","required":["kind","id","immutable"],"properties":{"kind":{"enum":["document_revision","interaction","approval","decision"]},"id":{"$ref":"#/$defs/stableId"},"immutable":{"const":true},"revisionId":{"$ref":"#/$defs/stableId"},"decisionId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true}}};const schema45 = {"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};const schema55 = {"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"};const func0 = typeof equalModule === "function" ? equalModule : equalModule.default;const pattern13 = new RegExp("^[a-z][a-z0-9_.:-]*$", "u");const schema52 = {"type":"object","required":["digest","redactionDisposition","references"],"properties":{"digest":{"type":"string","pattern":"^sha256:[a-f0-9]{64}$"},"redactionDisposition":{"enum":["digest_only","allowlisted_references","redacted"]},"references":{"type":"array","items":{"$ref":"#/$defs/safeReference"},"uniqueItems":true}},"additionalProperties":true};const pattern21 = new RegExp("^sha256:[a-f0-9]{64}$", "u");const schema53 = {"type":"object","required":["kind","id"],"properties":{"kind":{"enum":["task","document_revision","interaction","approval","decision","artifact","work_product","wake","monitor","audit","operation"]},"id":{"$ref":"#/$defs/stableId"}},"additionalProperties":true};function validate26(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate26.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.kind === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.id === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.kind !== undefined){let data0 = data.kind;if(!(((((((((((data0 === "task") || (data0 === "document_revision")) || (data0 === "interaction")) || (data0 === "approval")) || (data0 === "decision")) || (data0 === "artifact")) || (data0 === "work_product")) || (data0 === "wake")) || (data0 === "monitor")) || (data0 === "audit")) || (data0 === "operation"))){const err2 = {instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema53.properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.id !== undefined){let data1 = data.id;if(typeof data1 === "string"){if(func1(data1) > 240){const err3 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(func1(data1) < 1){const err4 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(!pattern4.test(data1)){const err5 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}else {const err6 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}else {const err7 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}validate26.errors = vErrors;return errors === 0;}validate26.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate25(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate25.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.digest === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "digest"},message:"must have required property '"+"digest"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.redactionDisposition === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "redactionDisposition"},message:"must have required property '"+"redactionDisposition"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.references === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "references"},message:"must have required property '"+"references"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.digest !== undefined){let data0 = data.digest;if(typeof data0 === "string"){if(!pattern21.test(data0)){const err3 = {instancePath:instancePath+"/digest",schemaPath:"#/properties/digest/pattern",keyword:"pattern",params:{pattern: "^sha256:[a-f0-9]{64}$"},message:"must match pattern \""+"^sha256:[a-f0-9]{64}$"+"\""};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}else {const err4 = {instancePath:instancePath+"/digest",schemaPath:"#/properties/digest/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.redactionDisposition !== undefined){let data1 = data.redactionDisposition;if(!(((data1 === "digest_only") || (data1 === "allowlisted_references")) || (data1 === "redacted"))){const err5 = {instancePath:instancePath+"/redactionDisposition",schemaPath:"#/properties/redactionDisposition/enum",keyword:"enum",params:{allowedValues: schema52.properties.redactionDisposition.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.references !== undefined){let data2 = data.references;if(Array.isArray(data2)){const len0 = data2.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data2[i1], data2[j0])){const err6 = {instancePath:instancePath+"/references",schemaPath:"#/properties/references/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;break outer0;}}}}}else {const err7 = {instancePath:instancePath+"/references",schemaPath:"#/properties/references/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}}else {const err8 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}validate25.errors = vErrors;return errors === 0;}validate25.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema60 = {"type":"object","required":["kind","id","immutable"],"properties":{"kind":{"enum":["document_revision","interaction","approval","decision"]},"id":{"$ref":"#/$defs/stableId"},"immutable":{"const":true},"revisionId":{"$ref":"#/$defs/stableId"},"decisionId":{"$ref":"#/$defs/stableId"}},"additionalProperties":true};function validate30(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate30.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.kind === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.id === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.immutable === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "immutable"},message:"must have required property '"+"immutable"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.kind !== undefined){let data0 = data.kind;if(!((((data0 === "document_revision") || (data0 === "interaction")) || (data0 === "approval")) || (data0 === "decision"))){const err3 = {instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema60.properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.id !== undefined){let data1 = data.id;if(typeof data1 === "string"){if(func1(data1) > 240){const err4 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(func1(data1) < 1){const err5 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(!pattern4.test(data1)){const err6 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}else {const err7 = {instancePath:instancePath+"/id",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.immutable !== undefined){if(true !== data.immutable){const err8 = {instancePath:instancePath+"/immutable",schemaPath:"#/properties/immutable/const",keyword:"const",params:{allowedValue: true},message:"must be equal to constant"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.revisionId !== undefined){let data3 = data.revisionId;if(typeof data3 === "string"){if(func1(data3) > 240){const err9 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data3) < 1){const err10 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(!pattern4.test(data3)){const err11 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}else {const err12 = {instancePath:instancePath+"/revisionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.decisionId !== undefined){let data4 = data.decisionId;if(typeof data4 === "string"){if(func1(data4) > 240){const err13 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data4) < 1){const err14 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(!pattern4.test(data4)){const err15 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}else {const err16 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}else {const err17 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}validate30.errors = vErrors;return errors === 0;}validate30.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate24(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate24.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.phase !== undefined){if("result" !== data.phase){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.outcome === undefined){const err1 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "outcome"},message:"must have required property '"+"outcome"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.code === undefined){const err2 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "code"},message:"must have required property '"+"code"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.retryable === undefined){const err3 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "retryable"},message:"must have required property '"+"retryable"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.authorizationBoundary === undefined){const err4 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "authorizationBoundary"},message:"must have required property '"+"authorizationBoundary"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.operationReceiptId === undefined){const err5 = {instancePath,schemaPath:"#/allOf/0/then/required",keyword:"required",params:{missingProperty: "operationReceiptId"},message:"must have required property '"+"operationReceiptId"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}var _valid0 = _errs5 === errors;valid1 = _valid0;}if(!valid1){const err6 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.schemaVersion === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.phase === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "phase"},message:"must have required property '"+"phase"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.operationId === undefined){const err10 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "operationId"},message:"must have required property '"+"operationId"+"'"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(data.callId === undefined){const err11 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "callId"},message:"must have required property '"+"callId"+"'"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(data.correlation === undefined){const err12 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "correlation"},message:"must have required property '"+"correlation"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data.idempotencyKey === undefined){const err13 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "idempotencyKey"},message:"must have required property '"+"idempotencyKey"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data.content === undefined){const err14 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "content"},message:"must have required property '"+"content"+"'"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(data.schema !== undefined){if("paperclip.prp.semantic_tool.v1" !== data.schema){const err15 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.semantic_tool.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.schemaVersion !== undefined){if(1 !== data.schemaVersion){const err16 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.phase !== undefined){let data3 = data.phase;if(!((data3 === "input") || (data3 === "result"))){const err17 = {instancePath:instancePath+"/phase",schemaPath:"#/properties/phase/enum",keyword:"enum",params:{allowedValues: schema44.properties.phase.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.operationId !== undefined){let data4 = data.operationId;if(typeof data4 === "string"){if(func1(data4) > 240){const err18 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(func1(data4) < 1){const err19 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(!pattern4.test(data4)){const err20 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}else {const err21 = {instancePath:instancePath+"/operationId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.callId !== undefined){let data5 = data.callId;if(typeof data5 === "string"){if(func1(data5) > 240){const err22 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(func1(data5) < 1){const err23 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(!pattern4.test(data5)){const err24 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}else {const err25 = {instancePath:instancePath+"/callId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.correlation !== undefined){let data6 = data.correlation;if(data6 && typeof data6 == "object" && !Array.isArray(data6)){if(data6.runId === undefined){const err26 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "runId"},message:"must have required property '"+"runId"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data6.normalizedSessionId === undefined){const err27 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "normalizedSessionId"},message:"must have required property '"+"normalizedSessionId"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data6.turnId === undefined){const err28 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "turnId"},message:"must have required property '"+"turnId"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data6.itemId === undefined){const err29 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/required",keyword:"required",params:{missingProperty: "itemId"},message:"must have required property '"+"itemId"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data6.runId !== undefined){let data7 = data6.runId;if(typeof data7 === "string"){if(func1(data7) > 240){const err30 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(func1(data7) < 1){const err31 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(!pattern4.test(data7)){const err32 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}else {const err33 = {instancePath:instancePath+"/correlation/runId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}if(data6.normalizedSessionId !== undefined){let data8 = data6.normalizedSessionId;if(typeof data8 === "string"){if(func1(data8) > 240){const err34 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if(func1(data8) < 1){const err35 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(!pattern4.test(data8)){const err36 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}else {const err37 = {instancePath:instancePath+"/correlation/normalizedSessionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data6.turnId !== undefined){let data9 = data6.turnId;if(typeof data9 === "string"){if(func1(data9) > 240){const err38 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(func1(data9) < 1){const err39 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}if(!pattern4.test(data9)){const err40 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}else {const err41 = {instancePath:instancePath+"/correlation/turnId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}if(data6.itemId !== undefined){let data10 = data6.itemId;if(typeof data10 === "string"){if(func1(data10) > 240){const err42 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}if(func1(data10) < 1){const err43 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}if(!pattern4.test(data10)){const err44 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}else {const err45 = {instancePath:instancePath+"/correlation/itemId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}if(data6.requestId !== undefined){let data11 = data6.requestId;if(typeof data11 === "string"){if(func1(data11) > 240){const err46 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}if(func1(data11) < 1){const err47 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}if(!pattern4.test(data11)){const err48 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}else {const err49 = {instancePath:instancePath+"/correlation/requestId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}}else {const err50 = {instancePath:instancePath+"/correlation",schemaPath:"#/properties/correlation/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}if(data.idempotencyKey !== undefined){let data12 = data.idempotencyKey;if((typeof data12 !== "string") && (data12 !== null)){const err51 = {instancePath:instancePath+"/idempotencyKey",schemaPath:"#/properties/idempotencyKey/type",keyword:"type",params:{type: schema44.properties.idempotencyKey.type},message:"must be string,null"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}if(typeof data12 === "string"){if(func1(data12) > 240){const err52 = {instancePath:instancePath+"/idempotencyKey",schemaPath:"#/properties/idempotencyKey/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err52];}else {vErrors.push(err52);}errors++;}if(func1(data12) < 1){const err53 = {instancePath:instancePath+"/idempotencyKey",schemaPath:"#/properties/idempotencyKey/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err53];}else {vErrors.push(err53);}errors++;}}}if(data.content !== undefined){if(!(validate25(data.content, {instancePath:instancePath+"/content",parentData:data,parentDataProperty:"content",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate25.errors : vErrors.concat(validate25.errors);errors = vErrors.length;}}if(data.outcome !== undefined){let data14 = data.outcome;if(!((((((data14 === "succeeded") || (data14 === "denied")) || (data14 === "conflict")) || (data14 === "duplicate")) || (data14 === "unavailable")) || (data14 === "failed"))){const err54 = {instancePath:instancePath+"/outcome",schemaPath:"#/properties/outcome/enum",keyword:"enum",params:{allowedValues: schema44.properties.outcome.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}}if(data.code !== undefined){let data15 = data.code;if(typeof data15 === "string"){if(func1(data15) > 160){const err55 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}if(func1(data15) < 1){const err56 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}if(!pattern13.test(data15)){const err57 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.:-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.:-]*$"+"\""};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}else {const err58 = {instancePath:instancePath+"/code",schemaPath:"#/$defs/stableCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}if(data.retryable !== undefined){if(typeof data.retryable !== "boolean"){const err59 = {instancePath:instancePath+"/retryable",schemaPath:"#/properties/retryable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data.authorizationBoundary !== undefined){let data17 = data.authorizationBoundary;if(!(((((((data17 === "company") || (data17 === "actor")) || (data17 === "active_task")) || (data17 === "grant")) || (data17 === "governed_action")) || (data17 === "lock")) || (data17 === "revision"))){const err60 = {instancePath:instancePath+"/authorizationBoundary",schemaPath:"#/properties/authorizationBoundary/enum",keyword:"enum",params:{allowedValues: schema44.properties.authorizationBoundary.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}if(data.operationReceiptId !== undefined){let data18 = data.operationReceiptId;if(typeof data18 === "string"){if(func1(data18) > 240){const err61 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}if(func1(data18) < 1){const err62 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}if(!pattern4.test(data18)){const err63 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}else {const err64 = {instancePath:instancePath+"/operationReceiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}}if(data.auditReceiptId !== undefined){let data19 = data.auditReceiptId;if(typeof data19 === "string"){if(func1(data19) > 240){const err65 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}if(func1(data19) < 1){const err66 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}if(!pattern4.test(data19)){const err67 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}}else {const err68 = {instancePath:instancePath+"/auditReceiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}if(data.currentRevision !== undefined){let data20 = data.currentRevision;const _errs51 = errors;let valid15 = false;let passing0 = null;const _errs52 = errors;if(!(((typeof data20 == "number") && (!(data20 % 1) && !isNaN(data20))) && (isFinite(data20)))){const err69 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/properties/currentRevision/oneOf/0/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}if((typeof data20 == "number") && (isFinite(data20))){if(data20 < 0 || isNaN(data20)){const err70 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/properties/currentRevision/oneOf/0/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}}var _valid1 = _errs52 === errors;if(_valid1){valid15 = true;passing0 = 0;}const _errs54 = errors;if(typeof data20 === "string"){if(func1(data20) > 240){const err71 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}if(func1(data20) < 1){const err72 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(!pattern4.test(data20)){const err73 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}}else {const err74 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err74];}else {vErrors.push(err74);}errors++;}var _valid1 = _errs54 === errors;if(_valid1 && valid15){valid15 = false;passing0 = [passing0, 1];}else {if(_valid1){valid15 = true;passing0 = 1;}}if(!valid15){const err75 = {instancePath:instancePath+"/currentRevision",schemaPath:"#/properties/currentRevision/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;}else {errors = _errs51;if(vErrors !== null){if(_errs51){vErrors.length = _errs51;}else {vErrors = null;}}}}if(data.duplicateOfReceiptId !== undefined){let data21 = data.duplicateOfReceiptId;if(typeof data21 === "string"){if(func1(data21) > 240){const err76 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}if(func1(data21) < 1){const err77 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}if(!pattern4.test(data21)){const err78 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err78];}else {vErrors.push(err78);}errors++;}}else {const err79 = {instancePath:instancePath+"/duplicateOfReceiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err79];}else {vErrors.push(err79);}errors++;}}if(data.artifactRefs !== undefined){let data22 = data.artifactRefs;if(Array.isArray(data22)){const len0 = data22.length;for(let i0=0; i0 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data22[i1], data22[j0])){const err82 = {instancePath:instancePath+"/artifactRefs",schemaPath:"#/properties/artifactRefs/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err82];}else {vErrors.push(err82);}errors++;break outer0;}}}}}else {const err83 = {instancePath:instancePath+"/artifactRefs",schemaPath:"#/properties/artifactRefs/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err83];}else {vErrors.push(err83);}errors++;}}if(data.targets !== undefined){let data25 = data.targets;if(Array.isArray(data25)){const len1 = data25.length;for(let i2=0; i2 1){outer1:for(;i3--;){for(j1 = i3; j1--;){if(func0(data25[i3], data25[j1])){const err84 = {instancePath:instancePath+"/targets",schemaPath:"#/properties/targets/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i3+" are identical)"};if(vErrors === null){vErrors = [err84];}else {vErrors.push(err84);}errors++;break outer1;}}}}}else {const err85 = {instancePath:instancePath+"/targets",schemaPath:"#/properties/targets/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err85];}else {vErrors.push(err85);}errors++;}}if(data.causalRefs !== undefined){let data27 = data.causalRefs;if(Array.isArray(data27)){const len2 = data27.length;for(let i4=0; i4 1){outer2:for(;i5--;){for(j2 = i5; j2--;){if(func0(data27[i5], data27[j2])){const err86 = {instancePath:instancePath+"/causalRefs",schemaPath:"#/properties/causalRefs/uniqueItems",keyword:"uniqueItems",params:{i: i5, j: j2},message:"must NOT have duplicate items (items ## "+j2+" and "+i5+" are identical)"};if(vErrors === null){vErrors = [err86];}else {vErrors.push(err86);}errors++;break outer2;}}}}}else {const err87 = {instancePath:instancePath+"/causalRefs",schemaPath:"#/properties/causalRefs/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err87];}else {vErrors.push(err87);}errors++;}}}else {const err88 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err88];}else {vErrors.push(err88);}errors++;}validate24.errors = vErrors;return errors === 0;}validate24.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema64 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/terminal.schema.json","title":"PRP run terminal state","type":"object","required":["schema","turnTerminalState","runTerminalState","reportedWorkDisposition"],"properties":{"schema":{"const":"paperclip.prp.terminal.v1"},"turnTerminalState":{"enum":["completed","failed","interrupted","cancelled"]},"runTerminalState":{"enum":["succeeded","failed","cancelled"]},"reportedWorkDisposition":{"enum":["done","blocked","needs_review","yielded"]},"workAssessmentId":{"type":"string","minLength":1},"statusDecisionId":{"type":"string","minLength":1},"stopReason":{"$ref":"https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json"}},"additionalProperties":true};const schema65 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json","title":"PRP terminal stop reason receipt","type":"object","required":["schema","schemaVersion","receiptId","kind","code","retryable","decisionId","limitClass","aggregate"],"properties":{"schema":{"const":"paperclip.prp.stop_reason.v1"},"schemaVersion":{"const":1},"receiptId":{"$ref":"#/$defs/stableId"},"kind":{"enum":["budget","cost"]},"code":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"},"retryable":{"type":"boolean"},"decisionId":{"$ref":"#/$defs/stableId"},"limitClass":{"enum":["company_monthly","actor_monthly","project_monthly","run_cost","provider_quota"]},"aggregate":{"type":"object","required":["unit","observed","limit","window"],"properties":{"unit":{"enum":["cents","usd_micros","tokens"]},"observed":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":0},"window":{"enum":["run","monthly_utc","provider_window"]}},"additionalProperties":true},"relatedReceiptIds":{"type":"array","items":{"$ref":"#/$defs/stableId"},"uniqueItems":true}},"additionalProperties":true,"$defs":{"stableId":{"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"}}};const schema66 = {"type":"string","minLength":1,"maxLength":240,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"};function validate36(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate36.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.schemaVersion === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.receiptId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "receiptId"},message:"must have required property '"+"receiptId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.kind === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.code === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "code"},message:"must have required property '"+"code"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.retryable === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "retryable"},message:"must have required property '"+"retryable"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.decisionId === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "decisionId"},message:"must have required property '"+"decisionId"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.limitClass === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "limitClass"},message:"must have required property '"+"limitClass"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.aggregate === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "aggregate"},message:"must have required property '"+"aggregate"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.schema !== undefined){if("paperclip.prp.stop_reason.v1" !== data.schema){const err9 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.stop_reason.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.schemaVersion !== undefined){if(1 !== data.schemaVersion){const err10 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.receiptId !== undefined){let data2 = data.receiptId;if(typeof data2 === "string"){if(func1(data2) > 240){const err11 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(func1(data2) < 1){const err12 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(!pattern4.test(data2)){const err13 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}else {const err14 = {instancePath:instancePath+"/receiptId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.kind !== undefined){let data3 = data.kind;if(!((data3 === "budget") || (data3 === "cost"))){const err15 = {instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: schema65.properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.code !== undefined){let data4 = data.code;if(typeof data4 === "string"){if(func1(data4) > 160){const err16 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data4) < 1){const err17 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(!pattern13.test(data4)){const err18 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.:-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.:-]*$"+"\""};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/code",schemaPath:"#/properties/code/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.retryable !== undefined){if(typeof data.retryable !== "boolean"){const err20 = {instancePath:instancePath+"/retryable",schemaPath:"#/properties/retryable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.decisionId !== undefined){let data6 = data.decisionId;if(typeof data6 === "string"){if(func1(data6) > 240){const err21 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(func1(data6) < 1){const err22 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(!pattern4.test(data6)){const err23 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}else {const err24 = {instancePath:instancePath+"/decisionId",schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}if(data.limitClass !== undefined){let data7 = data.limitClass;if(!(((((data7 === "company_monthly") || (data7 === "actor_monthly")) || (data7 === "project_monthly")) || (data7 === "run_cost")) || (data7 === "provider_quota"))){const err25 = {instancePath:instancePath+"/limitClass",schemaPath:"#/properties/limitClass/enum",keyword:"enum",params:{allowedValues: schema65.properties.limitClass.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}if(data.aggregate !== undefined){let data8 = data.aggregate;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.unit === undefined){const err26 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "unit"},message:"must have required property '"+"unit"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data8.observed === undefined){const err27 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "observed"},message:"must have required property '"+"observed"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data8.limit === undefined){const err28 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "limit"},message:"must have required property '"+"limit"+"'"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}if(data8.window === undefined){const err29 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/required",keyword:"required",params:{missingProperty: "window"},message:"must have required property '"+"window"+"'"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(data8.unit !== undefined){let data9 = data8.unit;if(!(((data9 === "cents") || (data9 === "usd_micros")) || (data9 === "tokens"))){const err30 = {instancePath:instancePath+"/aggregate/unit",schemaPath:"#/properties/aggregate/properties/unit/enum",keyword:"enum",params:{allowedValues: schema65.properties.aggregate.properties.unit.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}if(data8.observed !== undefined){let data10 = data8.observed;if(!(((typeof data10 == "number") && (!(data10 % 1) && !isNaN(data10))) && (isFinite(data10)))){const err31 = {instancePath:instancePath+"/aggregate/observed",schemaPath:"#/properties/aggregate/properties/observed/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if((typeof data10 == "number") && (isFinite(data10))){if(data10 < 0 || isNaN(data10)){const err32 = {instancePath:instancePath+"/aggregate/observed",schemaPath:"#/properties/aggregate/properties/observed/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}}if(data8.limit !== undefined){let data11 = data8.limit;if(!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))){const err33 = {instancePath:instancePath+"/aggregate/limit",schemaPath:"#/properties/aggregate/properties/limit/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if((typeof data11 == "number") && (isFinite(data11))){if(data11 < 0 || isNaN(data11)){const err34 = {instancePath:instancePath+"/aggregate/limit",schemaPath:"#/properties/aggregate/properties/limit/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}}if(data8.window !== undefined){let data12 = data8.window;if(!(((data12 === "run") || (data12 === "monthly_utc")) || (data12 === "provider_window"))){const err35 = {instancePath:instancePath+"/aggregate/window",schemaPath:"#/properties/aggregate/properties/window/enum",keyword:"enum",params:{allowedValues: schema65.properties.aggregate.properties.window.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}}else {const err36 = {instancePath:instancePath+"/aggregate",schemaPath:"#/properties/aggregate/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.relatedReceiptIds !== undefined){let data13 = data.relatedReceiptIds;if(Array.isArray(data13)){const len0 = data13.length;for(let i0=0; i0 240){const err37 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/maxLength",keyword:"maxLength",params:{limit: 240},message:"must NOT have more than 240 characters"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if(func1(data14) < 1){const err38 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if(!pattern4.test(data14)){const err39 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}else {const err40 = {instancePath:instancePath+"/relatedReceiptIds/" + i0,schemaPath:"#/$defs/stableId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}let i1 = data13.length;let j0;if(i1 > 1){outer0:for(;i1--;){for(j0 = i1; j0--;){if(func0(data13[i1], data13[j0])){const err41 = {instancePath:instancePath+"/relatedReceiptIds",schemaPath:"#/properties/relatedReceiptIds/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;break outer0;}}}}}else {const err42 = {instancePath:instancePath+"/relatedReceiptIds",schemaPath:"#/properties/relatedReceiptIds/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}}else {const err43 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}validate36.errors = vErrors;return errors === 0;}validate36.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate35(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/terminal.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate35.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.turnTerminalState === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "turnTerminalState"},message:"must have required property '"+"turnTerminalState"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.runTerminalState === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runTerminalState"},message:"must have required property '"+"runTerminalState"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.reportedWorkDisposition === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "reportedWorkDisposition"},message:"must have required property '"+"reportedWorkDisposition"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.schema !== undefined){if("paperclip.prp.terminal.v1" !== data.schema){const err4 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.terminal.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.turnTerminalState !== undefined){let data1 = data.turnTerminalState;if(!((((data1 === "completed") || (data1 === "failed")) || (data1 === "interrupted")) || (data1 === "cancelled"))){const err5 = {instancePath:instancePath+"/turnTerminalState",schemaPath:"#/properties/turnTerminalState/enum",keyword:"enum",params:{allowedValues: schema64.properties.turnTerminalState.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.runTerminalState !== undefined){let data2 = data.runTerminalState;if(!(((data2 === "succeeded") || (data2 === "failed")) || (data2 === "cancelled"))){const err6 = {instancePath:instancePath+"/runTerminalState",schemaPath:"#/properties/runTerminalState/enum",keyword:"enum",params:{allowedValues: schema64.properties.runTerminalState.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.reportedWorkDisposition !== undefined){let data3 = data.reportedWorkDisposition;if(!((((data3 === "done") || (data3 === "blocked")) || (data3 === "needs_review")) || (data3 === "yielded"))){const err7 = {instancePath:instancePath+"/reportedWorkDisposition",schemaPath:"#/properties/reportedWorkDisposition/enum",keyword:"enum",params:{allowedValues: schema64.properties.reportedWorkDisposition.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.workAssessmentId !== undefined){let data4 = data.workAssessmentId;if(typeof data4 === "string"){if(func1(data4) < 1){const err8 = {instancePath:instancePath+"/workAssessmentId",schemaPath:"#/properties/workAssessmentId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {const err9 = {instancePath:instancePath+"/workAssessmentId",schemaPath:"#/properties/workAssessmentId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.statusDecisionId !== undefined){let data5 = data.statusDecisionId;if(typeof data5 === "string"){if(func1(data5) < 1){const err10 = {instancePath:instancePath+"/statusDecisionId",schemaPath:"#/properties/statusDecisionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/statusDecisionId",schemaPath:"#/properties/statusDecisionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.stopReason !== undefined){if(!(validate36(data.stopReason, {instancePath:instancePath+"/stopReason",parentData:data,parentDataProperty:"stopReason",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate36.errors : vErrors.concat(validate36.errors);errors = vErrors.length;}}}else {const err12 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}validate35.errors = vErrors;return errors === 0;}validate35.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const formats0 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;function validate23(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/event.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate23.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}const _errs2 = errors;let valid1 = true;const _errs3 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("mcp_app.tool_input" !== data.eventType){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}var _valid0 = _errs3 === errors;errors = _errs2;if(vErrors !== null){if(_errs2){vErrors.length = _errs2;}else {vErrors = null;}}if(_valid0){const _errs5 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data1 = data.payload;if(data1 && typeof data1 == "object" && !Array.isArray(data1)){if(data1.semantic_tool !== undefined){let data2 = data1.semantic_tool;if(!(validate24(data2, {instancePath:instancePath+"/payload/semantic_tool",parentData:data1,parentDataProperty:"semantic_tool",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate24.errors : vErrors.concat(validate24.errors);errors = vErrors.length;}if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.phase !== undefined){if("input" !== data2.phase){const err1 = {instancePath:instancePath+"/payload/semantic_tool/phase",schemaPath:"#/allOf/0/then/properties/payload/properties/semantic_tool/allOf/1/properties/phase/const",keyword:"const",params:{allowedValue: "input"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}else {const err2 = {instancePath:instancePath+"/payload/semantic_tool",schemaPath:"#/allOf/0/then/properties/payload/properties/semantic_tool/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}}else {const err3 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/0/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}}var _valid0 = _errs5 === errors;valid1 = _valid0;if(valid1){var props0 = {};props0.payload = true;props0.eventType = true;}}if(!valid1){const err4 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}const _errs15 = errors;let valid7 = true;const _errs16 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("mcp_app.tool_result" !== data.eventType){const err5 = {};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}}var _valid1 = _errs16 === errors;errors = _errs15;if(vErrors !== null){if(_errs15){vErrors.length = _errs15;}else {vErrors = null;}}if(_valid1){const _errs18 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data5 = data.payload;if(data5 && typeof data5 == "object" && !Array.isArray(data5)){if(data5.semantic_tool !== undefined){let data6 = data5.semantic_tool;if(!(validate24(data6, {instancePath:instancePath+"/payload/semantic_tool",parentData:data5,parentDataProperty:"semantic_tool",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate24.errors : vErrors.concat(validate24.errors);errors = vErrors.length;}if(data6 && typeof data6 == "object" && !Array.isArray(data6)){if(data6.phase !== undefined){if("result" !== data6.phase){const err6 = {instancePath:instancePath+"/payload/semantic_tool/phase",schemaPath:"#/allOf/1/then/properties/payload/properties/semantic_tool/allOf/1/properties/phase/const",keyword:"const",params:{allowedValue: "result"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}else {const err7 = {instancePath:instancePath+"/payload/semantic_tool",schemaPath:"#/allOf/1/then/properties/payload/properties/semantic_tool/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}}else {const err8 = {instancePath:instancePath+"/payload",schemaPath:"#/allOf/1/then/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}var _valid1 = _errs18 === errors;valid7 = _valid1;if(valid7){var props1 = {};props1.payload = true;props1.eventType = true;}}if(!valid7){const err9 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(props0 !== true && props1 !== undefined){if(props1 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props1);}}const _errs28 = errors;let valid13 = true;const _errs29 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("run.terminal" !== data.eventType){const err10 = {};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}}var _valid2 = _errs29 === errors;errors = _errs28;if(vErrors !== null){if(_errs28){vErrors.length = _errs28;}else {vErrors = null;}}if(_valid2){const _errs31 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){if(!(validate35(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate35.errors : vErrors.concat(validate35.errors);errors = vErrors.length;}}}var _valid2 = _errs31 === errors;valid13 = _valid2;if(valid13){var props2 = {};props2.payload = true;props2.eventType = true;}}if(!valid13){const err11 = {instancePath,schemaPath:"#/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(props0 !== true && props2 !== undefined){if(props2 === true){props0 = true;}else {props0 = props0 || {};Object.assign(props0, props2);}}const _errs34 = errors;let valid16 = true;const _errs35 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.eventType !== undefined){if("run.result.proposed" !== data.eventType){const err12 = {};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}var _valid3 = _errs35 === errors;errors = _errs34;if(vErrors !== null){if(_errs34){vErrors.length = _errs34;}else {vErrors = null;}}if(_valid3){const _errs37 = errors;if(data && typeof data == "object" && !Array.isArray(data)){if(data.payload !== undefined){let data11 = data.payload;const _errs42 = errors;let valid21 = true;const _errs43 = errors;if(data11 && typeof data11 == "object" && !Array.isArray(data11)){if(data11.reportedWorkDisposition !== undefined){if("blocked" !== data11.reportedWorkDisposition){const err13 = {};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}var _valid4 = _errs43 === errors;errors = _errs42;if(vErrors !== null){if(_errs42){vErrors.length = _errs42;}else {vErrors = null;}}if(_valid4){const _errs45 = errors;if(data11 && typeof data11 == "object" && !Array.isArray(data11)){if(data11.blocker === undefined){const err14 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/0/then/required",keyword:"required",params:{missingProperty: "blocker"},message:"must have required property '"+"blocker"+"'"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}var _valid4 = _errs45 === errors;valid21 = _valid4;}if(!valid21){const err15 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}const _errs47 = errors;let valid23 = true;const _errs48 = errors;if(data11 && typeof data11 == "object" && !Array.isArray(data11)){if(data11.reportedWorkDisposition !== undefined){if("yielded" !== data11.reportedWorkDisposition){const err16 = {};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}var _valid5 = _errs48 === errors;errors = _errs47;if(vErrors !== null){if(_errs47){vErrors.length = _errs47;}else {vErrors = null;}}if(_valid5){const _errs50 = errors;if(data11 && typeof data11 == "object" && !Array.isArray(data11)){if(data11.continuation === undefined){const err17 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/1/then/required",keyword:"required",params:{missingProperty: "continuation"},message:"must have required property '"+"continuation"+"'"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}var _valid5 = _errs50 === errors;valid23 = _valid5;}if(!valid23){const err18 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if(data11 && typeof data11 == "object" && !Array.isArray(data11)){if(data11.schema === undefined){const err19 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data11.reportedWorkDisposition === undefined){const err20 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "reportedWorkDisposition"},message:"must have required property '"+"reportedWorkDisposition"+"'"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data11.summary === undefined){const err21 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "summary"},message:"must have required property '"+"summary"+"'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data11.completionClaim === undefined){const err22 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "completionClaim"},message:"must have required property '"+"completionClaim"+"'"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(data11.evidence === undefined){const err23 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "evidence"},message:"must have required property '"+"evidence"+"'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data11.verification === undefined){const err24 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "verification"},message:"must have required property '"+"verification"+"'"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(data11.attentionRequests === undefined){const err25 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "attentionRequests"},message:"must have required property '"+"attentionRequests"+"'"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(data11.artifacts === undefined){const err26 = {instancePath:instancePath+"/payload",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/required",keyword:"required",params:{missingProperty: "artifacts"},message:"must have required property '"+"artifacts"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data11.schema !== undefined){if("paperclip.run_result.v1" !== data11.schema){const err27 = {instancePath:instancePath+"/payload/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.run_result.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}if(data11.reportedWorkDisposition !== undefined){let data15 = data11.reportedWorkDisposition;if(!((((data15 === "done") || (data15 === "blocked")) || (data15 === "needs_review")) || (data15 === "yielded"))){const err28 = {instancePath:instancePath+"/payload/reportedWorkDisposition",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/reportedWorkDisposition/enum",keyword:"enum",params:{allowedValues: schema69.properties.reportedWorkDisposition.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data11.summary !== undefined){let data16 = data11.summary;if(typeof data16 === "string"){if(func1(data16) > 12000){const err29 = {instancePath:instancePath+"/payload/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/maxLength",keyword:"maxLength",params:{limit: 12000},message:"must NOT have more than 12000 characters"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if(func1(data16) < 1){const err30 = {instancePath:instancePath+"/payload/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}else {const err31 = {instancePath:instancePath+"/payload/summary",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/summary/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}}if(data11.completionClaim !== undefined){let data17 = data11.completionClaim;if(data17 && typeof data17 == "object" && !Array.isArray(data17)){if(data17.contractRevision === undefined){const err32 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "contractRevision"},message:"must have required property '"+"contractRevision"+"'"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(data17.objectiveSatisfied === undefined){const err33 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "objectiveSatisfied"},message:"must have required property '"+"objectiveSatisfied"+"'"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(data17.criteria === undefined){const err34 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "criteria"},message:"must have required property '"+"criteria"+"'"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if(data17.remainingWork === undefined){const err35 = {instancePath:instancePath+"/payload/completionClaim",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/required",keyword:"required",params:{missingProperty: "remainingWork"},message:"must have required property '"+"remainingWork"+"'"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(data17.contractRevision !== undefined){let data18 = data17.contractRevision;if(typeof data18 === "string"){if(func1(data18) < 1){const err36 = {instancePath:instancePath+"/payload/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}else {const err37 = {instancePath:instancePath+"/payload/completionClaim/contractRevision",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/contractRevision/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data17.objectiveSatisfied !== undefined){if(typeof data17.objectiveSatisfied !== "boolean"){const err38 = {instancePath:instancePath+"/payload/completionClaim/objectiveSatisfied",schemaPath:"https://paperclip.dev/schemas/prp/v1/result.schema.json/properties/completionClaim/properties/objectiveSatisfied/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data17.criteria !== undefined){let data20 = data17.criteria;if(Array.isArray(data20)){const len0 = data20.length;for(let i0=0; i0 160){const err114 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}if(func1(data56) < 1){const err115 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}}else {const err116 = {instancePath:instancePath+"/sourceEventId",schemaPath:"#/properties/sourceEventId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}}if(data.sourceSeq !== undefined){let data57 = data.sourceSeq;if(!(((typeof data57 == "number") && (!(data57 % 1) && !isNaN(data57))) && (isFinite(data57)))){const err117 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}if((typeof data57 == "number") && (isFinite(data57))){if(data57 < 1 || isNaN(data57)){const err118 = {instancePath:instancePath+"/sourceSeq",schemaPath:"#/properties/sourceSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}}}if(data.sourceInstanceId !== undefined){let data58 = data.sourceInstanceId;if(typeof data58 === "string"){if(func1(data58) > 160){const err119 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}if(func1(data58) < 1){const err120 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err120];}else {vErrors.push(err120);}errors++;}}else {const err121 = {instancePath:instancePath+"/sourceInstanceId",schemaPath:"#/properties/sourceInstanceId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err121];}else {vErrors.push(err121);}errors++;}}if(data.sourceKind !== undefined){let data59 = data.sourceKind;if(!((data59 === "runner") || (data59 === "control_plane"))){const err122 = {instancePath:instancePath+"/sourceKind",schemaPath:"#/properties/sourceKind/enum",keyword:"enum",params:{allowedValues: schema43.properties.sourceKind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}}if(data.runId !== undefined){let data60 = data.runId;if(typeof data60 === "string"){if(func1(data60) > 160){const err123 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}if(func1(data60) < 1){const err124 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}}else {const err125 = {instancePath:instancePath+"/runId",schemaPath:"#/properties/runId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}}if(data.normalizedSessionId !== undefined){let data61 = data.normalizedSessionId;if(typeof data61 === "string"){if(func1(data61) > 160){const err126 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err126];}else {vErrors.push(err126);}errors++;}if(func1(data61) < 1){const err127 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err127];}else {vErrors.push(err127);}errors++;}}else {const err128 = {instancePath:instancePath+"/normalizedSessionId",schemaPath:"#/properties/normalizedSessionId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err128];}else {vErrors.push(err128);}errors++;}}if(data.turnId !== undefined){let data62 = data.turnId;if(typeof data62 === "string"){if(func1(data62) > 160){const err129 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err129];}else {vErrors.push(err129);}errors++;}if(func1(data62) < 1){const err130 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err130];}else {vErrors.push(err130);}errors++;}}else {const err131 = {instancePath:instancePath+"/turnId",schemaPath:"#/properties/turnId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err131];}else {vErrors.push(err131);}errors++;}}if(data.itemId !== undefined){let data63 = data.itemId;if(typeof data63 === "string"){if(func1(data63) > 160){const err132 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err132];}else {vErrors.push(err132);}errors++;}if(func1(data63) < 1){const err133 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err133];}else {vErrors.push(err133);}errors++;}}else {const err134 = {instancePath:instancePath+"/itemId",schemaPath:"#/properties/itemId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err134];}else {vErrors.push(err134);}errors++;}}if(data.eventType !== undefined){let data64 = data.eventType;if(!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((data64 === "runner.connected") || (data64 === "runner.reconnected")) || (data64 === "runner.disconnected")) || (data64 === "runner.draining")) || (data64 === "runner.diagnostic")) || (data64 === "runtime.phase.changed")) || (data64 === "sandbox.metric")) || (data64 === "workspace.ready")) || (data64 === "harness.starting")) || (data64 === "harness.ready")) || (data64 === "harness.exited")) || (data64 === "harness.diagnostic")) || (data64 === "session.starting")) || (data64 === "session.started")) || (data64 === "session.resuming")) || (data64 === "session.resumed")) || (data64 === "session.reconciled")) || (data64 === "session.closed")) || (data64 === "session.failed")) || (data64 === "turn.submitted")) || (data64 === "turn.accepted")) || (data64 === "turn.started")) || (data64 === "turn.completed")) || (data64 === "turn.failed")) || (data64 === "turn.interrupted")) || (data64 === "turn.cancelled")) || (data64 === "item.started")) || (data64 === "item.delta")) || (data64 === "item.completed")) || (data64 === "item.failed")) || (data64 === "mcp_app.discovered")) || (data64 === "mcp_app.resource.resolved")) || (data64 === "mcp_app.initializing")) || (data64 === "mcp_app.ready")) || (data64 === "mcp_app.tool_input")) || (data64 === "mcp_app.tool_result")) || (data64 === "mcp_app.action.requested")) || (data64 === "mcp_app.action.resolved")) || (data64 === "mcp_app.host_context.changed")) || (data64 === "mcp_app.failed")) || (data64 === "mcp_app.teardown")) || (data64 === "runtime_request.created")) || (data64 === "runtime_request.resolved")) || (data64 === "runtime_request.expired")) || (data64 === "runtime_request.cancelled")) || (data64 === "interaction.request.proposed")) || (data64 === "interaction.request.materialized")) || (data64 === "interaction.request.rejected")) || (data64 === "interaction.response.progressed")) || (data64 === "interaction.response.resolved")) || (data64 === "interaction.response.delivered")) || (data64 === "run.result.proposed")) || (data64 === "run.result.accepted")) || (data64 === "run.result.rejected")) || (data64 === "attention.request.proposed")) || (data64 === "attention.request.routed")) || (data64 === "attention.request.resolved")) || (data64 === "attention.request.expired")) || (data64 === "attention.request.superseded")) || (data64 === "work.assessment.recorded")) || (data64 === "issue.status.decision.recorded")) || (data64 === "issue.status.decision.applied")) || (data64 === "issue.status.decision.rejected")) || (data64 === "issue.status.decision.superseded")) || (data64 === "run.terminal"))){const err135 = {instancePath:instancePath+"/eventType",schemaPath:"#/properties/eventType/enum",keyword:"enum",params:{allowedValues: schema43.properties.eventType.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err135];}else {vErrors.push(err135);}errors++;}}if(data.schemaVersion !== undefined){if(1 !== data.schemaVersion){const err136 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err136];}else {vErrors.push(err136);}errors++;}}if(data.priority !== undefined){let data66 = data.priority;if(!(((data66 === 0) || (data66 === 1)) || (data66 === 2))){const err137 = {instancePath:instancePath+"/priority",schemaPath:"#/properties/priority/enum",keyword:"enum",params:{allowedValues: schema43.properties.priority.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err137];}else {vErrors.push(err137);}errors++;}}if(data.emittedAt !== undefined){let data67 = data.emittedAt;if(typeof data67 === "string"){if(!(formats0.test(data67))){const err138 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err138];}else {vErrors.push(err138);}errors++;}}else {const err139 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/properties/emittedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err139];}else {vErrors.push(err139);}errors++;}}if(data.observedAt !== undefined){let data68 = data.observedAt;if(typeof data68 === "string"){if(!(formats0.test(data68))){const err140 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};if(vErrors === null){vErrors = [err140];}else {vErrors.push(err140);}errors++;}}else {const err141 = {instancePath:instancePath+"/observedAt",schemaPath:"#/properties/observedAt/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err141];}else {vErrors.push(err141);}errors++;}}if(data.payload !== undefined){let data69 = data.payload;if(data69 && typeof data69 == "object" && !Array.isArray(data69)){}else {const err142 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err142];}else {vErrors.push(err142);}errors++;}}if(data.debug !== undefined){let data70 = data.debug;if(data70 && typeof data70 == "object" && !Array.isArray(data70)){}else {const err143 = {instancePath:instancePath+"/debug",schemaPath:"#/properties/debug/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err143];}else {vErrors.push(err143);}errors++;}}}else {const err144 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err144];}else {vErrors.push(err144);}errors++;}validate23.errors = vErrors;return errors === 0;}validate23.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/fixture.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.fixtureVersion === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "fixtureVersion"},message:"must have required property '"+"fixtureVersion"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.protocolVersion === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "protocolVersion"},message:"must have required property '"+"protocolVersion"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.name === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "name"},message:"must have required property '"+"name"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.description === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "description"},message:"must have required property '"+"description"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.identity === undefined){const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "identity"},message:"must have required property '"+"identity"+"'"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(data.capabilities === undefined){const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "capabilities"},message:"must have required property '"+"capabilities"+"'"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(data.commands === undefined){const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "commands"},message:"must have required property '"+"commands"+"'"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(data.events === undefined){const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "events"},message:"must have required property '"+"events"+"'"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(data.result === undefined){const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "result"},message:"must have required property '"+"result"+"'"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(data.schema !== undefined){if("paperclip.prp.fixture.v1" !== data.schema){const err10 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.fixture.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.fixtureVersion !== undefined){if(1 !== data.fixtureVersion){const err11 = {instancePath:instancePath+"/fixtureVersion",schemaPath:"#/properties/fixtureVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.protocolVersion !== undefined){if(1 !== data.protocolVersion){const err12 = {instancePath:instancePath+"/protocolVersion",schemaPath:"#/properties/protocolVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.name !== undefined){let data3 = data.name;if(typeof data3 === "string"){if(func1(data3) > 120){const err13 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/maxLength",keyword:"maxLength",params:{limit: 120},message:"must NOT have more than 120 characters"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(func1(data3) < 1){const err14 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}else {const err15 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.description !== undefined){let data4 = data.description;if(typeof data4 === "string"){if(func1(data4) > 1000){const err16 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 1000},message:"must NOT have more than 1000 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data4) < 1){const err17 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}else {const err18 = {instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.identity !== undefined){if(!(validate21(data.identity, {instancePath:instancePath+"/identity",parentData:data,parentDataProperty:"identity",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate21.errors : vErrors.concat(validate21.errors);errors = vErrors.length;}}if(data.capabilities !== undefined){let data6 = data.capabilities;if(data6 && typeof data6 == "object" && !Array.isArray(data6)){if(data6.schema === undefined){const err19 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data6.sessionReusePolicy === undefined){const err20 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "sessionReusePolicy"},message:"must have required property '"+"sessionReusePolicy"+"'"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data6.driver === undefined){const err21 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "driver"},message:"must have required property '"+"driver"+"'"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(data6.steer === undefined){const err22 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "steer"},message:"must have required property '"+"steer"+"'"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(data6.interrupt === undefined){const err23 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "interrupt"},message:"must have required property '"+"interrupt"+"'"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(data6.resume === undefined){const err24 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "resume"},message:"must have required property '"+"resume"+"'"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if(data6.runtimeRequests === undefined){const err25 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "runtimeRequests"},message:"must have required property '"+"runtimeRequests"+"'"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if(data6.structuredResult === undefined){const err26 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "structuredResult"},message:"must have required property '"+"structuredResult"+"'"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data6.typedEvents === undefined){const err27 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/required",keyword:"required",params:{missingProperty: "typedEvents"},message:"must have required property '"+"typedEvents"+"'"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(data6.schema !== undefined){if("paperclip.prp.capabilities.v1" !== data6.schema){const err28 = {instancePath:instancePath+"/capabilities/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.capabilities.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data6.sessionReusePolicy !== undefined){let data8 = data6.sessionReusePolicy;if(!(((data8 === "new_per_run") || (data8 === "reuse_per_issue")) || (data8 === "reuse_per_workspace"))){const err29 = {instancePath:instancePath+"/capabilities/sessionReusePolicy",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/sessionReusePolicy/enum",keyword:"enum",params:{allowedValues: schema41.properties.sessionReusePolicy.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}if(data6.driver !== undefined){let data9 = data6.driver;if(data9 && typeof data9 == "object" && !Array.isArray(data9)){if(data9.kind === undefined){const err30 = {instancePath:instancePath+"/capabilities/driver",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}if(data9.version === undefined){const err31 = {instancePath:instancePath+"/capabilities/driver",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(data9.kind !== undefined){let data10 = data9.kind;if(typeof data10 === "string"){if(func1(data10) > 80){const err32 = {instancePath:instancePath+"/capabilities/driver/kind",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/kind/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(func1(data10) < 1){const err33 = {instancePath:instancePath+"/capabilities/driver/kind",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/kind/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}else {const err34 = {instancePath:instancePath+"/capabilities/driver/kind",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data9.version !== undefined){let data11 = data9.version;if(typeof data11 === "string"){if(func1(data11) > 80){const err35 = {instancePath:instancePath+"/capabilities/driver/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/version/maxLength",keyword:"maxLength",params:{limit: 80},message:"must NOT have more than 80 characters"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(func1(data11) < 1){const err36 = {instancePath:instancePath+"/capabilities/driver/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/version/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}else {const err37 = {instancePath:instancePath+"/capabilities/driver/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/properties/version/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}}else {const err38 = {instancePath:instancePath+"/capabilities/driver",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/driver/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}if(data6.steer !== undefined){if(typeof data6.steer !== "boolean"){const err39 = {instancePath:instancePath+"/capabilities/steer",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/steer/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}if(data6.interrupt !== undefined){if(typeof data6.interrupt !== "boolean"){const err40 = {instancePath:instancePath+"/capabilities/interrupt",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/interrupt/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}if(data6.resume !== undefined){if(typeof data6.resume !== "boolean"){const err41 = {instancePath:instancePath+"/capabilities/resume",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/resume/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}if(data6.runtimeRequests !== undefined){if(typeof data6.runtimeRequests !== "boolean"){const err42 = {instancePath:instancePath+"/capabilities/runtimeRequests",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/runtimeRequests/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}if(data6.structuredResult !== undefined){if(typeof data6.structuredResult !== "boolean"){const err43 = {instancePath:instancePath+"/capabilities/structuredResult",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/structuredResult/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}if(data6.typedEvents !== undefined){if(typeof data6.typedEvents !== "boolean"){const err44 = {instancePath:instancePath+"/capabilities/typedEvents",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/typedEvents/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}}if(data6.semanticTools !== undefined){let data18 = data6.semanticTools;if(data18 && typeof data18 == "object" && !Array.isArray(data18)){if(data18.schema === undefined){const err45 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}if(data18.schemaVersion === undefined){const err46 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}if(data18.operations === undefined){const err47 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/required",keyword:"required",params:{missingProperty: "operations"},message:"must have required property '"+"operations"+"'"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}if(data18.schema !== undefined){if("paperclip.prp.semantic_tools.v1" !== data18.schema){const err48 = {instancePath:instancePath+"/capabilities/semanticTools/schema",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.prp.semantic_tools.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}if(data18.schemaVersion !== undefined){if(1 !== data18.schemaVersion){const err49 = {instancePath:instancePath+"/capabilities/semanticTools/schemaVersion",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/schemaVersion/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}if(data18.operations !== undefined){let data21 = data18.operations;if(Array.isArray(data21)){const len0 = data21.length;for(let i0=0; i0 160){const err54 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err54];}else {vErrors.push(err54);}errors++;}if(func1(data23) < 1){const err55 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err55];}else {vErrors.push(err55);}errors++;}if(!pattern4.test(data23)){const err56 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}else {const err57 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/operationId",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/operationId/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}if(data22.version !== undefined){if(1 !== data22.version){const err58 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/version",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}if(data22.availability !== undefined){let data25 = data22.availability;if(!(((data25 === "available") || (data25 === "denied")) || (data25 === "unsupported"))){const err59 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/availability",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/availability/enum",keyword:"enum",params:{allowedValues: schema41.properties.semanticTools.properties.operations.items.properties.availability.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data22.redactionDisposition !== undefined){if("digest_only" !== data22.redactionDisposition){const err60 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/redactionDisposition",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/redactionDisposition/const",keyword:"const",params:{allowedValue: "digest_only"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}if(data22.requiredClaims !== undefined){let data27 = data22.requiredClaims;if(Array.isArray(data27)){const len1 = data27.length;for(let i1=0; i1 160){const err61 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/requiredClaims/" + i1,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/items/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}if(func1(data28) < 1){const err62 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/requiredClaims/" + i1,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/items/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}}else {const err63 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/requiredClaims/" + i1,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/items/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}}let i2 = data27.length;let j0;if(i2 > 1){const indices0 = {};for(;i2--;){let item0 = data27[i2];if(typeof item0 !== "string"){continue;}if(typeof indices0[item0] == "number"){j0 = indices0[item0];const err64 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/requiredClaims",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/uniqueItems",keyword:"uniqueItems",params:{i: i2, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i2+" are identical)"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;break;}indices0[item0] = i2;}}}else {const err65 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/requiredClaims",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/requiredClaims/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}}if(data22.reasonCode !== undefined){let data29 = data22.reasonCode;if(typeof data29 === "string"){if(func1(data29) > 160){const err66 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}if(func1(data29) < 1){const err67 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}if(!pattern13.test(data29)){const err68 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9_.:-]*$"},message:"must match pattern \""+"^[a-z][a-z0-9_.:-]*$"+"\""};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}else {const err69 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0+"/reasonCode",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/properties/reasonCode/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}}else {const err70 = {instancePath:instancePath+"/capabilities/semanticTools/operations/" + i0,schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/items/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}}}else {const err71 = {instancePath:instancePath+"/capabilities/semanticTools/operations",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/properties/operations/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}}else {const err72 = {instancePath:instancePath+"/capabilities/semanticTools",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/semanticTools/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}}if(data6.unsupported !== undefined){let data30 = data6.unsupported;if(Array.isArray(data30)){const len2 = data30.length;for(let i3=0; i3 1){const indices1 = {};for(;i4--;){let item1 = data30[i4];if(typeof item1 !== "string"){continue;}if(typeof indices1[item1] == "number"){j1 = indices1[item1];const err75 = {instancePath:instancePath+"/capabilities/unsupported",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/unsupported/uniqueItems",keyword:"uniqueItems",params:{i: i4, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i4+" are identical)"};if(vErrors === null){vErrors = [err75];}else {vErrors.push(err75);}errors++;break;}indices1[item1] = i4;}}}else {const err76 = {instancePath:instancePath+"/capabilities/unsupported",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/properties/unsupported/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err76];}else {vErrors.push(err76);}errors++;}}}else {const err77 = {instancePath:instancePath+"/capabilities",schemaPath:"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err77];}else {vErrors.push(err77);}errors++;}}if(data.commands !== undefined){let data32 = data.commands;if(Array.isArray(data32)){const len3 = data32.length;for(let i5=0; i5