feat(runner): add OpenCode and durable runner UX

This commit is contained in:
Dotta committed 2026-08-21 15:18:00 -05:00
1 parent f34591016e
commit 4c3e30cdad
100 files changed
+7156 -412

No files matched your search

+1 -1
View File
@@ -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/* \
+1 -1
View File
@@ -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
+5 -2
View File
@@ -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;
@@ -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> = {}): 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",
});
});
});
@@ -70,5 +70,17 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
delete config.nonInteractivePermissions;
delete config.stateDir;
delete config.warmHandleIdleMs;
return { ...config, provider: "codex" };
const provider = v.paperclipRunnerProvider
?? (v.adapterSchemaValues?.provider === "opencode" ? "opencode" : "codex");
return {
...config,
provider,
lifecycleMode: v.paperclipRunnerLifecycleMode ?? "per_turn",
...(v.paperclipRunnerLifecycleMode === "warm"
? { idleTimeoutMs: v.paperclipRunnerIdleTimeoutMs ?? 300_000 }
: {}),
...(provider === "opencode" && !v.model
? { model: "openrouter/deepseek/deepseek-v4-flash-0731" }
: {}),
};
}
@@ -21,6 +21,7 @@ import {
recallSession,
rememberSession,
type CapabilityCleanRoomIdentity,
type CapabilityHarnessConfiguration,
} from "./live-client";
import { parseCapabilityRoute, capabilityRouteHref, type CapabilityRoute } from "./route";
import { SurfaceNav, type CapabilityChatHistoryItem } from "./SurfaceNav";
@@ -47,8 +48,13 @@ const REPLAY_STEP_MS = 800;
const PENDING_ANNOUNCEMENT = "A request is waiting for your answer.";
const ANSWERED_ANNOUNCEMENT = "Your answer was recorded.";
const SETTLED_ANNOUNCEMENT = "The pending request is resolved.";
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts"];
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts", "wc-workspace-changes", "fr-file-reference"];
const CHAT_HISTORY_KEY = "paperclip-runner.capability.chat.history.v1";
const CHAT_HARNESS_KEY = "paperclip-runner.capability.chat.harness.v1";
const MODEL_PRESETS = {
codex: ["gpt-5.4-mini", "gpt-5.4"],
opencode: ["openrouter/deepseek/deepseek-v4-flash-0731"],
} as const;
interface EmbeddedEvalCheck {
id: string;
@@ -105,6 +111,40 @@ interface StoredChatSession {
snapshot: CapabilityIssueThreadSnapshot;
identity: CapabilityCleanRoomIdentity | null;
updatedAt: string;
configuration?: CapabilityHarnessConfiguration;
}
function defaultHarness(provider: CapabilityHarnessConfiguration["provider"] = "codex"): CapabilityHarnessConfiguration {
return {
provider,
model: MODEL_PRESETS[provider][0],
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
};
}
function readHarnessConfiguration(): CapabilityHarnessConfiguration {
try {
const value = JSON.parse(window.localStorage.getItem(CHAT_HARNESS_KEY) ?? "null") as Partial<CapabilityHarnessConfiguration> | 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<CapabilityIssueThreadSnapshot | null>(null);
const [devtools, setDevtools] = useState<CapabilityDevtoolsSnapshot | null>(null);
const [identity, setIdentity] = useState<CapabilityCleanRoomIdentity | null>(null);
const initialHarnessRef = useRef<CapabilityHarnessConfiguration>(readHarnessConfiguration());
const [harness, setHarness] = useState<CapabilityHarnessConfiguration>(initialHarnessRef.current);
const [activeHarness, setActiveHarness] = useState<CapabilityHarnessConfiguration | null>(null);
const [chatHistory, setChatHistory] = useState<StoredChatSession[]>(readChatHistory);
const [historicSessionId, setHistoricSessionId] = useState<string | null>(null);
const liveRoomRef = useRef<StoredChatSession | null>(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<ReturnType<typeof capabilityLiveClient.newCleanRoom>>) => {
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() {
<main className="pit-app-error">
<p className="pit-composer-reason" role="alert" data-testid="surface-error">
{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}
</p>
{chat ? (
<p className="pit-muted">
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.
</p>
) : null}
@@ -859,7 +921,7 @@ export function App() {
<main className="pit-app-error">
<p className="pit-muted" role="status" data-testid="surface-loading">
{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…"}
</p>
</main>
@@ -917,6 +979,95 @@ export function App() {
onSelectSegment={setSegment}
/>
{chat && embeddedEval === null ? (
<section className="pit-harness-picker" aria-label="Chat provider and model">
<label>
<span>Provider</span>
<select
data-testid="chat-provider"
value={harness.provider}
disabled={streamingTurn}
onChange={(event) => {
const provider = event.target.value as CapabilityHarnessConfiguration["provider"];
setHarness((current) => ({
...defaultHarness(provider),
lifecyclePolicy: current.lifecyclePolicy,
}));
}}
>
<option value="codex">Codex</option>
<option value="opencode">OpenCode</option>
</select>
</label>
<label>
<span>Execution</span>
<select
data-testid="chat-lifecycle-mode"
value={harness.lifecyclePolicy.mode}
disabled={streamingTurn}
onChange={(event) => setHarness((current) => ({
...current,
lifecyclePolicy: event.target.value === "per_turn"
? { mode: "per_turn", idleTimeoutMs: null }
: { mode: "warm", idleTimeoutMs: 300_000 },
}))}
>
<option value="warm">Warm session</option>
<option value="per_turn">Turn by turn</option>
</select>
</label>
{harness.lifecyclePolicy.mode === "warm" ? (
<label>
<span>Idle timeout (seconds)</span>
<input
data-testid="chat-idle-timeout"
type="number"
min={1}
step={1}
value={Math.round(harness.lifecyclePolicy.idleTimeoutMs / 1_000)}
disabled={streamingTurn}
onChange={(event) => {
const seconds = Math.max(1, Number.parseInt(event.target.value || "1", 10));
setHarness((current) => ({
...current,
lifecyclePolicy: { mode: "warm", idleTimeoutMs: seconds * 1_000 },
}));
}}
/>
</label>
) : null}
<label className="pit-harness-model">
<span>Model</span>
<input
data-testid="chat-model"
list={`chat-models-${harness.provider}`}
value={harness.model ?? ""}
disabled={streamingTurn}
placeholder={harness.provider === "opencode" ? "provider/model" : "model name"}
onChange={(event) => setHarness((current) => ({ ...current, model: event.target.value }))}
/>
<datalist id={`chat-models-${harness.provider}`}>
{MODEL_PRESETS[harness.provider].map((model) => <option key={model} value={model} />)}
</datalist>
</label>
<button
type="button"
className="pit-button"
data-variant="primary"
data-testid="chat-apply-harness"
disabled={streamingTurn || historicSessionId !== null}
onClick={newChat}
>
Start new chat
</button>
<span className="pit-harness-active" data-testid="chat-active-harness">
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"}` : ""}
</span>
</section>
) : null}
{embeddedEval !== null ? (
<nav className="pit-eval-nav" aria-label="Eval result navigation">
<a href={embeddedEval.navigation.suiteHref}>← All results</a>
@@ -1039,7 +1190,7 @@ export function App() {
recorded yet.
</p>
<p className="pit-muted">
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
@@ -142,7 +142,7 @@ export function DevtoolsInspector({ snapshot, onFork, tab, onTabChange, evalRepo
<div className="pit-devtools-pane pit-eval-inspector">
<div className="pit-eval-summary-head"><a href="../../index.html">← Eval suite</a><strong>{evalReport.passed ? "PASS" : evalReport.disposition.replaceAll("_", " ").toUpperCase()}</strong><code>{evalReport.attemptId}</code></div>
<dl className="pit-eval-run-facts">
<div><dt>Model</dt><dd>{evalReport.run.provider}/{evalReport.run.model}</dd></div>
<div><dt>Model</dt><dd>{evalReport.run.model.startsWith(`${evalReport.run.provider}/`) ? evalReport.run.model : `${evalReport.run.provider}/${evalReport.run.model}`}</dd></div>
<div><dt>Configuration</dt><dd>{evalReport.run.configuration}</dd></div>
<div><dt>Session</dt><dd>{evalReport.run.sessionId}</dd></div>
<div><dt>Duration</dt><dd>{evalReport.run.durationMs} ms</dd></div>
@@ -150,6 +150,147 @@ function ProgressItem({
);
}
function FileDiff({ diff }: { diff: string | null }) {
if (diff === null) return <p className="pit-workspace-binary">Binary or oversized file; textual diff unavailable.</p>;
return (
<pre className="pit-workspace-diff" aria-label="Unified file diff">
{diff.split("\n").map((line, index) => (
<span
key={`${index}:${line}`}
data-diff-line={line.startsWith("+") && !line.startsWith("+++")
? "addition"
: line.startsWith("-") && !line.startsWith("---")
? "deletion"
: line.startsWith("@@")
? "hunk"
: "context"}
>{line || " "}{"\n"}</span>
))}
</pre>
);
}
function WorkspaceChanges({
item,
}: {
item: Extract<CapabilityThreadItem, { kind: "workspace_changes" }>;
}) {
const [expanded, setExpanded] = useState(false);
const [selectedPath, setSelectedPath] = useState<string | null>(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]) => (
<span className="pit-workspace-stats">
{file.additions === null ? null : <span data-stat="add">+{file.additions}</span>}
{file.deletions === null ? null : <span data-stat="delete">-{file.deletions}</span>}
</span>
);
return (
<>
<article id={item.id} className="pit-workspace-card" data-thread-item="workspace_changes">
<header className="pit-workspace-head">
<span className="pit-workspace-icon" aria-hidden="true">▣</span>
<span>
<strong>{item.changeSet.complete ? "Edited" : "Editing"} {files.length} file{files.length === 1 ? "" : "s"}</strong>
<small>{item.changeSet.source === "runner_verified" ? "Verified from workspace" : "Reported by harness"}</small>
</span>
<span className="pit-workspace-total">
{item.changeSet.totals.additions === null ? null : <span data-stat="add">+{item.changeSet.totals.additions}</span>}
{item.changeSet.totals.deletions === null ? null : <span data-stat="delete">-{item.changeSet.totals.deletions}</span>}
</span>
<button type="button" className="pit-workspace-review" onClick={() => setSelectedPath(files[0]?.path ?? null)}>Review</button>
</header>
<div className="pit-workspace-files">
{visible.map((file) => (
<button type="button" key={`${file.operation}:${file.path}`} onClick={() => setSelectedPath(file.path)}>
<span className="pit-workspace-path">
{file.previousPath === null ? file.path : `${file.previousPath} → ${file.path}`}
<small>{file.operation.replace("_", " ")}</small>
</span>
{stats(file)}
</button>
))}
</div>
{files.length > 3 ? (
<button type="button" className="pit-workspace-more" onClick={() => setExpanded((value) => !value)}>
{expanded ? "Show fewer files" : `Show ${files.length - 3} more files`} <span aria-hidden="true">⌄</span>
</button>
) : null}
</article>
{selected !== null ? (
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setSelectedPath(null); }}>
<div className="pit-dialog pit-workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="workspace-diff-title">
<header className="pit-tool-dialog-head">
<div>
<span className="pit-card-meta">{selected.operation.replace("_", " ")} · workspace diff</span>
<h2 id="workspace-diff-title">{selected.path}</h2>
</div>
{stats(selected)}
<button type="button" className="pit-icon-button" aria-label="Close diff" onClick={() => setSelectedPath(null)}>×</button>
</header>
{files.length > 1 ? (
<nav className="pit-workspace-file-nav" aria-label="Changed files">
{files.map((file) => <button type="button" key={file.path} data-selected={file.path === selected.path} onClick={() => setSelectedPath(file.path)}>{file.path}</button>)}
</nav>
) : null}
<FileDiff diff={selected.diff} />
</div>
</div>
) : null}
</>
);
}
function WorkspaceFileReference({
item,
}: {
item: Extract<CapabilityThreadItem, { kind: "workspace_file_reference" }>;
}) {
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 (
<>
<article id={item.id} className="pit-file-reference-card" data-thread-item="workspace_file_reference">
<span className="pit-file-reference-icon" aria-hidden="true">▤</span>
<span className="pit-file-reference-copy">
<strong>{item.reference.displayName}</strong>
<small>{presentation} · {extension}{item.reference.line === null ? "" : ` · line ${item.reference.line}`}</small>
</span>
<button type="button" className="pit-workspace-review" onClick={() => setOpen(true)}>Open</button>
</article>
{open ? (
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setOpen(false); }}>
<div className="pit-dialog pit-file-reference-dialog" role="dialog" aria-modal="true" aria-labelledby="workspace-file-title">
<header className="pit-tool-dialog-head">
<div><span className="pit-card-meta">{item.reference.path}</span><h2 id="workspace-file-title">{item.reference.displayName}</h2></div>
<button type="button" className="pit-icon-button" aria-label="Close file" onClick={() => setOpen(false)}>×</button>
</header>
<div className="pit-file-reference-preview">
{item.reference.preview === null
? <p className="pit-workspace-binary">Preview unavailable. The reference remains recorded with its normalized workspace path.</p>
: item.reference.presentation === "document"
? <MarkdownBody>{item.reference.preview}</MarkdownBody>
: <pre className="pit-code">{item.reference.preview}</pre>}
{item.reference.previewTruncated ? <p className="pit-withheld-note">Preview truncated by the runner.</p> : null}
</div>
</div>
</div>
) : null}
</>
);
}
function ThreadItemView({
item,
callbacks,
@@ -230,6 +371,12 @@ function ThreadItemView({
case "progress_activity":
return <ProgressItem item={item} />;
case "workspace_changes":
return <WorkspaceChanges item={item} />;
case "workspace_file_reference":
return <WorkspaceFileReference item={item} />;
case "denial":
return (
<div id={item.id} className="pit-strip pit-denial" data-status="denied" data-thread-item="denial">
@@ -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;
}
@@ -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<CapabilityLiveResponse> {
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<CapabilityLiveResponse> {
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<CapabilityLiveResponse> {
return post("/cleanroom/session", sessionId === null ? {} : { sessionId });
newCleanRoom(sessionId: string | null, configuration: CapabilityHarnessConfiguration): Promise<CapabilityLiveResponse> {
return post("/cleanroom/session", { ...(sessionId === null ? {} : { sessionId }), ...configuration });
},
/**
* Runs one turn. `onFrame` fires for every interim projection the server
+2 -1
View File
@@ -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": {
".": {
@@ -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"
]
},
@@ -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": {
@@ -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
}
@@ -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
}
@@ -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<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
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::<String, String>::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<dyn std::error::Error>> {
"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<dyn std::error::Error>> {
}) {
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"}}}),
)?;
}
}
@@ -65,6 +65,13 @@ fn duration_value(args: &[String], name: &str, default: u64) -> Result<Duration,
))
}
fn optional_duration_value(
args: &[String],
name: &str,
) -> Result<Option<Duration>, LocalRunnerError> {
Ok(optional_u64(args, name)?.map(Duration::from_millis))
}
fn repeated_values(args: &[String], name: &str) -> Result<Vec<String>, LocalRunnerError> {
let mut values = Vec::new();
let mut index = 0;
@@ -189,6 +196,35 @@ fn run_durable_mode(
args: &[String],
bootstrap_ticket: Option<BootstrapTicket>,
) -> 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"))?;
@@ -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<String>,
@@ -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<Value, LocalRunnerError>;
fn interrupt_turn(&mut self, turn_id: &str) -> Result<Value, LocalRunnerError>;
fn read(&mut self) -> Result<Value, LocalRunnerError>;
fn poll(&mut self) -> Result<Option<CodexProviderEvent>, 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<String>,
},
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<Self, LocalRunnerError> {
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<Value, LocalRunnerError> {
CodexProvider::start_turn(self, message, cwd)
}
fn interrupt_turn(&mut self, turn_id: &str) -> Result<Value, LocalRunnerError> {
CodexProvider::interrupt_turn(self, turn_id)
}
fn read(&mut self) -> Result<Value, LocalRunnerError> {
self.read_thread()
}
fn poll(&mut self) -> Result<Option<CodexProviderEvent>, 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)
}
}
@@ -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<Instant> = 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<Option<crate::codex_provider::CodexProvider>, DurableRunnerError> {
let Some(provider_config) = state.codex_provider_config.clone() else {
) -> Result<Option<Box<dyn crate::codex_provider::Provider>>, 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<dyn crate::codex_provider::Provider>)
})
.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<crate::codex_provider::CodexProvider>,
provider: &mut Option<Box<dyn crate::codex_provider::Provider>>,
command: &Value,
) -> Result<ProcessedCommand, DurableRunnerError> {
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<crate::codex_provider::CodexProvider>,
provider: &mut Option<Box<dyn crate::codex_provider::Provider>>,
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<bool, DurableRunnerError> {
should_suspend_for_idle_at(state, current_unix_ms()?)
}
fn should_suspend_for_idle_at(state: &DurableRunnerState, now_unix_ms: u64) -> Result<bool, DurableRunnerError> {
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<Box<dyn crate::codex_provider::Provider>>,
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<Value, LocalRunnerError> {
unreachable!()
}
fn interrupt_turn(&mut self, _turn_id: &str) -> Result<Value, LocalRunnerError> {
unreachable!()
}
fn read(&mut self) -> Result<Value, LocalRunnerError> {
unreachable!()
}
fn poll(&mut self) -> Result<Option<CodexProviderEvent>, 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<Box<dyn Provider>> = 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}-{}",
@@ -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<Sha256>;
@@ -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<Duration>,
}
#[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<AttachmentIdentity>,
pub lifecycle: String,
#[serde(default = "default_lifecycle_mode")]
pub lifecycle_mode: String,
#[serde(default)]
pub idle_timeout_ms: Option<u64>,
#[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<CodexProviderConfig>,
#[serde(default)]
pub codex_provider_thread_id: Option<String>,
#[serde(default, alias = "codexProviderConfig")]
pub provider_config: Option<CodexProviderConfig>,
#[serde(default, alias = "codexProviderThreadId")]
pub provider_session_id: Option<String>,
}
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<String>) {
pub(crate) fn record_diagnostic(&mut self, message: impl Into<String>) {
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((
@@ -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
@@ -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);
@@ -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);
@@ -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;
}
_ => {}
}
}
}
@@ -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") {
@@ -16,6 +16,8 @@ const schemaNames = [
"identity",
"capabilities",
"command",
"workspace-diff",
"workspace-file-reference",
"semantic-tool",
"stop-reason",
"terminal",
@@ -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`<br>`runner.reconnected`<br>`runner.disconnected`<br>`runner.draining`<br>`runner.diagnostic` | 5 |
| `runner` | Runner connection, drain, and diagnostic lifecycle. | `runner.connected`<br>`runner.reconnected`<br>`runner.reconciled`<br>`runner.disconnected`<br>`runner.draining`<br>`runner.suspending`<br>`runner.suspended`<br>`runner.stopped`<br>`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`<br>`workspace.change.updated`<br>`workspace.diff.recorded`<br>`workspace.file.referenced` | 4 |
| `harness` | Provider harness startup, readiness, exit, and diagnostics. | `harness.starting`<br>`harness.ready`<br>`harness.exited`<br>`harness.diagnostic` | 4 |
| `session` | Provider-neutral session open, resume, reconciliation, close, and failure. | `session.starting`<br>`session.started`<br>`session.resuming`<br>`session.resumed`<br>`session.reconciled`<br>`session.closed`<br>`session.failed` | 7 |
| `turn` | Model turn submission through terminal turn disposition. | `turn.submitted`<br>`turn.accepted`<br>`turn.started`<br>`turn.completed`<br>`turn.failed`<br>`turn.interrupted`<br>`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`<br>`mcp_app.resource.resolved`<br>`mcp_app.initializing`<br>`mcp_app.ready`<br>`mcp_app.tool_input`<br>`mcp_app.tool_result`<br>`mcp_app.action.requested`<br>`mcp_app.action.resolved`<br>`mcp_app.host_context.changed`<br>`mcp_app.failed`<br>`mcp_app.teardown` | 11 |
| `runtime_request` | Runtime permission/input request lifecycle. | `runtime_request.created`<br>`runtime_request.resolved`<br>`runtime_request.expired`<br>`runtime_request.cancelled` | 4 |
| `interaction` | Issue-thread interaction proposal, materialization, response, delivery, and rejection. | `interaction.request.proposed`<br>`interaction.request.materialized`<br>`interaction.request.rejected`<br>`interaction.response.progressed`<br>`interaction.response.resolved`<br>`interaction.response.delivered` | 6 |
| `run` | Structured result negotiation and terminal run outcome. | `run.result.proposed`<br>`run.result.accepted`<br>`run.result.rejected`<br>`run.terminal` | 4 |
| `run` | Structured result negotiation and terminal run outcome. | `run.attached`<br>`run.detached`<br>`run.result.proposed`<br>`run.result.accepted`<br>`run.result.rejected`<br>`run.terminal` | 6 |
| `attention` | Continuation and attention routing lifecycle. | `attention.request.proposed`<br>`attention.request.routed`<br>`attention.request.resolved`<br>`attention.request.expired`<br>`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`<br>`issue.status.decision.applied`<br>`issue.status.decision.rejected`<br>`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`<br>`run.cancel` | 2 |
| `run` | Prepare or cancel a run. | `run.prepare`<br>`run.attach`<br>`run.cancel` | 3 |
| `session` | Open, snapshot, or close a normalized provider session. | `session.open`<br>`session.snapshot`<br>`session.close` | 3 |
| `turn` | Start, steer, interrupt, or stop a model turn. | `turn.start`<br>`turn.steer`<br>`turn.interrupt`<br>`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`<br>`runner.shutdown` | 2 |
| `runner` | Drain or shut down the runner process. | `runner.drain`<br>`runner.suspend`<br>`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`<br>`skill_test` | `admin` | `required` | no | `mock_extension:portability.export` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `finish_task` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>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`<br>`test_only` | `unbound`<br>test-only; excluded from product PRP evidence<br>catalog PRP status: `audit_pending` |
| `get_agent` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_approval` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_task_context` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_task_history` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_history` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `get_agent` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `agentService.getById (redacted projection)`<br>company equality + redacted actor projection<br>catalog PRP status: `bound` |
| `get_approval` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `approvalService.getById`<br>company equality check<br>catalog PRP status: `bound` |
| `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `approvalService.getById + issueApprovals`<br>company equality + linked-task projection<br>catalog PRP status: `bound` |
| `get_task_context` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority + issue/run assignment query`<br>shared PRP route + bound-context authorization test<br>catalog PRP status: `bound` |
| `get_task_history` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_history` | `scenario` + `live`<br>`live_codex` | `issueComments scoped by bound issue`<br>shared PRP route + bound-context authorization test<br>catalog PRP status: `bound` |
| `get_workspace_runtime` | `optional_agent_tool` | `workspace:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_workspace` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `inspect_operation_result` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `operation_result` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_agents` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_actors` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_approvals` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_approvals` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_agents` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_actors` | `scenario` + `live`<br>`live_codex` | `agentService.list (redacted projection)`<br>company-scoped actor projection<br>catalog PRP status: `bound` |
| `list_approvals` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_approvals` | `scenario` + `live`<br>`live_codex` | `approvalService.list`<br>company-scoped approval projection<br>catalog PRP status: `bound` |
| `list_cases` | `optional_agent_tool` | `cases:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:cases.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_company_skills` | `optional_agent_tool` | `company_skills:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:company_skills.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_document_revisions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_documents` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_document_revisions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `scenario` + `live`<br>`live_codex` | `documentService.listIssueDocumentRevisions`<br>bound issue/key revision read<br>catalog PRP status: `bound` |
| `list_documents` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `scenario` + `live`<br>`live_codex` | `documentService.listIssueDocuments`<br>bound issue document read<br>catalog PRP status: `bound` |
| `list_goals` | `optional_agent_tool` | `discovery:goals:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.goals` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `read_document` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `read_document` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`<br>`live_codex` | `documentService.getIssueDocumentByKey`<br>bound issue/key document read<br>catalog PRP status: `bound` |
| `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`<br>`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`<br>`scenario_mock` | `unbound`<br>redacted tool-result item event; secret value never reaches the wire<br>catalog PRP status: `audit_pending` |
| `register_deliverable` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:register_deliverable` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `report_progress` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `report_progress` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live`<br>`live_codex` | `issueService.addComment + heartbeatRuns semanticToolReceipts`<br>real service mutation + idempotent replay test<br>catalog PRP status: `bound` |
| `request_approval` | `optional_agent_tool` | `governance:approvals:request` | `standard`<br>`skill_test` | `governance` | `required` | no | `semantic_command:request_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
| `request_human_input` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `request_human_input` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live`<br>`live_codex` | `issueThreadInteractionService.create`<br>real structured interaction + continuation projection test<br>catalog PRP status: `bound` |
| `request_review` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_review` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `schedule_wake` | `optional_agent_tool` | `control_plane:wakes` | `standard`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `issueService.list`<br>shared PRP route + company scope test<br>catalog PRP status: `bound` |
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `audit_pending` |
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `upsert_case` | `optional_agent_tool` | `cases:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `mock_extension:cases.upsert` | `scenario`<br>`scenario_mock` | `unbound`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `audit_pending` |
| `write_document` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `write_document` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live`<br>`live_codex` | `documentService.upsertIssueDocument + heartbeatRuns semanticToolReceipts`<br>real revision mutation + optimistic concurrency + idempotent replay test<br>catalog PRP status: `bound` |
## Axis 3: the 16 behavioral eval groups
@@ -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<void> {
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<PrpEvent> {
for await (const event of this.#session.events()) {
if (event.eventType === "run.terminal") {
@@ -192,6 +204,10 @@ class HarnessNativeSession implements NativeSession {
};
}
async usage(): Promise<Record<string, unknown> | null> {
return this.#session.usage?.() ?? null;
}
close(input: { reason: string }) {
return this.#session.close(input);
}
@@ -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<void>;
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
dynamicToolHandler?: (call: {
tool: string;
callId: string;
threadId: string;
turnId: string;
arguments: unknown;
}) => Promise<unknown>;
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,
});
}
@@ -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<OpenCodeServerDriverOptions, "model" | "taskEnvelope">,
): 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.",
],
}),
}));
}
@@ -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", () => {
@@ -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",
"",
@@ -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;
@@ -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<string, { resolve(value: unknown): void; reject(error: Error): void }>();
let nextServerRequestId = 1;
let driver: OpenCodeServerDriver | null = null;
let session: HarnessSession | null = null;
let eventPump: Promise<void> | null = null;
let cwd = "";
let activeTurnId: string | null = null;
const announcedTurnIds = new Set<string>();
function send(value: unknown): void {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
function requestController(method: string, params: unknown): Promise<unknown> {
const id = `opencode-${nextServerRequestId++}`;
send({ id, method, params });
return new Promise((resolveValue, reject) => pending.set(id, { resolve: resolveValue, reject }));
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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<string, unknown>, resume: boolean): Promise<Record<string, unknown>> {
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<string, unknown> {
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<void> {
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<void> {
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<void> {
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(); });
@@ -328,6 +328,7 @@ export interface HarnessSession {
displayId?: string | null;
};
events(): AsyncIterable<PrpEvent>;
attachRun?(input: { runId: string }): Promise<void> | void;
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
interrupt?(input: { turnId?: string; reason?: string }): Promise<void>;
@@ -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<string, unknown>;
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<string, unknown>;
delete (legacy.session as Record<string, unknown>).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");
});
});
@@ -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"),
@@ -49,6 +49,7 @@ export interface NativeSessionRecoveryResult {
export interface NativeSession {
identity(): NativeRunIdentity;
capabilities(): Promise<NativeSessionCapabilities>;
attachRun?(input: { identity: NativeRunIdentity }): Promise<void>;
events(input?: { afterCursor?: string | null }): AsyncIterable<PrpEvent>;
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
@@ -59,6 +60,7 @@ export interface NativeSession {
terminal: PrpTerminalState;
turnId: string | null;
} | null>;
usage?(): Promise<Record<string, unknown> | null>;
snapshot(): Promise<PersistedNativeSession>;
close(input: { reason: string }): Promise<void>;
}
@@ -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<string, unknown>): 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<string, unknown>): void {
const event = envelope.payload as Record<string, unknown> | 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<string, unknown> | null = null;
let usage: Record<string, number> = {};
const projectedCalls = new Map<string, string>();
for (const record of records) {
const payload = record.payload as Record<string, unknown> | undefined;
if (record.eventType === "run.result.proposed" && payload && semanticResult === null) {
semanticResult = structuredClone(payload);
}
const semantic = payload?.semantic_tool as Record<string, unknown> | undefined;
if (semantic?.phase === "input" || semantic?.phase === "result") {
const wrapperInput = semantic.input as Record<string, unknown> | 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 },
@@ -20,6 +20,11 @@ export interface CodexAppServerTransport {
setServerRequestHandler(handler: CodexServerRequestHandler): void;
close(): Promise<void>;
processInfo?(): CodexTransportProcessInfo;
attachRun?(input: {
runId: string;
turnId: string;
itemId: string;
}): Promise<void>;
}
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 {
@@ -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"',
@@ -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<string, string> {
@@ -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<string, string>();
readonly #workspaceChangesByTurn = new Map<string, Record<string, unknown>>();
readonly #emittedFileReferences = new Set<string>();
readonly #itemChannels = new Map<string, "progress" | "final" | "summary" | "detail" | "unknown">();
readonly #pendingRuntimeRequests = new Map<string, PendingRuntimeRequest>();
readonly #lineage = new Map<string, HarnessThreadLineageEntry>();
#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<void> {
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<string, string> = {
@@ -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<string, unknown>): "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<string, unknown>[] => {
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<Record<string, unknown>> {
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"
@@ -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<string, unknown>,
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" }] },
});
});
});
@@ -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<string, unknown>;
}
export interface OpenCodeMcpCall {
tool: string;
callId: string;
arguments: unknown;
signal: AbortSignal;
}
export interface OpenCodeMcpBridgeOptions {
tools?: readonly Readonly<Record<string, unknown>>[];
handler: (call: OpenCodeMcpCall) => Promise<unknown>;
timeoutMs?: number;
maxBodyBytes?: number;
secret?: string;
}
export interface OpenCodeMcpBridge {
url: string;
secret: string;
close(): Promise<void>;
}
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<OpenCodeMcpBridge> {
const secret = options.secret ?? randomBytes(32).toString("base64url");
const tools = normalizeTools(options.tools ?? []);
const validators = compileValidators(tools);
const calls = new Map<string, { fingerprint: string; promise: Promise<unknown> }>();
const controllers = new Map<string, AbortController>();
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<void>((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<Record<string, unknown>>[]): OpenCodeMcpToolDefinition[] {
const tools: OpenCodeMcpToolDefinition[] = [];
const seen = new Set<string>();
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<string, ValidateFunction> {
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<string, ValidateFunction>;
calls: Map<string, { fingerprint: string; promise: Promise<unknown> }>;
controllers: Map<string, AbortController>;
handler: OpenCodeMcpBridgeOptions["handler"];
timeoutMs: number;
maxBodyBytes: number;
},
): Promise<void> {
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<string, unknown>;
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<string> {
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<string, unknown> {
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<void> {
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
function withCancellationAndTimeout<T>(
promise: Promise<T>,
controller: AbortController,
timeoutMs: number,
): Promise<T> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -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<Uint8Array>({
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" });
});
});
@@ -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<unknown>;
export interface OpenCodeServerDriverOptions {
model: string;
taskEnvelope?: CodexTaskEnvelope;
runnerInstanceId?: string;
command?: string;
runtimeDirectory: string;
environment?: NodeJS.ProcessEnv;
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
dynamicToolHandler?: DynamicToolHandler;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
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<void>;
}
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<HarnessDriverDescriptor> {
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<HarnessDriverConfigValidation> {
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<HarnessSession> {
return this.#open(input, null);
}
async recoverSession(snapshot: PersistedHarnessSession): Promise<HarnessSessionRecoveryResult> {
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<string> {
const raw = await readFile(join(sessionRoot(this.#options.runtimeDirectory, normalizedSessionId), "workspace"), "utf8");
return raw.trim();
}
async #open(input: OpenHarnessSessionInput, snapshot: PersistedHarnessSession | null): Promise<HarnessSession> {
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<PrpEvent>();
readonly #transcript: PrpEvent[] = [];
readonly #terminalTurns = new Map<string, string>();
readonly #seenProviderEvents = new Set<string>();
readonly #messageRoles = new Map<string, string>();
readonly #pendingMessageParts = new Map<string, Array<Record<string, unknown>>>();
readonly #partText = new Map<string, string>();
readonly #workspaceChangesByTurn = new Map<string, Record<string, unknown>>();
readonly #emittedFileReferences = new Set<string>();
#sourceSequence: number;
#activeTurnId: string | null;
#result: PrpStructuredRunResult | null;
#resultFingerprint: string | null;
#resultCallId: string | null;
#resultTurnId: string | null;
#usage: Record<string, unknown> | 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<PrpEvent> { 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<void> {
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<Record<string, unknown>> {
const messages = await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/message`);
return { sessionId: this.#providerSessionId, messages };
}
async reconcile(): Promise<Record<string, unknown>> {
const status = await api(this.#fetch, this.#runtime, "/session/status");
return { sessionId: this.#providerSessionId, status: record(status)[this.#providerSessionId] ?? null };
}
async usage(): Promise<Record<string, unknown> | null> { return this.#usage === null ? null : structuredClone(this.#usage); }
async transcript(): Promise<HarnessTranscriptSnapshot> {
return {
schema: "paperclip-runner/harness-transcript/v1",
complete: true,
eventCount: this.#transcript.length,
events: structuredClone(this.#transcript),
omissionReason: null,
};
}
async snapshot(): Promise<PersistedHarnessSession> {
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<void> {
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<unknown> {
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<void> {
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<string, unknown>, 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<string, unknown>[] => {
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<string, unknown>, 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<unknown>;
}): Promise<OpenCodeRuntime> {
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<void>((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<unknown> {
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<unknown> {
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<number> {
const server = createServer();
await new Promise<void>((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<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
return port;
}
async function* parseSse(stream: ReadableStream<Uint8Array>): AsyncIterable<unknown> {
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
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<string, string>): 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<string, unknown> {
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<string, unknown> { return isRecord(value) ? value : {}; }
function isRecord(value: unknown): value is Record<string, unknown> { 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<void> {
if (process.exitCode !== null || process.signalCode !== null) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, timeoutMs);
timer.unref?.();
process.once("exit", () => { clearTimeout(timer); resolve(); });
});
}
export const openCodeServerDriverInternals = { parseSse };
class AsyncQueue<T> implements AsyncIterable<T> {
#items: T[] = [];
#waiters: Array<{ resolve: (result: IteratorResult<T>) => 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<T> {
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 }));
},
};
}
}
@@ -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<Record<string, TokenRatesUsdPerMillion>> = 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 {
+5
View File
@@ -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";
@@ -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<CapabilityUiShotSlug, () => 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;
@@ -22,6 +22,8 @@ const REQUIRED_ITEM_KINDS: Array<CapabilityThreadItem["kind"]> = [
"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)
@@ -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,
@@ -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",
@@ -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<Record<CapabilityToolDispos
};
export interface CapabilityThreadIdentity {
/** `Real Codex` in live mode, `Fake agent` in fake mode, `Replay` in replay mode. */
/** Provider-aware live harness label, `Fake agent` in fake mode, or `Replay`. */
agentLabel: string;
/** `Real runnerd` in live mode, `In-process runner` otherwise. */
runnerLabel: string;
@@ -208,6 +211,18 @@ export type CapabilityThreadItem =
eventCount: number;
details: Record<string, CapabilityJsonValue>;
}
| {
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";
@@ -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);
});
});
@@ -57,6 +57,7 @@ interface FakeProviderState {
transports: FakeCapabilityCodexTransport[];
turns: Map<string, string>;
holdAfterTool: boolean;
onTurnStart?: () => Promise<void>;
}
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<string, unknown> : {};
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<Record<string, unknown>>)[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<string | undefined> = [];
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 () => {
@@ -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<string, CapabilityJsonValue> | 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<CapabilityLiveSession> {
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<CapabilityLiveSessionSnapshot> {
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<void> {
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<string, { providerRequests: number; inputTokens: number; outputTokens: number; cachedInputTokens: number; reasoningTokens: number }>();
#latestCumulativeUsage: Record<string, unknown> = {};
#idleTimer: ReturnType<typeof setTimeout> | null = null;
readonly #loadedOperationIds = new Set<string>();
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<CapabilityLiveTurnResult> {
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<string, unknown>;
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<void> {
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<void> {
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<void> {
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<void> {
this.#clearIdleTimer();
if (this.#activeTurnId !== null) {
try {
await this.interrupt(reason);
@@ -1378,10 +1577,12 @@ export class CapabilityLiveSession {
}
async #connect(resume: boolean): Promise<void> {
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",
@@ -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<never>((_, 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);
@@ -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<CapabilityRunnerdProcessEvidence>) => 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<Record<string, unknown>>[];
prpIdentity?: {
runnerInstanceId: string;
environmentLeaseId: string;
@@ -93,16 +115,20 @@ export function unwrapRunnerdProviderNotification(input: unknown): Record<string
class NotificationQueue implements AsyncIterable<CodexRpcNotification> {
#values: Array<{ value: CodexRpcNotification; bytes: number }> = [];
#waiters: Array<(value: IteratorResult<CodexRpcNotification>) => void> = [];
#waiters: Array<{
resolve: (value: IteratorResult<CodexRpcNotification>) => 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<CodexRpcNotification> {
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<CodexRpcNotification> {
@@ -128,8 +158,9 @@ class NotificationQueue implements AsyncIterable<CodexRpcNotification> {
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<string, unknown>>[]): Record<string, unknown> {
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<string, unknown>): 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<string, unknown> | null = null;
#closed = false;
#failure: Error | null = null;
#controlPlaneRelease: (() => Promise<void> | 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<string, unknown>): Promise<Record<string, unknown>> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
@@ -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();
});
});
@@ -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<string, WorkspaceFileSnapshot>;
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<PaperclipWorkspaceSnapshot> {
const result: PaperclipWorkspaceSnapshot = new Map();
let retainedBytes = 0;
let visitedDirectories = 0;
async function visit(directory: string): Promise<void> {
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<string, PaperclipWorkspaceFileChange>();
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,
};
}
@@ -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"]);
});
});
@@ -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<PaperclipWorkspaceFileReference, "mediaType" | "presentation"> {
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<string>();
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<PaperclipWorkspaceFileReference[]> {
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;
}
@@ -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",
@@ -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> | void;
}
function isTurnTerminal(event: PrpEvent): boolean {
@@ -62,7 +67,9 @@ async function consumeTurn(session: NativeSession, controlPlane: ControlPlanePor
export async function executeNativeSession(options: ExecuteNativeSessionOptions): Promise<NativeSessionExecutionResult> {
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" });
}
}
}
@@ -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,
@@ -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":trLine truncated
"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","run.attach","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.suspend","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 schema73 = {"$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":truLine truncated
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { validatePrpEvent, type PrpEvent } from "./replay-contract.js";
function workspaceEvent(eventType: "workspace.change.updated" | "workspace.diff.recorded"): PrpEvent {
return {
schema: "paperclip.prp.event.v1",
sourceEventId: `workspace:${eventType}`,
sourceSeq: eventType === "workspace.change.updated" ? 1 : 2,
sourceInstanceId: "runner-1",
sourceKind: "runner",
runId: "run-1",
turnId: "turn-1",
eventType,
schemaVersion: 1,
priority: 1,
emittedAt: "2026-08-21T12:00:00.000Z",
payload: {
schema: "paperclip.workspace.diff.v1",
changeSetId: "turn-1:workspace",
revision: 1,
source: "runner_verified",
complete: eventType === "workspace.diff.recorded",
files: [{
path: "src/index.ts",
operation: "modify",
previousPath: null,
additions: 1,
deletions: 1,
binary: false,
diff: "--- a/src/index.ts\n+++ b/src/index.ts\n-old\n+new\n",
}],
totals: { files: 1, additions: 1, deletions: 1 },
patchArtifactRef: null,
},
};
}
describe("PRP workspace change events", () => {
it("accepts provisional and complete canonical workspace changes", () => {
expect(validatePrpEvent(workspaceEvent("workspace.change.updated")).ok).toBe(true);
expect(validatePrpEvent(workspaceEvent("workspace.diff.recorded")).ok).toBe(true);
});
it("rejects traversal paths and incomplete recorded diffs", () => {
const traversal = workspaceEvent("workspace.diff.recorded");
traversal.payload.files[0]!.path = "../secret";
expect(validatePrpEvent(traversal).ok).toBe(false);
const incomplete = workspaceEvent("workspace.diff.recorded");
incomplete.payload.complete = false;
expect(validatePrpEvent(incomplete).ok).toBe(false);
});
it("validates a canonical individual workspace-file reference", () => {
const event: PrpEvent = {
schema: "paperclip.prp.event.v1",
sourceEventId: "workspace:file:1",
sourceSeq: 1,
sourceInstanceId: "runner-1",
sourceKind: "runner",
runId: "run-1",
turnId: "turn-1",
eventType: "workspace.file.referenced",
schemaVersion: 1,
priority: 1,
emittedAt: "2026-08-21T12:00:00.000Z",
payload: {
schema: "paperclip.workspace.file_reference.v1",
referenceId: "turn-1:file:guide",
source: "runner_verified",
path: "docs/guide.md",
displayName: "guide.md",
mediaType: "text/markdown",
presentation: "document",
line: null,
preview: "# Guide\n",
previewTruncated: false,
contentDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
};
expect(validatePrpEvent(event).ok).toBe(true);
event.payload.path = "/private/guide.md";
expect(validatePrpEvent(event).ok).toBe(false);
});
});
@@ -101,6 +101,14 @@ function handleRequest(message) {
if (method === "turn/start") {
const turnId = `turn-${++state.nextTurn}`;
state.turns[turnId] = "inProgress";
if (!state.heldOnce) {
// Persist which provider turn is intentionally held before exposing its
// tool call. If runnerd is killed immediately after delivering the tool
// result, a replacement provider must not accidentally hold the next
// turn as though the first response had never reached this process.
state.heldOnce = true;
state.heldTurnId = turnId;
}
save();
send({ id, result: { turn: { id: turnId, status: "inProgress" } } });
send({
@@ -147,8 +155,8 @@ function handle(message) {
const turnId = pendingTools.get(String(message.id));
if (!turnId) return;
pendingTools.delete(String(message.id));
if (!state.heldOnce) {
state.heldOnce = true;
if (state.heldTurnId === turnId) {
state.heldTurnId = null;
save();
return;
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import { createServer } from "node:http";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const args = process.argv.slice(2);
if (process.env.OPENROUTER_API_KEY) process.stderr.write(`fixture credential=${process.env.OPENROUTER_API_KEY}\n`);
const port = Number(args[args.indexOf("--port") + 1]);
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
const password = process.env.OPENCODE_SERVER_PASSWORD ?? "";
const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
const session = { id: "ses_fake_1", title: "Paperclip fake" };
const clients = new Set();
let eventConnections = 0;
const runtimeConfig = JSON.parse(await readFile(join(process.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"));
const mcp = runtimeConfig.mcp?.paperclip;
let mcpRequestId = 1;
const mcpEvidence = { tools: [], calls: [] };
await mkdir(process.env.XDG_DATA_HOME, { recursive: true });
await writeFile(join(process.env.XDG_DATA_HOME, "fake-environment.json"), JSON.stringify({
keys: Object.keys(process.env).sort(),
home: process.env.HOME,
configHome: process.env.XDG_CONFIG_HOME,
projectConfigDisabled: process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
}));
function json(response, status, value) {
response.statusCode = status;
response.setHeader("Content-Type", "application/json");
response.end(JSON.stringify(value));
}
function emit(value) {
const frame = `data: ${JSON.stringify(value)}\n\n`;
for (const response of clients) response.write(frame);
}
async function mcpRequest(method, params) {
const response = await fetch(mcp.url, {
method: "POST",
headers: { ...mcp.headers, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
body: JSON.stringify({ jsonrpc: "2.0", id: mcpRequestId++, method, ...(params ? { params } : {}) }),
});
return response.json();
}
async function callFirstPaperclipTool() {
if (!mcp?.url) return;
await mcpRequest("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "fake-opencode", version: "1" } });
const listed = await mcpRequest("tools/list");
mcpEvidence.tools = (listed.result?.tools ?? []).map((entry) => entry.name);
const tool = listed.result?.tools?.find((entry) =>
!["paperclip_finish", "paperclip_block"].includes(entry.name)
&& (entry.inputSchema?.required?.length ?? 0) === 0
);
if (tool) mcpEvidence.calls.push(await mcpRequest("tools/call", { name: `paperclip_${tool.name}`, arguments: {} }));
await writeFile(join(process.env.XDG_DATA_HOME, "fake-mcp-evidence.json"), JSON.stringify(mcpEvidence));
}
async function callTerminalTool(promptBody) {
const prompt = JSON.parse(promptBody.parts?.[0]?.text ?? "{}");
const blocked = String(prompt.message ?? "").includes("block-result");
const result = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: blocked ? "blocked" : "done",
summary: blocked ? "Fake provider is blocked." : "Fake provider completed the task.",
completionClaim: {
contractRevision: prompt.task?.completionContract?.revision ?? "codex-demo-v1",
objectiveSatisfied: !blocked,
criteria: (prompt.task?.completionContract?.criteria ?? []).map((criterion) => ({
criterionId: criterion.id,
status: blocked ? "unknown" : "satisfied",
evidenceRefs: [],
})),
remainingWork: blocked ? [{ description: "Waiting on a test dependency.", blocksCompletion: true }] : [],
},
evidence: [],
verification: [],
attentionRequests: blocked ? [{ kind: "blocker", summary: "Waiting on a test dependency." }] : [],
artifacts: [],
...(blocked ? { blocker: {
reasonCode: "test_dependency",
owner: { kind: "external", name: "fixture" },
unblockAction: "Release the fixture dependency.",
scope: "current_track",
} } : {}),
};
return mcpRequest("tools/call", {
name: blocked ? "paperclip_block" : "paperclip_finish",
arguments: result,
});
}
const server = createServer(async (request, response) => {
if (request.headers.authorization !== expectedAuth) return json(response, 401, { error: "unauthorized" });
if (request.url === "/global/health") return json(response, 200, { healthy: true, version: "1.18.17" });
if (request.url === "/event") {
eventConnections += 1;
response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
response.write(`data: ${JSON.stringify({ type: "server.connected", properties: {} })}\n\n`);
if (eventConnections === 1) {
response.end();
return;
}
clients.add(response);
request.on("close", () => clients.delete(response));
return;
}
if (request.method === "POST" && request.url === "/session") return json(response, 200, session);
if (request.method === "GET" && request.url === `/session/${session.id}`) return json(response, 200, session);
if (request.method === "GET" && request.url === `/session/${session.id}/message`) return json(response, 200, []);
if (request.method === "GET" && request.url === "/session/status") return json(response, 200, { [session.id]: { type: "idle" } });
if (request.method === "POST" && request.url === `/session/${session.id}/abort`) return json(response, 200, true);
if (request.method === "POST" && request.url === `/session/${session.id}/prompt_async`) {
const chunks = [];
request.on("data", (chunk) => chunks.push(chunk));
request.on("end", () => {
const promptPayload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (promptPayload.providerID !== "openrouter" || promptPayload.modelID !== "deepseek/deepseek-v4-flash-0731" || "model" in promptPayload) {
return json(response, 400, { error: "OpenCode 1.18 prompt model fields must be top-level" });
}
json(response, 204, null);
setTimeout(async () => {
await callFirstPaperclipTool();
await callTerminalTool(promptPayload);
emit({ type: "message.updated", id: "event-user", properties: { sessionID: session.id, info: { id: "message-user", sessionID: session.id, role: "user" } } });
emit({ type: "message.part.updated", id: "event-user-part", properties: { sessionID: session.id, part: { id: "part-user", messageID: "message-user", type: "text", text: "submitted prompt must not become assistant text" } } });
emit({ type: "message.updated", id: "event-assistant", properties: { sessionID: session.id, info: { id: "message-assistant", sessionID: session.id, role: "assistant" } } });
emit({ type: "message.part.updated", id: "event-patch", properties: { sessionID: session.id, part: { id: "part-patch", messageID: "message-assistant", type: "patch", files: ["src/index.ts"] } } });
emit({ type: "message.part.updated", id: "event-1", properties: { sessionID: session.id, part: { id: "part-1", messageID: "message-assistant", type: "text", text: "done [guide](guide.md)" } } });
emit({ type: "message.part.updated", id: "event-1", properties: { sessionID: session.id, part: { id: "part-1", messageID: "message-assistant", type: "text", text: "done" } } });
emit({ type: "message.updated", id: "event-2", properties: { info: { sessionID: session.id, tokens: { input: 3, output: 2 }, cost: 0.001 } } });
emit({ type: "session.idle", id: "event-3", properties: { sessionID: session.id } });
}, 100);
});
return;
}
json(response, 404, { error: "not found" });
});
server.listen(port, "127.0.0.1");
for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => server.close(() => process.exit(0)));
@@ -280,6 +280,7 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => {
},
workspace: { cwd: repoRoot, repoUrl: null, repoRef: null, branchName: null },
session: { normalizedSessionId: sessionId, driverKind: "codex_app_server", protocolVersion: 1 },
provider: { kind: "codex", model: null },
completionContract: {
id: contractId,
sha256: contractSha,
+13
View File
@@ -8,6 +8,19 @@ import { requireServerAdapter } from "./registry.js";
// obey the same fail-closed contract as an external adapter.
describe("built-in adapter login capabilities", () => {
it("publishes the qualified OpenCode runner configuration", async () => {
const adapter = requireServerAdapter("paperclip_runner");
expect(adapter.models).toContainEqual({
id: "openrouter/deepseek/deepseek-v4-flash-0731",
label: "OpenRouter · DeepSeek V4 Flash 0731",
});
expect(adapter.getRuntimeCommandSpec?.({ provider: "opencode" }))
.toMatchObject({ command: "opencode", installCommand: expect.stringContaining("opencode-ai@1.18.17") });
const schema = await adapter.getConfigSchema?.();
expect(schema?.fields.map((field) => field.key))
.toEqual(expect.arrayContaining(["provider", "model", "command"]));
});
it("registers the Codex device-login capability", () => {
const capability = requireServerAdapter("codex_local").loginCapability;
expect(capability).toBeDefined();
+107 -7
View File
@@ -3,6 +3,8 @@ import type {
AdapterRuntimeCommandSpec,
ServerAdapterModule,
} from "./types.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
import {
@@ -133,6 +135,68 @@ import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
const execFileAsync = promisify(execFile);
async function testPaperclipRunnerOpenCodeEnvironment(
context: Parameters<typeof openCodeTestEnvironment>[0],
) {
const result = await openCodeTestEnvironment(context);
if (context.executionTarget?.kind === "remote") {
return {
...result,
status: result.status === "pass" ? "warn" as const : result.status,
checks: [...result.checks, {
code: "opencode_version_unverified_remote",
level: "warn" as const,
message: "OpenCode version could not be qualified by the local runner probe on a remote target.",
}],
};
}
const command = readConfiguredCommand(context.config, "opencode");
try {
const { stdout, stderr } = await execFileAsync(command, ["--version"], {
timeout: 5_000,
env: process.env,
});
const output = `${stdout}\n${stderr}`;
const match = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
if (!match) throw new Error("OpenCode returned an unrecognized version string");
const version = `${match[1]}.${match[2]}.${match[3]}`;
const comparison = compareSemver(version, "1.18.17");
const check = comparison < 0
? { code: "opencode_version_too_old", level: "error" as const, message: `OpenCode ${version} is older than required 1.18.17.` }
: comparison > 0
? { code: "opencode_version_unqualified", level: "warn" as const, message: `OpenCode ${version} is newer than qualified version 1.18.17.` }
: { code: "opencode_version_qualified", level: "info" as const, message: "OpenCode 1.18.17 is installed and qualified." };
return {
...result,
status: check.level === "error" ? "fail" as const
: check.level === "warn" && result.status === "pass" ? "warn" as const
: result.status,
checks: [...result.checks, check],
};
} catch (error) {
return {
...result,
status: "fail" as const,
checks: [...result.checks, {
code: "opencode_version_probe_failed",
level: "error" as const,
message: error instanceof Error ? error.message : "OpenCode version probe failed.",
}],
};
}
}
function compareSemver(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 readConfiguredCommand(config: Record<string, unknown>, fallback: string): string {
const value = typeof config.command === "string" ? config.command.trim() : "";
return value.length > 0 ? value : fallback;
@@ -345,13 +409,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
};
},
async testEnvironment(context) {
const result = await codexTestEnvironment(context);
const provider = context.config.provider === "opencode" ? "opencode" : "codex";
const result = provider === "opencode"
? await testPaperclipRunnerOpenCodeEnvironment(context)
: await codexTestEnvironment(context);
return { ...result, adapterType: "paperclip_runner" };
},
listSkills: listCodexSkills,
syncSkills: syncCodexSkills,
sessionCodec: codexSessionCodec,
models: codexModels,
models: [
...codexModels,
{ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "OpenRouter · DeepSeek V4 Flash 0731" },
],
modelProfiles: codexModelProfiles,
listModels: listCodexModels,
refreshModels: refreshCodexModels,
@@ -359,17 +429,47 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) =>
buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex through the Rust Paperclip runner and authenticated PRP control-plane connection.\n`,
getRuntimeCommandSpec: (config) => config.provider === "opencode"
? buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai@1.18.17")
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex or OpenCode through the native Paperclip runner and authenticated PRP control-plane connection. OpenCode requires a provider/model identifier and runtime OPENROUTER_API_KEY.\n`,
getConfigSchema: () => ({
fields: [{
key: "provider",
label: "Provider",
type: "select",
default: "codex",
options: [{ value: "codex", label: "Codex" }],
hint: "Paperclip Runner currently supports the Codex app-server provider.",
options: [{ value: "codex", label: "Codex" }, { value: "opencode", label: "OpenCode 1.18.17" }],
hint: "Select the native provider. OpenCode runs through its authenticated loopback server.",
}, {
key: "lifecycleMode",
label: "Runner lifecycle",
type: "select",
default: "per_turn",
options: [
{ value: "per_turn", label: "Turn by turn" },
{ value: "warm", label: "Warm session" },
],
hint: "Warm sessions retain runnerd and the provider between separately governed runs.",
}, {
key: "idleTimeoutMs",
label: "Warm idle timeout (ms)",
type: "number",
default: 300000,
hint: "Warm sessions suspend resumably after this much inactivity.",
}, {
key: "model",
label: "OpenCode model",
type: "text",
default: "openrouter/deepseek/deepseek-v4-flash-0731",
placeholder: "openrouter/deepseek/deepseek-v4-flash-0731",
hint: "Required when provider is OpenCode; use provider/model form.",
}, {
key: "command",
label: "OpenCode command",
type: "text",
default: "opencode",
hint: "OpenCode executable. Version 1.18.17 is qualified.",
}],
}),
loginCapability: codexLoginCapability,
+14 -1
View File
@@ -15941,6 +15941,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
branchName: executionWorkspace.branchName,
},
normalizedSessionId: nativeSessionId,
provider: nativeRuntimeResolution.profile.backend === "opencode_server" ? "opencode" : "codex",
model: typeof parseObject(agent.adapterConfig).model === "string"
? String(parseObject(agent.adapterConfig).model)
: null,
lifecyclePolicy: parseObject(agent.adapterConfig).lifecycleMode === "warm"
? {
mode: "warm",
idleTimeoutMs: Number.isSafeInteger(parseObject(agent.adapterConfig).idleTimeoutMs)
&& Number(parseObject(agent.adapterConfig).idleTimeoutMs) > 0
? Number(parseObject(agent.adapterConfig).idleTimeoutMs)
: 300_000,
}
: { mode: "per_turn", idleTimeoutMs: null },
interactionResponses,
completionContract: {
id: completionContract.row.id,
@@ -15970,7 +15983,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
},
runnerInstanceId: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
nativeSessionId: lockedRun.nativeSessionId ?? nativeSessionId,
driverKind: lockedRun.driverKind ?? "codex_app_server",
driverKind: lockedRun.driverKind ?? nativeExecution?.session.driverKind ?? "codex_app_server",
driverVersion: lockedRun.driverVersion ?? "phase6-v1",
completionContractId: lockedRun.completionContractId ?? completionContract.row.id,
completionContractSha256: lockedRun.completionContractSha256 ?? completionContract.row.canonicalSha256,
@@ -26,6 +26,9 @@ export function buildNativeExecutionInput(input: {
branchName: string | null;
};
normalizedSessionId: string | null;
provider?: "codex" | "opencode";
model?: string | null;
lifecyclePolicy?: NativeExecutionInputV1["session"]["lifecyclePolicy"];
interactionResponses?: NativeInteractionResponseEnvelope[];
completionContract: {
id: string;
@@ -61,8 +64,13 @@ export function buildNativeExecutionInput(input: {
},
session: {
normalizedSessionId: input.normalizedSessionId,
driverKind: "codex_app_server",
driverKind: input.provider === "opencode" ? "opencode_server" : "codex_app_server",
protocolVersion: 1,
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
},
provider: {
kind: input.provider ?? "codex",
model: input.model ?? null,
},
completionContract: input.completionContract,
interactionResponses: input.interactionResponses ?? [],
@@ -23,7 +23,7 @@ const state = vi.hoisted(() => ({
}));
vi.mock("@paperclipai/paperclip-runner", () => ({
createCodexNativeSessionBackend: state.createBackend,
createNativeSessionBackend: state.createBackend,
executeNativeSession: state.execute,
}));
@@ -31,9 +31,11 @@ import {
cancelNativeSession,
executePaperclipNativeSession,
nativeSessionFailureDisposition,
nativeSessionFailureSourceCode,
} from "./native-session-executor.js";
const execution = {
provider: { kind: "codex", model: null },
binding: {
companyId: "company",
runId: "run-native-cancel",
@@ -43,11 +45,11 @@ const execution = {
completionContract: { id: "contract", sha256: "sha" },
} as NativeExecutionInputV1;
function leaseDb(): Db {
function leaseDb(boundExecution: NativeExecutionInputV1 = execution): Db {
const coordinator = {
runId: execution.binding.runId,
companyId: execution.binding.companyId,
issueId: execution.binding.issueId,
runId: boundExecution.binding.runId,
companyId: boundExecution.binding.companyId,
issueId: boundExecution.binding.issueId,
phase: "observed",
attempt: 0,
leaseOwner: null,
@@ -135,7 +137,73 @@ describe("native session cancellation", () => {
});
});
describe("native warm session supervision", () => {
it("reuses one session across distinct governed runs and closes it after idle expiry", async () => {
const close = vi.fn(async () => undefined);
const sharedSession = { close };
const base = {
...execution,
binding: {
...execution.binding,
executionWorkspaceId: "workspace",
},
workspace: { cwd: "/tmp/warm-native", repoUrl: null, repoRef: null, branchName: null },
session: {
normalizedSessionId: "session-warm-native",
driverKind: "codex_app_server" as const,
protocolVersion: 1 as const,
lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 20 },
},
} as NativeExecutionInputV1;
const second = {
...base,
binding: { ...base.binding, runId: "run-native-warm-second" },
};
const result = {
result: { summary: "completed" },
terminal: { runTerminalState: "succeeded" },
turnId: "turn",
normalizedSessionId: "session-warm-native",
providerSessionId: "provider-warm-native",
driverKind: "test",
driverVersion: "1",
nativeEventCount: 1,
highestContiguousSourceSeq: 1,
usage: null,
};
state.execute.mockReset()
.mockImplementationOnce(async (options) => {
expect(options.existingSession).toBeUndefined();
options.onSession?.(sharedSession);
return result;
})
.mockImplementationOnce(async (options) => {
expect(options.existingSession).toBe(sharedSession);
return result;
});
await executePaperclipNativeSession({ db: leaseDb(base), execution: base, runnerInstanceId: "runner" });
await executePaperclipNativeSession({ db: leaseDb(second), execution: second, runnerInstanceId: "runner" });
expect(close).not.toHaveBeenCalled();
await vi.waitFor(() => expect(close).toHaveBeenCalledWith({
reason: "warm native session idle timeout",
}), { timeout: 500 });
});
});
describe("native session bounded recovery", () => {
it("preserves stable provider and runner failure causes", () => {
expect(nativeSessionFailureSourceCode(new Error(
"provider_frame_too_large: harness stdout frame exceeded 4194304 bytes",
))).toBe("provider_frame_too_large");
expect(nativeSessionFailureSourceCode(new Error(
"native_runner_process_exited: runnerd exited unexpectedly with code 1",
))).toBe("native_runner_process_exited");
expect(nativeSessionFailureSourceCode(new Error(
"provider_transport_failed: invalid JSON-RPC",
))).toBe("provider_transport_failed");
});
it("retries the same run twice and stops at the third failed attempt", () => {
const now = new Date("2026-08-09T00:00:00.000Z");
expect(nativeSessionFailureDisposition(1, now)).toEqual({
@@ -191,10 +259,10 @@ describe("native process ownership", () => {
onSpawn,
});
expect(state.createBackend).toHaveBeenCalledWith(execution, {
expect(state.createBackend).toHaveBeenCalledWith(execution, expect.objectContaining({
runnerInstanceId: "runner",
onSpawn,
});
}));
expect(onSpawn).toHaveBeenCalledWith(processMetadata);
});
});
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import type { AdapterExecutionResult } from "../../adapters/index.js";
@@ -8,9 +8,10 @@ import type {
NativeExecutionInputV1,
NativeSession,
NativeSessionBackend,
PersistedNativeSession,
} from "../../vendor/paperclip-runner/index.js";
import {
createCodexNativeSessionBackend,
createNativeSessionBackend,
createRunnerdCodexTransport,
executeNativeSession,
} from "../../vendor/paperclip-runner/index.js";
@@ -23,6 +24,7 @@ import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { persistActivity, publishActivity } from "../activity-log.js";
import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
import {
NATIVE_STATUS_ARBITER_POLICY_VERSION,
type NativeAuthoritativeIssueStatus,
@@ -36,6 +38,134 @@ type ActiveNativeSession = {
const activeNativeSessions = new Map<string, ActiveNativeSession>();
type WarmNativeSession = {
session: NativeSession;
configDigest: string;
busy: boolean;
idleTimer: ReturnType<typeof setTimeout> | null;
lastActivityAt: string;
};
const warmNativeSessions = new Map<string, WarmNativeSession>();
class SessionToolAuthorityRouter {
#authority: PaperclipRunnerToolAuthority;
constructor(authority: PaperclipRunnerToolAuthority) {
this.#authority = authority;
}
bind(authority: PaperclipRunnerToolAuthority): void {
this.#authority = authority;
}
definitions() {
return this.#authority.definitions();
}
execute(call: Parameters<PaperclipRunnerToolAuthority["execute"]>[0]) {
return this.#authority.execute(call);
}
}
const sessionToolRouters = new Map<string, SessionToolAuthorityRouter>();
function nativeSessionKey(execution: NativeExecutionInputV1): string {
return execution.session.normalizedSessionId ?? `session-${execution.binding.runId}`;
}
function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
return `sha256:${createHash("sha256").update(JSON.stringify({
companyId: execution.binding.companyId,
issueId: execution.binding.issueId,
agentId: execution.binding.agentId,
workspaceId: execution.binding.executionWorkspaceId,
cwd: execution.workspace.cwd,
provider: execution.provider,
driverKind: execution.session.driverKind,
lifecyclePolicy: execution.session.lifecyclePolicy,
})).digest("hex")}`;
}
function nativeSessionCheckpointPath(sessionId: string): string {
const directory = resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "sessions");
mkdirSync(directory, { recursive: true, mode: 0o700 });
chmodSync(directory, 0o700);
return resolve(directory, `${createHash("sha256").update(sessionId).digest("hex")}.json`);
}
function persistWarmNativeCheckpoint(
sessionId: string,
configDigest: string,
snapshot: PersistedNativeSession,
): void {
const path = nativeSessionCheckpointPath(sessionId);
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
writeFileSync(temporary, JSON.stringify({
schema: "paperclip.native-session-supervisor.v1",
configDigest,
updatedAt: new Date().toISOString(),
snapshot,
}), { encoding: "utf8", mode: 0o600 });
renameSync(temporary, path);
chmodSync(path, 0o600);
}
function loadWarmNativeCheckpoint(
execution: NativeExecutionInputV1,
configDigest: string,
): PersistedNativeSession | null {
const path = nativeSessionCheckpointPath(nativeSessionKey(execution));
if (!existsSync(path)) return null;
const envelope = JSON.parse(readFileSync(path, "utf8")) as {
schema?: string;
configDigest?: string;
snapshot?: PersistedNativeSession;
};
if (envelope.schema !== "paperclip.native-session-supervisor.v1" || envelope.configDigest !== configDigest || !envelope.snapshot) {
throw new Error("native_session_supervisor_checkpoint_mismatch");
}
return {
...envelope.snapshot,
identity: {
runId: execution.binding.runId,
sessionId: nativeSessionKey(execution),
companyId: execution.binding.companyId,
issueId: execution.binding.issueId,
agentId: execution.binding.agentId,
},
semanticResult: null,
terminal: null,
activeTurnId: null,
terminalTurns: [],
pendingRuntimeRequests: [],
};
}
async function releaseWarmNativeSession(
sessionId: string,
idleTimeoutMs: number,
failed: boolean,
): Promise<void> {
const entry = warmNativeSessions.get(sessionId);
if (!entry) return;
entry.busy = false;
entry.lastActivityAt = new Date().toISOString();
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
if (failed) {
warmNativeSessions.delete(sessionId);
await entry.session.close({ reason: "warm native session failed" }).catch(() => undefined);
return;
}
entry.idleTimer = setTimeout(() => {
const current = warmNativeSessions.get(sessionId);
if (!current || current.busy) return;
warmNativeSessions.delete(sessionId);
void current.session.close({ reason: "warm native session idle timeout" });
}, idleTimeoutMs);
entry.idleTimer.unref();
}
export function nativeSessionFailureDisposition(attempt: number, now = new Date()) {
const exhausted = attempt >= 3;
return {
@@ -45,6 +175,24 @@ export function nativeSessionFailureDisposition(attempt: number, now = new Date(
};
}
export function nativeSessionFailureSourceCode(error: unknown):
| "provider_frame_too_large"
| "provider_transport_failed"
| "native_runner_process_exited"
| "native_session_interrupted" {
const message = error instanceof Error ? error.message : String(error);
if (/provider_frame_too_large|stdout frame exceeded/i.test(message)) {
return "provider_frame_too_large";
}
if (/provider_transport_failed|invalid JSON-RPC|provider failed/i.test(message)) {
return "provider_transport_failed";
}
if (/native_runner_process_exited|runnerd exited|runner process failed/i.test(message)) {
return "native_runner_process_exited";
}
return "native_session_interrupted";
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
@@ -301,6 +449,25 @@ export async function executePaperclipNativeSession(input: {
: undefined,
});
let native: Awaited<ReturnType<typeof executeNativeSession>>;
const lifecyclePolicy = input.execution.session?.lifecyclePolicy
?? { mode: "per_turn" as const, idleTimeoutMs: null };
const warmSessionId = lifecyclePolicy.mode === "warm" ? nativeSessionKey(input.execution) : null;
const warmConfigDigest = lifecyclePolicy.mode === "warm" ? nativeSessionConfigDigest(input.execution) : null;
let existingWarmSession: NativeSession | undefined;
let persistedWarmSession: PersistedNativeSession | null | undefined;
if (warmSessionId !== null && warmConfigDigest !== null) {
const entry = warmNativeSessions.get(warmSessionId);
if (entry) {
if (entry.configDigest !== warmConfigDigest) throw new Error("native_session_supervisor_config_mismatch");
if (entry.busy) throw new Error("native_session_supervisor_busy");
entry.busy = true;
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
entry.idleTimer = null;
existingWarmSession = entry.session;
} else {
persistedWarmSession = loadWarmNativeCheckpoint(input.execution, warmConfigDigest);
}
}
try {
const runnerdBackend = input.useRunnerd && input.backend === undefined
? createRunnerdBackend(input)
@@ -309,14 +476,33 @@ export async function executePaperclipNativeSession(input: {
input: input.execution,
backend: input.backend
?? runnerdBackend
?? createCodexNativeSessionBackend(input.execution, {
?? createNativeSessionBackend(input.execution, {
runnerInstanceId: input.runnerInstanceId,
onSpawn: input.onSpawn,
environment: process.env,
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
}),
controlPlane,
runnerInstanceId: input.runnerInstanceId,
controlPlaneInstanceId,
existingSession: existingWarmSession,
persistedSession: persistedWarmSession,
keepSessionOpen: warmSessionId !== null,
onCheckpoint: warmSessionId !== null && warmConfigDigest !== null
? async (snapshot) => persistWarmNativeCheckpoint(warmSessionId, warmConfigDigest, snapshot)
: undefined,
onSession: (session) => {
if (session && warmSessionId !== null && warmConfigDigest !== null) {
const existing = warmNativeSessions.get(warmSessionId);
if (existing) existing.session = session;
else warmNativeSessions.set(warmSessionId, {
session,
configDigest: warmConfigDigest,
busy: true,
idleTimer: null,
lastActivityAt: new Date().toISOString(),
});
}
if (session) activeNativeSessions.set(input.execution.binding.runId, {
session,
cancelRequested: false,
@@ -324,11 +510,17 @@ export async function executePaperclipNativeSession(input: {
else activeNativeSessions.delete(input.execution.binding.runId);
},
});
activeNativeSessions.delete(input.execution.binding.runId);
} catch (error) {
activeNativeSessions.delete(input.execution.binding.runId);
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
await releaseWarmNativeSession(warmSessionId, lifecyclePolicy.idleTimeoutMs, true);
}
const now = new Date();
const { phase, failureCode, nextAttemptAt } = nativeSessionFailureDisposition(attempt, now);
const exhausted = phase === "terminal_failure";
const message = error instanceof Error ? error.message.slice(0, 2_000) : String(error).slice(0, 2_000);
const sourceFailureCode = nativeSessionFailureSourceCode(error);
await input.db.transaction(async (tx) => {
const updated = await tx.update(nativeRunFinalizations).set({
phase,
@@ -337,7 +529,7 @@ export async function executePaperclipNativeSession(input: {
failureCode,
failureDetail: {
message,
originalFailureCode: "native_session_interrupted",
originalFailureCode: sourceFailureCode,
recoveryOwner: { kind: "agent", agentId: input.execution.binding.agentId },
nextAction: exhausted
? "Inspect the persisted native session after its bounded resume budget was exhausted."
@@ -354,7 +546,7 @@ export async function executePaperclipNativeSession(input: {
nativePhase: phase,
nativePhaseUpdatedAt: now,
error: message,
errorCode: failureCode,
errorCode: sourceFailureCode,
updatedAt: now,
}).where(eq(heartbeatRuns.id, input.execution.binding.runId));
await issueRecoveryActionService(tx as unknown as Db).upsertSourceScoped({
@@ -363,11 +555,16 @@ export async function executePaperclipNativeSession(input: {
kind: "active_run_watchdog",
ownerType: "agent",
ownerAgentId: input.execution.binding.agentId,
cause: failureCode,
cause: sourceFailureCode,
fingerprint: createHash("sha256")
.update(`${input.execution.binding.runId}:${failureCode}`)
.update(`${input.execution.binding.runId}:${sourceFailureCode}`)
.digest("hex"),
evidence: { runId: input.execution.binding.runId, coordinatorAttempt: attempt },
evidence: {
runId: input.execution.binding.runId,
coordinatorAttempt: attempt,
sourceFailureCode,
recoveryDisposition: failureCode,
},
nextAction: exhausted
? "Inspect or explicitly restart the exhausted persisted native session."
: "Resume the persisted native session on the same heartbeat run.",
@@ -405,6 +602,11 @@ export async function executePaperclipNativeSession(input: {
highestContiguousSourceSeq: native.highestContiguousSourceSeq,
workspaceFinalizeStatus: "pending",
};
// A following run cannot attach until the prior run's durable finalization
// is committed. Provider completion alone is not an authority boundary.
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
await releaseWarmNativeSession(warmSessionId, lifecyclePolicy.idleTimeoutMs, false);
}
return {
exitCode: native.terminal.runTerminalState === "succeeded" ? 0 : 1,
signal: null,
@@ -419,12 +621,41 @@ export async function executePaperclipNativeSession(input: {
summary: native.result.summary,
sessionId: native.normalizedSessionId,
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
provider: "openai",
provider: input.execution.provider.kind === "opencode"
? input.execution.provider.model?.split("/", 1)[0] ?? "opencode"
: "openai",
model: input.execution.provider.model,
usage: normalizeNativeUsage(native.usage),
costUsd: numericUsageField(native.usage, ["costUsd", "cost"]),
usageBasis: "per_run",
nativeFinalization: finalization,
};
}
function numericUsageField(
usage: Record<string, unknown> | null,
keys: string[],
): number | undefined {
if (!usage) return undefined;
for (const key of keys) {
const value = usage[key];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
}
return undefined;
}
function normalizeNativeUsage(usage: Record<string, unknown> | null) {
if (!usage) return undefined;
const cache = record(usage.cache);
const cachedInputTokens = numericUsageField(usage, ["cachedInputTokens", "cacheReadInputTokens"])
?? numericUsageField(cache, ["read"]);
return {
inputTokens: numericUsageField(usage, ["inputTokens", "input", "promptTokens"]) ?? 0,
outputTokens: numericUsageField(usage, ["outputTokens", "output", "completionTokens"]) ?? 0,
...(cachedInputTokens === undefined ? {} : { cachedInputTokens }),
};
}
function createRunnerdBackend(input: {
db: Db;
execution: NativeExecutionInputV1;
@@ -437,19 +668,28 @@ function createRunnerdBackend(input: {
runId: input.execution.binding.runId,
agentId: input.execution.binding.agentId,
});
const sessionId = nativeSessionKey(input.execution);
const existingRouter = sessionToolRouters.get(sessionId);
const authorityRouter = existingRouter ?? new SessionToolAuthorityRouter(authority);
authorityRouter.bind(authority);
sessionToolRouters.set(sessionId, authorityRouter);
const root = resolve(
process.env.PAPERCLIP_RUNNER_STATE_DIR ?? resolve(tmpdir(), "paperclip-runner"),
input.execution.binding.runId,
createHash("sha256").update(sessionId).digest("hex"),
);
mkdirSync(root, { recursive: true, mode: 0o700 });
return createCodexNativeSessionBackend(input.execution, {
return createNativeSessionBackend(input.execution, {
runnerInstanceId: input.runnerInstanceId,
onSpawn: input.onSpawn,
dynamicTools: authority.definitions(),
dynamicToolHandler: (call) => authority.execute(call),
transportFactory: () => createRunnerdCodexTransport({
dynamicTools: authorityRouter.definitions(),
dynamicToolHandler: (call) => authorityRouter.execute(call),
environment: process.env,
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
codexTransportFactory: () => createRunnerdCodexTransport({
stateDirectory: root,
environment: process.env,
lifecyclePolicy: input.execution.session.lifecyclePolicy,
resumeDynamicTools: authorityRouter.definitions(),
prpIdentity: {
runnerInstanceId: input.runnerInstanceId,
environmentLeaseId: input.execution.binding.executionWorkspaceId,
@@ -35,7 +35,24 @@ describe("resolveNativeRuntimeMode", () => {
runtimeConfig: {},
adapterConfig: { provider: "claude" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toThrow(/provider must be codex/);
})).toThrow(/provider must be codex or opencode/);
});
it("selects OpenCode only with a provider/model value", () => {
expect(resolveNativeRuntimeMode({
...eligible,
runtimeConfig: {},
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toEqual(expect.objectContaining({
profile: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
}));
expect(() => resolveNativeRuntimeMode({
...eligible,
runtimeConfig: {},
adapterConfig: { provider: "opencode", model: "deepseek" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toThrow(/provider\/model/);
});
it("preserves legacy as the default and as the kill-switch behavior", () => {
@@ -74,6 +91,22 @@ describe("resolveNativeRuntimeMode", () => {
expect(disabled.runtimeConfig.nativeRunner.mode).toBe("native");
});
it("keeps a persisted OpenCode recovery on its immutable driver", () => {
expect(resolveHeartbeatNativeRuntimeMode({
...eligible,
enabled: false,
adapterConfig: { provider: "codex" },
persisted: {
runtimeMode: "native",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date(),
driverKind: "opencode_server",
},
})).toEqual(expect.objectContaining({
profile: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
}));
});
it("rejects an explicit native profile outside the approved boundary", () => {
expect(() => resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } }))
.toThrow(NativeRuntimeEligibilityError);
@@ -17,7 +17,7 @@ export type NativeRuntimeResolution =
kind: "native";
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
reason: "eligible_opt_in";
profile: { mode: "native"; backend: "codex_app_server"; protocolVersion: 1 };
profile: { mode: "native"; backend: "codex_app_server" | "opencode_server"; protocolVersion: 1 };
authorityDecision: NativeStatusDecision;
};
@@ -47,8 +47,14 @@ export function resolveNativeRuntimeMode(input: {
const nativeRunner = record(record(input.runtimeConfig).nativeRunner);
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
const runnerProvider = record(input.adapterConfig).provider ?? "codex";
if (runnerAdapterSelected && runnerProvider !== "codex") {
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex");
if (runnerAdapterSelected && runnerProvider !== "codex" && runnerProvider !== "opencode") {
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex or opencode");
}
if (runnerAdapterSelected && runnerProvider === "opencode") {
const model = record(input.adapterConfig).model;
if (typeof model !== "string" || !model.includes("/") || model.trim().endsWith("/")) {
throw new NativeRuntimeEligibilityError("paperclip_runner OpenCode provider requires model in provider/model form");
}
}
const mode = runnerAdapterSelected ? "native" : nativeRunner.mode;
if (!runnerAdapterSelected && !input.enabled) {
@@ -101,7 +107,11 @@ export function resolveNativeRuntimeMode(input: {
kind: "native",
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
reason: "eligible_opt_in",
profile: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
profile: {
mode: "native",
backend: runnerProvider === "opencode" ? "opencode_server" : "codex_app_server",
protocolVersion: 1,
},
authorityDecision: rollout,
};
}
@@ -116,6 +126,7 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
runtimeMode: string | null;
runtimeModeReason: string | null;
runtimeModeResolvedAt: Date | null;
driverKind?: string | null;
};
enabled: boolean;
runtimeConfig: unknown;
@@ -131,7 +142,11 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
kind: "native",
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
reason: "eligible_opt_in",
profile: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
profile: {
mode: "native",
backend: input.persisted.driverKind === "opencode_server" ? "opencode_server" : "codex_app_server",
protocolVersion: 1,
},
authorityDecision: resolveNativeMigrationStatus({
facts: input.enabled
? { applicationEnabled: true }
+132 -45
View File
@@ -32,6 +32,17 @@ export function CodexLocalConfigFields({
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
const runnerManaged = adapterType === "paperclip_runner";
const runnerProvider = runnerManaged
? isCreate
? values!.paperclipRunnerProvider ?? "codex"
: eff("adapterConfig", "provider", String(config.provider ?? "codex"))
: "codex";
const openCodeRunner = runnerManaged && runnerProvider === "opencode";
const runnerLifecycleMode = runnerManaged
? isCreate
? values!.paperclipRunnerLifecycleMode ?? "per_turn"
: eff("adapterConfig", "lifecycleMode", String(config.lifecycleMode ?? "per_turn"))
: "per_turn";
const rawEngine = runnerManaged ? "cli" : isCreate
? values!.codexEngine ?? "auto"
: eff("adapterConfig", "engine", String(config.engine ?? "auto"));
@@ -73,12 +84,88 @@ export function CodexLocalConfigFields({
</select>
</Field>}
{runnerManaged && (
<Field label="Provider" hint="Paperclip Runner currently supports Codex through its app-server protocol.">
<select className={inputClass} value="codex" disabled>
<Field label="Provider" hint="The runner persists this provider with each run so recovery cannot drift after configuration changes.">
<select
className={inputClass}
value={runnerProvider}
onChange={(event) => {
const provider = event.target.value === "opencode" ? "opencode" : "codex";
if (isCreate) {
set!({
paperclipRunnerProvider: provider,
...(provider === "opencode" && !values!.model
? { model: "openrouter/deepseek/deepseek-v4-flash-0731" }
: {}),
});
} else {
mark("adapterConfig", "provider", provider);
if (provider === "opencode" && !String(config.model ?? "")) {
mark("adapterConfig", "model", "openrouter/deepseek/deepseek-v4-flash-0731");
}
}
}}
>
<option value="codex">Codex</option>
<option value="opencode">OpenCode 1.18.17</option>
</select>
</Field>
)}
{runnerManaged && (
<Field label="Runner lifecycle" hint="Turn by turn suspends after each run. Warm keeps the same provider process available between governed runs.">
<select
className={inputClass}
value={runnerLifecycleMode}
onChange={(event) => {
const value = event.target.value === "warm" ? "warm" : "per_turn";
isCreate
? set!({ paperclipRunnerLifecycleMode: value })
: mark("adapterConfig", "lifecycleMode", value);
}}
>
<option value="per_turn">Turn by turn</option>
<option value="warm">Warm session</option>
</select>
</Field>
)}
{runnerManaged && runnerLifecycleMode === "warm" && (
<Field label="Warm idle timeout (ms)" hint="After this much inactivity, runnerd checkpoints and suspends the provider session.">
{isCreate ? (
<input
type="number"
min={1}
className={inputClass}
value={values!.paperclipRunnerIdleTimeoutMs ?? 300_000}
onChange={(event) => set!({ paperclipRunnerIdleTimeoutMs: Math.max(1, Number(event.target.value)) })}
/>
) : (
<DraftNumberInput
value={eff("adapterConfig", "idleTimeoutMs", Number(config.idleTimeoutMs ?? 300_000))}
onCommit={(value) => mark("adapterConfig", "idleTimeoutMs", Math.max(1, value || 300_000))}
immediate
className={inputClass}
/>
)}
</Field>
)}
{openCodeRunner && (
<Field label="OpenCode command" hint="The OpenCode executable. Version 1.18.17 is qualified for this runner.">
<DraftInput
value={
isCreate
? values!.command ?? ""
: eff("adapterConfig", "command", String(config.command ?? ""))
}
onCommit={(value) =>
isCreate
? set!({ command: value })
: mark("adapterConfig", "command", value || undefined)
}
immediate
className={inputClass}
placeholder="opencode"
/>
</Field>
)}
{acpSelected && (
<>
<Field
@@ -217,49 +304,49 @@ export function CodexLocalConfigFields({
</div>
</Field>
)}
<ToggleField
label="Bypass sandbox"
hint={help.dangerouslyBypassSandbox}
checked={
isCreate
? values!.dangerouslyBypassSandbox
: eff(
"adapterConfig",
"dangerouslyBypassApprovalsAndSandbox",
bypassEnabled,
)
}
onChange={(v) =>
isCreate
? set!({ dangerouslyBypassSandbox: v })
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
}
/>
<ToggleField
label="Enable search"
hint={help.search}
checked={
isCreate
? values!.search
: eff("adapterConfig", "search", !!config.search)
}
onChange={(v) =>
isCreate
? set!({ search: v })
: mark("adapterConfig", "search", v)
}
/>
<ToggleField
label="Fast mode"
hint={help.fastMode}
checked={fastModeEnabled}
onChange={(v) =>
isCreate
? set!({ fastMode: v })
: mark("adapterConfig", "fastMode", v)
}
/>
{fastModeEnabled && (
{!openCodeRunner && <ToggleField
label="Bypass sandbox"
hint={help.dangerouslyBypassSandbox}
checked={
isCreate
? values!.dangerouslyBypassSandbox
: eff(
"adapterConfig",
"dangerouslyBypassApprovalsAndSandbox",
bypassEnabled,
)
}
onChange={(v) =>
isCreate
? set!({ dangerouslyBypassSandbox: v })
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
}
/>}
{!openCodeRunner && <ToggleField
label="Enable search"
hint={help.search}
checked={
isCreate
? values!.search
: eff("adapterConfig", "search", !!config.search)
}
onChange={(v) =>
isCreate
? set!({ search: v })
: mark("adapterConfig", "search", v)
}
/>}
{!openCodeRunner && <ToggleField
label="Fast mode"
hint={help.fastMode}
checked={fastModeEnabled}
onChange={(v) =>
isCreate
? set!({ fastMode: v })
: mark("adapterConfig", "fastMode", v)
}
/>}
{!openCodeRunner && fastModeEnabled && (
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
{fastModeMessage}
</div>
+37 -3
View File
@@ -29,9 +29,9 @@ describe("paperclip runner transcript projection", () => {
}), "2026-08-21T12:00:00.000Z");
expect(event("item.delta", { kind: "reasoning", text: "Inspecting the runner" }, "reason-1"))
.toEqual([{ kind: "thinking", ts: expect.any(String), text: "Inspecting the runner", delta: true }]);
.toEqual([{ kind: "thinking", ts: expect.any(String), text: "Inspecting the runner", delta: true, channel: "unknown" }]);
expect(event("item.delta", { kind: "agentMessage", text: "Here is" }, "message-1"))
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Here is", delta: true }]);
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Here is", delta: true, channel: "unknown" }]);
expect(event("item.started", {
kind: "commandExecution",
item: { id: "exec-1", type: "commandExecution", command: "pnpm test", status: "inProgress" },
@@ -54,8 +54,42 @@ describe("paperclip runner transcript projection", () => {
}), "2026-08-21T12:00:00.000Z");
expect(line("run.result.proposed", { summary: "Finished the task" }))
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Finished the task" }]);
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Finished the task", channel: "final" }]);
expect(line("run.result.accepted", { result: { summary: "Finished the task" } }))
.toEqual([]);
});
it("preserves progress, final-answer, and reasoning channels across item deltas", () => {
const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine;
const event = (eventType: string, payload: Record<string, unknown>, itemId: string) => parse(JSON.stringify({
type: "paperclip.prp.event",
event: { eventType, itemId, payload },
}), "2026-08-21T12:00:00.000Z");
expect(event("item.started", { kind: "agentMessage", channel: "progress", item: { id: "p1", type: "agentMessage", phase: "commentary", text: "" } }, "p1")).toEqual([]);
expect(event("item.delta", { kind: "agentMessage", channel: "progress", text: "Running it now." }, "p1"))
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Running it now.", delta: true, channel: "progress" }]);
expect(event("item.started", { kind: "agentMessage", channel: "final", item: { id: "f1", type: "agentMessage", phase: "final_answer", text: "" } }, "f1")).toEqual([]);
expect(event("item.delta", { kind: "agentMessage", channel: "final", text: "Completed." }, "f1"))
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Completed.", delta: true, channel: "final" }]);
expect(event("item.delta", { kind: "reasoning", channel: "summary", text: "Inspecting" }, "r1"))
.toEqual([{ kind: "thinking", ts: expect.any(String), text: "Inspecting", delta: true, channel: "summary" }]);
expect(event("item.delta", { kind: "reasoning", channel: "detail", text: "Detailed trace" }, "r1"))
.toEqual([{ kind: "thinking", ts: expect.any(String), text: "Detailed trace", delta: true, channel: "detail" }]);
});
it("never exposes the structured task result envelope as final-response prose", () => {
const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine;
const event = (eventType: string, payload: Record<string, unknown>, itemId = "result-1") => parse(JSON.stringify({
type: "paperclip.prp.event",
event: { eventType, itemId, payload },
}), "2026-08-21T12:00:00.000Z");
event("item.started", { kind: "agentMessage", channel: "final", item: { id: "result-1", type: "agentMessage", phase: "final_answer", text: "" } });
expect(event("item.delta", { kind: "agentMessage", channel: "final", text: "{\"schema\":" })).toEqual([]);
expect(event("item.delta", { kind: "agentMessage", channel: "final", text: "\"paperclip.run_result.v1\"}" })).toEqual([]);
expect(event("run.result.proposed", { summary: "Human-readable completion." }))
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Human-readable completion.", channel: "final" }]);
});
});
+51 -5
View File
@@ -10,6 +10,27 @@ interface PaperclipRunnerParserState {
reasoningDeltaItemIds: Set<string>;
resultSummaries: Set<string>;
toolOutputItemIds: Set<string>;
itemChannels: Map<string, "progress" | "final" | "summary" | "detail" | "unknown">;
structuredFinalItemIds: Set<string>;
}
function itemChannel(payload: JsonRecord): "progress" | "final" | "summary" | "detail" | "unknown" {
const channel = text(payload.channel);
return channel === "progress" || channel === "final" || channel === "summary" || channel === "detail"
? channel
: "unknown";
}
function structuredResultSummary(value: string): string | null {
if (!value.trimStart().startsWith("{")) return null;
try {
const parsed = record(JSON.parse(value));
return text(parsed.schema) === "paperclip.run_result.v1" && text(parsed.summary)
? text(parsed.summary)
: null;
} catch {
return null;
}
}
function record(value: unknown): JsonRecord {
@@ -193,14 +214,31 @@ function parseItemEvent(
const item = record(payload.item);
const type = normalizedItemType(item, payload);
const id = itemId(event, item);
const channel = itemChannel(payload);
if (phase === "started") state.itemChannels.set(id, channel);
const resolvedChannel = channel === "unknown" ? state.itemChannels.get(id) ?? "unknown" : channel;
if (type === "agentmessage") {
const value = itemText(item, payload);
if (phase === "completed") state.itemChannels.delete(id);
if (!value || state.assistantDeltaItemIds.has(id)) return [];
return [{ kind: "assistant", ts, text: value }];
if (resolvedChannel === "final") {
const summary = structuredResultSummary(value);
if (summary) {
state.resultSummaries.add(summary);
return [{ kind: "assistant", ts, text: summary, channel: "final" }];
}
}
return [{ kind: "assistant", ts, text: value, channel: resolvedChannel === "final" ? "final" : resolvedChannel === "progress" ? "progress" : "unknown" }];
}
if (type === "reasoning") {
const value = itemText(item, payload);
if (value && !state.reasoningDeltaItemIds.has(id)) return [{ kind: "thinking", ts, text: value }];
if (phase === "completed") state.itemChannels.delete(id);
if (value && !state.reasoningDeltaItemIds.has(id)) return [{
kind: "thinking",
ts,
text: value,
channel: resolvedChannel === "detail" ? "detail" : resolvedChannel === "summary" ? "summary" : "unknown",
}];
return phase === "started"
? [{ kind: "system", ts, text: "Reasoning started" }]
: [];
@@ -233,14 +271,20 @@ function parseDeltaEvent(
const kind = text(payload.kind).replaceAll("_", "").toLowerCase();
const value = text(payload.text);
const id = text(event.itemId, `${kind || "item"}-delta`);
const explicitChannel = itemChannel(payload);
const channel = explicitChannel === "unknown" ? state.itemChannels.get(id) ?? "unknown" : explicitChannel;
if (!value) return [];
if (kind === "agentmessage") {
state.assistantDeltaItemIds.add(id);
return [{ kind: "assistant", ts, text: value, delta: true }];
if (channel === "final" && (state.structuredFinalItemIds.has(id) || value.trimStart().startsWith("{"))) {
state.structuredFinalItemIds.add(id);
return [];
}
return [{ kind: "assistant", ts, text: value, delta: true, channel: channel === "final" ? "final" : channel === "progress" ? "progress" : "unknown" }];
}
if (kind === "reasoning") {
state.reasoningDeltaItemIds.add(id);
return [{ kind: "thinking", ts, text: value, delta: true }];
return [{ kind: "thinking", ts, text: value, delta: true, channel: channel === "detail" ? "detail" : channel === "summary" ? "summary" : "unknown" }];
}
if (kind === "commandexecution") {
state.toolOutputItemIds.add(id);
@@ -309,7 +353,7 @@ function parsePrpEvent(
const summary = text(result.summary);
if (!summary || state.resultSummaries.has(summary)) return [];
state.resultSummaries.add(summary);
return [{ kind: "assistant", ts, text: summary }];
return [{ kind: "assistant", ts, text: summary, channel: "final" }];
}
if (eventType === "harness.diagnostic" || eventType === "session.failed") {
return [{ kind: "system", ts, text: `Runner: ${text(payload.message, text(payload.code, eventType))}` }];
@@ -323,6 +367,8 @@ function createParserState(): PaperclipRunnerParserState {
reasoningDeltaItemIds: new Set(),
resultSummaries: new Set(),
toolOutputItemIds: new Set(),
itemChannels: new Map(),
structuredFinalItemIds: new Set(),
};
}
+28
View File
@@ -116,6 +116,34 @@ describe("TaskChatThread draft pass-through", () => {
});
});
describe("TaskChatThread native-runner failures", () => {
it("replaces an empty failed transcript with actionable retry feedback and keeps the composer enabled", () => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
linkedRuns={[{
runId: "run-failed",
status: "failed",
agentId: "agent-1",
adapterType: "paperclip-runner",
createdAt: "2026-08-21T12:00:00.000Z",
startedAt: "2026-08-21T12:00:00.000Z",
finishedAt: "2026-08-21T12:00:01.000Z",
errorCode: "provider_frame_too_large",
hasStoredOutput: false,
}]}
/>,
);
expect(container.textContent).toContain("Run failed");
expect(container.textContent).toContain("Provider output exceeded the safe limit");
expect(container.textContent).not.toContain("Waiting for transcript");
expect(container.querySelector("textarea")?.disabled ?? false).toBe(false);
});
});
describe("TaskChatThread composer alignment (PAP-498)", () => {
it("matches the thread width on mobile and stays narrower on larger screens", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} />);
+100 -24
View File
@@ -5,6 +5,7 @@ import {
type RunTranscriptSource,
} from "@/components/transcript/useLiveRunTranscripts";
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
import {
assembleThreadItems,
@@ -349,6 +350,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// B4), so the transcript stays mounted through the settle gap without ever
// double-rendering beside its own settled turn.
const settledRunIds = new Set<string>();
const entriesWithFailures = [...orderedEntries];
// Settled turns for every terminal run whose transcript we have. Messages
// and thinking are excluded entirely (PAP-361): the final reply already
// landed as the run's comment bubble, interstitial updates are ephemeral
@@ -362,8 +364,33 @@ export function TaskChatThread(props: TaskChatThreadProps) {
if (!isTerminalRunStatus(source.status)) continue;
if (liveRun && source.id === liveRun.id) continue;
const entries = transcriptByRun.get(source.id) ?? [];
if (entries.length === 0) continue;
const meta = linkedRunMetaById.get(source.id);
if (entries.length === 0) {
if (source.status === "failed" || source.status === "timed_out") {
settledRunIds.add(source.id);
const code = meta?.errorCode ?? "native_runner_process_exited";
const retryDetail = meta?.scheduledRetryAt
? "Retry scheduled automatically."
: "You can retry this message now.";
const detail = code === "provider_frame_too_large"
? `Provider output exceeded the safe limit. ${retryDetail}`
: `The runner stopped before returning an answer (${code}). ${retryDetail}`;
const id = `${source.id}:failure`;
entriesWithFailures.push({
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
order: 3,
id,
item: {
id,
kind: "marker",
variant: "interrupted",
label: "Run failed",
detail,
},
});
}
continue;
}
const started = meta?.startedAt ? new Date(meta.startedAt).getTime() : NaN;
const finished = meta?.finishedAt ? new Date(meta.finishedAt).getTime() : NaN;
const durationMs =
@@ -376,7 +403,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
running: false,
});
const children = settledRunChildren(parsed);
if (children.length === 0) continue;
if (children.length === 0 && source.adapterType !== "paperclip_runner") continue;
settledRunIds.add(source.id);
const failed = source.status !== "succeeded";
const startSlotRaw = meta?.startedAt ?? meta?.createdAt;
@@ -390,6 +417,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
id: `${source.id}:turn`,
kind: "turn",
settled: true,
standaloneHeader: source.adapterType === "paperclip_runner",
animateFold: liveSeenRef.current.has(source.id),
items: children,
summary: buildTurnSummary(entries, { durationMs, failed }),
@@ -420,7 +448,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// (stopped runs, or a reply not yet fetched) interleave chronologically at
// their run's start time instead of piling up under the newest message
// (PAP-367).
const out = assembleThreadItems(orderedEntries, turnsByAnchor, unanchored);
entriesWithFailures.sort(
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
);
const out = assembleThreadItems(entriesWithFailures, turnsByAnchor, unanchored);
// PAP-362: two runs replying back-to-back (same agent, nothing but the
// agent's own bubbles between) fold into ONE "Worked" row below the last
@@ -468,10 +499,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// Hand off once the settled turn or its reply comment is in the thread; a
// stopped run that yields neither is released by the backstop timeout so the
// tail never lingers indefinitely.
const settledRunRendered =
settlingRun != null &&
(settledRunIds.has(settlingRun.id) ||
comments.some((comment) => comment.runId === settlingRun.id && !comment.deletedAt));
const settlingHasComment = settlingRun != null &&
comments.some((comment) => comment.runId === settlingRun.id && !comment.deletedAt);
const settlingIsPaperclipRunner = settlingRun != null &&
runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner";
const settledRunRendered = settlingRun != null &&
(settlingHasComment || (!settlingIsPaperclipRunner && settledRunIds.has(settlingRun.id)));
useEffect(() => {
if (!settlingRun) return;
if (settledRunRendered) {
@@ -492,6 +525,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
const tailRunId = liveRun ? liveRun.id : showSettlingTail ? settlingRun!.id : null;
const tailStreaming = Boolean(liveRun);
const tailEntries = tailRunId ? (transcriptByRun.get(tailRunId) ?? []) : [];
const tailAdapterType = tailRunId ? runs.find((run) => run.id === tailRunId)?.adapterType : null;
const paperclipRunnerTail = tailAdapterType === "paperclip_runner";
const tailContentKey = tailEntries.reduce((total, entry) => {
if ("text" in entry) return total + entry.text.length;
if ("content" in entry) return total + entry.content.length;
@@ -553,6 +588,35 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
[tailRunId, tailContentKey, tailStreaming, tailAgentName],
);
const assigneeUsesPaperclipRunner = Boolean(
issueAssigneeAgentId && agentMap?.get(issueAssigneeAgentId)?.adapterType === "paperclip_runner",
);
const [runnerSubmissionPending, setRunnerSubmissionPending] = useState(false);
useEffect(() => {
if (tailRunId) setRunnerSubmissionPending(false);
}, [tailRunId]);
useEffect(() => {
if (!runnerSubmissionPending || tailRunId) return;
const timeout = window.setTimeout(() => setRunnerSubmissionPending(false), 10_000);
return () => window.clearTimeout(timeout);
}, [runnerSubmissionPending, tailRunId]);
const handleThreadAdd = useCallback(
async (...args: Parameters<typeof onAdd>) => {
if (assigneeUsesPaperclipRunner) setRunnerSubmissionPending(true);
try {
await onAdd(...args);
} catch (error) {
setRunnerSubmissionPending(false);
throw error;
}
},
[assigneeUsesPaperclipRunner, onAdd],
);
const optimisticRunnerStartup = assigneeUsesPaperclipRunner && !tailRunId && (
runnerSubmissionPending || commentItems.some(
(item) => item.kind === "message" && item.author === "human" && item.optimistic === "pending",
)
);
// Feedback votes keyed by the comment they target (targetType
// "issue_comment"), mirroring IssueChatThread — the redesign attaches the
@@ -681,24 +745,36 @@ export function TaskChatThread(props: TaskChatThreadProps) {
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
renderMessageActions={renderMessageActions}
renderQueuedAction={renderQueuedAction}
tail={tailRunId || bottomBlockerLinks ? (
tail={tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? (
<>
{tailRunId ? (
{tailRunId || optimisticRunnerStartup ? (
<div data-testid="task-chat-live-transcript">
<TaskChatLiveRunPill
status={tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
<TaskChatLiveTail
items={tailItems}
emptyMessage={
tailStatus === "queued"
? "Waiting to start..."
: "Waiting for transcript..."
}
/>
{paperclipRunnerTail || optimisticRunnerStartup ? (
<TaskChatRunnerTurn
items={tailItems}
status={optimisticRunnerStartup ? "queued" : tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
) : (
<>
<TaskChatLiveRunPill
status={tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
<TaskChatLiveTail
items={tailItems}
emptyMessage={
tailStatus === "queued"
? "Waiting to start..."
: "Waiting for transcript..."
}
/>
</>
)}
</div>
) : null}
{bottomBlockerLinks}
@@ -733,7 +809,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
>
{composerAccessory}
<TaskChatComposer
onAdd={onAdd}
onAdd={handleThreadAdd}
workMode={issueWorkMode}
onWorkModeChange={onWorkModeChange}
disabled={Boolean(composerDisabledReason)}
@@ -29,6 +29,8 @@ interface TaskChatBubbleProps {
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
*/
attachedTurn?: ReactNode;
/** New-runner Worked header, rendered above the durable final response. */
beforeTurn?: ReactNode;
/**
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
* Rendered here only for a runless reply (leading the bare timestamp); when
@@ -63,7 +65,7 @@ function galleryItemForImage(src: string, name?: string): GalleryMediaItem {
};
}
export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: TaskChatBubbleProps) {
export function TaskChatBubble({ item, queuedAction, attachedTurn, beforeTurn, actions }: TaskChatBubbleProps) {
// Clicking an embedded image opens the full-screen lightbox (with download);
// arrow keys walk across the other images in the same bubble.
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
@@ -96,6 +98,7 @@ export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: Ta
: Math.max(0, galleryItems.findIndex((galleryItem) => galleryItem.contentPath === lightboxSrc));
return (
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
{!isHuman && item.authorName ? (
<span className="flex items-center gap-2 px-1">
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
@@ -1,4 +1,4 @@
import { Loader2 } from "lucide-react";
import { ChevronRight, Loader2 } from "lucide-react";
import type { TranscriptEntry } from "../../adapters";
import { cn } from "@/lib/utils";
import { useSecondTick } from "@/hooks/useSecondTick";
@@ -47,6 +47,9 @@ export function TaskChatLiveRunPill({
startedAtMs,
finishedAtMs,
toolSummary,
runnerStyle = false,
expanded,
onToggle,
}: {
status: string;
/** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */
@@ -54,6 +57,10 @@ export function TaskChatLiveRunPill({
/** Run finish in ms once terminal; drives the settled elapsed readout. */
finishedAtMs?: number | null;
toolSummary: string | null;
/** Codex-style new-runner header: no spinner, whole-second compact duration. */
runnerStyle?: boolean;
expanded?: boolean;
onToggle?: () => void;
}) {
const active = !isTerminalRunStatus(status);
// One shared page-wide ticker drives the live elapsed readout, matching the
@@ -62,10 +69,42 @@ export function TaskChatLiveRunPill({
const elapsedMs =
startedAtMs == null ? null : (active ? Date.now() : finishedAtMs ?? Date.now()) - startedAtMs;
const elapsed = elapsedMs != null ? formatDurationWords(elapsedMs) : null;
const elapsed = elapsedMs != null
? runnerStyle
? `${Math.max(0, Math.floor(elapsedMs / 1000))}s`
: formatDurationWords(elapsedMs)
: null;
const verb = active ? "Working" : "Worked";
const suffix = elapsed ? `for ${elapsed}` : null;
if (runnerStyle) {
const content = (
<>
<span className={cn("text-sm font-medium text-foreground/80", active && "shimmer-text")}>{verb}</span>
{suffix ? <span className="text-sm text-muted-foreground">{suffix}</span> : null}
<ChevronRight
className={cn("ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform", expanded && "rotate-90")}
aria-hidden
/>
</>
);
return onToggle ? (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="flex w-full min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2 text-left transition-colors hover:text-foreground"
data-testid="task-chat-live-run-pill"
>
{content}
</button>
) : (
<div className="flex min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2" data-testid="task-chat-live-run-pill">
{content}
</div>
);
}
return (
<div
className="flex min-w-0 items-center gap-2.5 px-1 py-2"
@@ -29,12 +29,18 @@ import { buildActivityPhases } from "./transcript-adapter";
export function TaskChatLiveTail({
items,
emptyMessage,
excludeFinal = false,
}: {
items: readonly TaskChatItem[];
/** Shown when nothing renderable has streamed yet (queued / pre-first-token). */
emptyMessage?: string;
/** New-runner turn renders final-answer messages in its dedicated response slot. */
excludeFinal?: boolean;
}) {
const rows = buildActivityPhases(items, true)
const visibleItems = excludeFinal
? items.filter((item) => item.kind !== "message" || item.interstitial)
: items;
const rows = buildActivityPhases(visibleItems, true)
.map((item) => renderTailRow(item))
.filter((row): row is ReactElement => row != null);
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn";
import type { TaskChatItem } from "./task-chat-model";
describe("TaskChatRunnerTurn", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (items: TaskChatItem[], status = "running") => act(() => root.render(
<ThemeProvider>
<TaskChatRunnerTurn items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
</ThemeProvider>,
));
it("shows immediate Thinking before the first runner event", () => {
render([], "queued");
expect(container.textContent).toContain("Thinking");
expect(container.textContent).not.toContain("Working");
expect(container.textContent).not.toContain("Waiting for transcript");
});
it("keeps progress separate from the replace-in-place current command", () => {
render([
{ id: "p1", kind: "message", author: "agent", text: "Running the exact command now.", interstitial: true, channel: "progress", streaming: true },
{ id: "t1", kind: "tool", name: "command", rawName: "Bash", target: "for i in 1 2 3 4; do echo STREAM-$i; done", status: "in_progress", detail: "STREAM-1\n" },
]);
expect(container.querySelector('[data-testid="task-chat-progress-update"]')?.textContent).toContain("Running the exact command now.");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Running a command");
expect(container.textContent).toContain("STREAM-$i");
});
it("streams the final response in its durable slot and hides current activity", () => {
render([
{ id: "p1", kind: "message", author: "agent", text: "Checking.", interstitial: true, channel: "progress" },
{ id: "f1", kind: "message", author: "agent", text: "Completed successfully.", channel: "final", streaming: true },
]);
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent).toContain("Completed successfully.");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
});
});
@@ -0,0 +1,103 @@
import { useState } from "react";
import { Brain } from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { cn } from "@/lib/utils";
import type { TaskChatItem, TaskChatMessageItem, TaskChatThinkingItem, TaskChatToolItem } from "./task-chat-model";
import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
import { TaskChatLiveTail } from "./TaskChatLiveTail";
import { toolTaxonomy } from "./tool-taxonomy";
function lastOf<T extends TaskChatItem>(items: readonly TaskChatItem[], predicate: (item: TaskChatItem) => item is T): T | undefined {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (predicate(item)) return item;
}
return undefined;
}
function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
const activity = lastOf<TaskChatThinkingItem | TaskChatToolItem>(
items,
(item): item is TaskChatThinkingItem | TaskChatToolItem => item.kind === "thinking" || item.kind === "tool",
);
if (!activity || activity.kind === "thinking") {
return (
<div className="flex items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
<Brain className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="shimmer-text shimmer-text-muted">Thinking</span>
</div>
);
}
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
const Icon = taxonomy.icon;
const active = activity.status === "pending" || activity.status === "in_progress";
return (
<div className="flex min-w-0 items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className={cn("shrink-0", active && "shimmer-text shimmer-text-muted")}>{taxonomy.verbLabel}</span>
{activity.target ? <span className="min-w-0 truncate font-mono text-xs">{activity.target}</span> : null}
</div>
);
}
export function TaskChatRunnerTurn({
items,
status,
startedAtMs,
finishedAtMs,
toolSummary,
}: {
items: readonly TaskChatItem[];
status: string;
startedAtMs: number | null;
finishedAtMs?: number | null;
toolSummary: string | null;
}) {
const [open, setOpen] = useState(false);
const progress = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && Boolean(item.interstitial));
const final = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final");
if (status === "queued") {
return (
<div className="py-1" data-testid="task-chat-runner-turn" data-phase="startup">
<CurrentActivity items={items} />
</div>
);
}
return (
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn">
<TaskChatLiveRunPill
status={status}
startedAtMs={startedAtMs}
finishedAtMs={finishedAtMs}
toolSummary={toolSummary}
runnerStyle
expanded={open}
onToggle={() => setOpen((value) => !value)}
/>
<div className="tc-turn-fold" data-folded={open ? "false" : "true"} aria-hidden={!open}>
<div>
<div className="flex flex-col gap-2 py-2">
<TaskChatLiveTail items={items as TaskChatItem[]} excludeFinal />
</div>
</div>
</div>
{!open ? (
<div className="flex min-w-0 flex-col py-1">
{progress ? (
<div className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90" data-testid="task-chat-progress-update">
<MarkdownBody softBreaks linkIssueReferences>{progress.text}</MarkdownBody>
</div>
) : null}
{!final ? <CurrentActivity items={items} /> : null}
</div>
) : null}
{final ? (
<div className="tc-enter-bubble px-1 py-2 text-sm text-foreground" data-testid="task-chat-final-response">
<MarkdownBody softBreaks linkIssueReferences>{final.text}</MarkdownBody>
</div>
) : null}
</div>
);
}
@@ -9,7 +9,8 @@ export function TaskChatThinking({ item }: { item: TaskChatThinkingItem }) {
const [open, setOpen] = useState(item.streaming || !item.collapsed);
const body = item.lines.join("\n").trim();
if (!body) return null;
const label = item.streaming ? "Reasoning…" : item.summaryLabel ?? "Reasoning";
const baseLabel = item.channel === "detail" ? "Reasoning detail" : "Reasoning";
const label = item.streaming ? `${baseLabel}…` : item.summaryLabel ?? baseLabel;
return (
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-thinking">
@@ -69,21 +69,21 @@ function renderItem(
// put when the tool history expands (PAP-413). The two paths are mutually
// exclusive at runtime, so only one host ever mounts the node.
const actions = renderMessageActions?.(item);
const turn = item.attachedTurn ? (
<TaskChatTurn
item={item.attachedTurn}
timestampPrefix={item.attachedTurn.standaloneHeader ? undefined : item.timestamp}
leading={item.attachedTurn.standaloneHeader ? undefined : actions}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
) : undefined;
return (
<TaskChatBubble
item={item}
actions={actions}
actions={item.attachedTurn?.standaloneHeader ? actions : item.attachedTurn ? undefined : actions}
queuedAction={renderQueuedAction?.(item)}
attachedTurn={
item.attachedTurn ? (
<TaskChatTurn
item={item.attachedTurn}
timestampPrefix={item.timestamp}
leading={actions}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
) : undefined
}
beforeTurn={item.attachedTurn?.standaloneHeader ? turn : undefined}
attachedTurn={item.attachedTurn?.standaloneHeader ? undefined : turn}
/>
);
}
@@ -124,6 +124,13 @@ describe("TaskChatTurn", () => {
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("renders the new-runner settled header as Worked for N above the response", () => {
renderTurn({ ...SETTLED, standaloneHeader: true });
expect(summaryBtn()?.textContent).toContain("Worked for 38s");
expect(summaryBtn()?.textContent).not.toContain("3 tools");
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("toggles open on summary click", () => {
renderTurn(SETTLED);
flushSync(() => summaryBtn()!.click());
+12 -5
View File
@@ -85,7 +85,10 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
className={cn(
"group flex w-full items-center gap-2 px-1 text-muted-foreground transition-colors hover:text-foreground",
item.standaloneHeader ? "border-b border-border/70 py-2 text-sm" : "py-0.5 text-xs",
)}
data-testid="task-chat-turn-summary"
>
{timestampPrefix ? (
@@ -94,9 +97,13 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
<span aria-hidden className="text-(length:--text-micro)">·</span>
</>
) : null}
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
{!item.standaloneHeader ? <SummaryIcon className="h-3.5 w-3.5 shrink-0" /> : null}
<span>
{item.standaloneHeader && item.summary.durationLabel
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
: item.summary.failed ? "Stopped" : "Worked"}
</span>
{!item.standaloneHeader && turnSummaryMetrics(item.summary) ? (
// Time/tools/tokens is demoted, not deleted (PAP-502): it stays in the
// DOM (and the accessible tree) but fades in only on hover/focus so the
// settled line reads as "2:34 PM · ✓ Worked" at rest. Revealed too when
@@ -110,7 +117,7 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
{turnSummaryMetrics(item.summary)}
</span>
) : null}
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", item.standaloneHeader && "ml-auto", open ? "rotate-90" : null)} />
</button>
) : parentRow ? (
// The pill renders the expand button itself, wrapped around only the
@@ -80,14 +80,10 @@ export interface TaskChatMessageItem {
* "for {user}" chip beside the author name (the open cross-task write design (attribution)).
*/
onBehalfOfUserName?: string;
/**
* Agent text streamed inside a run turn (interstitial updates between tool
* calls). Ephemeral in the redesigned view (PAP-361): while streaming it
* takes the live parent row's line (TaskChatStatusItem.selfTalk); once
* finished it renders nowhere — the run log / classic transcript remain the
* archive. Tagged for live and settled transcripts alike.
*/
/** A transient provider progress update, distinct from the durable final response. */
interstitial?: boolean;
/** Normalized runner message channel. Only paperclip_runner currently emits it. */
channel?: "progress" | "final" | "unknown";
/** Epoch ms of the message's first streamed chunk. */
atMs?: number;
/**
@@ -121,6 +117,8 @@ export interface TaskChatThinkingItem {
collapsed?: boolean;
/** Human-readable elapsed label for the collapsed header. */
summaryLabel?: string;
/** Provider-emitted reasoning surface; never synthesized by the UI. */
channel?: "summary" | "detail" | "unknown";
}
/** A tool invocation row (ACP tool_call / tool_call_update). */
@@ -267,6 +265,8 @@ export interface TaskChatTurnItem {
liveStatus?: TaskChatStatusItem;
/** Animate the fold when settling (false = collapse instantly, e.g. history). */
animateFold?: boolean;
/** New-runner turns keep the Worked header standalone above the final response. */
standaloneHeader?: boolean;
summary: {
/** e.g. "38s" — omitted when unknown. */
durationLabel?: string;
@@ -64,10 +64,10 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
},
"agent-message": {
id: "agent-message",
label: "Agent message",
label: "Final response",
tier: "live",
surface: "thread",
protocol: "text_delta stream:output (ACP agent_message_chunk)",
protocol: 'PRP item.delta kind:"agentMessage" channel:"final"',
},
thinking: {
id: "thinking",
@@ -78,14 +78,14 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
},
responding: {
id: "responding",
label: "Responding (streaming)",
label: "Progress update (streaming)",
tier: "live",
surface: "thread",
protocol: "text_delta stream:output, streaming",
protocol: 'PRP item.delta kind:"agentMessage" channel:"progress"',
},
"responding-burst": {
id: "responding-burst",
label: "Responding (update burst)",
label: "Progress update burst",
tier: "live",
surface: "thread",
protocol: "text_delta stream:output ×N, tool calls between (PAP-368 dwell)",
@@ -21,6 +21,7 @@ describe("toolTaxonomy", () => {
verbLabel: "Running a command",
});
expect(toolTaxonomy("Shell").family).toBe("terminal");
expect(toolTaxonomy("command").family).toBe("terminal");
expect(toolTaxonomy("Grep")).toEqual({
family: "grep",
+1 -1
View File
@@ -88,7 +88,7 @@ function classify(n: string): Exclude<ToolFamily, "mcp"> {
if (first === "edit" || first === "write" || n === "multiedit" || n === "notebookedit") {
return "edit";
}
if (first === "bash" || first === "shell" || first === "run" || n.includes("terminal")) {
if (first === "bash" || first === "shell" || first === "run" || first === "command" || n.includes("terminal")) {
return "terminal";
}
// Content grep is its own action (search-code glyph); file-name globbing and
@@ -407,6 +407,24 @@ describe("settledRunChildren (PAP-361)", () => {
});
});
describe("paperclip runner semantic channels", () => {
it("keeps progress/final messages and reasoning summary/detail in distinct items", () => {
const parsed = transcriptToTaskChatItems([
{ kind: "assistant", ts: TS, text: "Checking.", channel: "progress", delta: true },
{ kind: "thinking", ts: TS, text: "Summary", channel: "summary", delta: true },
{ kind: "thinking", ts: TS, text: "Detail", channel: "detail", delta: true },
{ kind: "assistant", ts: TS, text: "Done.", channel: "final", delta: true },
], { runId: "run-channels", running: true });
expect(parsed.map((item) => item.kind)).toEqual(["message", "thinking", "thinking", "message"]);
expect(parsed[0]).toMatchObject({ interstitial: true, channel: "progress" });
expect(parsed[1]).toMatchObject({ channel: "summary" });
expect(parsed[2]).toMatchObject({ channel: "detail" });
expect(parsed[3]).toMatchObject({ interstitial: false, channel: "final" });
const history = settledRunChildren(parsed);
expect(JSON.stringify(history)).not.toContain("Done.");
});
});
describe("buildMergedTurnSummary (PAP-362)", () => {
const runA: TranscriptEntry[] = [
{ kind: "tool_call", ts: "2026-07-31T12:00:00.000Z", name: "Read", toolUseId: "a-1" } as TranscriptEntry,
@@ -168,18 +168,22 @@ export function transcriptToTaskChatItems(
const thinkingStartTs = new Map<number, string>();
let lastToolIndex = -1;
let thinkingIndex = -1;
let thinkingChannel: "summary" | "detail" | "unknown" | undefined;
let messageIndex = -1;
let messageChannel: "progress" | "final" | "unknown" | undefined;
const resetInline = () => {
thinkingIndex = -1;
thinkingChannel = undefined;
messageIndex = -1;
messageChannel = undefined;
};
for (const [i, entry] of entries.entries()) {
switch (entry.kind) {
case "thinking": {
if (!entry.text) break;
if (thinkingIndex >= 0) {
if (thinkingIndex >= 0 && thinkingChannel === entry.channel) {
const it = items[thinkingIndex];
if (it.kind === "thinking") {
it.lines.push(...entry.text.split("\n"));
@@ -196,16 +200,20 @@ export function transcriptToTaskChatItems(
// Settled history folds its thinking behind the header (v7);
// the in-flight run streams it expanded.
collapsed: !running,
channel: entry.channel,
});
thinkingIndex = items.length - 1;
thinkingChannel = entry.channel;
thinkingStartTs.set(thinkingIndex, entry.ts);
messageIndex = -1;
messageChannel = undefined;
}
break;
}
case "assistant": {
if (!entry.text) break;
if (messageIndex >= 0) {
const channel = entry.channel;
if (messageIndex >= 0 && messageChannel === channel) {
const it = items[messageIndex];
if (it.kind === "message") it.text += entry.text;
} else {
@@ -217,12 +225,15 @@ export function transcriptToTaskChatItems(
authorName: agentName,
text: entry.text,
streaming: running,
// Everything the agent says inside a run turn is self-talk until it
// lands as the posted comment — live and history tag it alike.
interstitial: true,
// Explicit final-answer items stream durably. Unclassified text
// remains transient for backwards compatibility and fail-closed
// handling of a delta whose start metadata was missed.
interstitial: channel !== "final",
channel,
atMs: Number.isFinite(atMs) ? atMs : undefined,
});
messageIndex = items.length - 1;
messageChannel = channel;
thinkingIndex = -1;
}
break;
@@ -440,6 +451,9 @@ export function buildActivityPhases(
const lastVisible = [...parsed].reverse().find((item) => item.kind !== "thinking");
for (const item of parsed) {
if (item.kind === "message") {
// Durable final-answer text is rendered beside the Worked header, never
// duplicated inside its expandable activity history.
if (!item.interstitial) continue;
// A settled transcript's trailing assistant text is the posted reply.
// Live/settle-gap tails keep it visible until that canonical reply lands.
if (!running && item === lastVisible) continue;
+2
View File
@@ -49,6 +49,8 @@ export interface IssueChatLinkedRun {
hasStoredOutput?: boolean;
logBytes?: number | null;
errorCode?: string | null;
scheduledRetryAt?: string | null;
nextAction?: string | null;
resultJson?: Record<string, unknown> | null;
}
+11 -5
View File
@@ -1143,7 +1143,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
const { data: liveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
refetchInterval: 3000,
refetchInterval: 1000,
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
});
const resolvedLiveRuns = liveRuns ?? [];
@@ -1152,7 +1152,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
queryKey: queryKeys.issues.activeRun(issueId),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
enabled: !!executionRunId || issueStatus === "in_progress",
refetchInterval: liveRunCount > 0 ? false : 3000,
refetchInterval: liveRunCount > 0 ? false : 1000,
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
});
const resolvedActiveRun = useMemo(
@@ -1163,7 +1163,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
const { data: linkedRuns } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
refetchInterval: hasLiveRuns ? 5000 : false,
refetchInterval: hasLiveRuns || issueStatus === "in_progress" ? 1000 : false,
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
});
const resolvedActivity = activity ?? [];
@@ -2738,8 +2738,12 @@ export function IssueDetail() {
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
issuesApi.addComment(issueId!, body, reopen, interrupt),
onMutate: async ({ body, reopen, interrupt }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
// Start cache cancellation immediately but do not put it in front of the
// optimistic echo. The new-runner startup placeholder must paint in the
// same frame as send, even when an in-flight comments query is slow to
// cancel.
const cancelComments = queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
const cancelIssue = queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
const queuedComment = !interrupt
@@ -2766,6 +2770,8 @@ export function IssueDetail() {
);
}
await Promise.all([cancelComments, cancelIssue]);
return {
optimisticCommentId: optimisticComment?.clientId ?? null,
queuedCommentTargetRunId: queuedComment?.id ?? null,