diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 5ac568312ed..1e4920a3491 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -214,3 +214,17 @@ The closed snapshot has been exercised against actual pinned Pi through ACP initialization and the missing-credential admission path without a model call. The exact reported `currentModelId` and billable receipt still need credentialed local and Linux/Daytona qualification. + +Profile version 2 declaration digest: `sha256:b18deb44976b92e5e6b4f874f6e28dbbf23b34c917879ee3f7cdc03472919476`. It hashes the versioned +profile domain, patched wrapper source and platform closure pins. Every native +closure remains independently checked at launch. Version 1 warm sessions cannot +be reused with this integration. + +Pi 0.84.2 emits `compaction_start`/`compaction_end`; the wrapper maps those +events, includes terminal `result.usage` receipts once, and retains bounded +summarization retry progress. Manual compaction requires an idle session and has +a 120-second RPC deadline; deadline expiry terminates Pi so a timed-out operation +cannot continue invisibly. Successful compaction reports +`assistant_message_and_compaction_receipts` provenance. Pi's summarization retry +helper discards failed-attempt usage, so any retry or missing compaction receipt +invalidates complete-turn token/cost totals instead of inventing complete coverage. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 3c84813dd42..b015701cdc0 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -170,7 +170,7 @@ impl AcpxProviderDescriptor { "0.0.33", Some("@earendil-works/pi-coding-agent"), Some("0.84.2"), - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + "sha256:b18deb44976b92e5e6b4f874f6e28dbbf23b34c917879ee3f7cdc03472919476", ), "cursor" => ( self.model.as_str(), @@ -2461,7 +2461,7 @@ mod tests { "pi", "pi-acp", "0.0.33", - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + "sha256:b18deb44976b92e5e6b4f874f6e28dbbf23b34c917879ee3f7cdc03472919476", Some("@earendil-works/pi-coding-agent"), Some("0.84.2"), "openrouter/deepseek/deepseek-v4-flash-0731", diff --git a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs index a31291f2eff..1daaba6d2da 100644 --- a/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs +++ b/packages/paperclip-runner/scripts/materialize-pi-distribution.mjs @@ -18,8 +18,8 @@ const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch"); const supportedTargets = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]); export const PI_DISTRIBUTION_PINS = Object.freeze({ wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION, - wrapperSha256: "18375d30e7eca9fddf6c024f49899d6da38c064d8587aa1678fd4c2d70308fbc", - helperSha256: "57dd03e3d1e59d0191f0dd66f5a809e6c825de3c4b6660bff697686c160a659f", + wrapperSha256: "80c0e302e75b39d71404c69afb2d5182c468ccdee68bfe8b09f1f40e20a47fb4", + helperSha256: "4333d284ff06ab1195e49630041f0b03a486a92fc678100525ad107e0982dba1", }); const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts index f1142176d4e..38381472c3d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.test.ts @@ -91,6 +91,16 @@ describe("Pi ACP bridge", () => { } }); }); + it("accounts compaction receipts once and does not fabricate partial coverage", () => { + const usage = new PiTurnUsage(); + usage.accept({ role: "assistant", timestamp: 1, stopReason: "error", usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } } }); + const compacted = { firstKeptEntryId: "entry-2", tokensBefore: 100, summary: "Context summary", usage: { input: 10, output: 4, cacheRead: 2, cacheWrite: 0, cost: { total: 0.02 } } }; + usage.acceptCompaction(compacted); usage.acceptCompaction(compacted); + expect(usage.response()).toMatchObject({ usage: { inputTokens: 11, outputTokens: 6, totalTokens: 19, _meta: { paperclipPi: { provenance: "assistant_message_and_compaction_receipts", costUsd: 0.03 } } }, _meta: { jetbrains: { air: { sessionFailure: { severity: "error" } } } } }); + usage.acceptCompaction({ summary: "Missing usage" }); + expect(usage.response().usage).toEqual({ _meta: { paperclipPi: { provenance: "assistant_message_and_compaction_receipts" } } }); + }); + it("launches verified paths only and rejects session escapes", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-pi-launch-")); temporary.push(root); await mkdir(join(root, "sessions")); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts index d8b9156474d..09a8295568a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-acp-runtime.ts @@ -210,7 +210,7 @@ export class PiUiBridge { } } -/** Sum actual assistant receipts, never context occupancy or estimated cost. */ +/** Sum native usage receipts; preserve missing fields and label catalog pricing. */ export class PiTurnUsage { private seen = new Set(); private total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; @@ -218,9 +218,10 @@ export class PiTurnUsage { private unknown = new Set(); private costObserved = false; private costIncomplete = false; + private compactionObserved = false; private failed = false; private observed = false; - reset(): void { this.seen.clear(); this.total = { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, totalTokens: 0 }; this.cost = 0; this.unknown.clear(); this.costObserved = false; this.costIncomplete = false; this.failed = false; this.observed = false; } + reset(): void { 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: unknown): void { const message = record(value); if (message.role !== "assistant") return; @@ -252,9 +253,30 @@ export class PiTurnUsage { } else this.costIncomplete = true; } + markIncomplete(): void { + for (const name of Object.keys(this.total)) this.unknown.add(name as keyof typeof this.total); + this.costIncomplete = true; + } + + acceptCompaction(value: unknown): void { + 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(): RecordValue { return { - ...(this.observed ? { usage: { ...Object.fromEntries(Object.entries(this.total).filter(([name]) => !this.unknown.has(name as keyof typeof this.total))), _meta: { paperclipPi: { provenance: "assistant_message_receipts", ...(this.costObserved && !this.costIncomplete ? { costUsd: this.cost, costSource: "pi_pricing_estimate" } : {}) } } } } : {}), + ...(this.observed ? { usage: { ...Object.fromEntries(Object.entries(this.total).filter(([name]) => !this.unknown.has(name as keyof typeof this.total))), _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" } } } } } : {}), }; } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts index 298e00f63a6..9ee942494be 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-closure-pins.ts @@ -6,7 +6,7 @@ * three pins. Never accept a digest supplied only by an installed manifest. */ export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({ - "darwin-arm64": "f2c95f532e4b56a57e938ceb62b5ea1a86bc721cba29b41de6270afe3fe0041f", - "darwin-x64": "93f3c06c91fc9e06084dff083f3b43ac100dee3ac2c5f5981e3d281f1e561a20", - "linux-x64": "99707d2c78656bebdf5df4b85e4674a72d5d30a3a0c730536246251a2889d0a8", + "darwin-arm64": "2fde022b3714c497f352e628789c4dd47b6446351dc69779293b3a0eafcbc17f", + "darwin-x64": "049fe566c4844db05187cc7e3e94b813f9a3fa8600d37c344f3b425b8776f743", + "linux-x64": "12ff126d91bc296f39bd6c82b52b95dbf8075076e6257bb73256b043f614f391", }); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index 04990d95f60..b9cbfc005d0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -1,5 +1,6 @@ -import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { once } from "node:events"; +import { createHash } from "node:crypto"; import { StringDecoder } from "node:string_decoder"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -22,6 +23,13 @@ async function fixture() { } describe("Pi installation factory", () => { + it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { + const digest = createHash("sha256").update("paperclip.pi.rich-acp.profile.v2\0") + .update(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url))) + .update("\0").update(await readFile(new URL("./pi-closure-pins.ts", import.meta.url))).digest("hex"); + expect(QUALIFIED_ACPX_PROFILES.pi.commandDigest).toBe(`sha256:${digest}`); + }); + it("rejects legacy profiles and caller-selected identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 2"); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index aff023bb8f1..c1547430f88 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -47,13 +47,14 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, acpxVersion: QUALIFIED_ACPX_VERSION, agent: "pi", - agentProfileVersion: 1, + agentProfileVersion: 2, + qualificationStatus: "pending", agentServerPackage: "pi-acp", agentServerVersion: "0.0.33", agentRuntimePackage: "@earendil-works/pi-coding-agent", agentRuntimeVersion: "0.84.2", commandDigest: - "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + "sha256:b18deb44976b92e5e6b4f874f6e28dbbf23b34c917879ee3f7cdc03472919476", qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731", reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs index 838898c56ab..9d641195954 100644 --- a/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs +++ b/packages/paperclip-runner/test-fixtures/pi-acp/fake-pi.mjs @@ -9,6 +9,7 @@ writeFileSync(sessionFile, JSON.stringify({ type: "session", cwd: process.cwd() const output = (value) => process.stdout.write(JSON.stringify(value) + "\n"); let active = false; let model = "fixture-model"; +const compaction = () => ({ firstKeptEntryId: "fixture-entry", tokensBefore: 50, summary: "Fixture compacted", usage: { input: 5, output: 2, cacheRead: 1, cacheWrite: 0, cost: { total: 0.02 } } }); const finish = (answer = "done") => { output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: answer } }); output({ type: "message_end", message: { role: "assistant", timestamp: Date.now(), stopReason: "stop", usage: { input: 11, output: 3, cacheRead: 2, cacheWrite: 0, cost: { total: 0.01 } } } }); @@ -27,11 +28,13 @@ createInterface({ input: process.stdin }).on("line", (line) => { if (request.type === "get_commands") { response({ commands: process.env.PI_FIXTURE_EXTENSION_FAIL ? [] : [{ name: "paperclip-runtime-ready-v1", description: "Paperclip runtime gate v1", source: "extension" }] }); return; } if (request.type === "steer") { response(); output({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: `steered:${request.message}` } }); return; } if (request.type === "follow_up") { response(); finish(`follow-up:${request.message}`); return; } + if (request.type === "compact") { output({ type: "compaction_start", reason: "manual" }); const result = compaction(); output({ type: "compaction_end", reason: "manual", result, aborted: false, willRetry: false }); response(result); return; } if (request.type === "abort") { response(); if (active) { active = false; output({ type: "agent_settled" }); } return; } if (request.type === "prompt") { response({ disposition: "started" }); active = true; output({ type: "agent_start" }); if (request.message === "die") { process.exit(4); } if (request.message === "long") return; + if (request.message === "auto-compact" || request.message === "retry-compact") { output({ type: "compaction_start", reason: "threshold" }); if (request.message === "retry-compact") output({ type: "summarization_retry_scheduled" }); output({ type: "compaction_end", reason: "threshold", result: compaction(), aborted: false, willRetry: true }); finish("compacted"); return; } if (request.message === "question") { output({ type: "extension_ui_request", id: "question-id", method: "input", title: "Project name", placeholder: "Name" }); return; } if (request.message === "permission") { output({ type: "extension_ui_request", id: "permission-id", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-1","toolName":"bash","input":{"command":"pwd"}}', options: ["Allow once", "Allow for this session", "Deny"] }); return; } if (request.message === "failure") { diff --git a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs index f6243380e44..04c3b479107 100644 --- a/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs +++ b/packages/paperclip-runner/test/pi-acp-package-contract.test.mjs @@ -77,7 +77,7 @@ async function fixture(t, extra = {}) { await rm(root, { recursive: true, force: true }); }); const initialized = await call("initialize", { protocolVersion: 1, clientCapabilities: { elicitation: { form: {} } } }); - return { call, initialized, root, notifications, requests, setAnswer(value) { answer = value; } }; + return { call, notify(method, params) { send({ jsonrpc: "2.0", method, params }); }, initialized, root, notifications, requests, setAnswer(value) { answer = value; } }; } test("actual patched ACP process streams thinking and waits for settlement with usage", async (t) => { @@ -122,3 +122,23 @@ test("provider failure is typed and unadmitted slash commands cannot bypass cont assert.equal(result._meta.jetbrains.air.sessionFailure.severity, "error"); await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "/export /outside/report.html" }] }), /not admitted/); }); + +test("manual and automatic compaction retain Pi 0.84.2 progress and usage", async (t) => { + const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] }); + const prompt = (text) => f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text }] }); + const manual = await prompt("/compact"); + assert.equal(manual.usage.inputTokens, 5); assert.equal(manual.usage.totalTokens, 8); + assert.equal(manual.usage._meta.paperclipPi.provenance, "assistant_message_and_compaction_receipts"); + const automatic = await prompt("auto-compact"); + assert.equal(automatic.usage.inputTokens, 16); assert.equal(automatic.usage.totalTokens, 24); + assert.equal(automatic.usage._meta.paperclipPi.costUsd, 0.03); + assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "Context compaction finished.")); + const retried = await prompt("retry-compact"); + assert.equal(retried.usage.inputTokens, undefined); + assert.equal(retried.usage._meta.paperclipPi.costUsd, undefined); + assert.ok(f.notifications.some((event) => event.params?.update?.content?.text === "Context summarization is retrying a provider request.")); + const active = prompt("long"); await new Promise((resolve) => setTimeout(resolve, 25)); + await assert.rejects(prompt("/compact"), /requires an idle session/); + f.notify("session/cancel", { sessionId: session.sessionId }); await active; + assert.match(packageSource, /this\.request\(\{ type: "compact", customInstructions \}, 120000\)/); +}); diff --git a/patches/pi-acp@0.0.33.patch b/patches/pi-acp@0.0.33.patch index 826cf5915cc..2ca62476dc8 100644 --- a/patches/pi-acp@0.0.33.patch +++ b/patches/pi-acp@0.0.33.patch @@ -1,4 +1,3 @@ -diff --git a/dist/index.js b/dist/index.js --- a/dist/index.js +++ b/dist/index.js @@ -11,6 +11,7 @@ @@ -127,7 +126,23 @@ diff --git a/dist/index.js b/dist/index.js } } /** -@@ -288,12 +287,17 @@ +@@ -242,7 +241,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 +282,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)} `; @@ -138,7 +153,7 @@ diff --git a/dist/index.js b/dist/index.js - this.pending.delete(id); - reject(error); - }); -+ const timer = setTimeout(() => finish(new Error("Pi RPC request timed out")), 30000); ++ 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; @@ -150,7 +165,7 @@ diff --git a/dist/index.js b/dist/index.js }); } writeLine(line) { -@@ -337,7 +341,7 @@ +@@ -337,7 +342,7 @@ { authMethods: getAuthMethods() }, @@ -159,7 +174,7 @@ diff --git a/dist/index.js b/dist/index.js ); } -@@ -714,6 +718,7 @@ +@@ -714,6 +719,7 @@ try { proc = await PiRpcProcess.spawn({ cwd: params.cwd, @@ -167,7 +182,7 @@ diff --git a/dist/index.js b/dist/index.js piCommand: params.piCommand }); } catch (e) { -@@ -808,6 +813,9 @@ +@@ -808,6 +814,9 @@ this.proc = opts.proc; this.conn = opts.conn; this.fileCommands = opts.fileCommands ?? []; @@ -177,7 +192,7 @@ diff --git a/dist/index.js b/dist/index.js this.proc.onEvent((ev) => this.handlePiEvent(ev)); } setStartupInfo(text) { -@@ -852,6 +860,7 @@ +@@ -852,6 +861,7 @@ } async cancel() { this.cancelRequested = true; @@ -185,7 +200,7 @@ diff --git a/dist/index.js b/dist/index.js if (this.turnQueue.length) { const queued = this.turnQueue.splice(0, this.turnQueue.length); for (const t of queued) t.resolve("cancelled"); -@@ -920,12 +929,16 @@ +@@ -920,12 +930,16 @@ this.cancelRequested = false; this.inAgentLoop = false; this.pendingTurn = { resolve: t.resolve, reject: t.reject }; @@ -202,7 +217,7 @@ diff --git a/dist/index.js b/dist/index.js const authErr = maybeAuthRequiredError(err); if (authErr) { this.pendingTurn?.reject(authErr); -@@ -946,6 +959,23 @@ +@@ -946,6 +960,23 @@ handlePiEvent(ev) { const type = String(ev.type ?? ""); switch (type) { @@ -226,7 +241,7 @@ diff --git a/dist/index.js b/dist/index.js case "message_update": { const ame = ev.assistantMessageEvent; if (ame?.type === "text_delta" && typeof ame.delta === "string") { -@@ -1047,7 +1077,7 @@ +@@ -1047,7 +1078,7 @@ if (p) { try { const abs = isAbsolute(p) ? p : resolvePath(this.cwd, p); @@ -235,7 +250,7 @@ diff --git a/dist/index.js b/dist/index.js this.fileSnapshots.set(toolCallId, { path: p, oldText: snapshotOldText }); if (toolName === "edit") { for (const needle of getEditOldTexts(args)) { -@@ -1125,7 +1155,7 @@ +@@ -1125,7 +1156,7 @@ if (!isError && snapshot) { try { const abs = isAbsolute(snapshot.path) ? snapshot.path : resolvePath(this.cwd, snapshot.path); @@ -244,7 +259,7 @@ diff --git a/dist/index.js b/dist/index.js if (snapshot.oldText === null || newText !== snapshot.oldText) { hasStructuredDiff = true; content = [ -@@ -1154,14 +1184,11 @@ +@@ -1154,14 +1185,11 @@ break; } case "extension_ui_request": { @@ -263,7 +278,46 @@ diff --git a/dist/index.js b/dist/index.js break; } case "auto_retry_start": { -@@ -1210,9 +1237,12 @@ +@@ -1178,24 +1206,22 @@ + }); + break; + } +- case "auto_compaction_start": { +- this.emit({ +- sessionUpdate: "agent_message_chunk", +- content: { +- type: "text", +- text: "Context nearing limit, running automatic compaction..." +- } +- }); ++ case "compaction_start": { ++ this.emit({ sessionUpdate: "agent_message_chunk", content: { ++ type: "text", text: ev.reason === "manual" ? "Compacting context..." : "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 "compaction_end": { ++ (this.pendingTurn ? this.turnUsage : this.manualCompactionUsage)?.acceptCompaction(ev.result); ++ this.emit({ sessionUpdate: "agent_message_chunk", content: { ++ type: "text", text: ev.aborted ? "Context compaction cancelled." : ev.errorMessage ? "Context compaction failed." : "Context compaction finished." ++ } }); ++ break; ++ } ++ case "summarization_retry_scheduled": { ++ (this.pendingTurn ? this.turnUsage : this.manualCompactionUsage)?.markIncomplete(); ++ this.emit({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Context summarization is retrying a provider request." } }); + break; + } + case "agent_start": { +@@ -1210,9 +1236,12 @@ break; } case "agent_settled": { @@ -277,7 +331,7 @@ diff --git a/dist/index.js b/dist/index.js this.pendingTurn = null; this.inAgentLoop = false; const next = this.turnQueue.shift(); -@@ -1725,6 +1755,7 @@ +@@ -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) { @@ -285,7 +339,7 @@ diff --git a/dist/index.js b/dist/index.js const merged = getMergedSettings(cwd); const direct = merged.enableSkillCommands; if (typeof direct === "boolean") return direct; -@@ -1733,6 +1764,7 @@ +@@ -1733,6 +1763,7 @@ return true; } function getQuietStartup(cwd) { @@ -293,7 +347,7 @@ diff --git a/dist/index.js b/dist/index.js const merged = getMergedSettings(cwd); const direct = merged.quietStartup; if (typeof direct === "boolean") return direct; -@@ -1824,6 +1856,7 @@ +@@ -1824,6 +1855,7 @@ const out = []; const seen = /* @__PURE__ */ new Set(); for (const c of [...a, ...b]) { @@ -301,7 +355,7 @@ diff --git a/dist/index.js b/dist/index.js if (seen.has(c.name)) continue; seen.add(c.name); out.push(c); -@@ -1884,11 +1917,13 @@ +@@ -1884,11 +1916,13 @@ throw RequestError3.invalidParams(`Unknown sessionId: ${sessionId}`); } const cwd = opts?.cwd ?? stored.cwd; @@ -315,7 +369,7 @@ diff --git a/dist/index.js b/dist/index.js piCommand: process.env.PI_ACP_PI_COMMAND }); } catch (e) { -@@ -1897,7 +1932,7 @@ +@@ -1897,7 +1931,7 @@ } throw e; } @@ -324,7 +378,7 @@ diff --git a/dist/index.js b/dist/index.js const session = this.sessions.getOrCreate(sessionId, { cwd, mcpServers: opts?.mcpServers ?? [], -@@ -1917,6 +1952,7 @@ +@@ -1917,6 +1951,7 @@ } } async initialize(params) { @@ -332,7 +386,7 @@ diff --git a/dist/index.js b/dist/index.js const supportedVersion = 1; const requested = params.protocolVersion; return { -@@ -1933,7 +1969,8 @@ +@@ -1933,7 +1968,8 @@ }), agentCapabilities: { loadSession: true, @@ -342,7 +396,7 @@ diff --git a/dist/index.js b/dist/index.js promptCapabilities: { image: true, audio: false, -@@ -1942,8 +1979,7 @@ +@@ -1942,8 +1978,7 @@ sessionCapabilities: { // **UNSTABLE** ACP capability used by Zed's codex-acp adapter. // Enables a native session picker in clients that support it. @@ -352,7 +406,7 @@ diff --git a/dist/index.js b/dist/index.js } } }; -@@ -1953,7 +1989,7 @@ +@@ -1953,7 +1988,7 @@ throw RequestError3.invalidParams(`cwd must be an absolute path: ${params.cwd}`); } this.lastSessionCwd = params.cwd; @@ -361,7 +415,7 @@ diff --git a/dist/index.js b/dist/index.js const enableSkillCommands = getEnableSkillCommands(params.cwd); const session = await this.sessions.create({ cwd: params.cwd, -@@ -1994,14 +2030,14 @@ +@@ -1994,14 +2029,14 @@ this.cleanupFailedNewSession(session.sessionId, state); throw RequestError3.authRequired( { authMethods: getAuthMethods() }, @@ -378,8 +432,11 @@ diff --git a/dist/index.js b/dist/index.js ); } const { configOptions, models, modes } = await getSessionConfiguration(session.proc, { -@@ -2066,6 +2102,8 @@ +@@ -2064,34 +2099,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]; @@ -387,7 +444,42 @@ diff --git a/dist/index.js b/dist/index.js const trimmed = message.trim(); const space = trimmed.indexOf(" "); const cmd = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space); -@@ -2432,8 +2470,18 @@ + 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(); +@@ -2432,8 +2460,18 @@ } } const result = await session.prompt(message, images); @@ -408,7 +500,7 @@ diff --git a/dist/index.js b/dist/index.js } async cancel(params) { const session = this.sessions.maybeGet(params.sessionId); -@@ -2473,7 +2521,7 @@ +@@ -2473,7 +2511,7 @@ mcpServers: params.mcpServers }); const proc = session.proc; @@ -417,7 +509,7 @@ diff --git a/dist/index.js b/dist/index.js this.sessions.closeAllExcept?.(session.sessionId); this.store.upsert({ sessionId: params.sessionId, -@@ -2607,6 +2655,7 @@ +@@ -2607,6 +2645,7 @@ return response; } async deleteSession(params) { @@ -425,7 +517,7 @@ diff --git a/dist/index.js b/dist/index.js const stored = this.store.get(params.sessionId); const piSession = findPiSession(params.sessionId); if (!stored && !piSession) { -@@ -2828,6 +2877,7 @@ +@@ -2828,6 +2867,7 @@ return 0; } function buildUpdateNotice() { @@ -433,7 +525,7 @@ diff --git a/dist/index.js b/dist/index.js try { const piVersion = spawnSync("pi", ["--version"], { encoding: "utf-8" }); const installed = (String(piVersion.stdout ?? "").trim() || String(piVersion.stderr ?? "").trim()).replace( -@@ -2848,6 +2898,7 @@ +@@ -2848,6 +2888,7 @@ } } function buildStartupInfo(opts) { @@ -441,7 +533,7 @@ diff --git a/dist/index.js b/dist/index.js void opts.fileCommands; const md = []; try { -@@ -2980,6 +3031,7 @@ +@@ -2980,6 +3021,7 @@ // src/index.ts if (process.argv.includes("--terminal-login")) { @@ -449,7 +541,7 @@ diff --git a/dist/index.js b/dist/index.js const { spawnSync: spawnSync2 } = await import("child_process"); const cmd = getPiCommand(process.env.PI_ACP_PI_COMMAND); const res = spawnSync2(cmd, [], { -@@ -3019,11 +3071,12 @@ +@@ -3019,11 +3061,12 @@ } }); var stream = ndJsonStream(input, output); @@ -464,11 +556,9 @@ diff --git a/dist/index.js b/dist/index.js } catch { } try { -diff --git a/dist/paperclip-runtime.js b/dist/paperclip-runtime.js -new file mode 100644 ---- /dev/null +--- a/dist/paperclip-runtime.js +++ b/dist/paperclip-runtime.js -@@ -0,0 +1,261 @@ +@@ -0,0 +1,283 @@ +/** 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"; @@ -681,7 +771,7 @@ new file mode 100644 + } +} + -+/** Sum actual assistant receipts, never context occupancy or estimated cost. */ ++/** 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 }; @@ -689,9 +779,10 @@ new file mode 100644 + 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.failed = false; this.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; @@ -723,9 +814,30 @@ new file mode 100644 + } 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: "assistant_message_receipts", ...(this.costObserved && !this.costIncomplete ? { costUsd: this.cost, costSource: "pi_pricing_estimate" } : {}) } } } } : {}), ++ ...(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" } } } } } : {}), + }; + }