mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 16:11:46 +02:00
Require explicit native-effective thinking modes, reject drift across reconnects, and retain observed settings in qualification artifacts. Version the profile and pinned wrapper closure for the new contract. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1473 lines
73 KiB
Diff
1473 lines
73 KiB
Diff
--- a/dist/index.js
|
|
+++ b/dist/index.js
|
|
@@ -11,6 +11,7 @@
|
|
// src/acp/auth.ts
|
|
var PI_SETUP_METHOD_ID = "pi_terminal_login";
|
|
function getAuthMethods(opts) {
|
|
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return [];
|
|
const supportsTerminalAuthMeta = opts?.supportsTerminalAuthMeta ?? true;
|
|
const method = {
|
|
id: PI_SETUP_METHOD_ID,
|
|
@@ -53,7 +54,7 @@
|
|
|
|
// src/pi-rpc/process.ts
|
|
import { spawn } from "child_process";
|
|
-import * as readline from "readline";
|
|
+import { PI_ACP_FEATURES, PiRpcMessageDeltas, piNativeFailure, PiAssistantMessages, piAssistantChunk, piHistoricalAssistantChunk, PiToolIdentities, piHistoricalToolIdentity, PiRpcFrames, PiUiBridge, PiTurnUsage, createPiLaunchSpec, snapshotPiWorkspaceFile } from "./paperclip-runtime.js";
|
|
|
|
// src/pi-rpc/command.ts
|
|
import { platform } from "os";
|
|
@@ -94,51 +95,44 @@
|
|
pending = /* @__PURE__ */ new Map();
|
|
eventHandlers = [];
|
|
preludeLines = [];
|
|
- constructor(child) {
|
|
+ constructor(child, invocationNamespace) {
|
|
+ this.toolIdentities = new PiToolIdentities(invocationNamespace);
|
|
+ this.assistantMessages = new PiAssistantMessages(invocationNamespace);
|
|
+ this.rpcMessages = new PiRpcMessageDeltas();
|
|
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", failure: piNativeFailure(error) });
|
|
+ };
|
|
+ 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);
|
|
+ const event = this.toolIdentities.normalize(this.assistantMessages.normalize(this.rpcMessages.normalize(msg)));
|
|
+ for (const handler of this.eventHandlers) handler(event);
|
|
});
|
|
- 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) => {
|
|
@@ -172,7 +166,7 @@
|
|
}
|
|
child.stderr.on("data", () => {
|
|
});
|
|
- const proc = new _PiRpcProcess(child);
|
|
+ const proc = new _PiRpcProcess(child, launch.invocationNamespace);
|
|
try {
|
|
const state = await proc.getState();
|
|
const sessionFile = typeof state?.sessionFile === "string" ? state.sessionFile : null;
|
|
@@ -181,21 +175,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", failure: piNativeFailure(this.exited) }));
|
|
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));
|
|
}
|
|
}
|
|
/**
|
|
@@ -229,6 +232,11 @@
|
|
if (!res.success) throw new Error(`pi set_model failed: ${res.error ?? JSON.stringify(res.data)}`);
|
|
return res.data;
|
|
}
|
|
+ async getAvailableThinkingLevels() {
|
|
+ const res = await this.request({ type: "get_available_thinking_levels" });
|
|
+ if (!res.success) throw new Error(`pi get_available_thinking_levels failed: ${res.error ?? JSON.stringify(res.data)}`);
|
|
+ return res.data;
|
|
+ }
|
|
async setThinkingLevel(level) {
|
|
const res = await this.request({ type: "set_thinking_level", level });
|
|
if (!res.success) throw new Error(`pi set_thinking_level failed: ${res.error ?? JSON.stringify(res.data)}`);
|
|
@@ -242,7 +250,7 @@
|
|
if (!res.success) throw new Error(`pi set_steering_mode failed: ${res.error ?? JSON.stringify(res.data)}`);
|
|
}
|
|
async compact(customInstructions) {
|
|
- const res = await this.request({ type: "compact", customInstructions });
|
|
+ const res = await this.request({ type: "compact", customInstructions }, 120000);
|
|
if (!res.success) throw new Error(`pi compact failed: ${res.error ?? JSON.stringify(res.data)}`);
|
|
return res.data;
|
|
}
|
|
@@ -283,17 +291,23 @@
|
|
await this.writeLine(`${JSON.stringify({ type: "extension_ui_response", ...response })}
|
|
`);
|
|
}
|
|
- request(cmd) {
|
|
+ request(cmd, timeoutMs = 30000) {
|
|
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120000) throw new Error("Invalid Pi RPC deadline");
|
|
const id = crypto.randomUUID();
|
|
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")); this.dispose("SIGKILL"); }, timeoutMs);
|
|
+ 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) {
|
|
@@ -337,7 +351,7 @@
|
|
{
|
|
authMethods: getAuthMethods()
|
|
},
|
|
- "Configure an API key or log in with an OAuth provider."
|
|
+ "Bind the configured OpenRouter API credential to this Paperclip runtime."
|
|
);
|
|
}
|
|
|
|
@@ -534,6 +548,14 @@
|
|
const record = value;
|
|
const command = record?.command ?? record?.cmd ?? record?.args?.command ?? record?.args?.cmd ?? record?.input?.command ?? record?.input?.cmd ?? record?.rawInput?.command ?? record?.rawInput?.cmd ?? record?.toolInput?.command ?? record?.toolInput?.cmd ?? record?.details?.command ?? record?.details?.cmd;
|
|
return typeof command === "string" && command.trim() ? command : void 0;
|
|
+}
|
|
+// Standard ACP data survives clients that do not consume terminal metadata.
|
|
+// Keep each structured native value whole or explicitly omit it at a byte bound.
|
|
+function boundedBashValue(value) {
|
|
+ const encoded = JSON.stringify(value);
|
|
+ if (encoded === void 0) return void 0;
|
|
+ const bytes = Buffer.byteLength(encoded);
|
|
+ return bytes <= 65536 ? value : { _meta: { paperclipPi: { omitted: "tool value exceeds 65536 bytes", originalBytes: bytes } } };
|
|
}
|
|
function bashResultText(result) {
|
|
const record = result;
|
|
@@ -714,6 +736,7 @@
|
|
try {
|
|
proc = await PiRpcProcess.spawn({
|
|
cwd: params.cwd,
|
|
+ mcpServers: params.mcpServers,
|
|
piCommand: params.piCommand
|
|
});
|
|
} catch (e) {
|
|
@@ -808,6 +831,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) {
|
|
@@ -822,10 +848,7 @@
|
|
sendStartupInfoIfPending() {
|
|
if (this.startupInfoSent || !this.startupInfo) return;
|
|
this.startupInfoSent = true;
|
|
- this.emit({
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: { type: "text", text: this.startupInfo }
|
|
- });
|
|
+ this.emitNotice("startup", this.startupInfo, "info");
|
|
}
|
|
async prompt(message, images = []) {
|
|
const expandedMessage = expandSlashCommand(message, this.fileCommands);
|
|
@@ -852,6 +875,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");
|
|
@@ -870,6 +894,8 @@
|
|
return this.cancelRequested;
|
|
}
|
|
emit(update) {
|
|
+ const provenance = typeof update.toolCallId === "string" ? this.proc.toolIdentities.provenance(update.toolCallId) : undefined;
|
|
+ if (provenance) update = { ...update, _meta: { ...update._meta, paperclipPi: { ...update._meta?.paperclipPi, ...provenance } } };
|
|
this.lastEmit = this.lastEmit.then(
|
|
() => this.conn.sessionUpdate({
|
|
sessionId: this.sessionId,
|
|
@@ -877,6 +903,12 @@
|
|
})
|
|
).catch(() => {
|
|
});
|
|
+ }
|
|
+ emitNotice(category, summary, severity = "info", details = {}) {
|
|
+ this.lastEmit = this.lastEmit.then(() => this.conn.extNotification("paperclip/pi_notice", {
|
|
+ sessionId: this.sessionId, category, severity,
|
|
+ summary: String(summary).slice(0, 4000), details
|
|
+ })).catch(() => {});
|
|
}
|
|
async flushEmits() {
|
|
await this.lastEmit;
|
|
@@ -890,6 +922,7 @@
|
|
kind: "execute",
|
|
status: params.status,
|
|
locations: params.locations,
|
|
+ rawInput: boundedBashValue(params.args),
|
|
...params.includeTerminal ? { content: bashTerminalContent(params.toolCallId) } : {},
|
|
...params.includeTerminal ? { _meta: bashTerminalInfoMeta(params.toolCallId, this.cwd) } : {}
|
|
});
|
|
@@ -903,6 +936,7 @@
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: params.toolCallId,
|
|
status: params.status,
|
|
+ rawOutput: boundedBashValue(params.result),
|
|
_meta: {
|
|
...delta ? bashTerminalOutputMeta(params.toolCallId, delta) : {},
|
|
...params.status === "completed" || params.status === "failed" ? bashTerminalExitMeta(params.toolCallId, bashExitCode(params.result, Boolean(params.isError))) : {}
|
|
@@ -920,18 +954,24 @@
|
|
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) => {
|
|
+ const failure = piNativeFailure(err);
|
|
+ if (!this.cancelRequested) this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason });
|
|
void this.flushEmits().finally(() => {
|
|
+ if (epoch !== this.turnEpoch || !this.pendingTurn) return;
|
|
+ this.uiBridge.cancelAll();
|
|
const authErr = maybeAuthRequiredError(err);
|
|
if (authErr) {
|
|
this.pendingTurn?.reject(authErr);
|
|
} else {
|
|
- const reason = this.cancelRequested ? "cancelled" : "error";
|
|
- this.pendingTurn?.resolve(reason);
|
|
+ if (this.cancelRequested) this.pendingTurn?.resolve("cancelled");
|
|
+ else this.pendingTurn?.reject(RequestError3.internalError({ paperclipPi: failure }, failure.summary));
|
|
}
|
|
this.pendingTurn = null;
|
|
this.inAgentLoop = false;
|
|
@@ -946,20 +986,47 @@
|
|
handlePiEvent(ev) {
|
|
const type = String(ev.type ?? "");
|
|
switch (type) {
|
|
+ case "paperclip_process_exit": {
|
|
+ this.uiBridge.cancelAll();
|
|
+ const failure = piNativeFailure(ev.failure?.summary);
|
|
+ this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason });
|
|
+ const turns = [...(this.pendingTurn ? [this.pendingTurn] : []), ...this.turnQueue.splice(0)];
|
|
+ this.pendingTurn = null;
|
|
+ this.turnEpoch += 1;
|
|
+ void this.flushEmits().finally(() => {
|
|
+ for (const turn of turns) turn.reject(RequestError3.internalError({ paperclipPi: failure }, failure.summary));
|
|
+ });
|
|
+ break;
|
|
+ }
|
|
+ case "message_start": {
|
|
+ if (ev.paperclipAssistantMessage) this.emit(piAssistantChunk(ev.paperclipAssistantMessage));
|
|
+ break;
|
|
+ }
|
|
+ case "message_end": {
|
|
+ if (ev.message?.role === "assistant" && ev.message.stopReason === "error") {
|
|
+ const failure = piNativeFailure(ev.message.errorMessage);
|
|
+ this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason });
|
|
+ }
|
|
+ if (ev.paperclipAssistantMessage) this.emit(piAssistantChunk(ev.paperclipAssistantMessage));
|
|
+ try { this.turnUsage.accept(ev.message); } catch {
|
|
+ const failure = piNativeFailure("Pi usage receipt is invalid");
|
|
+ this.emitNotice("runtime_failure", failure.summary, "error", { reason: failure.reason });
|
|
+ const pending = this.pendingTurn;
|
|
+ this.pendingTurn = null;
|
|
+ void this.flushEmits().finally(() => pending?.reject(RequestError3.internalError({ paperclipPi: failure }, failure.summary)));
|
|
+ this.uiBridge.cancelAll();
|
|
+ this.proc.dispose("SIGKILL");
|
|
+ }
|
|
+ break;
|
|
+ }
|
|
case "message_update": {
|
|
const ame = ev.assistantMessageEvent;
|
|
if (ame?.type === "text_delta" && typeof ame.delta === "string") {
|
|
- this.emit({
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: { type: "text", text: ame.delta }
|
|
- });
|
|
+ this.emit(piAssistantChunk(ev.paperclipAssistantMessage, ame.delta));
|
|
break;
|
|
}
|
|
if (ame?.type === "thinking_delta" && typeof ame.delta === "string") {
|
|
- this.emit({
|
|
- sessionUpdate: "agent_thought_chunk",
|
|
- content: { type: "text", text: ame.delta }
|
|
- });
|
|
+ this.emit(piAssistantChunk(ev.paperclipAssistantMessage, ame.delta, true));
|
|
break;
|
|
}
|
|
if (ame?.type === "toolcall_start" || ame?.type === "toolcall_delta" || ame?.type === "toolcall_end") {
|
|
@@ -1047,7 +1114,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 +1192,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,48 +1221,41 @@
|
|
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.emitNotice("extension_notify", typeof ev.message === "string" ? ev.message : "Pi notification", ["warning", "error"].includes(ev.notifyType) ? ev.notifyType : "info");
|
|
+ void this.uiBridge.handle(ev).catch(() => {
|
|
+ this.emitNotice("invalid_question", "Pi structured question is unsupported or invalid; the session was stopped", "error");
|
|
+ this.proc.dispose("SIGKILL");
|
|
});
|
|
break;
|
|
}
|
|
case "auto_retry_start": {
|
|
- this.emit({
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: { type: "text", text: formatAutoRetryMessage(ev) }
|
|
+ this.emitNotice("auto_retry_start", formatAutoRetryMessage(ev), "warning", {
|
|
+ attempt: ev.attempt, maxAttempts: ev.maxAttempts, delayMs: ev.delayMs,
|
|
+ errorMessage: typeof ev.errorMessage === "string" ? ev.errorMessage.slice(0, 4000) : undefined
|
|
});
|
|
break;
|
|
}
|
|
case "auto_retry_end": {
|
|
- this.emit({
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: { type: "text", text: "Retry finished, resuming." }
|
|
+ this.emitNotice("auto_retry_end", ev.success === true ? "Retry finished, resuming."
|
|
+ : ev.success === false ? "Retry failed; the provider request did not recover."
|
|
+ : "Retry finished; the provider did not report an outcome.", ev.success === true ? "info" : "warning", { attempt: ev.attempt, success: ev.success });
|
|
+ break;
|
|
+ }
|
|
+ case "compaction_start": {
|
|
+ this.emitNotice("compaction_start", ev.reason === "manual" ? "Compacting context..." : "Context nearing limit, running automatic compaction...", "info", { reason: ev.reason });
|
|
+ break;
|
|
+ }
|
|
+ case "compaction_end": {
|
|
+ (this.pendingTurn ? this.turnUsage : this.manualCompactionUsage)?.acceptCompaction(ev.result);
|
|
+ this.emitNotice("compaction_end", ev.aborted ? "Context compaction cancelled." : ev.errorMessage ? "Context compaction failed." : "Context compaction finished.", ev.errorMessage ? "error" : ev.aborted ? "warning" : "info", {
|
|
+ reason: ev.reason, aborted: ev.aborted, willRetry: ev.willRetry,
|
|
+ errorMessage: typeof ev.errorMessage === "string" ? ev.errorMessage.slice(0, 4000) : undefined
|
|
});
|
|
break;
|
|
}
|
|
- case "auto_compaction_start": {
|
|
- this.emit({
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: {
|
|
- type: "text",
|
|
- text: "Context nearing limit, running automatic compaction..."
|
|
- }
|
|
- });
|
|
- break;
|
|
- }
|
|
- case "auto_compaction_end": {
|
|
- this.emit({
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: {
|
|
- type: "text",
|
|
- text: "Automatic compaction finished; context was summarized to continue the session."
|
|
- }
|
|
- });
|
|
+ case "summarization_retry_scheduled": {
|
|
+ (this.pendingTurn ? this.turnUsage : this.manualCompactionUsage)?.markIncomplete();
|
|
+ this.emitNotice("summarization_retry_scheduled", "Context summarization is retrying a provider request.", "warning");
|
|
break;
|
|
}
|
|
case "agent_start": {
|
|
@@ -1210,9 +1270,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 +1788,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 +1797,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;
|
|
@@ -1824,6 +1889,7 @@
|
|
const out = [];
|
|
const seen = /* @__PURE__ */ new Set();
|
|
for (const c of [...a, ...b]) {
|
|
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && !["compact", "session", "autocompact"].includes(c.name)) continue;
|
|
if (seen.has(c.name)) continue;
|
|
seen.add(c.name);
|
|
out.push(c);
|
|
@@ -1884,11 +1950,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 +1965,7 @@
|
|
}
|
|
throw e;
|
|
}
|
|
- const fileCommands = loadSlashCommands(cwd);
|
|
+ const fileCommands = [];
|
|
const session = this.sessions.getOrCreate(sessionId, {
|
|
cwd,
|
|
mcpServers: opts?.mcpServers ?? [],
|
|
@@ -1917,6 +1985,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 +2002,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 +2012,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 +2022,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,
|
|
@@ -1994,14 +2063,14 @@
|
|
this.cleanupFailedNewSession(session.sessionId, state);
|
|
throw RequestError3.authRequired(
|
|
{ authMethods: getAuthMethods() },
|
|
- "Configure an API key or log in with an OAuth provider."
|
|
+ "Bind the configured OpenRouter API credential to this Paperclip runtime."
|
|
);
|
|
}
|
|
if (stateErr && maybeAuthRequiredError(stateErr)) {
|
|
this.cleanupFailedNewSession(session.sessionId, state);
|
|
throw RequestError3.authRequired(
|
|
{ authMethods: getAuthMethods() },
|
|
- "Configure an API key or log in with an OAuth provider."
|
|
+ "Bind the configured OpenRouter API credential to this Paperclip runtime."
|
|
);
|
|
}
|
|
const { configOptions, models, modes } = await getSessionConfiguration(session.proc, {
|
|
@@ -2064,34 +2133,27 @@
|
|
}
|
|
async prompt(params) {
|
|
const session = await this.restoreSession(params.sessionId);
|
|
+ if (session.manualCompactionUsage) throw RequestError3.invalidParams("Pi context compaction is active");
|
|
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);
|
|
const argsString = space === -1 ? "" : trimmed.slice(space + 1);
|
|
const args = parseCommandArgs(argsString);
|
|
if (cmd === "compact") {
|
|
+ if (session.pendingTurn || session.manualCompactionUsage) throw RequestError3.invalidParams("Manual compaction requires an idle session");
|
|
const customInstructions = args.join(" ").trim() || void 0;
|
|
- const res = await session.proc.compact(customInstructions);
|
|
- const r = res && typeof res === "object" ? res : null;
|
|
- const tokensBefore = typeof r?.tokensBefore === "number" ? r.tokensBefore : null;
|
|
- const summary = typeof r?.summary === "string" ? r.summary : null;
|
|
- const headerLines = [
|
|
- `Compaction completed.${customInstructions ? " (custom instructions applied)" : ""}`,
|
|
- tokensBefore !== null ? `Tokens before: ${tokensBefore}` : null
|
|
- ].filter(Boolean);
|
|
- const text = headerLines.join("\n") + (summary ? `
|
|
-
|
|
-${summary}` : "");
|
|
- await this.conn.sessionUpdate({
|
|
- sessionId: session.sessionId,
|
|
- update: {
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: { type: "text", text }
|
|
- }
|
|
- });
|
|
- return { stopReason: "end_turn" };
|
|
+ const compactionUsage = new PiTurnUsage();
|
|
+ session.manualCompactionUsage = compactionUsage;
|
|
+ try {
|
|
+ const res = await session.proc.compact(customInstructions);
|
|
+ compactionUsage.acceptCompaction(res);
|
|
+ await session.flushEmits();
|
|
+ return { stopReason: "end_turn", ...compactionUsage.response() };
|
|
+ } finally { session.manualCompactionUsage = null; }
|
|
}
|
|
if (cmd === "session") {
|
|
const stats = await session.proc.getSessionStats();
|
|
@@ -2112,13 +2174,8 @@
|
|
}
|
|
const text = lines.length ? lines.join("\n") : `Session stats:
|
|
${JSON.stringify(stats, null, 2)}`;
|
|
- await this.conn.sessionUpdate({
|
|
- sessionId: session.sessionId,
|
|
- update: {
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: { type: "text", text }
|
|
- }
|
|
- });
|
|
+ session.emitNotice("session_stats", text, "info");
|
|
+ await session.flushEmits();
|
|
return { stopReason: "end_turn" };
|
|
}
|
|
if (cmd === "name") {
|
|
@@ -2418,22 +2475,24 @@
|
|
enabled = !current;
|
|
}
|
|
await session.proc.setAutoCompaction(enabled);
|
|
- await this.conn.sessionUpdate({
|
|
- sessionId: session.sessionId,
|
|
- update: {
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: {
|
|
- type: "text",
|
|
- text: `Auto-compaction ${enabled ? "enabled" : "disabled"}.`
|
|
- }
|
|
- }
|
|
- });
|
|
+ session.emitNotice("auto_compaction_policy", `Auto-compaction ${enabled ? "enabled" : "disabled"}.`, "info", { enabled });
|
|
+ await session.flushEmits();
|
|
return { stopReason: "end_turn" };
|
|
}
|
|
}
|
|
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 || response.data?.disposition !== "queued") throw RequestError3.internalError({}, "Pi continuation was not queued by the native runtime");
|
|
+ return { accepted: true, sessionId: session.sessionId, kind: method === "pi/steer" ? "steer" : "follow_up", disposition: response.data.disposition };
|
|
}
|
|
async cancel(params) {
|
|
const session = this.sessions.maybeGet(params.sessionId);
|
|
@@ -2473,7 +2532,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,
|
|
@@ -2482,7 +2541,7 @@
|
|
});
|
|
const data = await proc.getMessages();
|
|
const messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
- for (const m of messages) {
|
|
+ for (const [messageIndex, m] of messages.entries()) {
|
|
const role = String(m?.role ?? "");
|
|
if (role === "user") {
|
|
const text = normalizePiMessageText(m?.content);
|
|
@@ -2501,16 +2560,14 @@
|
|
if (text) {
|
|
await this.conn.sessionUpdate({
|
|
sessionId: session.sessionId,
|
|
- update: {
|
|
- sessionUpdate: "agent_message_chunk",
|
|
- content: { type: "text", text }
|
|
- }
|
|
+ update: piHistoricalAssistantChunk(params.sessionId, messageIndex, text)
|
|
});
|
|
}
|
|
}
|
|
if (role === "toolResult") {
|
|
const toolName = String(m?.toolName ?? "tool");
|
|
- const toolCallId = String(m?.toolCallId ?? crypto.randomUUID());
|
|
+ const historical = piHistoricalToolIdentity(params.sessionId, messageIndex, String(m?.toolCallId ?? ""));
|
|
+ const toolCallId = historical.id;
|
|
const isError = Boolean(m?.isError);
|
|
const isBash = isBashTool(toolName);
|
|
if (isBash) {
|
|
@@ -2524,7 +2581,7 @@
|
|
kind: "execute",
|
|
status: "completed",
|
|
content: bashTerminalContent(toolCallId),
|
|
- _meta: bashTerminalInfoMeta(toolCallId, params.cwd)
|
|
+ _meta: { ...bashTerminalInfoMeta(toolCallId, params.cwd), paperclipPi: historical.provenance }
|
|
}
|
|
});
|
|
await this.conn.sessionUpdate({
|
|
@@ -2535,7 +2592,8 @@
|
|
status: isError ? "failed" : "completed",
|
|
_meta: {
|
|
...text2 ? bashTerminalOutputMeta(toolCallId, text2) : {},
|
|
- ...bashTerminalExitMeta(toolCallId, bashExitCode(m, isError))
|
|
+ ...bashTerminalExitMeta(toolCallId, bashExitCode(m, isError)),
|
|
+ paperclipPi: historical.provenance
|
|
}
|
|
}
|
|
});
|
|
@@ -2549,6 +2607,7 @@
|
|
title: toolName,
|
|
kind: toolName === "read" ? "read" : toolName === "write" || toolName === "edit" ? "edit" : "other",
|
|
status: "completed",
|
|
+ _meta: { paperclipPi: historical.provenance },
|
|
rawInput: null,
|
|
rawOutput: m
|
|
}
|
|
@@ -2560,6 +2619,7 @@
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId,
|
|
status: isError ? "failed" : "completed",
|
|
+ _meta: { paperclipPi: historical.provenance },
|
|
content: text ? [{ type: "content", content: { type: "text", text } }] : null,
|
|
rawOutput: m
|
|
}
|
|
@@ -2607,6 +2667,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) {
|
|
@@ -2630,15 +2691,12 @@
|
|
async setSessionMode(params) {
|
|
const session = await this.restoreSession(params.sessionId);
|
|
const mode = String(params.modeId);
|
|
- if (!isThinkingLevel(mode)) {
|
|
- throw RequestError3.invalidParams(`Unknown modeId: ${mode}`);
|
|
- }
|
|
- await session.proc.setThinkingLevel(mode);
|
|
- void this.conn.sessionUpdate({
|
|
+ const modes = await setVerifiedThinkingLevel(session.proc, mode);
|
|
+ await this.conn.sessionUpdate({
|
|
sessionId: session.sessionId,
|
|
update: {
|
|
sessionUpdate: "current_mode_update",
|
|
- currentModeId: mode
|
|
+ currentModeId: modes.currentModeId
|
|
}
|
|
});
|
|
await emitConfigOptionsUpdate(this.conn, session.sessionId, session.proc);
|
|
@@ -2653,15 +2711,12 @@
|
|
if (configId === MODEL_CONFIG_ID) {
|
|
await setSessionModel(session.proc, params.value);
|
|
} else if (configId === THOUGHT_LEVEL_CONFIG_ID) {
|
|
- if (!isThinkingLevel(params.value)) {
|
|
- throw RequestError3.invalidParams(`Unknown thinking level: ${params.value}`);
|
|
- }
|
|
- await session.proc.setThinkingLevel(params.value);
|
|
- void this.conn.sessionUpdate({
|
|
+ const modes = await setVerifiedThinkingLevel(session.proc, params.value);
|
|
+ await this.conn.sessionUpdate({
|
|
sessionId: session.sessionId,
|
|
update: {
|
|
sessionUpdate: "current_mode_update",
|
|
- currentModeId: params.value
|
|
+ currentModeId: modes.currentModeId
|
|
}
|
|
});
|
|
} else {
|
|
@@ -2672,20 +2727,18 @@
|
|
}
|
|
};
|
|
function isThinkingLevel(x) {
|
|
- return x === "off" || x === "minimal" || x === "low" || x === "medium" || x === "high" || x === "xhigh";
|
|
+ return x === "off" || x === "minimal" || x === "low" || x === "medium" || x === "high" || x === "xhigh" || x === "max";
|
|
}
|
|
async function getThinkingState(proc, pre) {
|
|
- let current = "medium";
|
|
- const state = pre?.state ?? await (async () => {
|
|
- try {
|
|
- return await proc.getState();
|
|
- } catch {
|
|
- return null;
|
|
- }
|
|
- })();
|
|
- const tl = typeof state?.thinkingLevel === "string" ? state.thinkingLevel : null;
|
|
- if (tl && isThinkingLevel(tl)) current = tl;
|
|
- const available = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
+ const available = (await proc.getAvailableThinkingLevels())?.levels;
|
|
+ if (!Array.isArray(available) || available.length === 0 || available.some((level) => !isThinkingLevel(level)) || new Set(available).size !== available.length) {
|
|
+ throw new Error("Pi returned invalid supported thinking levels");
|
|
+ }
|
|
+ const state = pre?.state ?? await proc.getState();
|
|
+ const current = state?.thinkingLevel;
|
|
+ if (!isThinkingLevel(current) || !available.includes(current)) {
|
|
+ throw new Error("Pi returned an unsupported effective thinking level");
|
|
+ }
|
|
return {
|
|
currentModeId: current,
|
|
availableModes: available.map((id) => ({
|
|
@@ -2694,6 +2747,18 @@
|
|
description: null
|
|
}))
|
|
};
|
|
+}
|
|
+async function setVerifiedThinkingLevel(proc, requested) {
|
|
+ const before = await getThinkingState(proc);
|
|
+ if (!before.availableModes.some((mode) => mode.id === requested)) {
|
|
+ throw RequestError3.invalidParams(`Unsupported thinking level for the current model: ${requested}`);
|
|
+ }
|
|
+ await proc.setThinkingLevel(requested);
|
|
+ const after = await getThinkingState(proc);
|
|
+ if (after.currentModeId !== requested) {
|
|
+ throw new Error("Pi did not apply the requested thinking level");
|
|
+ }
|
|
+ return after;
|
|
}
|
|
async function getSessionConfiguration(proc, pre) {
|
|
const [models, modes] = await Promise.all([getModelState(proc, pre), getThinkingState(proc, { state: pre?.state })]);
|
|
@@ -2828,6 +2893,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 +2914,7 @@
|
|
}
|
|
}
|
|
function buildStartupInfo(opts) {
|
|
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1") return "";
|
|
void opts.fileCommands;
|
|
const md = [];
|
|
try {
|
|
@@ -2980,6 +3047,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 +3087,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 {
|
|
--- a/dist/paperclip-runtime.js
|
|
+++ b/dist/paperclip-runtime.js
|
|
@@ -0,0 +1,577 @@
|
|
+/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */
|
|
+import { createHash, randomUUID } from "node:crypto";
|
|
+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",
|
|
+});
|
|
+
|
|
+
|
|
+
|
|
+
|
|
+
|
|
+
|
|
+
|
|
+/** Pi 1 RPC serializes message_update without message/partial. Reconstruct only
|
|
+ * the streaming view, anchored to the actual message_start; message_end remains
|
|
+ * the native authoritative receipt. Never infer an executable tool from text. */
|
|
+export class PiRpcMessageDeltas {
|
|
+ active = null;
|
|
+ blocks = new Map ();
|
|
+ streamedBytes = 0;
|
|
+ failed = false;
|
|
+ fail() { this.failed = true; throw new Error("Pi RPC message delta boundary is invalid"); }
|
|
+ normalize(event ) {
|
|
+ if (this.failed) return this.fail();
|
|
+ if (event.type === "message_start" && event.message && typeof event.message === "object" && (event.message ).role === "assistant") {
|
|
+ if (this.active) return this.fail();
|
|
+ this.active = event.message ; this.blocks.clear(); this.streamedBytes = 0;
|
|
+ return event;
|
|
+ }
|
|
+ if (event.type === "message_end" && event.message && typeof event.message === "object" && (event.message ).role === "assistant") {
|
|
+ const message = event.message ;
|
|
+ if (!this.active || message.timestamp !== this.active.timestamp) return this.fail();
|
|
+ if (!["error", "aborted"].includes(String(message.stopReason)) && [...this.blocks.values()].some(block => !block.ended)) return this.fail();
|
|
+ for (const [index, block] of this.blocks) if (block.ended) {
|
|
+ const final = Array.isArray(message.content) ? message.content[index] : undefined;
|
|
+ if (!final || typeof final !== "object") return this.fail();
|
|
+ if (block.kind === "toolcall") {
|
|
+ if (final.type !== "toolCall" || toolSignature(final.id, final.name, final.arguments) !== block.final) return this.fail();
|
|
+ } else if (final.type !== block.kind || final[block.kind === "text" ? "text" : "thinking"] !== block.json) return this.fail();
|
|
+ }
|
|
+ this.active = null; this.blocks.clear(); return event;
|
|
+ }
|
|
+ if (event.type !== "message_update") return event;
|
|
+ if (!this.active || event.message !== undefined || !event.assistantMessageEvent || typeof event.assistantMessageEvent !== "object" || Array.isArray(event.assistantMessageEvent)) return this.fail();
|
|
+ const update = event.assistantMessageEvent ;
|
|
+ if (update.partial !== undefined || typeof update.type !== "string") return this.fail();
|
|
+ const match = /^(text|thinking|toolcall)_(start|delta|end)$/.exec(update.type);
|
|
+ const index = update.contentIndex;
|
|
+ if (!match || !Number.isSafeInteger(index) || (index ) < 0 || (index ) >= 4096) return this.fail();
|
|
+ const [, kind, phase] = match;
|
|
+ let block = this.blocks.get(index );
|
|
+ if (phase === "start") {
|
|
+ if (block) return this.fail();
|
|
+ block = { kind: kind , ended: false, json: "" };
|
|
+ if (kind === "toolcall") {
|
|
+ if (typeof update.id !== "string" || Buffer.byteLength(update.id) > 256 || typeof update.toolName !== "string" || Buffer.byteLength(update.toolName) > 256) return this.fail();
|
|
+ if (update.id && [...this.blocks.values()].some(other => other.id === update.id)) return this.fail();
|
|
+ block.id = update.id; block.name = update.toolName;
|
|
+ }
|
|
+ this.blocks.set(index , block);
|
|
+ } else if (!block || block.ended || block.kind !== kind) return this.fail();
|
|
+ if (phase === "delta") {
|
|
+ if (typeof update.delta !== "string" || Buffer.byteLength(update.delta) > 262_144) return this.fail();
|
|
+ this.streamedBytes += Buffer.byteLength(update.delta);
|
|
+ if (this.streamedBytes > 4 * 1024 * 1024) return this.fail();
|
|
+ block .json += update.delta;
|
|
+ if (kind === "toolcall" && Buffer.byteLength(block .json) > 1_048_576) return this.fail();
|
|
+ }
|
|
+ let toolCall ;
|
|
+ if (kind === "toolcall") {
|
|
+ if (phase === "end") {
|
|
+ if (!update.toolCall || typeof update.toolCall !== "object" || Array.isArray(update.toolCall)) return this.fail();
|
|
+ const final = update.toolCall ;
|
|
+ if (final.type !== "toolCall" || typeof final.id !== "string" || !final.id || Buffer.byteLength(final.id) > 256 || typeof final.name !== "string" || !final.name || Buffer.byteLength(final.name) > 256
|
|
+ || block .id && block .id !== final.id || block .name && block .name !== final.name
|
|
+ || [...this.blocks.entries()].some(([otherIndex, other]) => otherIndex !== index && other.id === final.id)
|
|
+ || !final.arguments || typeof final.arguments !== "object" || Array.isArray(final.arguments) || Buffer.byteLength(JSON.stringify(final.arguments)) > 1_048_576) return this.fail();
|
|
+ block .id = final.id; block .name = final.name; block .final = toolSignature(final.id, final.name, final.arguments); toolCall = final;
|
|
+ } else toolCall = { type: "toolCall", id: block .id, name: block .name, partialArgs: block .json };
|
|
+ }
|
|
+ if (phase === "end") {
|
|
+ if (kind !== "toolcall" && (typeof update.content !== "string" || update.content !== block .json)) return this.fail();
|
|
+ block .ended = true;
|
|
+ }
|
|
+ return { ...event, message: { ...this.active, content: [] }, assistantMessageEvent: { ...update, ...(toolCall ? { toolCall } : {}) } };
|
|
+ }
|
|
+}
|
|
+
|
|
+function toolSignature(id , name , args ) {
|
|
+ const canonical = (value ) => Array.isArray(value) ? value.map(canonical)
|
|
+ : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value;
|
|
+ return JSON.stringify([id, name, canonical(args)]);
|
|
+}
|
|
+
|
|
+/** A closed diagnostic vocabulary: raw exception text, paths, provider bodies
|
|
+ * and credentials never cross the wrapper boundary. Unknown failures stay
|
|
+ * generic rather than masquerading as a known local or provider condition. */
|
|
+export function piNativeFailure(value ) {
|
|
+ const message = value instanceof Error ? value.message : typeof value === "string" ? value : "";
|
|
+ const known = {
|
|
+ "Pi RPC message delta boundary is invalid": "rpc_delta_boundary_invalid",
|
|
+ "Pi native assistant message boundary is invalid": "assistant_boundary_invalid",
|
|
+ "Pi native tool invocation identity conflict": "tool_identity_conflict",
|
|
+ "Pi model iteration identity is ambiguous": "model_iteration_ambiguous",
|
|
+ "Pi model iteration identity is unavailable": "model_iteration_unavailable",
|
|
+ "Pi tool invocation has no active model iteration": "tool_iteration_missing",
|
|
+ "Pi tool identity is outside its iteration bound": "tool_iteration_bound",
|
|
+ "Pi RPC frame exceeds its bound": "rpc_frame_oversized",
|
|
+ "Pi RPC stream ended inside a frame": "rpc_frame_incomplete",
|
|
+ "Pi usage receipt is invalid": "usage_receipt_invalid",
|
|
+ "Pi process exited before its next request": "provider_process_exited",
|
|
+ };
|
|
+ if (Object.hasOwn(known, message)) return { reason: known[message] , summary: message };
|
|
+ const status = /^(?:pi prompt failed: )?(401|403|429|500|502|503|504)(?::|\b)/.exec(message.slice(0, 128))?.[1];
|
|
+ if (status) return { reason: `provider_http_${status}`, summary: `Pi provider request failed (HTTP ${status})` };
|
|
+ return { reason: "unknown_native_failure", summary: "Pi native runtime failed; diagnostic details were withheld" };
|
|
+}
|
|
+
|
|
+/** IDs are allocated only at actual SDK assistant message_start events. Tool
|
|
+ * iterations, retries, notices and ACP prompts cannot create assistant IDs. */
|
|
+export class PiAssistantMessages {
|
|
+ ordinal = 0;
|
|
+ active = null;
|
|
+ poisoned = false;
|
|
+ namespace ;
|
|
+ constructor(namespace ) {
|
|
+ this.namespace = namespace;
|
|
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi assistant namespace is invalid");
|
|
+ }
|
|
+ fail() { this.poisoned = true; throw new Error("Pi native assistant message boundary is invalid"); }
|
|
+ normalize(event ) {
|
|
+ if (this.poisoned) return this.fail();
|
|
+ if (event.type === "agent_settled") {
|
|
+ if (this.active) return this.fail();
|
|
+ return event;
|
|
+ }
|
|
+ if (!["message_start", "message_update", "message_end"].includes(String(event.type))) return event;
|
|
+ const message = event.message;
|
|
+ if (!message || typeof message !== "object" || Array.isArray(message)) return this.fail();
|
|
+ const native = message ;
|
|
+ if (native.role !== "assistant") {
|
|
+ if (event.type === "message_update" || this.active || !["user", "toolResult", "system"].includes(String(native.role))) return this.fail();
|
|
+ // Pi 1 records prompt/tool declaration updates as structural system messages.
|
|
+ // They never receive an assistant occurrence or become final-answer content.
|
|
+ if (native.role === "system" && (typeof native.content !== "string" || !Number.isSafeInteger(native.timestamp) || (native.timestamp ) < 0)) return this.fail();
|
|
+ return event;
|
|
+ }
|
|
+ if (!Number.isSafeInteger(native.timestamp) || (native.timestamp ) < 0 || !Array.isArray(native.content)) return this.fail();
|
|
+ let boundary ;
|
|
+ if (event.type === "message_start") {
|
|
+ if (this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) return this.fail();
|
|
+ const messageId = `pi-message-${createHash("sha256").update(JSON.stringify([this.namespace, ++this.ordinal])).digest("hex")}`;
|
|
+ this.active = { messageId, timestamp: native.timestamp };
|
|
+ boundary = { messageId, phase: "start" };
|
|
+ } else {
|
|
+ if (!this.active || this.active.timestamp !== native.timestamp) return this.fail();
|
|
+ boundary = { messageId: this.active.messageId, phase: event.type === "message_end" ? "end" : "delta" };
|
|
+ if (event.type === "message_end") {
|
|
+ if (!["stop", "length", "toolUse", "error", "aborted"].includes(String(native.stopReason))) return this.fail();
|
|
+ boundary.stopReason = native.stopReason ;
|
|
+ this.active = null;
|
|
+ }
|
|
+ }
|
|
+ return { ...event, paperclipAssistantMessage: boundary };
|
|
+ }
|
|
+}
|
|
+
|
|
+export function piAssistantChunk(boundaryValue , textValue = "", thought = false) {
|
|
+ const boundary = record(boundaryValue);
|
|
+ const messageId = text(boundary.messageId, 96);
|
|
+ if (!/^pi-message-[a-f0-9]{64}$/.test(messageId) || !["start", "delta", "end"].includes(String(boundary.phase))) throw new Error("Pi assistant chunk lacks native provenance");
|
|
+ if (boundary.phase === "end" && !["stop", "length", "toolUse", "error", "aborted"].includes(String(boundary.stopReason))) throw new Error("Pi assistant chunk end is invalid");
|
|
+ const content = text(textValue, 262_144);
|
|
+ if (boundary.phase !== "delta" && (content || thought)) throw new Error("Pi assistant boundary cannot carry synthetic content");
|
|
+ return { sessionUpdate: thought ? "agent_thought_chunk" : "agent_message_chunk", messageId,
|
|
+ content: { type: "text", text: content },
|
|
+ _meta: { origin: "pi-native-assistant", source: "pi-rpc-message-v1", kind: boundary.phase === "end" ? `end:${boundary.stopReason}` : boundary.phase },
|
|
+ };
|
|
+}
|
|
+
|
|
+export function piHistoricalAssistantChunk(sessionId , messageIndex , value ) {
|
|
+ if (!sessionId || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi historical assistant identity is invalid");
|
|
+ return { sessionUpdate: "agent_message_chunk",
|
|
+ messageId: `pi-history-message-${createHash("sha256").update(JSON.stringify([text(sessionId, 1024), messageIndex])).digest("hex")}`,
|
|
+ content: { type: "text", text: text(value, 262_144) },
|
|
+ _meta: { origin: "pi-history-assistant", source: "pi-session-history-v1", kind: "history" },
|
|
+ };
|
|
+}
|
|
+
|
|
+/** One trusted Pi model iteration, not one ACP prompt. Pi resets its own turnIndex
|
|
+ * on warm prompts; our ordinal is monotonic for the lifetime of the child. */
|
|
+export class PiToolIdentities {
|
|
+ ordinal = 0;
|
|
+ active = false;
|
|
+ poisoned = false;
|
|
+ entries = new Map ();
|
|
+ namespace ;
|
|
+ constructor(namespace ) {
|
|
+ this.namespace = namespace;
|
|
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(namespace)) throw new Error("Pi invocation namespace is invalid");
|
|
+ }
|
|
+ fail(message ) { this.poisoned = true; throw new Error(message); }
|
|
+ begin() {
|
|
+ if (this.poisoned || this.active || this.ordinal >= Number.MAX_SAFE_INTEGER) this.fail("Pi model iteration identity is ambiguous");
|
|
+ this.ordinal++; this.active = true; this.entries.clear();
|
|
+ }
|
|
+ end() {
|
|
+ if (this.poisoned || !this.active) this.fail("Pi model iteration identity is unavailable");
|
|
+ this.active = false;
|
|
+ }
|
|
+ identity(nativeId ) {
|
|
+ if (this.poisoned || this.ordinal === 0) this.fail("Pi model iteration identity is unavailable");
|
|
+ let native ;
|
|
+ try { native = text(nativeId, 256); } catch { return this.fail("Pi native tool identity is invalid"); }
|
|
+ if (!native) this.fail("Pi native tool identity is missing");
|
|
+ const prior = this.entries.get(native);
|
|
+ if (prior) return prior.id;
|
|
+ if (!this.active || this.entries.size >= 4096) this.fail("Pi tool identity is outside its iteration bound");
|
|
+ const id = `pi-${createHash("sha256").update(JSON.stringify([this.namespace, this.ordinal, native])).digest("hex")}`;
|
|
+ this.entries.set(native, { id, nativeId: native, claimed: false });
|
|
+ return id;
|
|
+ }
|
|
+ bind(nativeId , name , args , claim = false) {
|
|
+ if (this.poisoned || !this.active) this.fail("Pi tool invocation has no active model iteration");
|
|
+ const id = this.identity(nativeId); const entry = this.entries.get(nativeId) ;
|
|
+ const canonical = (value ) => Array.isArray(value) ? value.map(canonical)
|
|
+ : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value;
|
|
+ let encoded ;
|
|
+ try { encoded = JSON.stringify([text(name, 256), canonical(args)]); }
|
|
+ catch { return this.fail("Pi tool invocation is invalid"); }
|
|
+ if (Buffer.byteLength(encoded) > 1_048_576) this.fail("Pi tool invocation is oversized");
|
|
+ const fingerprint = createHash("sha256").update(encoded).digest("hex");
|
|
+ if ((claim && entry.claimed) || (entry.fingerprint !== undefined && entry.fingerprint !== fingerprint)) this.fail("Pi native tool invocation identity conflict");
|
|
+ entry.fingerprint = fingerprint; if (claim) entry.claimed = true;
|
|
+ return id;
|
|
+ }
|
|
+ provenance(id ) {
|
|
+ for (const entry of this.entries.values()) if (entry.id === id) return { nativeToolCallId: entry.nativeId, modelIteration: this.ordinal, identityScope: "native-model-iteration" };
|
|
+ return undefined;
|
|
+ }
|
|
+ normalize(event ) {
|
|
+ if (event.type === "turn_start") { this.begin(); return event; }
|
|
+ if (event.type === "turn_end") { this.end(); return event; }
|
|
+ if (["tool_execution_start", "tool_execution_update", "tool_execution_end"].includes(String(event.type))) {
|
|
+ const native = text(event.toolCallId, 256);
|
|
+ const id = event.type === "tool_execution_start" ? this.bind(native, text(event.toolName, 256), event.args, true) : this.identity(native);
|
|
+ return { ...event, toolCallId: id };
|
|
+ }
|
|
+ if (event.type === "message_update" && event.assistantMessageEvent) {
|
|
+ const update = record(event.assistantMessageEvent);
|
|
+ const normalizeTool = (value , direct = false) => {
|
|
+ if (!value || typeof value !== "object") return value;
|
|
+ const block = record(value);
|
|
+ // Empty IDs during initial streaming do not identify an executable call.
|
|
+ return (direct || block.type === "toolCall") && typeof block.id === "string" && block.id
|
|
+ ? { ...block, id: direct && update.type === "toolcall_end"
|
|
+ ? this.bind(block.id, text(block.name, 256), block.arguments) : this.identity(block.id) } : value;
|
|
+ };
|
|
+ const partial = update.partial && typeof update.partial === "object" ? record(update.partial) : undefined;
|
|
+ return { ...event, assistantMessageEvent: { ...update,
|
|
+ ...(update.toolCall ? { toolCall: normalizeTool(update.toolCall, true) } : {}),
|
|
+ ...(partial && Array.isArray(partial.content) ? { partial: { ...partial, content: partial.content.map((block) => normalizeTool(block)) } } : {}),
|
|
+ } };
|
|
+ }
|
|
+ return event;
|
|
+ }
|
|
+}
|
|
+
|
|
+/** Session history is presentation-only and must never acquire a live delivery ID. */
|
|
+export function piHistoricalToolIdentity(sessionId , messageIndex , nativeId ) {
|
|
+ const session = text(sessionId, 1024); const native = text(nativeId, 256);
|
|
+ if (!session || !native || !Number.isSafeInteger(messageIndex) || messageIndex < 0) throw new Error("Pi history tool identity is invalid");
|
|
+ return { id: `pi-history-${createHash("sha256").update(JSON.stringify([session, messageIndex, native])).digest("hex")}`,
|
|
+ provenance: { nativeToolCallId: native, historyMessageIndex: messageIndex, identityScope: "history-display-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 suppliedAgentHome = environment.PAPERCLIP_PI_AGENT_HOME;
|
|
+ let agentHome ;
|
|
+ if (suppliedAgentHome !== undefined) {
|
|
+ if (!isAbsolute(suppliedAgentHome) || /[\0\r\n]/.test(suppliedAgentHome) || suppliedAgentHome === "/" || !lstatSync(suppliedAgentHome).isDirectory() || realpathSync(suppliedAgentHome) !== suppliedAgentHome) throw new Error("Pi agent files require a canonical registered directory");
|
|
+ agentHome = suppliedAgentHome;
|
|
+ }
|
|
+ const invocationNamespace = randomUUID();
|
|
+ const configuration = JSON.stringify({
|
|
+ invocationNamespace, workspace, servers: params.mcpServers ?? [],
|
|
+ readOnly: environment.PAPERCLIP_PI_READ_ONLY === "1",
|
|
+ readRoots, protectedRoots,
|
|
+ ...(agentHome ? { agentHome } : {}),
|
|
+ instructions: environment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS ?? "",
|
|
+ });
|
|
+ if (Buffer.byteLength(configuration) > 64 * 1024) throw new Error("Pi launch configuration exceeds its bound");
|
|
+ const guard = environment.PAPERCLIP_PI_MODULE_GUARD_PATH === undefined ? []
|
|
+ : ["--require", requiredFile(environment, "PAPERCLIP_PI_MODULE_GUARD_PATH")];
|
|
+ const args = [...guard, 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);
|
|
+ }
|
|
+ const env = { ...environment, PAPERCLIP_PI_RUNTIME_CONFIGURATION: configuration };
|
|
+ // Ambient AGENT_HOME never grants filesystem authority. Only the runner's
|
|
+ // authenticated working-copy binding reaches both the model and tool gate.
|
|
+ if (agentHome) env.AGENT_HOME = agentHome; else delete env.AGENT_HOME;
|
|
+ return { command, args, invocationNamespace, env };
|
|
+}
|
|
+
|
|
+// Same UTF-16 character bound as the canonical question-set title/header/label.
|
|
+export const PI_QUESTION_LABEL_MAX_LENGTH = 1_000;
|
|
+export function piQuestionLabel(value ) {
|
|
+ if (typeof value !== "string" || !value.trim() || value.length > PI_QUESTION_LABEL_MAX_LENGTH) throw new Error("Pi question label is invalid or oversized");
|
|
+ return value;
|
|
+}
|
|
+
|
|
+
|
|
+
|
|
+
|
|
+
|
|
+
|
|
+
|
|
+/** 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, _meta: { paperclipPi: { nativeToolCallId: text(permission.nativeToolCallId, 256), modelIteration: permission.modelIteration } }, 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;
|
|
+ }
|
|
+ piQuestionLabel(title);
|
|
+ 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) => piQuestionLabel(option));
|
|
+ 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 {
|
|
+ if (done) return; // An expired request cannot fail a later live session.
|
|
+ await finish({ cancelled: true }).catch(() => {});
|
|
+ throw new Error("Pi structured question is unsupported or invalid");
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+/** Sum native usage receipts; preserve missing fields and label catalog pricing. */
|
|
+export class PiTurnUsage {
|
|
+ seen = new Set ();
|
|
+ total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 };
|
|
+ cost = 0;
|
|
+ unknown = new Set ();
|
|
+ costObserved = false;
|
|
+ costIncomplete = false;
|
|
+ compactionObserved = false;
|
|
+ failed = false;
|
|
+ observed = false;
|
|
+ reset() { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.unknown.clear(); this.costObserved = false; this.costIncomplete = false; this.compactionObserved = false; this.failed = false; this.observed = false; }
|
|
+ accept(value ) {
|
|
+ const message = record(value);
|
|
+ if (message.role !== "assistant") return;
|
|
+ if (!message.usage || typeof message.usage !== "object") {
|
|
+ this.failed = message.stopReason === "error";
|
|
+ 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);
|
|
+ this.failed = message.stopReason === "error";
|
|
+ const usage = record(message.usage);
|
|
+ const valid = (value ) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
+ const fields = ["inputTokens", "outputTokens", "cachedReadTokens", "cachedWriteTokens"] ;
|
|
+ const numbers = ["input", "output", "cacheRead", "cacheWrite"].map((name) => usage[name]);
|
|
+ for (const [index, name] of fields.entries()) {
|
|
+ const value = numbers[index];
|
|
+ if (valid(value)) { this.total[name] += value; this.observed = true; }
|
|
+ else this.unknown.add(name);
|
|
+ }
|
|
+ const total = valid(usage.totalTokens) ? usage.totalTokens
|
|
+ : numbers.every(valid) ? (numbers ).reduce((sum, value) => sum + value, 0) : undefined;
|
|
+ if (valid(total)) this.total.totalTokens += total;
|
|
+ else this.unknown.add("totalTokens");
|
|
+ const cost = usage.cost && typeof usage.cost === "object" ? record(usage.cost).total : undefined;
|
|
+ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) {
|
|
+ this.cost += cost; this.costObserved = true;
|
|
+ } else this.costIncomplete = true;
|
|
+ }
|
|
+
|
|
+ markIncomplete() {
|
|
+ for (const name of Object.keys(this.total)) this.unknown.add(name );
|
|
+ this.costIncomplete = true;
|
|
+ }
|
|
+
|
|
+ acceptCompaction(value ) {
|
|
+ this.compactionObserved = true;
|
|
+ const result = value && typeof value === "object" && !Array.isArray(value) ? record(value) : {};
|
|
+ if (!result.usage || typeof result.usage !== "object") {
|
|
+ // A compaction may consume tokens even if its terminal receipt is absent.
|
|
+ // Known assistant counters alone cannot establish the complete turn.
|
|
+ this.markIncomplete();
|
|
+ return;
|
|
+ }
|
|
+ const previousFailure = this.failed;
|
|
+ this.accept({ role: "assistant", id: "compaction", timestamp: result.firstKeptEntryId,
|
|
+ content: [result.tokensBefore, result.summary], usage: result.usage });
|
|
+ // A successful summary is not proof that an earlier failed model turn recovered.
|
|
+ this.failed = previousFailure;
|
|
+ }
|
|
+
|
|
+ response() {
|
|
+ return {
|
|
+ ...(this.observed ? { usage: { ...Object.fromEntries(Object.entries(this.total).filter(([name]) => !this.unknown.has(name ))), _meta: { paperclipPi: { provenance: this.compactionObserved ? "assistant_message_and_compaction_receipts" : "assistant_message_receipts", ...(this.costObserved && !this.costIncomplete ? { costUsd: this.cost, costSource: "pi_pricing_estimate" } : {}) } } } } : {}),
|
|
+ ...(this.failed ? { _meta: { jetbrains: { air: { version: 1, sessionFailure: { severity: "error", category: "service", title: "Pi provider request failed" } } } } } : {}),
|
|
+ };
|
|
+ }
|
|
+}
|