Add pinned Pi rich ACP wrapper and owned runtime extension

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-28 15:14:57 -05:00
1 parent f063fbf2bc
commit d975d4ceb7
10 files changed
+1865

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
# Pi rich ACP runtime
Status: implementation candidate, 2026-09-28. Deterministic protocol tests and an
uncredentialed admission probe pass. Paid local inference and Linux x64 Daytona
qualification are pending; this document does not promote the candidate to a
qualified production runtime.
The runner pins `pi-acp@0.0.33` and
`@earendil-works/pi-coding-agent@0.84.2`. The candidate model is
`openrouter/deepseek/deepseek-v4-flash-0731`; only an explicitly bound OpenRouter
credential may reach this profile. `patches/pi-acp@0.0.33.patch` repairs the ACP
wrapper. `pi-runtime-extension.ts` supplies the runner-owned semantic bridge and
pre-execution native tool policy. This keeps the upstream session, model,
streaming, diff, retry, and compaction implementation while making its missing
boundaries explicit.
## Capabilities and differences from Codex app-server
| Surface | Pi implementation and boundary |
| --- | --- |
| Sessions | Native Pi JSONL create/load; resume is confined to the execution's private session home and original workspace. Cold `prompt` cannot implicitly load an arbitrary session. |
| Text and reasoning | Upstream streams text and thought chunks. The common runner's private reasoning policy still applies; provider support does not authorize retention or display. |
| Native tools | `read`, `grep`, `find`, `ls`, `write`, `edit`, and `bash` pass the immutable extension gate before execution. File roots and read-only mode are checked before and after a permission wait. The host sandbox remains authoritative for shell commands and races. |
| Permissions | Native tool approval uses ACP `session/request_permission`, with allow once, allow for the session, and deny. Only offered options are accepted. Questions never become approvals. |
| Questions | Pi `select`, `confirm`, `input`, and `editor` map to ACP form elicitation with typed schemas. Decline, cancellation, timeout, malformed replies, and duplicate/late replies cannot become accepted answers. |
| Semantic tools | Runner-owned loopback HTTP MCP catalogs register under exact `mcp__<server>__<tool>` names. Calls retain the native tool call ID and cancellation signal. Authenticated PRP tool handling owns semantic authorization and durable interactions. Ambient MCP and external MCP servers are not admitted. |
| Plans and artifacts | Pi has no native structured plan or artifact channel. Paperclip plan and artifact semantic tools remain available through the MCP bridge; native file edits retain bounded, workspace-confined ACP diff projection. Tool text/image results are preserved, and resource blocks are recorded without following URLs. |
| Steering | Capability-negotiated `pi/steer` issues native RPC `steer` during an active turn. `pi/follow_up` explicitly queues native RPC `follow_up`. Neither is inferred from a second ACP prompt. Each takes `{sessionId, message}` and returns `{accepted: true}`. |
| Usage | Prompt results sum actual assistant message usage receipts across continuations. Input, output, cache reads/writes, total tokens and provider-reported cost have provenance. Context-window occupancy is not billed usage. No receipt means no usage assertion. |
| Retry and compaction | Upstream retry/compaction notices are retained. `agent_settled`, rather than a transient `agent_end`, settles a prompt. A final provider error remains a failed prompt and does not become successful completion. |
| Images | Upstream ACP image prompt blocks are passed to native Pi RPC. Model-specific image support still requires live qualification. |
| Cancellation and death | Cancellation expires live UI waits and calls native abort. Pi process exit rejects pending RPC requests and all active/queued turns. Partial RPC frames, oversized frames, and malformed JSON fail closed. |
| Fork, durable goal, native plan | Unavailable. Do not advertise Codex parity for these features. |
`initialize` advertises `_meta.paperclipPi.version = 1`, `steering`,
`queuedFollowUp`, the four question methods, `nativePermissions`, `promptUsage`,
`nativePlan: false`, and `pendingRequestRecovery: "live-process-only"`. The common
host must inspect this advertisement before sending provider extension requests.
## Lifetime and recovery
A provider UI request is a live Pi RPC promise. Paperclip's durable interaction
can survive a control-plane connection loss while the runner, wrapper, and Pi
process remain alive. A response is forwarded once to that exact pending request.
If Pi or the wrapper dies, the interaction must expire; neither a new process nor
a loaded JSONL session has the original promise. Do not replay an approval into a
replacement process. Normal conversation continuation can load the private JSONL
session after a separately admitted restart.
The wrapper attaches a per-turn generation to settlement work, fails queued
turns on provider death, and bounds RPC request waits. This does not constitute a
claim of generic provider-process handoff or exactly-once external side effects.
## Verified launch and packaging contract
The wrapper rejects ambient launch unless `PAPERCLIP_ACPX_ISOLATED_CONTEXT=1`.
It executes an absolute bound Node executable with an absolute bound Pi CLI path,
without a shell or PATH lookup. The common command lease must supply:
- `PAPERCLIP_PI_NODE_EXECUTABLE`, `PAPERCLIP_PI_ENTRYPOINT`, and
`PAPERCLIP_PI_EXTENSION_PATH`: files in the immutable private provider snapshot.
- `PI_CODING_AGENT_DIR`: isolated persisted session/auth/settings home.
- `PAPERCLIP_PI_READ_ONLY`: exactly `0` or `1`.
- `PAPERCLIP_PI_READ_ROOTS` and `PAPERCLIP_PI_PROTECTED_ROOTS`: JSON arrays of
absolute allowed skill/read roots and protected runtime/config roots.
- `PAPERCLIP_PI_SYSTEM_INSTRUCTIONS`: admitted instructions, bounded to 32 KiB.
The wrapper synthesizes `PAPERCLIP_PI_RUNTIME_CONFIGURATION` for its child,
including only session-bound MCP servers. The owned extension captures and deletes
this configuration environment variable before model shell execution. Credentials
remain accessible only through the deliberately admitted runtime mechanisms.
Pi starts with `--no-extensions --no-skills --no-prompt-templates --no-themes
--no-approve --offline`, followed by the explicit immutable extension and admitted
skill paths. Project trust is denied; user-bash RPC and terminal login are disabled.
Only the controlled `/compact`, `/session`, and `/autocompact` slash commands are
accepted. Startup package update checks are disabled.
Pi can continue after a broken extension. Therefore the wrapper probes
`get_commands` and requires the exact readiness sentinel
`paperclip-runtime-ready-v1` / `Paperclip runtime gate v1`. The extension registers
this command only after gates and every MCP catalog finish initialization. A
missing sentinel kills the subprocess before the first model prompt.
`pi-verified-runtime.ts` inventories and checks the complete graph, including
Node, wrapper helper, extension, package metadata, native libraries, WASM and
resources. The manifest schema is `paperclip.pi-runtime-files.v1`, with relative
`node`, `piEntrypoint`, `extension`, `wrapperEntrypoint` fields and a complete
`files` array of SHA-256-bound regular files or contained relative symlinks.
Hardlinked files, escaping links, changed files, and undeclared files fail
admission. Its verifier returns the three launch environment bindings and a
manifest digest. Admission verification alone is not a command lease: the shared
host must retain its immutable snapshot and process guardian through termination.
The published Pi package has a shrinkwrap and a nested dependency graph. Provider
pack creation must copy the complete installed graph and use the same pinned Pi
family dependencies, rather than reconstructing the graph from `cli.js` imports.
The emitted `dist/drivers/acpx/pi-runtime-extension.js` must be included in every
macOS arm64/x64 and Linux x64 pack. The wrapper's `dist/paperclip-runtime.js` is
part of its verified package and must never be omitted from a copy or hash.
## Verification and maintenance
The three colocated Vitest suites exercise questions, permissions, timeouts,
framing, usage, file policies, MCP behavior, and graph verification. The Node test
`test/pi-acp-package-contract.test.mjs` launches the actual patched npm wrapper
against a deterministic RPC fixture. It proves initialization, streaming,
terminal usage, question versus permission routing, explicit steering, provider
exit, required sentinel, and typed failure settlement. It requires the exact
installed pinned package; an absent or unpatched dependency is a failure.
`PAPERCLIP_TEST_PI_ACP_PACKAGE` can select a separately installed pinned fixture.
These fixtures invoke no model or paid API.
A no-key probe against the real Pi 0.84.2 CLI loaded the compiled owned extension
and returned the exact readiness command. This checks the extension ABI and
explicit loading path. It does not prove authenticated inference, native tool
execution, MCP model invocation, credential renewal, or remote sandbox behavior.
Those remain required local and Daytona qualification evidence under the shared
live-spend cap. No live inference spend was incurred by these deterministic tests.
The helper in `patches/pi-acp@0.0.33.patch` is generated from
`src/drivers/acpx/pi-acp-runtime.ts` with Node `stripTypeScriptTypes`, trimming
trailing whitespace. The package contract checks that these bytes match. When
updating the upstream version, rebase the wrapper patch and rerun the actual
package tests; do not preserve an old qualified command hash after changing bytes.
Primary references reviewed:
- [Harness priorities report](https://pages.paperclip.ing/2026-09-25-harness-priorities/report.md)
- [pi-acp source](https://github.com/svkozak/pi-acp)
- [Pi RPC protocol](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md)
- [Pi extensions](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)
- [ACP protocol and SDK](https://github.com/agentclientprotocol/typescript-sdk)
@@ -0,0 +1,96 @@
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPiLaunchSpec, PiRpcFrames, PiTurnUsage, PiUiBridge } from "./pi-acp-runtime.js";
const temporary: string[] = [];
afterEach(async () => { vi.useRealTimers(); for (const path of temporary.splice(0)) await rm(path, { recursive: true, force: true }); });
function fixture() {
const connection = { requestPermission: vi.fn(), unstable_createElicitation: vi.fn() };
const process = { sendExtensionUiResponse: vi.fn().mockResolvedValue(undefined) };
return { connection, process, bridge: new PiUiBridge("session-a", connection, process) };
}
describe("Pi ACP bridge", () => {
it.each([
["select", { options: ["Red", "Blue"] }, "Blue", { value: "Blue" }],
["confirm", { message: "Proceed?" }, false, { confirmed: false }],
["input", { placeholder: "Name" }, "Ada", { value: "Ada" }],
["editor", { prefill: "Old\nText" }, "New\nText", { value: "New\nText" }],
])("round-trips %s as questions independently of permission auto-approval", async (method, extra, answer, expected) => {
const f = fixture(); f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer } });
await f.bridge.handle({ id: "q1", method, title: "Choice", ...extra });
expect(f.connection.requestPermission).not.toHaveBeenCalled();
expect(f.connection.unstable_createElicitation).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-a", mode: "form", requestedSchema: expect.objectContaining({ required: ["answer"] }) }));
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q1", ...expected });
});
it("sends only offered native permission choices and keeps questions separate", async () => {
const f = fixture(); f.connection.requestPermission.mockResolvedValue({ outcome: { outcome: "selected", optionId: "allow_always" } });
const event = { id: "p1", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-a","toolName":"bash","input":{"command":"pwd"}}' };
await f.bridge.handle(event);
expect(f.connection.unstable_createElicitation).not.toHaveBeenCalled();
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "p1", value: "Allow for this session" });
expect(f.connection.requestPermission.mock.calls[0]![0]).toMatchObject({ toolCall: { toolCallId: "tool-a", kind: "execute" }, options: [{ kind: "allow_once" }, { kind: "allow_always" }, { kind: "reject_once" }] });
await f.bridge.handle(event); expect(f.connection.requestPermission).toHaveBeenCalledTimes(1);
});
it("expires pending requests and discards a late answer exactly once", async () => {
const f = fixture(); let answer!: (value: unknown) => void;
f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; }));
const request = f.bridge.handle({ id: "q", method: "input", title: "Question" });
f.bridge.cancelAll(); f.bridge.cancelAll();
answer({ action: "accept", content: { answer: "stale" } }); await request;
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true });
});
it("honors Pi dialog deadlines and rejects invalid answers", async () => {
vi.useFakeTimers(); const f = fixture(); let answer!: (value: unknown) => void;
f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; }));
const request = f.bridge.handle({ id: "q", method: "select", title: "Question", options: ["A"], timeout: 100 });
await vi.advanceTimersByTimeAsync(100);
answer({ action: "accept", content: { answer: "A" } }); await request;
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true });
f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer: "other" } });
await f.bridge.handle({ id: "q2", method: "select", title: "Question", options: ["A"] });
expect(f.process.sendExtensionUiResponse).toHaveBeenLastCalledWith({ id: "q2", cancelled: true });
});
it("uses LF framing across UTF-8 chunks without splitting Unicode separators", () => {
const seen: unknown[] = []; const frames = new PiRpcFrames((value) => seen.push(value));
const bytes = Buffer.from(JSON.stringify({ text: "🌒\u2028\u2029" }) + "\r\n");
for (const byte of bytes) frames.write(Uint8Array.of(byte)); frames.end();
expect(seen).toEqual([{ text: "🌒\u2028\u2029" }]);
expect(() => new PiRpcFrames(() => {}, 2).write(Buffer.from("xxx"))).toThrow("bound");
const truncated = new PiRpcFrames(() => {}); truncated.write(Buffer.from('{"x":1}'));
expect(() => truncated.end()).toThrow("inside");
});
it("reports actual incremental usage and resets the failure after a successful retry", () => {
const usage = new PiTurnUsage(); const failed = { role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 4, output: 1, cacheRead: 2, cacheWrite: 3, cost: { total: 0.01 } } };
usage.accept(failed); usage.accept(failed);
expect(usage.response()).toMatchObject({ usage: { totalTokens: 10 }, _meta: { jetbrains: { air: { sessionFailure: { severity: "error" } } } } });
usage.accept({ ...failed, timestamp: 2, stopReason: "stop" });
expect(usage.response()).toMatchObject({ usage: { inputTokens: 8, totalTokens: 20 } });
expect(usage.response()._meta).toBeUndefined();
usage.reset(); expect(usage.response()).toEqual({});
});
it("launches verified paths only and rejects session escapes", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-launch-")); temporary.push(root);
await mkdir(join(root, "sessions"));
for (const name of ["node", "cli.js", "extension.js"]) await writeFile(join(root, name), "verified");
const environment = {
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0",
PAPERCLIP_PI_NODE_EXECUTABLE: join(root, "node"), PAPERCLIP_PI_ENTRYPOINT: join(root, "cli.js"), PAPERCLIP_PI_EXTENSION_PATH: join(root, "extension.js"),
PI_CODING_AGENT_DIR: root,
};
const launch = createPiLaunchSpec({ cwd: root, mcpServers: [] }, environment);
expect(launch.command).toBe(join(root, "node"));
expect(launch.args).toEqual(expect.arrayContaining(["--no-extensions", "--no-approve", "--no-skills", "--offline"]));
expect(() => createPiLaunchSpec({ cwd: root }, { ...environment, PAPERCLIP_PI_NODE_EXECUTABLE: "pi" })).toThrow("binding");
await symlink(join(root, "cli.js"), join(root, "sessions", "escape.jsonl"));
expect(() => createPiLaunchSpec({ cwd: root, sessionPath: join(root, "sessions", "escape.jsonl") }, environment)).toThrow("escaped");
});
});
@@ -0,0 +1,248 @@
/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */
import { StringDecoder } from "node:string_decoder";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs";
const PERMISSION_PREFIX = "paperclip.pi.permission.v1:";
const PERMISSION_CHOICES = [
{ optionId: "allow_once", name: "Allow once", kind: "allow_once" },
{ optionId: "allow_always", name: "Allow for this session", kind: "allow_always" },
{ optionId: "reject_once", name: "Deny", kind: "reject_once" },
];
type RecordValue = Record<string, unknown>;
function record(value: unknown): RecordValue {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Pi runtime record");
return value as RecordValue;
}
function text(value: unknown, max = 16_384): string {
if (typeof value !== "string" || Buffer.byteLength(value) > max) throw new Error("Invalid Pi runtime text");
return value;
}
export const PI_ACP_FEATURES = Object.freeze({
version: 1, steering: true, queuedFollowUp: true,
questions: ["select", "confirm", "input", "editor"],
nativePermissions: true, promptUsage: true, nativePlan: false,
pendingRequestRecovery: "live-process-only",
});
/** Strict LF framing: Unicode line separators inside JSON are ordinary text. */
export class PiRpcFrames {
private decoder = new StringDecoder("utf8");
private pending = "";
private readonly receive: (value: RecordValue) => void;
private readonly limit: number;
constructor(receive: (value: RecordValue) => void, limit = 4 * 1024 * 1024) { this.receive = receive; this.limit = limit; }
write(chunk: Uint8Array): void {
this.pending += this.decoder.write(Buffer.from(chunk));
for (;;) {
const index = this.pending.indexOf("\n");
if (index < 0) break;
const line = this.pending.slice(0, index).replace(/\r$/, "");
this.pending = this.pending.slice(index + 1);
if (Buffer.byteLength(line) > this.limit) throw new Error("Pi RPC frame exceeds its bound");
if (line.trim()) this.receive(record(JSON.parse(line)));
}
if (Buffer.byteLength(this.pending) > this.limit) throw new Error("Pi RPC frame exceeds its bound");
}
end(): void {
this.pending += this.decoder.end();
if (this.pending.trim()) throw new Error("Pi RPC stream ended inside a frame");
}
}
function requiredFile(environment: NodeJS.ProcessEnv, name: string): string {
const path = environment[name];
if (!path || !isAbsolute(path) || /[\0\r\n]/.test(path)) throw new Error(`Missing verified Pi launch binding: ${name}`);
// This checks the handoff shape. The runner command lease verifies all bytes,
// retains their directory identity, and owns the subprocess lifetime.
if (!lstatSync(path).isFile()) throw new Error(`Invalid verified Pi launch binding: ${name}`);
return path;
}
function pathArray(raw: string | undefined): string[] {
const paths: unknown = JSON.parse(raw ?? "[]");
if (!Array.isArray(paths) || paths.length > 128 || paths.some((p) => typeof p !== "string" || !isAbsolute(p) || /[\0\r\n]/.test(p))) throw new Error("Invalid Pi runtime paths");
return paths;
}
/** Provider tool events are untrusted; diff projection cannot read host files. */
export function snapshotPiWorkspaceFile(cwd: string, path: string, environment: NodeJS.ProcessEnv = process.env): string {
const root = realpathSync(cwd);
const logical = resolve(root, path);
const physical = realpathSync(logical);
const within = (base: string, target: string): boolean => {
const suffix = relative(base, target);
return !isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`);
};
if (!within(root, logical) || !within(root, physical)) throw new Error("Pi diff escaped its workspace");
for (const protectedPath of pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS)) {
if (within(protectedPath, logical)) throw new Error("Pi diff targets protected state");
let protectedPhysical: string;
try { protectedPhysical = realpathSync(protectedPath); } catch { continue; }
if (within(protectedPhysical, physical)) throw new Error("Pi diff targets protected state");
}
const fd = openSync(physical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
const before = fstatSync(fd, { bigint: true });
if (!before.isFile() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Pi diff file is unsupported");
const result = readFileSync(fd, "utf8");
const after = fstatSync(fd, { bigint: true });
const named = lstatSync(physical, { bigint: true });
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.ctimeNs !== after.ctimeNs || named.ino !== before.ino || named.dev !== before.dev || realpathSync(logical) !== physical) throw new Error("Pi diff file changed during capture");
return result;
} finally { closeSync(fd); }
}
export function createPiLaunchSpec(
params: { cwd: string; mcpServers?: unknown[]; sessionPath?: string },
environment: NodeJS.ProcessEnv,
): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
if (environment.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw new Error("Pi requires an isolated runner launch");
const command = requiredFile(environment, "PAPERCLIP_PI_NODE_EXECUTABLE");
const entrypoint = requiredFile(environment, "PAPERCLIP_PI_ENTRYPOINT");
const extension = requiredFile(environment, "PAPERCLIP_PI_EXTENSION_PATH");
if (environment.PAPERCLIP_PI_READ_ONLY !== "0" && environment.PAPERCLIP_PI_READ_ONLY !== "1") throw new Error("Pi requires an explicit read-only policy");
const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS);
const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS);
const workspace = realpathSync(params.cwd);
const configuration = JSON.stringify({
workspace, servers: params.mcpServers ?? [],
readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1",
readRoots, protectedRoots,
instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "",
});
if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound");
const args = [entrypoint, "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension];
for (const root of readRoots) args.push("--skill", root);
if (params.sessionPath) {
const home = environment.PI_CODING_AGENT_DIR;
if (!home) throw new Error("Pi session home is missing");
const sessionRoot = realpathSync(resolve(home, "sessions"));
const sessionPath = realpathSync(params.sessionPath);
const suffix = relative(sessionRoot, sessionPath);
if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`) || !lstatSync(params.sessionPath).isFile()) throw new Error("Pi session escaped its private home");
args.push("--session", sessionPath);
}
return { command, args, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } };
}
interface UiConnection {
requestPermission(params: RecordValue): Promise<unknown>;
unstable_createElicitation(params: RecordValue): Promise<unknown>;
}
interface UiProcess { sendExtensionUiResponse(response: RecordValue): Promise<unknown> }
/** Live promises never become authority to replay an answer after provider death. */
export class PiUiBridge {
private readonly pending = new Map<string, { cancel(): void }>();
private readonly seen = new Set<string>();
private readonly sessionId: string;
private readonly connection: UiConnection;
private readonly process: UiProcess;
constructor(sessionId: string, connection: UiConnection, process: UiProcess) { this.sessionId = sessionId; this.connection = connection; this.process = process; }
cancelAll(): void { for (const value of this.pending.values()) value.cancel(); }
async handle(event: RecordValue): Promise<void> {
const id = text(event.id, 256);
const method = text(event.method, 64);
if (!["select", "confirm", "input", "editor"].includes(method)) return;
if (this.seen.has(id)) return;
if (this.seen.size >= 4096) throw new Error("Pi UI request history exceeds its bound");
this.seen.add(id);
let done = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
const finish = async (response: RecordValue): Promise<void> => {
if (done) return;
done = true;
if (timeout) clearTimeout(timeout);
this.pending.delete(id);
await this.process.sendExtensionUiResponse({ id, ...response });
};
this.pending.set(id, { cancel: () => { void finish({ cancelled: true }).catch(() => {}); } });
if (typeof event.timeout === "number" && Number.isFinite(event.timeout)) {
timeout = setTimeout(() => { void finish({ cancelled: true }).catch(() => {}); }, Math.max(0, Math.min(event.timeout, 2_147_483_647)));
timeout.unref?.();
}
try {
const title = text(event.title ?? "Additional information needed", 65_536);
if (method === "select" && title.startsWith(PERMISSION_PREFIX)) {
const permission = record(JSON.parse(title.slice(PERMISSION_PREFIX.length)));
const toolCallId = text(permission.toolCallId, 256);
const toolName = text(permission.toolName, 128);
const input = record(permission.input);
const result = record(await this.connection.requestPermission({
sessionId: this.sessionId,
toolCall: { toolCallId, title: `Pi ${toolName}`, kind: toolName === "bash" ? "execute" : ["write", "edit"].includes(toolName) ? "edit" : "read", status: "pending", rawInput: input },
options: PERMISSION_CHOICES,
}));
const outcome = record(result.outcome);
const selected = outcome.outcome === "selected" ? PERMISSION_CHOICES.find((option) => option.optionId === outcome.optionId) : undefined;
await finish(selected ? { value: selected.name } : { cancelled: true });
return;
}
const property: RecordValue = { type: method === "confirm" ? "boolean" : "string", title };
if (method === "select") {
if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options");
property.enum = event.options.map((option) => text(option, 4096));
if (new Set(property.enum as string[]).size !== event.options.length) throw new Error("Ambiguous Pi question options");
}
if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder);
if (method === "editor" && typeof event.prefill === "string") property.default = text(event.prefill);
const result = record(await this.connection.unstable_createElicitation({
sessionId: this.sessionId, mode: "form", message: typeof event.message === "string" ? text(event.message) : title,
requestedSchema: { type: "object", title, properties: { answer: property }, required: ["answer"] },
_meta: { paperclipPi: { version: 1, requestId: id, method } },
}));
if (result.action !== "accept") { await finish({ cancelled: true }); return; }
const answer = record(result.content).answer;
if (method === "confirm") {
if (typeof answer !== "boolean") throw new Error("Invalid Pi confirmation response");
await finish({ confirmed: answer });
} else {
const value = text(answer, 65_536);
if (method === "select" && !(property.enum as string[]).includes(value)) throw new Error("Invalid Pi question response");
await finish({ value });
}
} catch { await finish({ cancelled: true }).catch(() => {}); }
}
}
/** Sum actual assistant receipts, never context occupancy or estimated cost. */
export class PiTurnUsage {
private seen = new Set<string>();
private total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 };
private cost = 0;
private failed = false;
private observed = false;
reset(): void { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.failed = false; this.observed = false; }
accept(value: unknown): void {
const message = record(value);
if (message.role !== "assistant") return;
this.failed = message.stopReason === "error";
if (!message.usage || typeof message.usage !== "object") return;
const key = JSON.stringify([message.timestamp, message.id, message.usage, message.content]);
if (this.seen.has(key)) return;
if (this.seen.size >= 8192) throw new Error("Pi usage receipts exceed their bound");
this.seen.add(key);
const usage = record(message.usage);
const numbers = ["input", "output", "cacheRead", "cacheWrite"].map((name) => usage[name] ?? 0);
if (numbers.some((value) => typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) return;
this.observed = true;
for (const [index, field] of ["inputTokens", "outputTokens", "cachedReadTokens", "cachedWriteTokens"].entries()) {
const name = field as keyof typeof this.total;
this.total[name] += numbers[index] as number;
}
this.total.totalTokens = this.total.inputTokens + this.total.outputTokens + this.total.cachedReadTokens + this.total.cachedWriteTokens;
if (usage.cost && typeof usage.cost === "object") {
const cost = record(usage.cost).total;
if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) this.cost += cost;
}
}
response(): RecordValue {
return {
...(this.observed ? { usage: { ...this.total, _meta: { paperclipPi: { provenance: "assistant_message_receipts", costUsd: this.cost } } } } : {}),
...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}),
};
}
}
@@ -0,0 +1,116 @@
import { createServer } from "node:http";
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
checkPiNativeTool, installPiRuntimeExtension, piMcpRequest, readPiRuntimeConfiguration,
PI_PERMISSION_TITLE_PREFIX, type PiExtensionApi, type PiRuntimeConfiguration, type PiToolDefinition,
} from "./pi-runtime-extension.js";
const directories: string[] = [];
afterEach(async () => { for (const path of directories.splice(0)) await rm(path, { recursive: true, force: true }); });
async function workspace() {
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-extension-")); directories.push(root);
await mkdir(join(root, "workspace")); await mkdir(join(root, "skills")); await mkdir(join(root, "private"));
return { root, config: {
workspace: join(root, "workspace"), readOnly: false,
readRoots: [join(root, "skills")], protectedRoots: [join(root, "private")],
instructions: "Use the assigned Paperclip tools", servers: [],
} satisfies PiRuntimeConfiguration };
}
function harness() {
const handlers = new Map<string, (...args: any[]) => any>();
const tools: PiToolDefinition[] = [];
const api = { on: (event: string, handler: (...args: any[]) => any) => handlers.set(event, handler), registerTool: (tool: PiToolDefinition) => tools.push(tool), registerCommand: vi.fn() } as unknown as PiExtensionApi;
return { api, handlers, tools };
}
describe("owned Pi runtime extension", () => {
it("requires explicit isolated configuration and loopback authentication", () => {
expect(() => readPiRuntimeConfiguration({})).toThrow("missing");
const value = { workspace: "/work/project", readOnly: false, readRoots: [], protectedRoots: [], instructions: "", servers: [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234/mcp", headers: [{ name: "Authorization", value: "Bearer 1234567890123456" }] }] };
const parse = () => readPiRuntimeConfiguration({ PAPERCLIP_PI_RUNTIME_CONFIGURATION: JSON.stringify(value) });
expect(parse().servers).toHaveLength(1);
value.servers[0]!.url = "https://127.0.0.1.evil.test/mcp";
expect(parse).toThrow("loopback");
value.servers[0]!.url = "http://user:password@127.0.0.1/mcp";
expect(parse).toThrow("loopback");
});
it("denies escapes and read-only mutations before requesting provider permission", async () => {
const { root, config } = await workspace(); const h = harness();
await symlink(join(root, "private"), join(config.workspace, "escape"));
await installPiRuntimeExtension(h.api, config);
const select = vi.fn().mockResolvedValue("Allow once");
const context = { cwd: config.workspace, ui: { select } };
const tool = h.handlers.get("tool_call")!;
expect(await tool({ toolName: "write", toolCallId: "t1", input: { path: "escape/new-file" } }, context)).toMatchObject({ block: true });
expect(await tool({ toolName: "read", toolCallId: "t2", input: { path: "../../outside" } }, context)).toMatchObject({ block: true });
config.readOnly = true;
expect(await tool({ toolName: "bash", toolCallId: "t3", input: { command: "touch result" } }, context)).toMatchObject({ block: true });
expect(select).not.toHaveBeenCalled();
});
it("revalidates paths after a human wait and never treats cancellation as approval", async () => {
const { root, config } = await workspace(); const h = harness();
await installPiRuntimeExtension(h.api, config);
const context = { cwd: config.workspace, ui: { select: vi.fn(async () => {
await symlink(join(root, "private"), join(config.workspace, "target"));
return "Allow once";
}) } };
expect(await h.handlers.get("tool_call")!({ toolName: "write", toolCallId: "t", input: { path: "target/new" } }, context)).toMatchObject({ block: true });
expect(context.ui.select.mock.calls[0]![0]).toMatch(PI_PERMISSION_TITLE_PREFIX);
context.ui.select = vi.fn().mockResolvedValue(undefined);
expect(await h.handlers.get("tool_call")!({ toolName: "bash", toolCallId: "b", input: { command: "pwd" } }, context)).toMatchObject({ block: true });
});
it("makes assigned skills readable but never writable", async () => {
const { config } = await workspace();
await writeFile(join(config.readRoots[0]!, "SKILL.md"), "assigned");
const context = { cwd: config.workspace, ui: { select: vi.fn() } };
expect(await checkPiNativeTool({ toolName: "read", toolCallId: "r", input: { path: join(config.readRoots[0]!, "SKILL.md") } }, context, config)).toBeNull();
expect(await checkPiNativeTool({ toolName: "write", toolCallId: "w", input: { path: join(config.readRoots[0]!, "SKILL.md") } }, context, config)).toMatch("outside");
});
it("registers exact authenticated MCP tools with stable idempotency and abort signal", async () => {
const { config } = await workspace(); const h = harness();
config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }];
const request = vi.fn(async (_server, method) => method === "tools/list"
? { tools: [{ name: "report_progress", description: "Report progress", inputSchema: { type: "object", properties: {} } }] }
: method === "tools/call" ? { content: [{ type: "text", text: "recorded" }] } : {});
await installPiRuntimeExtension(h.api, config, request);
const tool = h.tools[0]!; expect(tool.name).toBe("mcp__paperclip__report_progress");
const signal = new AbortController().signal;
await expect(tool.execute("call-1", { text: "done" }, signal)).resolves.toMatchObject({ content: [{ text: "recorded" }] });
expect(request).toHaveBeenLastCalledWith(config.servers[0], "tools/call", { name: "report_progress", arguments: { text: "done" } }, "call-1", signal);
const select = vi.fn(); const context = { cwd: config.workspace, ui: { select } };
expect(await h.handlers.get("tool_call")!({ toolName: tool.name, toolCallId: "m", input: {} }, context)).toBeUndefined();
expect(await h.handlers.get("tool_call")!({ toolName: "mcp__paperclip__invented", toolCallId: "i", input: {} }, context)).toMatchObject({ block: true });
expect(select).not.toHaveBeenCalled();
});
it("rejects incomplete catalogs and tool-result errors without exposing auth", async () => {
const { config } = await workspace(); const h = harness();
config.servers = [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }];
const request = vi.fn(async (_server, method) => method === "tools/list" ? { tools: [], nextCursor: "more" } : {});
await expect(installPiRuntimeExtension(h.api, config, request)).rejects.toThrow("incomplete");
});
it("does not follow MCP redirects or forward credentials to another origin", async () => {
const server = createServer((_req, res) => { res.writeHead(302, { Location: "http://127.0.0.1:1/steal" }); res.end(); });
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as { port: number };
try {
await expect(piMcpRequest({ type: "http", name: "paperclip", url: `http://127.0.0.1:${address.port}`, headers: [{ name: "Authorization", value: "Bearer secret" }] }, "tools/list", {}, "1")).rejects.toThrow();
} finally { server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); }
});
it("validates response IDs and bounds tool output", async () => {
const server = { type: "http" as const, name: "paperclip", url: "http://127.0.0.1:1", headers: [] };
const fetch_ = vi.fn(async () => new Response(JSON.stringify({ jsonrpc: "2.0", id: "other", result: {} })));
await expect(piMcpRequest(server, "tools/list", {}, "1", undefined, fetch_)).rejects.toThrow("identity");
fetch_.mockImplementation(async () => new Response("x".repeat(4 * 1024 * 1024 + 1)));
await expect(piMcpRequest(server, "tools/list", {}, "1", undefined, fetch_)).rejects.toThrow("oversized");
});
});
@@ -0,0 +1,267 @@
/**
* The only extension admitted to the qualified Pi process. The launcher loads
* this immutable module explicitly; project/global extension discovery is off.
* Structural types keep this module independent of Pi's optional UI packages.
*/
import { realpath } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
export const PI_PERMISSION_TITLE_PREFIX = "paperclip.pi.permission.v1:";
export const PI_PERMISSION_OPTIONS = ["Allow once", "Allow for this session", "Deny"] as const;
const MAX_CONFIGURATION_BYTES = 64 * 1024;
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const MAX_TOOLS = 512;
interface PiUi {
select(title: string, options: string[]): Promise<string | undefined>;
}
export interface PiExtensionContext { cwd: string; ui: PiUi }
export interface PiToolEvent {
toolName: string;
toolCallId: string;
input: Record<string, unknown>;
}
export interface PiToolDefinition {
name: string;
label: string;
description: string;
parameters: Record<string, unknown>;
execute(
toolCallId: string,
arguments_: Record<string, unknown>,
signal?: AbortSignal,
): Promise<{ content: Array<Record<string, unknown>>; details?: unknown }>;
}
export interface PiExtensionApi {
registerTool(tool: PiToolDefinition): void;
registerCommand(name: string, command: { description: string; handler(): Promise<void> }): void;
on(event: "tool_call", handler: (
event: PiToolEvent, context: PiExtensionContext,
) => Promise<{ block: true; reason: string } | undefined>): void;
on(event: "user_bash", handler: () => { result: { output: string; exitCode: number; cancelled: boolean; truncated: boolean } }): void;
on(event: "project_trust", handler: () => { trusted: "no"; remember: false }): void;
on(event: "before_agent_start", handler: (event: { systemPrompt: string }) => { systemPrompt: string } | undefined): void;
}
export interface PiMcpServer {
type: "http";
name: string;
url: string;
headers: Array<{ name: string; value: string }>;
}
export interface PiRuntimeConfiguration {
servers: PiMcpServer[];
workspace: string;
readOnly: boolean;
readRoots: string[];
protectedRoots: string[];
instructions: string;
}
/** This environment is synthesized by the verified wrapper, never the model. */
export function readPiRuntimeConfiguration(environment: NodeJS.ProcessEnv): PiRuntimeConfiguration {
const raw = environment.PAPERCLIP_PI_RUNTIME_CONFIGURATION;
if (!raw || Buffer.byteLength(raw) > MAX_CONFIGURATION_BYTES) throw new Error("Pi runtime configuration is missing or oversized");
const value = asRecord(JSON.parse(raw));
const workspace = absolutePath(value.workspace, "workspace");
if (typeof value.readOnly !== "boolean") throw new Error("Pi read-only policy is missing");
if (!Array.isArray(value.servers) || value.servers.length > 16) throw new Error("Pi MCP configuration is invalid");
const names = new Set<string>();
const servers = value.servers.map((entry): PiMcpServer => {
const server = asRecord(entry);
if (server.type !== "http" || typeof server.name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(server.name) || names.has(server.name)) {
throw new Error("Pi MCP server identity is invalid");
}
names.add(server.name);
if (typeof server.url !== "string") throw new Error("Pi MCP endpoint is invalid");
const url = new URL(server.url);
if (!['http:', 'https:'].includes(url.protocol) || !['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.hash) {
throw new Error("Pi MCP endpoint must be a runner-owned loopback endpoint");
}
if (!Array.isArray(server.headers) || server.headers.length !== 1) throw new Error("Pi MCP authentication is invalid");
const header = asRecord(server.headers[0]);
if (header.name !== "Authorization" || typeof header.value !== "string" || !/^Bearer [A-Za-z0-9._~-]{16,4096}$/.test(header.value)) {
throw new Error("Pi MCP authentication is invalid");
}
return { type: "http", name: server.name, url: url.href, headers: [{ name: "Authorization", value: header.value }] };
});
if (typeof value.instructions !== "string" || Buffer.byteLength(value.instructions) > 32 * 1024) throw new Error("Pi runtime instructions are invalid");
return {
servers, workspace, readOnly: value.readOnly,
readRoots: pathList(value.readRoots, "read roots"),
protectedRoots: pathList(value.protectedRoots, "protected roots"),
instructions: value.instructions,
};
}
function pathList(value: unknown, label: string): string[] {
if (!Array.isArray(value) || value.length > 128) throw new Error(`Pi ${label} are invalid`);
return value.map((path) => absolutePath(path, label));
}
function absolutePath(value: unknown, label: string): string {
if (typeof value !== "string" || !isAbsolute(value) || /[\0\r\n]/.test(value) || value === "/") throw new Error(`Pi ${label} path is invalid`);
return resolve(value);
}
function asRecord(value: unknown): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Pi runtime record is invalid");
return value as Record<string, unknown>;
}
function inside(root: string, target: string): boolean {
const suffix = relative(root, target);
return suffix === "" || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`));
}
/** Resolve every existing ancestor, including symlinks, before creating files. */
async function physicalPath(path: string): Promise<string> {
try { return await realpath(path); }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(path) === path) throw error;
return resolve(await physicalPath(dirname(path)), relative(dirname(path), path));
}
}
export async function checkPiNativeTool(
event: PiToolEvent, context: PiExtensionContext, config: PiRuntimeConfiguration,
): Promise<string | null> {
if (await realpath(context.cwd) !== await realpath(config.workspace)) return "Pi workspace changed after admission";
const readTools = new Set(["read", "grep", "find", "ls"]);
const fileTools = new Set([...readTools, "edit", "write"]);
if (!fileTools.has(event.toolName) && event.toolName !== "bash") return "Unregistered Pi tool is not admitted";
if (config.readOnly && !readTools.has(event.toolName)) return "This execution permits reading only";
// Shell syntax is not a filesystem authorization language. The host sandbox
// remains authoritative for commands; every command still crosses ACP policy.
if (event.toolName === "bash") return null;
const pathValue = event.input.path ?? (readTools.has(event.toolName) ? "." : undefined);
if (typeof pathValue !== "string" || /[\0\r\n]/.test(pathValue)) return "Pi tool path is invalid";
const logical = resolve(config.workspace, pathValue);
const target = await physicalPath(logical);
for (const root of config.protectedRoots) {
if (inside(root, logical) || inside(await physicalPath(root), target)) return "Pi tool targets protected runtime state";
}
const roots = readTools.has(event.toolName) ? [config.workspace, ...config.readRoots] : [config.workspace];
for (const root of roots) {
if (inside(root, logical) && inside(await realpath(root), target)) return null;
}
return "Pi tool path is outside its assigned workspace";
}
/** Bounded JSON MCP client for runner-owned HTTP bridges. No redirects or files. */
export async function piMcpRequest(
server: PiMcpServer,
method: string,
params: Record<string, unknown>,
id: string,
signal?: AbortSignal,
fetch_: typeof fetch = fetch,
): Promise<Record<string, unknown>> {
const request = JSON.stringify({ jsonrpc: "2.0", id, method, params });
if (Buffer.byteLength(request) > 1_048_576) throw new Error("Pi MCP request is oversized");
const timeout = AbortSignal.timeout(method === "tools/call" ? 24 * 60 * 60 * 1000 : 30_000);
const response = await fetch_(server.url, {
method: "POST", redirect: "error",
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...Object.fromEntries(server.headers.map((h) => [h.name, h.value])) },
body: request, signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
if (!response.ok || !response.body) throw new Error(`Pi MCP ${method} failed (HTTP ${response.status})`);
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let length = 0;
try {
for (;;) {
const item = await reader.read();
if (item.done) break;
length += item.value.byteLength;
if (length > MAX_RESPONSE_BYTES) throw new Error("Pi MCP response is oversized");
chunks.push(item.value);
}
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
const message = asRecord(JSON.parse(Buffer.concat(chunks).toString("utf8")));
if (message.jsonrpc !== "2.0" || message.id !== id) throw new Error("Pi MCP response identity mismatch");
if (message.error) throw new Error(`Pi MCP ${method} was rejected`);
return asRecord(message.result);
}
export async function installPiRuntimeExtension(
pi: PiExtensionApi,
config: PiRuntimeConfiguration,
request: typeof piMcpRequest = piMcpRequest,
): Promise<void> {
// Registered names are held in this closure; provider-originated metadata
// cannot promote a tool to the authenticated Paperclip bridge.
const bridgeTools = new Set<string>();
pi.on("project_trust", () => ({ trusted: "no", remember: false }));
pi.on("user_bash", () => ({ result: { output: "Interactive shell commands are disabled in Paperclip Runner", exitCode: 1, cancelled: false, truncated: false } }));
pi.on("before_agent_start", (event) => config.instructions
? { systemPrompt: `${event.systemPrompt}\n\n${config.instructions}` } : undefined);
pi.on("tool_call", async (event, context) => {
if (bridgeTools.has(event.toolName)) return;
try {
const denial = await checkPiNativeTool(event, context, config);
if (denial) return { block: true, reason: denial };
const detail = JSON.stringify({ toolCallId: event.toolCallId, toolName: event.toolName, input: event.input });
if (Buffer.byteLength(detail) > 48 * 1024) return { block: true, reason: "Pi permission request is oversized" };
const selection = await context.ui.select(`${PI_PERMISSION_TITLE_PREFIX}${detail}`, [...PI_PERMISSION_OPTIONS]);
if (selection !== "Allow once" && selection !== "Allow for this session") return { block: true, reason: "Pi operation was denied or cancelled" };
// Re-check file bindings after a human wait; approval never freezes paths.
const changed = await checkPiNativeTool(event, context, config);
return changed ? { block: true, reason: changed } : undefined;
} catch { return { block: true, reason: "Pi permission boundary is unavailable" }; }
});
let count = 0;
for (const server of config.servers) {
await request(server, "initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "paperclip-pi", version: "1" } }, `pi-init-${server.name}`);
const catalog = await request(server, "tools/list", {}, `pi-tools-${server.name}`);
if (!Array.isArray(catalog.tools) || catalog.nextCursor) throw new Error("Pi MCP catalog is incomplete");
for (const rawTool of catalog.tools) {
const tool = asRecord(rawTool);
if (typeof tool.name !== "string" || !/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) throw new Error("Pi MCP tool name is invalid");
const nativeName = `mcp__${server.name}__${tool.name}`;
if (bridgeTools.has(nativeName) || ++count > MAX_TOOLS) throw new Error("Pi MCP catalog is ambiguous or oversized");
const schema = asRecord(tool.inputSchema);
if (schema.type !== "object") throw new Error("Pi MCP tool parameters must be an object");
const sourceName = tool.name;
const definition: PiToolDefinition = {
name: nativeName, label: `${server.name}: ${sourceName}`,
description: typeof tool.description === "string" ? tool.description : sourceName,
parameters: structuredClone(schema),
async execute(callId, arguments_, signal) {
const result = await request(server, "tools/call", { name: sourceName, arguments: arguments_ }, callId, signal);
if (result.isError === true) throw new Error("Paperclip tool request was rejected");
if (!Array.isArray(result.content)) throw new Error("Pi MCP tool result is invalid");
const content = result.content.map((item) => {
const block = asRecord(item);
if (block.type === "text" && typeof block.text === "string") return { type: "text", text: block.text };
if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") return { type: "image", data: block.data, mimeType: block.mimeType };
// Resource links and structured outputs stay visible as data. They
// never cause the extension to fetch a URL or open a returned path.
return { type: "text", text: JSON.stringify(block) };
});
return { content, ...(result.structuredContent === undefined ? {} : { details: result.structuredContent }) };
},
};
pi.registerTool(definition);
bridgeTools.add(nativeName);
}
}
// Pi reports extension-load failures and may continue. The wrapper requires
// this exact sentinel before admitting prompts, so a failed extension cannot
// silently leave native tools unguarded. Register only after every MCP binds.
pi.registerCommand("paperclip-runtime-ready-v1", {
description: "Paperclip runtime gate v1",
async handler() {},
});
}
export default async function paperclipPiExtension(pi: PiExtensionApi): Promise<void> {
const configuration = readPiRuntimeConfiguration(process.env);
// Do not let the model's shell tool inherit authenticated MCP credentials.
delete process.env.PAPERCLIP_PI_RUNTIME_CONFIGURATION;
await installPiRuntimeExtension(pi, configuration);
}
@@ -0,0 +1,46 @@
import { link, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { inventoryPiRuntimeFiles, PI_RUNTIME_MANIFEST_SCHEMA, verifyPiRuntimeManifest, type PiRuntimeManifest } from "./pi-verified-runtime.js";
const temporary: string[] = [];
afterEach(async () => { for (const root of temporary.splice(0)) await rm(root, { recursive: true, force: true }); });
async function fixture() {
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-verified-")); temporary.push(root);
await mkdir(join(root, "assets"));
for (const name of ["node", "pi.js", "extension.js", "wrapper.js", "assets/native.node", "assets/image.wasm"]) await writeFile(join(root, name), `pinned:${name}`);
const manifest: PiRuntimeManifest = { schema: PI_RUNTIME_MANIFEST_SCHEMA, node: "node", piEntrypoint: "pi.js", extension: "extension.js", wrapperEntrypoint: "wrapper.js", files: await inventoryPiRuntimeFiles(root) };
return { root, manifest };
}
describe("Pi complete runtime manifest", () => {
it("binds interpreter, Pi, extension, wrapper, native modules and resources", async () => {
const { root, manifest } = await fixture();
const result = await verifyPiRuntimeManifest(root, manifest);
expect(result.environment.PAPERCLIP_PI_EXTENSION_PATH).toBe(await realpath(join(root, "extension.js")));
expect(result.manifestDigest).toMatch(/^sha256:[a-f0-9]{64}$/);
await writeFile(join(root, "assets/image.wasm"), "replacement");
await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("package graph");
});
it("refuses unrecorded resources and duplicate manifest entries", async () => {
const { root, manifest } = await fixture();
await writeFile(join(root, "extra.js"), "untrusted");
await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("package graph");
manifest.files.push(manifest.files[0]!);
await expect(verifyPiRuntimeManifest(root, manifest)).rejects.toThrow("entry");
});
it("allows only in-pack relative links and rejects hardlinked mutable files", async () => {
const { root, manifest } = await fixture();
await symlink("assets", join(root, "resources"));
manifest.files = await inventoryPiRuntimeFiles(root);
await expect(verifyPiRuntimeManifest(root, manifest)).resolves.toHaveProperty("manifestDigest");
await symlink(tmpdir(), join(root, "escape"));
await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("escapes");
await rm(join(root, "escape"));
await link(join(root, "pi.js"), join(root, "hardlink.js"));
await expect(inventoryPiRuntimeFiles(root)).rejects.toThrow("writable name");
});
});
@@ -0,0 +1,114 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, readdir, readlink, realpath } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
export const PI_RUNTIME_MANIFEST_SCHEMA = "paperclip.pi-runtime-files.v1" as const;
export interface PiRuntimeFile {
path: string;
kind: "file" | "symlink";
sha256: string;
/** A link target is data in the manifest and must remain inside the pack. */
target?: string;
}
export interface PiRuntimeManifest {
schema: typeof PI_RUNTIME_MANIFEST_SCHEMA;
node: string;
piEntrypoint: string;
extension: string;
wrapperEntrypoint: string;
files: PiRuntimeFile[];
}
function contained(root: string, path: string): boolean {
const suffix = relative(root, path);
return suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix);
}
function safeRelative(path: string): boolean {
return path.length > 0 && !isAbsolute(path) && !/[\0\r\n\\]/.test(path) && path.split("/").every((part) => part !== "" && part !== "." && part !== "..");
}
function hash(bytes: Uint8Array | string): string { return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; }
/**
* Inventory a complete provider pack, not just Pi's cli.js. Native modules,
* WASM, data, package metadata, and the owned extension all participate.
* Used while building a pack; the caller records/signs this immutable result.
*/
export async function inventoryPiRuntimeFiles(root: string): Promise<PiRuntimeFile[]> {
const physicalRoot = await realpath(root);
if ((await lstat(root)).isSymbolicLink()) throw new Error("Pi runtime root must not be a symlink");
const files: PiRuntimeFile[] = [];
const visit = async (directory: string): Promise<void> => {
const entries = (await readdir(directory)).sort();
for (const name of entries) {
const path = join(directory, name);
const rel = relative(physicalRoot, path).split(sep).join("/");
if (!safeRelative(rel)) throw new Error("Pi runtime contains an invalid filename");
const stat = await lstat(path);
if (stat.isDirectory()) { await visit(path); continue; }
if (files.length >= 100_000) throw new Error("Pi runtime file inventory exceeds its bound");
if (stat.isSymbolicLink()) {
const target = await readlink(path);
if (isAbsolute(target) || !contained(physicalRoot, resolve(dirname(path), target)) || !contained(physicalRoot, await realpath(path))) throw new Error("Pi runtime link escapes its pack");
files.push({ path: rel, kind: "symlink", target, sha256: hash(target) });
} else if (stat.isFile()) {
if (stat.nlink !== 1) throw new Error("Pi runtime file has another writable name");
files.push({ path: rel, kind: "file", sha256: await hashFile(path) });
} else throw new Error("Pi runtime contains a non-file resource");
}
};
await visit(physicalRoot);
return files;
}
async function hashFile(path: string): Promise<string> {
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
const before = await file.stat({ bigint: true });
if (!before.isFile() || before.nlink !== 1n || before.size > 512n * 1024n * 1024n) throw new Error("Pi runtime file identity is invalid");
const digest = createHash("sha256");
for await (const chunk of file.createReadStream({ autoClose: false })) digest.update(chunk);
const after = await file.stat({ bigint: true });
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) throw new Error("Pi runtime file changed during verification");
return `sha256:${digest.digest("hex")}`;
} finally { await file.close(); }
}
/**
* Verify an immutable private snapshot at admission. This returns only launch
* paths; the common command lease must continue holding its snapshot and
* process guardian. This function alone is deliberately not a command lease.
*/
export async function verifyPiRuntimeManifest(
root: string, manifest: PiRuntimeManifest,
): Promise<{ environment: Record<string, string>; manifestDigest: string }> {
if (manifest.schema !== PI_RUNTIME_MANIFEST_SCHEMA || !Array.isArray(manifest.files) || manifest.files.length === 0 || manifest.files.length > 100_000) throw new Error("Pi runtime manifest is invalid");
const unique = new Set<string>();
for (const entry of manifest.files) {
if (!safeRelative(entry.path) || unique.has(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256) || !["file", "symlink"].includes(entry.kind)) throw new Error("Pi runtime manifest entry is invalid");
if (entry.kind === "symlink" ? typeof entry.target !== "string" : entry.target !== undefined) throw new Error("Pi runtime manifest link is invalid");
unique.add(entry.path);
}
const sorted = manifest.files.map((entry): PiRuntimeFile => entry.kind === "file"
? { path: entry.path, kind: entry.kind, sha256: entry.sha256 }
: { path: entry.path, kind: entry.kind, target: entry.target, sha256: entry.sha256 })
.sort((a, b) => a.path.localeCompare(b.path, "en"));
const actual = (await inventoryPiRuntimeFiles(root)).sort((a, b) => a.path.localeCompare(b.path, "en"));
if (JSON.stringify(actual) !== JSON.stringify(sorted)) throw new Error("Pi runtime package graph differs from its qualified manifest");
const physicalRoot = await realpath(root);
const boundPath = async (path: string): Promise<string> => {
if (!safeRelative(path) || !unique.has(path)) throw new Error("Pi executable is absent from its manifest");
const value = await realpath(join(physicalRoot, path));
if (!contained(physicalRoot, value) || !(await lstat(value)).isFile()) throw new Error("Pi executable escaped its verified snapshot");
return value;
};
await boundPath(manifest.wrapperEntrypoint);
return {
environment: {
PAPERCLIP_PI_NODE_EXECUTABLE: await boundPath(manifest.node),
PAPERCLIP_PI_ENTRYPOINT: await boundPath(manifest.piEntrypoint),
PAPERCLIP_PI_EXTENSION_PATH: await boundPath(manifest.extension),
},
manifestDigest: hash(JSON.stringify({ ...manifest, files: sorted })),
};
}
@@ -0,0 +1,45 @@
// Protocol fixture: no provider network calls and no secrets.
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createInterface } from "node:readline";
const home = process.env.PI_CODING_AGENT_DIR;
mkdirSync(join(home, "sessions"), { recursive: true });
const sessionFile = join(home, "sessions", "fixture.jsonl");
writeFileSync(sessionFile, JSON.stringify({ type: "session", cwd: process.cwd() }) + "\n");
const output = (value) => process.stdout.write(JSON.stringify(value) + "\n");
let active = false;
let model = "fixture-model";
const finish = (answer = "done") => {
output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: answer } });
output({ type: "message_end", message: { role: "assistant", timestamp: Date.now(), stopReason: "stop", usage: { input: 11, output: 3, cacheRead: 2, cacheWrite: 0, cost: { total: 0.01 } } } });
output({ type: "agent_end" });
// An agent_end alone must not settle the ACP prompt.
setTimeout(() => { active = false; output({ type: "agent_settled" }); }, 15);
};
createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
const response = (data = {}) => output({ type: "response", id: request.id, command: request.type, success: true, data });
if (request.type === "extension_ui_response") { finish(JSON.stringify(request)); return; }
if (request.type === "get_state") { response({ sessionId: "fixture-session", sessionFile, model: { provider: "openrouter", id: model }, thinkingLevel: "off" }); return; }
if (request.type === "get_available_models") { response({ models: [{ provider: "openrouter", id: "fixture-model", name: "Fixture" }] }); return; }
if (request.type === "set_model") { model = request.modelId; response({ model: { provider: request.provider, id: model } }); return; }
if (request.type === "get_messages") { response({ messages: [] }); return; }
if (request.type === "get_commands") { response({ commands: process.env.PI_FIXTURE_EXTENSION_FAIL ? [] : [{ name: "paperclip-runtime-ready-v1", description: "Paperclip runtime gate v1", source: "extension" }] }); return; }
if (request.type === "steer") { response(); output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: `steered:${request.message}` } }); return; }
if (request.type === "follow_up") { response(); finish(`follow-up:${request.message}`); return; }
if (request.type === "abort") { response(); if (active) { active = false; output({ type: "agent_settled" }); } return; }
if (request.type === "prompt") {
response({ disposition: "started" }); active = true; output({ type: "agent_start" });
if (request.message === "die") { process.exit(4); }
if (request.message === "long") return;
if (request.message === "question") { output({ type: "extension_ui_request", id: "question-id", method: "input", title: "Project name", placeholder: "Name" }); return; }
if (request.message === "permission") { output({ type: "extension_ui_request", id: "permission-id", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-1","toolName":"bash","input":{"command":"pwd"}}', options: ["Allow once", "Allow for this session", "Deny"] }); return; }
if (request.message === "failure") {
output({ type: "message_end", message: { role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 1, output: 0 } } });
active = false; output({ type: "agent_settled" }); return;
}
output({ type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "fixture reasoning" } });
finish("hello🌒\u2028world"); return;
}
response();
}).on("close", () => process.exit(0));
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { createRequire, stripTypeScriptTypes } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { StringDecoder } from "node:string_decoder";
import test from "node:test";
import { fileURLToPath } from "node:url";
// Optional test-only package location permits the isolated Pi branch to test
// before the integration owner updates package.json and the shared lockfile.
const packageJson = process.env.PAPERCLIP_TEST_PI_ACP_PACKAGE
?? createRequire(import.meta.url).resolve("pi-acp/package.json");
const packageRoot = dirname(packageJson);
const packageMetadata = JSON.parse(await readFile(packageJson, "utf8"));
const packageSource = await readFile(join(packageRoot, "dist/index.js"), "utf8");
const helperSource = await readFile(join(packageRoot, "dist/paperclip-runtime.js"), "utf8");
const localHelper = await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8");
const normalize = (source) => source.split("\n").map((line) => line.trimEnd()).join("\n");
test("Pi patch embeds the reviewed helper and pins exact upstream version", () => {
assert.equal(packageMetadata.version, "0.0.33");
assert.equal(normalize(helperSource), normalize(stripTypeScriptTypes(localHelper)));
assert.match(packageSource, /createPiLaunchSpec\(params, process\.env\)/);
assert.match(packageSource, /shell: false/);
assert.match(packageSource, /snapshotPiWorkspaceFile\(this\.cwd, abs\)/);
});
async function fixture(t, extra = {}) {
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-acp-contract-")));
await mkdir(join(root, "workspace")); await mkdir(join(root, "agent"));
await writeFile(join(root, "extension.js"), "export default function() {}\n");
const fakePi = fileURLToPath(new URL("../test-fixtures/pi-acp/fake-pi.mjs", import.meta.url));
const child = spawn(process.execPath, [join(packageRoot, "dist/index.js")], {
cwd: join(root, "workspace"), stdio: "pipe",
env: {
PATH: process.env.PATH, HOME: root, PI_CODING_AGENT_DIR: join(root, "agent"),
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", PAPERCLIP_PI_READ_ONLY: "0",
PAPERCLIP_PI_NODE_EXECUTABLE: await realpath(process.execPath),
PAPERCLIP_PI_ENTRYPOINT: fakePi, PAPERCLIP_PI_EXTENSION_PATH: join(root, "extension.js"),
...extra,
},
});
let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk; });
const pending = new Map(); const notifications = []; const requests = [];
let id = 0; let answer = async (request) => request.method === "session/request_permission"
? { outcome: { outcome: "selected", optionId: "allow_once" } }
: { action: "accept", content: { answer: "Paperclip" } };
const send = (value) => child.stdin.write(JSON.stringify(value) + "\n");
const receive = (line) => {
const message = JSON.parse(line);
if (message.method && Object.hasOwn(message, "id")) {
requests.push(message);
void answer(message).then((result) => send({ jsonrpc: "2.0", id: message.id, result }));
} else if (Object.hasOwn(message, "id")) {
const waiter = pending.get(message.id); pending.delete(message.id);
if (message.error) waiter?.reject(new Error(JSON.stringify(message.error))); else waiter?.resolve(message.result);
} else notifications.push(message);
};
const decoder = new StringDecoder("utf8"); let buffered = "";
child.stdout.on("data", (chunk) => {
buffered += decoder.write(chunk);
for (;;) { const at = buffered.indexOf("\n"); if (at < 0) break; const line = buffered.slice(0, at); buffered = buffered.slice(at + 1); if (line.trim()) receive(line); }
});
child.once("exit", () => { for (const waiter of pending.values()) waiter.reject(new Error(`wrapper exited: ${stderr}`)); pending.clear(); });
const call = (method, params) => new Promise((resolve_, reject) => {
const requestId = ++id;
const timer = setTimeout(() => { pending.delete(requestId); reject(new Error(`timeout: ${method}: ${stderr}`)); }, 5000);
pending.set(requestId, { resolve: (value) => { clearTimeout(timer); resolve_(value); }, reject: (error) => { clearTimeout(timer); reject(error); } });
send({ jsonrpc: "2.0", id: requestId, method, params });
});
t.after(async () => {
child.stdin.end();
if (child.exitCode === null) { const timer = setTimeout(() => child.kill("SIGKILL"), 3000); await once(child, "exit"); clearTimeout(timer); }
await rm(root, { recursive: true, force: true });
});
const initialized = await call("initialize", { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } });
return { call, initialized, root, notifications, requests, setAnswer(value) { answer = value; } };
}
test("actual patched ACP process streams thinking and waits for settlement with usage", async (t) => {
const f = await fixture(t);
assert.equal(f.initialized.agentCapabilities._meta.paperclipPi.steering, true);
const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] });
assert.equal(result.stopReason, "end_turn"); assert.equal(result.usage.inputTokens, 11); assert.equal(result.usage.totalTokens, 16);
assert.ok(f.notifications.some((event) => event.params?.update?.sessionUpdate === "agent_thought_chunk"));
assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "hello🌒\u2028world"));
});
test("actual patched ACP process keeps text questions separate from native permissions", async (t) => {
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "question" }] });
assert.equal(f.requests.length, 1); assert.notEqual(f.requests[0].method, "session/request_permission");
await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "permission" }] });
assert.equal(f.requests[1].method, "session/request_permission");
assert.equal(f.requests[1].params.options[1].kind, "allow_always");
});
test("native steering is explicit and does not replace the active ACP turn", async (t) => {
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
const prompt = f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "long" }] });
await f.call("pi/steer", { sessionId: session.sessionId, message: "focus" });
await f.call("pi/follow_up", { sessionId: session.sessionId, message: "then finish" });
assert.equal((await prompt).stopReason, "end_turn");
await assert.rejects(f.call("pi/steer", { sessionId: session.sessionId, message: "too late" }), /active turn/);
});
test("provider exit fails promptly and missing owned extension fails admission", async (t) => {
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "die" }] }), /exited/);
const missing = await fixture(t, { PI_FIXTURE_EXTENSION_FAIL: "1" });
await assert.rejects(missing.call("session/new", { cwd: join(missing.root, "workspace"), mcpServers: [] }), /failed admission/);
});
test("provider failure is typed and unadmitted slash commands cannot bypass controls", async (t) => {
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
const result = await f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "failure" }] });
assert.equal(result._meta.jetbrains.air.sessionFailure.severity, "error");
await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "/export /outside/report.html" }] }), /not admitted/);
});
+677
View File
@@ -0,0 +1,677 @@
diff --git a/dist/index.js b/dist/index.js
--- a/dist/index.js
+++ b/dist/index.js
@@ -53,7 +53,7 @@
// src/pi-rpc/process.ts
import { spawn } from "child_process";
-import * as readline from "readline";
+import { PI_ACP_FEATURES, PiRpcFrames, PiUiBridge, PiTurnUsage, createPiLaunchSpec, snapshotPiWorkspaceFile } from "./paperclip-runtime.js";
// src/pi-rpc/command.ts
import { platform } from "os";
@@ -96,49 +96,38 @@
preludeLines = [];
constructor(child) {
this.child = child;
- const rl = readline.createInterface({ input: child.stdout });
- rl.on("line", (line) => {
- if (!line.trim()) return;
- let msg;
- try {
- msg = JSON.parse(line);
- } catch {
- const cleaned = stripAnsi(String(line)).trimEnd();
- if (cleaned) this.preludeLines.push(cleaned);
+ this.exited = null;
+ const fail = (error) => {
+ if (this.exited) return;
+ this.exited = error;
+ for (const [, pending] of this.pending) pending.reject(error);
+ this.pending.clear();
+ for (const handler of this.eventHandlers) handler({ type: "paperclip_process_exit" });
+ };
+ const frames = new PiRpcFrames((msg) => {
+ if (msg.type === "response") {
+ if (typeof msg.id === "string") this.pending.get(msg.id)?.resolve(msg);
return;
}
- if (msg?.type === "response") {
- const id = typeof msg.id === "string" ? msg.id : void 0;
- if (id) {
- const pending = this.pending.get(id);
- if (pending) {
- this.pending.delete(id);
- pending.resolve(msg);
- return;
- }
- }
- }
- for (const h of this.eventHandlers) h(msg);
+ for (const handler of this.eventHandlers) handler(msg);
});
- child.on("exit", (code, signal) => {
- const err = new Error(`pi process exited (code=${code}, signal=${signal})`);
- for (const [, p] of this.pending) p.reject(err);
- this.pending.clear();
+ child.stdout.on("data", (chunk) => {
+ try { frames.write(chunk); } catch (error) { fail(error); this.dispose("SIGKILL"); }
});
- child.on("error", (err) => {
- for (const [, p] of this.pending) p.reject(err);
- this.pending.clear();
+ child.stdout.on("end", () => {
+ try { frames.end(); } catch (error) { fail(error); }
});
+ child.on("exit", () => fail(new Error("Pi process exited before its next request")));
+ child.on("error", fail);
}
static async spawn(params) {
- const cmd = getPiCommand(params.piCommand);
- const args = ["--mode", "rpc", "--no-themes"];
- if (params.sessionPath) args.push("--session", params.sessionPath);
- const child = spawn(cmd, args, {
+ const launch = createPiLaunchSpec(params, process.env);
+ const cmd = launch.command;
+ const child = spawn(cmd, launch.args, {
cwd: params.cwd,
stdio: "pipe",
- env: process.env,
- shell: shouldUseShellForPiCommand(cmd)
+ env: launch.env,
+ shell: false
});
try {
await new Promise((resolve4, reject) => {
@@ -181,21 +170,30 @@
const { dirname: dirname3 } = await import("path");
mkdirSync2(dirname3(sessionFile), { recursive: true });
}
- } catch {
+ const commands = await proc.getCommands();
+ if (!Array.isArray(commands?.commands) || !commands.commands.some((command) => command.name === "paperclip-runtime-ready-v1" && command.description === "Paperclip runtime gate v1")) throw new Error("Pi owned runtime extension did not initialize");
+ } catch (error) {
+ proc.dispose("SIGKILL");
+ throw new PiRpcSpawnError("Pi owned runtime extension failed admission", { cause: error });
}
return proc;
}
onEvent(handler) {
this.eventHandlers.push(handler);
+ if (this.exited) queueMicrotask(() => handler({ type: "paperclip_process_exit" }));
return () => {
this.eventHandlers = this.eventHandlers.filter((h) => h !== handler);
};
}
dispose(signal = "SIGTERM") {
- if (this.child.killed) return;
- try {
- this.child.kill(signal);
- } catch {
+ if (this.child.exitCode !== null || this.child.signalCode !== null) return;
+ try { this.child.stdin.end(); this.child.kill(signal); } catch {}
+ if (signal !== "SIGKILL") {
+ const timer = setTimeout(() => {
+ if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill("SIGKILL");
+ }, 2000);
+ timer.unref();
+ this.child.once("exit", () => clearTimeout(timer));
}
}
/**
@@ -288,12 +286,17 @@
const withId = { ...cmd, id };
const line = `${JSON.stringify(withId)}
`;
+ if (this.exited) return Promise.reject(this.exited);
return new Promise((resolve4, reject) => {
- this.pending.set(id, { resolve: resolve4, reject });
- void this.writeLine(line).catch((error) => {
- this.pending.delete(id);
- reject(error);
- });
+ const timer = setTimeout(() => finish(new Error("Pi RPC request timed out")), 30000);
+ timer.unref();
+ const finish = (error, value) => {
+ if (!this.pending.delete(id)) return;
+ clearTimeout(timer);
+ if (error) reject(error); else resolve4(value);
+ };
+ this.pending.set(id, { resolve: (value) => finish(null, value), reject: (error) => finish(error) });
+ void this.writeLine(line).catch((error) => finish(error));
});
}
writeLine(line) {
@@ -714,6 +717,7 @@
try {
proc = await PiRpcProcess.spawn({
cwd: params.cwd,
+ mcpServers: params.mcpServers,
piCommand: params.piCommand
});
} catch (e) {
@@ -808,6 +812,9 @@
this.proc = opts.proc;
this.conn = opts.conn;
this.fileCommands = opts.fileCommands ?? [];
+ this.uiBridge = new PiUiBridge(this.sessionId, this.conn, this.proc);
+ this.turnUsage = new PiTurnUsage();
+ this.turnEpoch = 0;
this.proc.onEvent((ev) => this.handlePiEvent(ev));
}
setStartupInfo(text) {
@@ -852,6 +859,7 @@
}
async cancel() {
this.cancelRequested = true;
+ this.uiBridge.cancelAll();
if (this.turnQueue.length) {
const queued = this.turnQueue.splice(0, this.turnQueue.length);
for (const t of queued) t.resolve("cancelled");
@@ -920,12 +928,16 @@
this.cancelRequested = false;
this.inAgentLoop = false;
this.pendingTurn = { resolve: t.resolve, reject: t.reject };
+ const epoch = ++this.turnEpoch;
+ this.turnUsage.reset();
this.emit({
sessionUpdate: "session_info_update",
_meta: { piAcp: { queueDepth: this.turnQueue.length, running: true } }
});
this.proc.prompt(t.message, t.images).catch((err) => {
void this.flushEmits().finally(() => {
+ if (epoch !== this.turnEpoch || !this.pendingTurn) return;
+ this.uiBridge.cancelAll();
const authErr = maybeAuthRequiredError(err);
if (authErr) {
this.pendingTurn?.reject(authErr);
@@ -946,6 +958,23 @@
handlePiEvent(ev) {
const type = String(ev.type ?? "");
switch (type) {
+ case "paperclip_process_exit": {
+ this.uiBridge.cancelAll();
+ this.pendingTurn?.reject(new Error("Pi provider process exited"));
+ this.pendingTurn = null;
+ for (const turn of this.turnQueue.splice(0)) turn.reject(new Error("Pi provider process exited"));
+ this.turnEpoch += 1;
+ break;
+ }
+ case "message_end": {
+ try { this.turnUsage.accept(ev.message); } catch {
+ this.pendingTurn?.reject(new Error("Pi usage receipt is invalid"));
+ this.pendingTurn = null;
+ this.uiBridge.cancelAll();
+ this.proc.dispose("SIGKILL");
+ }
+ break;
+ }
case "message_update": {
const ame = ev.assistantMessageEvent;
if (ame?.type === "text_delta" && typeof ame.delta === "string") {
@@ -1047,7 +1076,7 @@
if (p) {
try {
const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p);
- snapshotOldText = readFileSync3(abs, "utf8");
+ snapshotOldText = snapshotPiWorkspaceFile(this.cwd, abs);
this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText });
if (toolName === "edit") {
for (const needle of getEditOldTexts(args)) {
@@ -1125,7 +1154,7 @@
if (!isError && snapshot) {
try {
const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path);
- const newText = readFileSync3(abs, "utf8");
+ const newText = snapshotPiWorkspaceFile(this.cwd, abs);
if (snapshot.oldText === null || newText !== snapshot.oldText) {
hasStructuredDiff = true;
content = [
@@ -1154,14 +1183,11 @@
break;
}
case "extension_ui_request": {
- void this.handleExtensionUiRequest(ev).catch(() => {
- const id = stringProp(ev, "id");
- if (!id) {
- return;
- }
- void this.proc.sendExtensionUiResponse({ id, cancelled: true }).catch(() => {
- });
+ if (ev.method === "notify") this.emit({
+ sessionUpdate: "session_info_update",
+ _meta: { paperclipPi: { notice: { message: typeof ev.message === "string" ? ev.message.slice(0, 4096) : "Pi notification", level: ev.notifyType } } }
});
+ void this.uiBridge.handle(ev).catch(() => { this.proc.dispose("SIGKILL"); });
break;
}
case "auto_retry_start": {
@@ -1210,9 +1236,12 @@
break;
}
case "agent_settled": {
+ const epoch = this.turnEpoch;
void this.flushEmits().finally(() => {
+ if (epoch !== this.turnEpoch || !this.pendingTurn) return;
+ this.uiBridge.cancelAll();
const reason = this.cancelRequested ? "cancelled" : "end_turn";
- this.pendingTurn?.resolve(reason);
+ this.pendingTurn?.resolve({ stopReason: reason, ...this.turnUsage.response() });
this.pendingTurn = null;
this.inAgentLoop = false;
const next = this.turnQueue.shift();
@@ -1725,6 +1754,7 @@
return process.env.PI_CODING_AGENT_DIR ? resolve3(process.env.PI_CODING_AGENT_DIR) : join4(homedir4(), ".pi", "agent");
}
function getEnableSkillCommands(cwd) {
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return true;
const merged = getMergedSettings(cwd);
const direct = merged.enableSkillCommands;
if (typeof direct === "boolean") return direct;
@@ -1733,6 +1763,7 @@
return true;
}
function getQuietStartup(cwd) {
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return true;
const merged = getMergedSettings(cwd);
const direct = merged.quietStartup;
if (typeof direct === "boolean") return direct;
@@ -1884,11 +1915,13 @@
throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`);
}
const cwd = opts?.cwd ?? stored.cwd;
+ if (cwd !== stored.cwd || !opts) throw RequestError3.invalidParams("Pi recovery requires session/load in the original workspace");
let proc;
try {
proc = await PiRpcProcess.spawn({
cwd,
sessionPath: stored.sessionFile,
+ mcpServers: opts?.mcpServers ?? [],
piCommand: process.env.PI_ACP_PI_COMMAND
});
} catch (e) {
@@ -1897,7 +1930,7 @@
}
throw e;
}
- const fileCommands = loadSlashCommands(cwd);
+ const fileCommands = [];
const session = this.sessions.getOrCreate(sessionId, {
cwd,
mcpServers: opts?.mcpServers ?? [],
@@ -1917,6 +1950,7 @@
}
}
async initialize(params) {
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw RequestError3.invalidParams("Pi requires a qualified Paperclip launch");
const supportedVersion = 1;
const requested = params.protocolVersion;
return {
@@ -1933,7 +1967,8 @@
}),
agentCapabilities: {
loadSession: true,
- mcpCapabilities: { http: false, sse: false },
+ mcpCapabilities: { http: true, sse: false },
+ _meta: { paperclipPi: PI_ACP_FEATURES },
promptCapabilities: {
image: true,
audio: false,
@@ -1942,8 +1977,7 @@
sessionCapabilities: {
// **UNSTABLE** ACP capability used by Zed's codex-acp adapter.
// Enables a native session picker in clients that support it.
- list: {},
- delete: {}
+ list: {}
}
}
};
@@ -1953,7 +1987,7 @@
throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`);
}
this.lastSessionCwd = params.cwd;
- const fileCommands = loadSlashCommands(params.cwd);
+ const fileCommands = [];
const enableSkillCommands = getEnableSkillCommands(params.cwd);
const session = await this.sessions.create({
cwd: params.cwd,
@@ -2066,6 +2100,8 @@
const session = await this.restoreSession(params.sessionId);
const { message, images } = promptToPiMessage(params.prompt);
if (images.length === 0 && message.trimStart().startsWith("/")) {
+ const command = message.trim().split(/\s+/)[0];
+ if (!["/compact", "/session", "/autocompact"].includes(command)) throw RequestError3.invalidParams("Pi slash command is not admitted by Paperclip Runner");
const trimmed = message.trim();
const space = trimmed.indexOf(" ");
const cmd = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space);
@@ -2432,8 +2468,18 @@
}
}
const result = await session.prompt(message, images);
- const stopReason = result === "error" ? session.wasCancelRequested() ? "cancelled" : "end_turn" : result;
- return { stopReason };
+ if (result && typeof result === "object") return result;
+ if (result === "error") throw RequestError3.internalError({}, "Pi prompt failed");
+ return { stopReason: result };
+ }
+ async extMethod(method, params) {
+ if (method !== "pi/steer" && method !== "pi/follow_up") throw RequestError3.methodNotFound(method);
+ if (typeof params.sessionId !== "string" || typeof params.message !== "string" || !params.message.trim() || Buffer.byteLength(params.message) > 65536) throw RequestError3.invalidParams("Invalid Pi continuation");
+ const session = this.sessions.maybeGet(params.sessionId);
+ if (!session?.pendingTurn || session.cancelRequested) throw RequestError3.invalidParams("Pi continuation requires an active turn");
+ const response = await session.proc.request({ type: method === "pi/steer" ? "steer" : "follow_up", message: params.message });
+ if (!response.success) throw RequestError3.internalError({}, "Pi continuation was rejected");
+ return { accepted: true, sessionId: session.sessionId, kind: method === "pi/steer" ? "steer" : "follow_up" };
}
async cancel(params) {
const session = this.sessions.maybeGet(params.sessionId);
@@ -2473,7 +2519,7 @@
mcpServers: params.mcpServers
});
const proc = session.proc;
- const fileCommands = loadSlashCommands(params.cwd);
+ const fileCommands = [];
this.sessions.closeAllExcept?.(session.sessionId);
this.store.upsert({
sessionId: params.sessionId,
@@ -2607,6 +2653,7 @@
return response;
}
async deleteSession(params) {
+ throw RequestError3.methodNotFound("session/delete");
const stored = this.store.get(params.sessionId);
const piSession = findPiSession(params.sessionId);
if (!stored && !piSession) {
@@ -2828,6 +2875,7 @@
return 0;
}
function buildUpdateNotice() {
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return null;
try {
const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" });
const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace(
@@ -2848,6 +2896,7 @@
}
}
function buildStartupInfo(opts) {
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return "";
void opts.fileCommands;
const md = [];
try {
@@ -2980,6 +3029,7 @@
// src/index.ts
if (process.argv.includes("--terminal-login")) {
+ throw new Error("Interactive Pi authentication is not available in Paperclip Runner");
const { spawnSync: spawnSync2 } = await import("child_process");
const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND);
const res = spawnSync2(cmd, [], {
@@ -3019,11 +3069,12 @@
}
});
var stream = ndJsonStream(input, output);
-var agent = new AgentSideConnection((conn) => new PiAcpAgent(conn), stream);
+var ownedPiAgent;
+var agent = new AgentSideConnection((conn) => (ownedPiAgent = new PiAcpAgent(conn)), stream);
function shutdown() {
try {
;
- agent?.agent?.dispose?.();
+ ownedPiAgent?.dispose?.();
} catch {
}
try {
diff --git a/dist/paperclip-runtime.js b/dist/paperclip-runtime.js
new file mode 100644
--- /dev/null
+++ b/dist/paperclip-runtime.js
@@ -0,0 +1,248 @@
+/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */
+import { StringDecoder } from "node:string_decoder";
+import { isAbsolute, relative, resolve, sep } from "node:path";
+import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs";
+
+const PERMISSION_PREFIX = "paperclip.pi.permission.v1:";
+const PERMISSION_CHOICES = [
+ { optionId: "allow_once", name: "Allow once", kind: "allow_once" },
+ { optionId: "allow_always", name: "Allow for this session", kind: "allow_always" },
+ { optionId: "reject_once", name: "Deny", kind: "reject_once" },
+];
+
+function record(value ) {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Pi runtime record");
+ return value ;
+}
+function text(value , max = 16_384) {
+ if (typeof value !== "string" || Buffer.byteLength(value) > max) throw new Error("Invalid Pi runtime text");
+ return value;
+}
+
+export const PI_ACP_FEATURES = Object.freeze({
+ version: 1, steering: true, queuedFollowUp: true,
+ questions: ["select", "confirm", "input", "editor"],
+ nativePermissions: true, promptUsage: true, nativePlan: false,
+ pendingRequestRecovery: "live-process-only",
+});
+
+/** Strict LF framing: Unicode line separators inside JSON are ordinary text. */
+export class PiRpcFrames {
+ decoder = new StringDecoder("utf8");
+ pending = "";
+ receive ;
+ limit ;
+ constructor(receive , limit = 4 * 1024 * 1024) { this.receive = receive; this.limit = limit; }
+ write(chunk ) {
+ this.pending += this.decoder.write(Buffer.from(chunk));
+ for (;;) {
+ const index = this.pending.indexOf("\n");
+ if (index < 0) break;
+ const line = this.pending.slice(0, index).replace(/\r$/, "");
+ this.pending = this.pending.slice(index + 1);
+ if (Buffer.byteLength(line) > this.limit) throw new Error("Pi RPC frame exceeds its bound");
+ if (line.trim()) this.receive(record(JSON.parse(line)));
+ }
+ if (Buffer.byteLength(this.pending) > this.limit) throw new Error("Pi RPC frame exceeds its bound");
+ }
+ end() {
+ this.pending += this.decoder.end();
+ if (this.pending.trim()) throw new Error("Pi RPC stream ended inside a frame");
+ }
+}
+
+function requiredFile(environment , name ) {
+ const path = environment[name];
+ if (!path || !isAbsolute(path) || /[\0\r\n]/.test(path)) throw new Error(`Missing verified Pi launch binding: ${name}`);
+ // This checks the handoff shape. The runner command lease verifies all bytes,
+ // retains their directory identity, and owns the subprocess lifetime.
+ if (!lstatSync(path).isFile()) throw new Error(`Invalid verified Pi launch binding: ${name}`);
+ return path;
+}
+function pathArray(raw ) {
+ const paths = JSON.parse(raw ?? "[]");
+ if (!Array.isArray(paths) || paths.length > 128 || paths.some((p) => typeof p !== "string" || !isAbsolute(p) || /[\0\r\n]/.test(p))) throw new Error("Invalid Pi runtime paths");
+ return paths;
+}
+
+/** Provider tool events are untrusted; diff projection cannot read host files. */
+export function snapshotPiWorkspaceFile(cwd , path , environment = process.env) {
+ const root = realpathSync(cwd);
+ const logical = resolve(root, path);
+ const physical = realpathSync(logical);
+ const within = (base , target ) => {
+ const suffix = relative(base, target);
+ return !isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`);
+ };
+ if (!within(root, logical) || !within(root, physical)) throw new Error("Pi diff escaped its workspace");
+ for (const protectedPath of pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS)) {
+ if (within(protectedPath, logical)) throw new Error("Pi diff targets protected state");
+ let protectedPhysical ;
+ try { protectedPhysical = realpathSync(protectedPath); } catch { continue; }
+ if (within(protectedPhysical, physical)) throw new Error("Pi diff targets protected state");
+ }
+ const fd = openSync(physical, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
+ try {
+ const before = fstatSync(fd, { bigint: true });
+ if (!before.isFile() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Pi diff file is unsupported");
+ const result = readFileSync(fd, "utf8");
+ const after = fstatSync(fd, { bigint: true });
+ const named = lstatSync(physical, { bigint: true });
+ if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.ctimeNs !== after.ctimeNs || named.ino !== before.ino || named.dev !== before.dev || realpathSync(logical) !== physical) throw new Error("Pi diff file changed during capture");
+ return result;
+ } finally { closeSync(fd); }
+}
+
+export function createPiLaunchSpec(
+ params ,
+ environment ,
+) {
+ if (environment.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") throw new Error("Pi requires an isolated runner launch");
+ const command = requiredFile(environment, "PAPERCLIP_PI_NODE_EXECUTABLE");
+ const entrypoint = requiredFile(environment, "PAPERCLIP_PI_ENTRYPOINT");
+ const extension = requiredFile(environment, "PAPERCLIP_PI_EXTENSION_PATH");
+ if (environment.PAPERCLIP_PI_READ_ONLY !== "0" && environment.PAPERCLIP_PI_READ_ONLY !== "1") throw new Error("Pi requires an explicit read-only policy");
+ const readRoots = pathArray(environment.PAPERCLIP_PI_READ_ROOTS);
+ const protectedRoots = pathArray(environment.PAPERCLIP_PI_PROTECTED_ROOTS);
+ const workspace = realpathSync(params.cwd);
+ const configuration = JSON.stringify({
+ workspace, servers: params.mcpServers ?? [],
+ readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1",
+ readRoots, protectedRoots,
+ instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "",
+ });
+ if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound");
+ const args = [entrypoint, "--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-approve", "--offline", "-e", extension];
+ for (const root of readRoots) args.push("--skill", root);
+ if (params.sessionPath) {
+ const home = environment.PI_CODING_AGENT_DIR;
+ if (!home) throw new Error("Pi session home is missing");
+ const sessionRoot = realpathSync(resolve(home, "sessions"));
+ const sessionPath = realpathSync(params.sessionPath);
+ const suffix = relative(sessionRoot, sessionPath);
+ if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`) || !lstatSync(params.sessionPath).isFile()) throw new Error("Pi session escaped its private home");
+ args.push("--session", sessionPath);
+ }
+ return { command, args, env: { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration } };
+}
+
+
+
+
+
+
+
+/** Live promises never become authority to replay an answer after provider death. */
+export class PiUiBridge {
+ pending = new Map ();
+ seen = new Set ();
+ sessionId ;
+ connection ;
+ process ;
+ constructor(sessionId , connection , process ) { this.sessionId = sessionId; this.connection = connection; this.process = process; }
+
+ cancelAll() { for (const value of this.pending.values()) value.cancel(); }
+
+ async handle(event ) {
+ const id = text(event.id, 256);
+ const method = text(event.method, 64);
+ if (!["select", "confirm", "input", "editor"].includes(method)) return;
+ if (this.seen.has(id)) return;
+ if (this.seen.size >= 4096) throw new Error("Pi UI request history exceeds its bound");
+ this.seen.add(id);
+ let done = false;
+ let timeout ;
+ const finish = async (response ) => {
+ if (done) return;
+ done = true;
+ if (timeout) clearTimeout(timeout);
+ this.pending.delete(id);
+ await this.process.sendExtensionUiResponse({ id, ...response });
+ };
+ this.pending.set(id, { cancel: () => { void finish({ cancelled: true }).catch(() => {}); } });
+ if (typeof event.timeout === "number" && Number.isFinite(event.timeout)) {
+ timeout = setTimeout(() => { void finish({ cancelled: true }).catch(() => {}); }, Math.max(0, Math.min(event.timeout, 2_147_483_647)));
+ timeout.unref?.();
+ }
+ try {
+ const title = text(event.title ?? "Additional information needed", 65_536);
+ if (method === "select" && title.startsWith(PERMISSION_PREFIX)) {
+ const permission = record(JSON.parse(title.slice(PERMISSION_PREFIX.length)));
+ const toolCallId = text(permission.toolCallId, 256);
+ const toolName = text(permission.toolName, 128);
+ const input = record(permission.input);
+ const result = record(await this.connection.requestPermission({
+ sessionId: this.sessionId,
+ toolCall: { toolCallId, title: `Pi ${toolName}`, kind: toolName === "bash" ? "execute" : ["write", "edit"].includes(toolName) ? "edit" : "read", status: "pending", rawInput: input },
+ options: PERMISSION_CHOICES,
+ }));
+ const outcome = record(result.outcome);
+ const selected = outcome.outcome === "selected" ? PERMISSION_CHOICES.find((option) => option.optionId === outcome.optionId) : undefined;
+ await finish(selected ? { value: selected.name } : { cancelled: true });
+ return;
+ }
+ const property = { type: method === "confirm" ? "boolean" : "string", title };
+ if (method === "select") {
+ if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options");
+ property.enum = event.options.map((option) => text(option, 4096));
+ if (new Set(property.enum ).size !== event.options.length) throw new Error("Ambiguous Pi question options");
+ }
+ if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder);
+ if (method === "editor" && typeof event.prefill === "string") property.default = text(event.prefill);
+ const result = record(await this.connection.unstable_createElicitation({
+ sessionId: this.sessionId, mode: "form", message: typeof event.message === "string" ? text(event.message) : title,
+ requestedSchema: { type: "object", title, properties: { answer: property }, required: ["answer"] },
+ _meta: { paperclipPi: { version: 1, requestId: id, method } },
+ }));
+ if (result.action !== "accept") { await finish({ cancelled: true }); return; }
+ const answer = record(result.content).answer;
+ if (method === "confirm") {
+ if (typeof answer !== "boolean") throw new Error("Invalid Pi confirmation response");
+ await finish({ confirmed: answer });
+ } else {
+ const value = text(answer, 65_536);
+ if (method === "select" && !(property.enum ).includes(value)) throw new Error("Invalid Pi question response");
+ await finish({ value });
+ }
+ } catch { await finish({ cancelled: true }).catch(() => {}); }
+ }
+}
+
+/** Sum actual assistant receipts, never context occupancy or estimated cost. */
+export class PiTurnUsage {
+ seen = new Set ();
+ total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 };
+ cost = 0;
+ failed = false;
+ observed = false;
+ reset() { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.failed = false; this.observed = false; }
+ accept(value ) {
+ const message = record(value);
+ if (message.role !== "assistant") return;
+ this.failed = message.stopReason === "error";
+ if (!message.usage || typeof message.usage !== "object") return;
+ const key = JSON.stringify([message.timestamp, message.id, message.usage, message.content]);
+ if (this.seen.has(key)) return;
+ if (this.seen.size >= 8192) throw new Error("Pi usage receipts exceed their bound");
+ this.seen.add(key);
+ const usage = record(message.usage);
+ const numbers = ["input", "output", "cacheRead", "cacheWrite"].map((name) => usage[name] ?? 0);
+ if (numbers.some((value) => typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) return;
+ this.observed = true;
+ for (const [index, field] of ["inputTokens", "outputTokens", "cachedReadTokens", "cachedWriteTokens"].entries()) {
+ const name = field ;
+ this.total[name] += numbers[index] ;
+ }
+ this.total.totalTokens = this.total.inputTokens + this.total.outputTokens + this.total.cachedReadTokens + this.total.cachedWriteTokens;
+ if (usage.cost && typeof usage.cost === "object") {
+ const cost = record(usage.cost).total;
+ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) this.cost += cost;
+ }
+ }
+ response() {
+ return {
+ ...(this.observed ? { usage: { ...this.total, _meta: { paperclipPi: { provenance: "assistant_message_receipts", costUsd: this.cost } } } } : {}),
+ ...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}),
+ };
+ }
+}