mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
678 lines
30 KiB
Diff
678 lines
30 KiB
Diff
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" } } } } } : {}),
|
|
+ };
|
|
+ }
|
|
+}
|