Files
PaperClipAI/patches/pi-acp@0.0.33.patch
T

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" } } } } } : {}),
+ };
+ }
+}