diff --git a/packages/paperclip-runner/src/provider-events.test.ts b/packages/paperclip-runner/src/provider-events.test.ts index ea9469238d2..3b8b39d3ad7 100644 --- a/packages/paperclip-runner/src/provider-events.test.ts +++ b/packages/paperclip-runner/src/provider-events.test.ts @@ -6,6 +6,7 @@ import { canonicalProviderEventsFromAcpxRuntimeEvent, canonicalProviderEventsFromCodex, canonicalProviderEventsFromOpenCodePart, + boundedAcpMeta, createAcpxToolEventNormalizer, createGrokMessageNormalizer, providerFamilyCapabilities, @@ -555,6 +556,84 @@ describe("provider-neutral events", () => { }); }); + it("forwards ACP _meta as a bounded JSON-safe copy on every runtime event", () => { + const normalize = createAcpxToolEventNormalizer(); + const tool = normalize({ + type: "tool_call", + tag: "tool_call", + toolCallId: "tool-meta", + title: "Terminal", + status: "pending", + _meta: { + claudeCode: { toolName: "Bash", parentToolUseId: "toolu_parent" }, + terminal_exit: { exit_code: 2, signal: null }, + ["__proto__"]: { polluted: true }, + }, + } as never); + expect(tool._meta).toEqual({ + claudeCode: { toolName: "Bash", parentToolUseId: "toolu_parent" }, + terminal_exit: { exit_code: 2, signal: null }, + }); + const status = normalize({ + type: "status", + tag: "usage_update", + text: "usage updated", + _meta: { "_claude/rateLimit": { status: "allowed", utilization: 0.78 } }, + } as never); + expect(status._meta).toEqual({ + "_claude/rateLimit": { status: "allowed", utilization: 0.78 }, + }); + expect( + normalize({ type: "status", text: "x", _meta: "not an object" } as never), + ).not.toHaveProperty("_meta"); + }); + + it("bounds ACP _meta depth, breadth, string size and total size", () => { + let deep: Record = { leaf: true }; + for (let index = 0; index < 10; index += 1) deep = { next: deep }; + const wide = Object.fromEntries( + Array.from({ length: 100 }, (_, index) => [`k${index}`, index]), + ); + const bounded = boundedAcpMeta({ + deep, + wide, + long: "x".repeat(10_000), + big: Array.from({ length: 64 }, () => "y".repeat(3_000)), + notFinite: Number.POSITIVE_INFINITY, + fn: () => undefined, + })!; + expect(Buffer.byteLength(JSON.stringify(bounded))).toBeLessThanOrEqual(16_000); + expect(Object.keys(bounded.wide as object)).toHaveLength(64); + expect((bounded.long as string).length).toBe(4_000); + expect(bounded).not.toHaveProperty("notFinite"); + expect(bounded).not.toHaveProperty("fn"); + expect(JSON.stringify(bounded.deep)).not.toContain("leaf"); + expect(boundedAcpMeta([1, 2])).toBeUndefined(); + expect(boundedAcpMeta({})).toBeUndefined(); + }); + + it("charges every retained container, separator and escaped byte against the ACP _meta budget", () => { + let tree: unknown = []; + for (let level = 0; level < 3; level += 1) tree = Array.from({ length: 64 }, () => tree); + const nested = boundedAcpMeta({ a: tree })!; + expect(Buffer.byteLength(JSON.stringify(nested))).toBeLessThanOrEqual(16_000); + const escaped = boundedAcpMeta({ nulls: "\u0000".repeat(4_000), ok: "kept?" })!; + expect(Buffer.byteLength(JSON.stringify(escaped))).toBeLessThanOrEqual(16_000); + expect(escaped).not.toHaveProperty("nulls"); + expect(escaped.ok).toBe("kept?"); + }); + + it("inspects a bounded number of keys on a very wide ACP _meta object", () => { + let reads = 0; + const wide = new Proxy( + Object.fromEntries(Array.from({ length: 10_000 }, (_, index) => [`__proto__x${index}`.replace("__proto__x", "k"), index])), + { get(target, key, receiver) { reads += 1; return Reflect.get(target, key, receiver); } }, + ); + const bounded = boundedAcpMeta({ wide })!; + expect(Object.keys(bounded.wide as object)).toHaveLength(64); + expect(reads).toBeLessThanOrEqual(64); + }); + it("preserves ACPX identity across title-less progress and completion updates", () => { const normalize = createAcpxToolEventNormalizer(); const started = normalize({ diff --git a/packages/paperclip-runner/src/provider-events.ts b/packages/paperclip-runner/src/provider-events.ts index dac4af58b80..d949d320fb5 100644 --- a/packages/paperclip-runner/src/provider-events.ts +++ b/packages/paperclip-runner/src/provider-events.ts @@ -264,6 +264,95 @@ function boundedAcpxLifecycleLocations(value: unknown): unknown[] | undefined { }); } +const MAX_ACP_META_DEPTH = 6; +const MAX_ACP_META_ENTRIES = 64; +const MAX_ACP_META_STRING_BYTES = 4_000; +const MAX_ACP_META_TOTAL_BYTES = 16_000; +const UNSAFE_ACP_META_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** + * Bounded, JSON-safe copy of an ACP update `_meta` object. The patched ACPX + * runtime forwards `_meta` unvalidated on tool and status events (for example + * Claude's `_meta.claudeCode.toolName`, `parentToolUseId`, `terminal_exit` + * and `_claude/rateLimit`). The values are producer-controlled hints: they + * can name a tool or carry a provider counter, but they never grant authority. + * + * Limits: depth 6, 64 keys or items per container, 4 KB per string and about + * 16 KB in total. Entries beyond a limit are dropped, never partially parsed. + * Returns undefined when nothing usable remains. + */ +export function boundedAcpMeta( + value: unknown, +): Record | undefined { + // The budget is the serialized JSON size of the retained copy: every + // scalar at its escaped length, every bracket, separator and key. A value + // that does not fit is dropped whole, so the copy never exceeds the bound. + let budget = MAX_ACP_META_TOTAL_BYTES; + const charge = (bytes: number): boolean => { + if (bytes > budget) return false; + budget -= bytes; + return true; + }; + const visit = (input: unknown, depth: number): unknown => { + if (input === null || typeof input === "boolean") { + return charge(String(input).length) ? input : undefined; + } + if (typeof input === "number") { + if (!Number.isFinite(input)) return undefined; + return charge(JSON.stringify(input).length) ? input : undefined; + } + if (typeof input === "string") { + const bounded = boundedAcpxLifecycleText(input, MAX_ACP_META_STRING_BYTES) ?? ""; + return charge(Buffer.byteLength(JSON.stringify(bounded))) ? bounded : undefined; + } + if (depth >= MAX_ACP_META_DEPTH || typeof input !== "object") return undefined; + if (!charge(2)) return undefined; + if (Array.isArray(input)) { + const items: unknown[] = []; + const limit = Math.min(input.length, MAX_ACP_META_ENTRIES); + for (let index = 0; index < limit && budget > 0; index += 1) { + if (items.length > 0 && !charge(1)) break; + const next = visit(input[index], depth + 1); + if (next !== undefined) items.push(next); + else if (items.length > 0) budget += 1; + } + return items; + } + const output: Record = {}; + let retained = 0; + let inspected = 0; + // for...in stops early; Object.entries would materialize every key of a + // wide producer object before any limit applied. + for (const key in input as Record) { + if (!Object.prototype.hasOwnProperty.call(input, key)) continue; + if (retained >= MAX_ACP_META_ENTRIES || inspected >= MAX_ACP_META_ENTRIES * 4 || budget <= 0) break; + inspected += 1; + if (UNSAFE_ACP_META_KEYS.has(key) || Buffer.byteLength(key) > 160) continue; + const keyBytes = Buffer.byteLength(JSON.stringify(key)) + 1 + (retained > 0 ? 1 : 0); + if (!charge(keyBytes)) break; + const next = visit((input as Record)[key], depth + 1); + if (next === undefined) { + budget += keyBytes; + continue; + } + output[key] = next; + retained += 1; + } + return output; + }; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const bounded = visit(value, 0) as Record | undefined; + return bounded && Object.keys(bounded).length > 0 ? bounded : undefined; +} + +/** Bound any forwarded `_meta` on a runtime event, whatever its type. */ +function withBoundedAcpMeta(event: T): T { + if (!Object.prototype.hasOwnProperty.call(event, "_meta")) return event; + const { _meta: raw, ...rest } = event as T & { _meta?: unknown }; + const meta = boundedAcpMeta(raw); + return (meta ? { ...rest, _meta: meta } : rest) as T; +} + /** * ACP tool updates are deltas: a later update may omit the title, kind, and * locations that were present on the opening event. ACPX represents a missing @@ -291,7 +380,8 @@ export function createAcpxToolEventNormalizer< T extends AcpRuntimeEventShape, >(): (event: T) => T { const lifecycle = new Map(); - return (event) => { + return (input) => { + const event = withBoundedAcpMeta(input); if (event.type !== "tool_call" || !event.toolCallId) return event; const toolCallId = boundedAcpxLifecycleId(event.toolCallId); const boundedTitle = boundedAcpxLifecycleText( diff --git a/patches/acpx@0.13.1.patch b/patches/acpx@0.13.1.patch index 1ed192d4f31..700eabdf516 100644 --- a/patches/acpx@0.13.1.patch +++ b/patches/acpx@0.13.1.patch @@ -1,5 +1,5 @@ diff --git a/dist/client-CxNllqui.d.ts b/dist/client-CxNllqui.d.ts -index 5e2113ac0..45aff03c3 100644 +index 5e2113ac0a1a92c99322cf01e5c106760a0b0dbf..45aff03c37427071de49b45eb0361a9a808786e0 100644 --- a/dist/client-CxNllqui.d.ts +++ b/dist/client-CxNllqui.d.ts @@ -120,6 +120,8 @@ declare class AcpClient { @@ -20,9 +20,10 @@ index 5e2113ac0..45aff03c3 100644 private setSessionModelThroughConfig; private setSessionModelThroughLegacyMethod; diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js +index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..b29bee391efcdc1ca9c37e9608cc34f527fe428e 100644 --- a/dist/live-checkpoint-BSIrfgVo.js +++ b/dist/live-checkpoint-BSIrfgVo.js -@@ -6,7 +6,7 @@ +@@ -6,7 +6,7 @@ import os from "node:os"; import { randomUUID } from "node:crypto"; import { execFile, spawn } from "node:child_process"; import { Readable, Writable } from "node:stream"; @@ -31,7 +32,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js import readline from "node:readline/promises"; //#region src/errors.ts var AcpxOperationalError = class extends Error { -@@ -1068,12 +1068,14 @@ +@@ -1068,12 +1068,14 @@ function serializeSessionRecordForDisk(record) { last_agent_disconnect_reason: canonical.lastAgentDisconnectReason, protocol_version: canonical.protocolVersion, agent_capabilities: canonical.agentCapabilities, @@ -46,7 +47,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js acpx: canonical.acpx, imported_from: canonical.importedFrom ? { record_id: canonical.importedFrom.recordId, -@@ -1239,7 +1241,8 @@ +@@ -1239,7 +1241,8 @@ function parseRequestTokenUsage(raw) { for (const [key, value] of Object.entries(record)) { const parsed = parseTokenUsage(value); if (parsed == null) return null; @@ -56,7 +57,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js } return usage; } -@@ -1325,6 +1328,93 @@ +@@ -1325,6 +1328,93 @@ function isAgentMessage$1(raw) { function isConversationMessage(raw) { return raw === "Resume" || isUserMessage$1(raw) || isAgentMessage$1(raw); } @@ -150,7 +151,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js function parseConversationRecord(record) { if (!hasValidConversationCore(record)) return; const title = parseConversationTitle(record.title); -@@ -1339,7 +1429,8 @@ +@@ -1339,7 +1429,8 @@ function parseConversationRecord(record) { updated_at: record.updated_at, cumulative_token_usage: cumulativeTokenUsage ?? {}, cumulative_cost: cumulativeCost, @@ -160,7 +161,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js }; } const INVALID_VALUE = Symbol("invalid"); -@@ -1541,12 +1632,14 @@ +@@ -1541,12 +1632,14 @@ function parseSessionRecord(raw) { lastAgentDisconnectReason: optionals.lastAgentDisconnectReason, protocolVersion: typeof record.protocol_version === "number" ? record.protocol_version : void 0, agentCapabilities: asRecord$4(record.agent_capabilities), @@ -175,7 +176,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js acpx: parseAcpxState(record.acpx), importedFrom: metadata.importedFrom }; -@@ -1661,8 +1754,9 @@ +@@ -1661,8 +1754,9 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([ "RedactedThinking", "ToolUse" ]); @@ -186,7 +187,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js "agent_capabilities", "messages.Agent.content.ToolUse.input", "acpx.desired_config_options", -@@ -3104,10 +3198,10 @@ +@@ -3104,10 +3198,10 @@ function authEnvKey(methodId) { authEnvKeyCache.set(methodId, key); return key; } @@ -199,7 +200,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (typeof value === "string" && value.trim().length > 0) return value; } function protectedEnvKey(key) { -@@ -3135,8 +3229,21 @@ +@@ -3135,8 +3229,21 @@ function promotePrefixedAuthEnvironment(env) { } return protectedKeys; } @@ -223,7 +224,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env); if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) { addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential); -@@ -3178,10 +3285,10 @@ +@@ -3178,10 +3285,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) { const configCredentials = authCredentials ?? {}; return configCredentials[methodId] ?? configCredentials[toEnvToken(methodId)]; } @@ -236,7 +237,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js stdio: [ "pipe", "pipe", -@@ -3286,28 +3393,12 @@ +@@ -3286,28 +3393,12 @@ function formatAvailableModelIds(models) { const ids = models?.availableModels.map((model) => model.modelId.trim()).filter((modelId) => modelId.length > 0) ?? []; return ids.length > 0 ? ids.join(", ") : "none advertised"; } @@ -266,7 +267,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js } //#endregion //#region src/acp/session-control-errors.ts -@@ -3961,10 +4052,13 @@ +@@ -3961,10 +4052,13 @@ function resolveClientCapabilities(params) { ...params.elicitationModes.includes("url") ? { url: {} } : {} } } : {} }; @@ -282,7 +283,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js }; } function hasResponseField(response, field) { -@@ -4067,6 +4161,27 @@ +@@ -4067,6 +4161,27 @@ function createNdJsonMessageStream(agentCommand, output, input) { } }) }; } @@ -310,7 +311,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js var AcpClient = class { options; connection; -@@ -4105,7 +4220,9 @@ +@@ -4105,7 +4220,9 @@ var AcpClient = class { cwd: asAbsoluteCwd(options.cwd), authPolicy: options.authPolicy ?? "skip", permissionPolicy: snapshotPermissionPolicy(options.permissionPolicy), @@ -321,7 +322,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js }; this.eventHandlers = { onAcpMessage: this.options.onAcpMessage, -@@ -4216,9 +4333,21 @@ +@@ -4216,9 +4333,21 @@ var AcpClient = class { this.lastAgentExit = void 0; this.lastKnownPid = child.pid ?? void 0; this.attachAgentLifecycleObservers(child); @@ -343,7 +344,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (!this.options.verbose) return; process.stderr.write(chunk); }); -@@ -4226,6 +4355,7 @@ +@@ -4226,6 +4355,7 @@ var AcpClient = class { const output = Readable.toWeb(child.stdout); const stream = this.createTappedStream(createNdJsonMessageStream(this.options.agentCommand, input, output)); const connection = this.createConnection(stream, launch); @@ -351,7 +352,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js connection.signal.addEventListener("abort", () => { this.recordAgentExit("connection_close", child.exitCode ?? null, child.signalCode ?? null); }, { once: true }); -@@ -4253,7 +4383,12 @@ +@@ -4253,7 +4383,12 @@ var AcpClient = class { geminiAcp: isGeminiAcpCommand(spawnCommand, args), copilotAcp: isCopilotAcpCommand(spawnCommand, args), claudeAcp: isClaudeAcpCommand(spawnCommand, args), @@ -365,7 +366,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js }; } logAgentLaunch(plan) { -@@ -4279,10 +4414,17 @@ +@@ -4279,10 +4414,17 @@ var AcpClient = class { } async spawnAgentProcess(plan) { const spawnCommand = buildAgentSpawnCommand(plan.spawnCommand, plan.args, process.platform, plan.spawnOptions.env); @@ -385,7 +386,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js try { await waitForSpawn$1(spawnedChild); } catch (error) { -@@ -4293,10 +4435,10 @@ +@@ -4293,10 +4435,10 @@ var AcpClient = class { createConnection(stream, launch) { const app = client({ name: "acpx" }).onNotification(methods.client.session.update, async ({ params }) => { await this.handleSessionUpdate(params); @@ -399,7 +400,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js }).onRequest(methods.client.fs.readTextFile, async ({ params }) => { return await this.handleReadTextFile(params); }).onRequest(methods.client.fs.writeTextFile, async ({ params }) => { -@@ -4312,6 +4454,11 @@ +@@ -4312,6 +4454,11 @@ var AcpClient = class { }).onRequest(methods.client.terminal.release, async ({ params }) => { return await this.handleReleaseTerminal(params); }); @@ -411,7 +412,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (launch.devinAcp) app.onRequest("_cognition.ai/request_diagnostics", (params) => { return params && typeof params === "object" && !Array.isArray(params) ? params : {}; }, async () => ({})); -@@ -4333,12 +4480,12 @@ +@@ -4333,12 +4480,12 @@ var AcpClient = class { async initializeProtocolConnection(connection, launch) { const initializePromise = connection.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -426,7 +427,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js clientInfo: resolveClientInfo(launch.devinAcp) }); const initialized = launch.geminiAcp ? await withTimeout(initializePromise, resolveGeminiAcpStartupTimeoutMs()) : await initializePromise; -@@ -4358,6 +4505,49 @@ +@@ -4358,6 +4505,49 @@ var AcpClient = class { throw normalizedError; } createTappedStream(base) { @@ -476,7 +477,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js const onAcpMessage = () => this.eventHandlers.onAcpMessage; const onAcpOutputMessage = () => this.eventHandlers.onAcpOutputMessage; const elicitationRequestIds = /* @__PURE__ */ new Set(); -@@ -4367,8 +4557,12 @@ +@@ -4367,8 +4557,12 @@ var AcpClient = class { const shouldSuppressInboundReplaySessionUpdate = (message) => { return this.suppressReplaySessionUpdateMessages && isSessionUpdateNotification(message); }; @@ -490,7 +491,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (requestId !== void 0) { elicitationRequestIds.add(requestId); return; -@@ -4379,6 +4573,7 @@ +@@ -4379,6 +4573,7 @@ var AcpClient = class { } }; return { @@ -498,7 +499,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js readable: new ReadableStream({ async start(controller) { const reader = base.readable.getReader(); try { -@@ -4386,16 +4581,27 @@ +@@ -4386,16 +4581,27 @@ var AcpClient = class { const { value, done } = await reader.read(); if (done) break; if (!value) continue; @@ -528,7 +529,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js const promptOwner = promptRequestOwner(message); if (promptOwner) bindPromptOwner(promptOwner); const id = responseId(message); -@@ -4403,10 +4609,20 @@ +@@ -4403,10 +4609,20 @@ var AcpClient = class { onAcpOutputMessage()?.("outbound", message); onAcpMessage()?.("outbound", message); } @@ -549,7 +550,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js writer.releaseLock(); } } }) -@@ -4422,7 +4638,10 @@ +@@ -4422,7 +4638,10 @@ var AcpClient = class { const createPromise = this.runConnectionRequest(() => connection.newSession({ cwd: sessionCwd, mcpServers: this.options.mcpServers ?? [], @@ -561,7 +562,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js })); result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise; } catch (error) { -@@ -4454,6 +4673,7 @@ +@@ -4454,6 +4673,7 @@ var AcpClient = class { } async loadSessionWithOptions(sessionId, cwd = this.options.cwd, options = {}) { const connection = this.getConnection(); @@ -569,7 +570,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js const sessionCwd = await resolveAgentSessionCwd(cwd, this.options.agentCommand); const previousSuppression = this.applySessionUpdateSuppression(Boolean(options.suppressReplayUpdates)); let response; -@@ -4461,7 +4681,9 @@ +@@ -4461,7 +4681,9 @@ var AcpClient = class { response = await this.runConnectionRequest(() => connection.loadSession({ sessionId, cwd: sessionCwd, @@ -580,7 +581,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js })); await this.waitForSessionUpdateDrain(options.replayIdleMs ?? REPLAY_IDLE_MS, options.replayDrainTimeoutMs ?? REPLAY_DRAIN_TIMEOUT_MS); } finally { -@@ -4474,11 +4696,14 @@ +@@ -4474,11 +4696,14 @@ var AcpClient = class { } async resumeSession(sessionId, cwd = this.options.cwd) { const connection = this.getConnection(); @@ -596,7 +597,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js })); this.loadedSessionId = sessionId; const result = toReconnectedSessionResult(response); -@@ -4590,15 +4815,17 @@ +@@ -4590,15 +4815,17 @@ var AcpClient = class { throw maybeWrapSessionControlError("session/set_config_option", error, `for "${configId}"="${value}"`); } } @@ -620,7 +621,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js } async setSessionModelThroughConfig(sessionId, modelId, configId) { const connection = this.getConnection(); -@@ -4608,7 +4835,9 @@ +@@ -4608,7 +4835,9 @@ var AcpClient = class { configId, value: modelId })); @@ -631,7 +632,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js return response; } catch (error) { return this.throwSessionModelError("session/set_config_option", modelId, error); -@@ -4864,7 +5093,7 @@ +@@ -4864,7 +5093,7 @@ var AcpClient = class { } selectAuthMethod(methods) { for (const method of methods) { @@ -640,7 +641,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (envCredential) return { methodId: method.id, credential: envCredential, -@@ -4890,7 +5119,7 @@ +@@ -4890,7 +5119,7 @@ var AcpClient = class { } readAgentSpecificEnvCredential(methodId) { if (!this.isGrokBuildAcpCommand() || methodId !== "xai.api_key") return; @@ -649,7 +650,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js return typeof value === "string" && value.trim().length > 0 ? value : void 0; } selectAgentManagedAuthMethod(methodId) { -@@ -4915,9 +5144,9 @@ +@@ -4915,9 +5144,9 @@ var AcpClient = class { await connection.authenticate({ methodId: selected.methodId }); this.log(`authenticated with method ${selected.methodId} (${selected.source})`); } @@ -661,7 +662,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (hostResponse) return hostResponse; const { response, recorded } = await this.resolvePermissionRequestFromMode(params); if (!recorded) { -@@ -4926,14 +5155,81 @@ +@@ -4926,14 +5155,81 @@ var AcpClient = class { } return response; } @@ -745,7 +746,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js })).then((response) => ({ kind: "response", response -@@ -4970,7 +5266,7 @@ +@@ -4970,7 +5266,7 @@ var AcpClient = class { isElicitationSessionCancelling(sessionId) { return this.closing || this.cancellingSessionIds.has(sessionId); } @@ -754,7 +755,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js if (!this.options.onPermissionRequest) return; const signal = this.cancellationSignalForSession(params.sessionId); try { -@@ -4978,7 +5274,7 @@ +@@ -4978,7 +5274,7 @@ var AcpClient = class { sessionId: params.sessionId, raw: params, inferredKind: inferToolKind(params) @@ -763,7 +764,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js return this.hostPermissionDecisionResponse(params, signal, decision); } catch (error) { return this.hostPermissionErrorResponse(params, signal, error); -@@ -5028,6 +5324,12 @@ +@@ -5028,6 +5324,12 @@ var AcpClient = class { attachAgentLifecycleObservers(child) { child.once("exit", (exitCode, signal) => { this.recordAgentExit("process_exit", exitCode, signal); @@ -776,7 +777,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js }); child.once("close", (exitCode, signal) => { this.recordAgentExit("process_close", exitCode, signal); -@@ -5100,6 +5402,9 @@ +@@ -5100,6 +5402,9 @@ var AcpClient = class { return await this.filesystem.readTextFile(params); } catch (error) { this.recordPermissionError(params.sessionId, error); @@ -786,7 +787,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js throw error; } } -@@ -5239,6 +5544,7 @@ +@@ -5239,6 +5544,7 @@ function applyConversation(record, conversation) { record.cumulative_token_usage = conversation.cumulative_token_usage; record.cumulative_cost = conversation.cumulative_cost; record.request_token_usage = conversation.request_token_usage; @@ -794,7 +795,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js } //#endregion //#region src/runtime/engine/session-options.ts -@@ -5701,7 +6007,8 @@ +@@ -5701,7 +6007,8 @@ function cloneSessionConversation(conversation) { updated_at: conversation.updated_at, cumulative_token_usage: deepClone(conversation.cumulative_token_usage ?? {}), cumulative_cost: cloneUsageCost(conversation.cumulative_cost), @@ -804,7 +805,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js }; } function cloneUsageCost(cost) { -@@ -5771,10 +6078,20 @@ +@@ -5771,10 +6078,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN trimConversationForRuntime(conversation); return acpx; } @@ -827,7 +828,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js updateConversationTimestamp(conversation, timestamp); trimConversationForRuntime(conversation); return true; -@@ -6136,6 +6453,7 @@ +@@ -6136,6 +6453,7 @@ async function connectAndLoadSession(options) { pendingAgentSessionId = loadState.pendingAgentSessionId; sessionModels = loadState.sessionModels; const preferenceReplay = await replayFreshSessionPreferences({ @@ -835,7 +836,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js client, record, createdFreshSession, -@@ -6193,14 +6511,16 @@ +@@ -6193,14 +6511,16 @@ function logReconnectAttempt(record, storedProcessAlive, shouldReconnect, verbos if (shouldReconnect) process.stderr.write(`[acpx] saved session pid ${record.pid} is dead; respawning agent and attempting session reconnect\n`); } async function replayFreshSessionPreferences(params) { @@ -854,7 +855,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js client: params.client, sessionId: params.sessionId, desiredModeId: params.desiredModeId, -@@ -6219,7 +6539,7 @@ +@@ -6219,7 +6539,7 @@ async function replayFreshSessionPreferences(params) { verbose: params.verbose, suppressWarnings: params.suppressWarnings }); @@ -863,7 +864,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js client: params.client, record: params.record, sessionId: params.sessionId, -@@ -6435,6 +6755,27 @@ +@@ -6435,6 +6755,27 @@ async function withConnectedSession(options) { //#region src/runtime/engine/prompt-turn.ts const SESSION_REPLY_IDLE_MS = 1e3; const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3; @@ -891,7 +892,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js async function runPromptTurn(params) { try { const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation); -@@ -6444,7 +6785,23 @@ +@@ -6444,7 +6785,23 @@ async function runPromptTurn(params) { idleMs: SESSION_REPLY_IDLE_MS, timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS }).catch(() => {}); @@ -916,7 +917,7 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js stopReason: response.stopReason, source: "rpc" diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts -index e8102acb0..377c5d838 100644 +index e8102acb03c4c38830ad5ec22f356125eb0423b7..04c7e3ae0453b352447efb53af17f6e25ee98307 100644 --- a/dist/runtime.d.ts +++ b/dist/runtime.d.ts @@ -1,7 +1,8 @@ @@ -949,10 +950,15 @@ index e8102acb0..377c5d838 100644 type AcpRuntimeEvent = { type: "text_delta"; text: string; -@@ -186,6 +193,10 @@ type AcpRuntimeEvent = { +@@ -186,6 +193,15 @@ type AcpRuntimeEvent = { * non-null `input` schema. */ availableCommands?: AcpRuntimeAvailableCommand[]; ++ /** ++ * Paperclip patch: the ACP update's `_meta`, forwarded by reference and ++ * unvalidated. Producer-controlled; consumers must bound and allowlist it. ++ */ ++ _meta?: Record; +} | { + type: "plan"; + tag: "plan"; @@ -960,7 +966,19 @@ index e8102acb0..377c5d838 100644 } | { type: "tool_call"; text: string; -@@ -312,7 +323,51 @@ type AcpRuntimeOptions = { +@@ -198,6 +214,11 @@ type AcpRuntimeEvent = { + rawInput?: unknown; + rawOutput?: unknown; + content?: ToolCallContent[]; ++ /** ++ * Paperclip patch: the ACP update's `_meta`, forwarded by reference and ++ * unvalidated. Producer-controlled; consumers must bound and allowlist it. ++ */ ++ _meta?: Record; + } | + /** + * Compatibility terminal event emitted by runTurn(...). startTurn(...).events +@@ -312,7 +333,51 @@ type AcpRuntimeOptions = { elicitationModes?: readonly AcpElicitationMode[]; onPermissionRequest?: (req: AcpPermissionRequest, ctx: { signal: AbortSignal; @@ -1012,7 +1030,7 @@ index e8102acb0..377c5d838 100644 }; type AcpFileSessionStoreOptions = { stateDir: string; -@@ -369,6 +424,7 @@ declare class AcpRuntimeManager { +@@ -369,6 +434,7 @@ declare class AcpRuntimeManager { private createAndSaveRuntimeRecord; private retainInitializedSessionOwner; startTurn(input: { @@ -1020,7 +1038,7 @@ index e8102acb0..377c5d838 100644 handle: AcpRuntimeHandle; text: string; attachments?: AcpRuntimeTurnAttachment[]; -@@ -414,6 +470,7 @@ declare class AcpRuntimeManager { +@@ -414,6 +480,7 @@ declare class AcpRuntimeManager { private cleanupRuntimeTurn; private finalizeRuntimeTurnRecord; runTurn(input: { @@ -1029,7 +1047,7 @@ index e8102acb0..377c5d838 100644 text: string; attachments?: AcpRuntimeTurnAttachment[]; diff --git a/dist/runtime.js b/dist/runtime.js -index a1f4a70a0..a17fce3b3 100644 +index a1f4a70a003792c6eacf68b6b038f37bfec1db53..f8603a26e626253b454812865b99b0d7aa4495f6 100644 --- a/dist/runtime.js +++ b/dist/runtime.js @@ -153,7 +153,7 @@ function resolveTextChunk(params) { @@ -1050,7 +1068,15 @@ index a1f4a70a0..a17fce3b3 100644 return { type: "text_delta", text, -@@ -371,7 +371,7 @@ const PROMPT_EVENT_PARSERS = { +@@ -321,6 +321,7 @@ function assignForwardedToolPayload(event, payload) { + if (Object.prototype.hasOwnProperty.call(payload, "rawInput")) event.rawInput = payload.rawInput; + if (Object.prototype.hasOwnProperty.call(payload, "rawOutput")) event.rawOutput = payload.rawOutput; + if (shouldForwardArray(payload.content)) event.content = payload.content; ++ if (isRecord(payload._meta)) event._meta = payload._meta; + } + function parsePromptEventLine(line) { + const trimmed = line.trim(); +@@ -371,7 +372,7 @@ const PROMPT_EVENT_PARSERS = { current_mode_update: (payload) => statusUpdateEvent("current_mode_update", payload), config_option_update: (payload) => statusUpdateEvent("config_option_update", payload), session_info_update: (payload) => statusUpdateEvent("session_info_update", payload), @@ -1059,7 +1085,32 @@ index a1f4a70a0..a17fce3b3 100644 client_operation: clientOperationEvent, update: updateStatusEvent, done: () => null, -@@ -424,6 +424,34 @@ function availableCommandsUpdateEvent(payload) { +@@ -388,11 +389,12 @@ function usageUpdateEvent(payload) { + used, + size, + cost: normalizeUsageCost(payload.cost), +- breakdown: normalizeUsageBreakdown(meta?.usage) ++ breakdown: normalizeUsageBreakdown(meta?.usage), ++ meta + }); + } + function buildUsageUpdateEvent(parts) { +- const { used, size, cost, breakdown } = parts; ++ const { used, size, cost, breakdown, meta } = parts; + return { + type: "status", + text: used != null && size != null ? `usage updated: ${used}/${size}` : "usage updated", +@@ -400,7 +402,8 @@ function buildUsageUpdateEvent(parts) { + ...used != null ? { used } : {}, + ...size != null ? { size } : {}, + ...cost ? { cost } : {}, +- ...breakdown ? { breakdown } : {} ++ ...breakdown ? { breakdown } : {}, ++ ...meta ? { _meta: meta } : {} + }; + } + function availableCommandsUpdateEvent(payload) { +@@ -424,6 +427,34 @@ function availableCommandsUpdateEvent(payload) { availableCommands }; } @@ -1094,7 +1145,17 @@ index a1f4a70a0..a17fce3b3 100644 function normalizeUsageCost(value) { if (!isRecord(value)) return; const amount = asOptionalFiniteNumber(value.amount); -@@ -812,7 +840,22 @@ var AcpRuntimeManager = class { +@@ -474,7 +505,8 @@ function statusUpdateEvent(tag, payload) { + return { + type: "status", + text, +- tag ++ tag, ++ ...isRecord(payload._meta) ? { _meta: payload._meta } : {} + }; + } + function clientOperationEvent(payload, tag) { +@@ -812,7 +844,22 @@ var AcpRuntimeManager = class { this.deps = deps; } createClient(options) { @@ -1118,7 +1179,7 @@ index a1f4a70a0..a17fce3b3 100644 } createSessionOwner(input) { const owner = { -@@ -821,12 +864,16 @@ var AcpRuntimeManager = class { +@@ -821,12 +868,16 @@ var AcpRuntimeManager = class { pendingSessionUpdates: [] }; input.client.setEventHandlers({ @@ -1135,7 +1196,7 @@ index a1f4a70a0..a17fce3b3 100644 const active = owner.activeTurn; if (active) { const { task, turn } = active; -@@ -863,6 +910,9 @@ var AcpRuntimeManager = class { +@@ -863,6 +914,9 @@ var AcpRuntimeManager = class { projection.checkpoint.request(); } routeOwnedClientOperation(owner, operation) { @@ -1145,7 +1206,7 @@ index a1f4a70a0..a17fce3b3 100644 const active = owner.activeTurn; if (!active) return; const { task, turn } = active; -@@ -1008,6 +1058,8 @@ var AcpRuntimeManager = class { +@@ -1008,6 +1062,8 @@ var AcpRuntimeManager = class { record.closedAt = void 0; record.protocolVersion = owner.client.initializeResult?.protocolVersion; record.agentCapabilities = owner.client.initializeResult?.agentCapabilities; @@ -1154,7 +1215,7 @@ index a1f4a70a0..a17fce3b3 100644 applyLifecycleSnapshotToRecord(record, owner.client.getAgentLifecycleSnapshot()); } async finishBufferedOwnerControl(owner, record) { -@@ -1086,6 +1138,11 @@ var AcpRuntimeManager = class { +@@ -1086,6 +1142,11 @@ var AcpRuntimeManager = class { record.closed = false; record.closedAt = void 0; this.closingActiveRecords.delete(record.acpxRecordId); @@ -1166,7 +1227,7 @@ index a1f4a70a0..a17fce3b3 100644 await this.options.sessionStore.save(record); return record; } -@@ -1149,6 +1206,8 @@ var AcpRuntimeManager = class { +@@ -1149,6 +1210,8 @@ var AcpRuntimeManager = class { this.closingActiveRecords.delete(record.acpxRecordId); record.protocolVersion = client.initializeResult?.protocolVersion; record.agentCapabilities = client.initializeResult?.agentCapabilities; @@ -1175,7 +1236,7 @@ index a1f4a70a0..a17fce3b3 100644 applyConfigOptionsToRecord(record, session.sessionResult); const modelApplication = await applyRequestedModelIfAdvertised({ client, -@@ -1301,9 +1360,11 @@ var AcpRuntimeManager = class { +@@ -1301,9 +1364,11 @@ var AcpRuntimeManager = class { client: turn.client, sessionId, prompt: task.promptInput, @@ -1187,7 +1248,7 @@ index a1f4a70a0..a17fce3b3 100644 onPromptRequestStarted: () => task.promptStarted.resolve(), onElicitation: task.input.onElicitation }); -@@ -1469,6 +1530,10 @@ var AcpRuntimeManager = class { +@@ -1469,6 +1534,10 @@ var AcpRuntimeManager = class { setSessionConfigOption: async (configId, value) => { return (await task.state.activeController.setResolvedSessionConfigOption(configId, value)).response; }, @@ -1198,7 +1259,7 @@ index a1f4a70a0..a17fce3b3 100644 setResolvedSessionConfigOption: async (configId, value) => await this.setRuntimeResolvedSessionConfigOption(task, turn, configId, value) }; } -@@ -1575,6 +1640,8 @@ var AcpRuntimeManager = class { +@@ -1575,6 +1644,8 @@ var AcpRuntimeManager = class { reconcileAgentSessionId(turn.record, turn.record.agentSessionId); turn.record.protocolVersion = turn.client.initializeResult?.protocolVersion; turn.record.agentCapabilities = turn.client.initializeResult?.agentCapabilities; @@ -1207,7 +1268,7 @@ index a1f4a70a0..a17fce3b3 100644 turn.record.acpx = turn.acpxState; applyConversation(turn.record, turn.conversation); applyLifecycleSnapshotToRecord(turn.record, turn.client.getAgentLifecycleSnapshot()); -@@ -1707,6 +1774,17 @@ var AcpRuntimeManager = class { +@@ -1707,6 +1778,17 @@ var AcpRuntimeManager = class { }); await this.options.sessionStore.save(result.record); } @@ -1225,7 +1286,7 @@ index a1f4a70a0..a17fce3b3 100644 async cancel(handle) { await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt(); } -@@ -2055,6 +2133,7 @@ var AcpxRuntime = class { +@@ -2055,6 +2137,7 @@ var AcpxRuntime = class { const turnPromise = this.getManager().then((manager) => manager.startTurn({ handle, text: input.text, @@ -1233,7 +1294,7 @@ index a1f4a70a0..a17fce3b3 100644 attachments: input.attachments, mode: input.mode, sessionMode: state.mode, -@@ -2087,6 +2166,7 @@ var AcpxRuntime = class { +@@ -2087,6 +2170,7 @@ var AcpxRuntime = class { yield* (await this.getManager()).runTurn({ handle, text: input.text, @@ -1241,7 +1302,7 @@ index a1f4a70a0..a17fce3b3 100644 attachments: input.attachments, mode: input.mode, sessionMode: state.mode, -@@ -2119,6 +2199,14 @@ var AcpxRuntime = class { +@@ -2119,6 +2203,14 @@ var AcpxRuntime = class { const { handle, state } = this.resolveManagerHandle(input.handle); await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode); } @@ -1257,7 +1318,7 @@ index a1f4a70a0..a17fce3b3 100644 const { handle } = this.resolveManagerHandle(input.handle); await (await this.getManager()).cancel(handle); diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts -index c3da16452..40ae05653 100644 +index c3da1645235bbea22de3f8484149051cd7dca56b..40ae0565394aaa96e0315a2cf3c943c500c77560 100644 --- a/dist/session-options-DwRDODlr.d.ts +++ b/dist/session-options-DwRDODlr.d.ts @@ -1,4 +1,5 @@