diff --git a/docs/claude-instructions-cache-stabilize/PAPER_OCXFIX.pdf b/docs/claude-instructions-cache-stabilize/PAPER_OCXFIX.pdf
new file mode 100644
index 0000000000..5206d30d1f
Binary files /dev/null and b/docs/claude-instructions-cache-stabilize/PAPER_OCXFIX.pdf differ
diff --git a/docs/claude-instructions-cache-stabilize/README.md b/docs/claude-instructions-cache-stabilize/README.md
new file mode 100644
index 0000000000..44cf88a995
--- /dev/null
+++ b/docs/claude-instructions-cache-stabilize/README.md
@@ -0,0 +1,28 @@
+# Claude instructions cache stabilize (OCXFIX)
+
+OpenCodex inbound conversion puts Claude Code system text into OpenAI Responses
+`instructions`. Claude Code then appends a growing `…`
+footer (and occasional TaskCreate nudges) on every turn. That prefix churn
+causes prompt-cache misses on Muse/Go.
+
+This change strips those dynamic footers from `instructions` and reattaches the
+latest notice as a trailing `input` message so the cacheable prefix stays stable.
+
+## Paper
+
+See [PAPER_OCXFIX.pdf](./PAPER_OCXFIX.pdf) (Warexpor).
+
+Measured cache-hit rates on Muse Spark 1.3 via OpenCode Go / OpenCodex:
+
+| Slice | Baseline mean | OCXFIX mean |
+| --- | ---: | ---: |
+| Claude Code (n=75) | 0.168384 | 0.864374 |
+| Claude S/T4 (n=5) | 0.134922 | 0.982700 |
+| Grok Build (n=75) | 0.966526 | 0.941345 |
+
+Cause: Anthropic→Responses conversion stores Claude system text in `instructions`;
+Claude Code appends growing `` (and rare TaskCreate nudges), so
+`instructions_sha` changes every turn. Grok traffic has no `instructions` field
+and is the control.
+
+Code: `src/claude/inbound-cache-stabilize.ts`, wired from `src/claude/inbound.ts`.
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 0ff7d4861c..ef04b588e2 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -304,6 +304,7 @@
"claude-desktop-remote-hub.test.ts": "claude-integration",
"claude-dotenv-provenance-transport.test.ts": "claude-integration",
"claude-gateway-cache.test.ts": "claude-integration",
+ "claude-inbound-cache-stabilize.test.ts": "claude-integration",
"claude-inbound-debug.test.ts": "claude-integration",
"claude-inbound.test.ts": "claude-integration",
"claude-management-api.test.ts": "claude-integration",
diff --git a/src/claude/inbound-cache-stabilize.ts b/src/claude/inbound-cache-stabilize.ts
new file mode 100644
index 0000000000..e5e3e2aedf
--- /dev/null
+++ b/src/claude/inbound-cache-stabilize.ts
@@ -0,0 +1,48 @@
+/**
+ * Claude Code appends growing `…` footers (and
+ * occasional TaskCreate nudges) into system text that becomes Responses
+ * `instructions`. That churn breaks Muse/Go prefix cache on the instructions
+ * prefix even when tools stay stable. Strip dynamics from instructions;
+ * surface the latest notice on `input` instead.
+ *
+ * Only canonical harness notices are stripped: a standalone integer
+ * `` line, and the exact TaskCreate reminder paragraph.
+ * Inline documentation of those tags/tools is left in `instructions`.
+ */
+
+/** Standalone harness footer: own line, integer payload, not mid-sentence docs. */
+const TOTAL_TOKENS_RE =
+ /(?:^|\r?\n)[ \t]*(\d+<\/total_tokens>)[ \t]*(?=\r?\n|$)/g;
+
+/** Canonical Claude Code reminder: distinctive opening sentence through closing reminder. */
+const TASKCREATE_NUDGE_RE =
+ /(?:^|\r?\n)[ \t]*(The task tools haven't been used recently\.\s+If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them\.\s+Only use these if relevant to the current work\.\s+This is just a gentle reminder - ignore if not applicable\.)[ \t]*(?=\r?\n|$)/g;
+
+export function stabilizeClaudeInstructionsForPromptCache(
+ instructions: string,
+): { instructions: string; dynamicNotice: string | null } {
+ if (!instructions) {
+ return { instructions: "", dynamicNotice: null };
+ }
+
+ let latestTotal: string | null = null;
+ for (const m of instructions.matchAll(TOTAL_TOKENS_RE)) {
+ latestTotal = m[1] ?? m[0];
+ }
+
+ let latestNudge: string | null = null;
+ for (const m of instructions.matchAll(TASKCREATE_NUDGE_RE)) {
+ latestNudge = (m[1] ?? m[0]).trim();
+ }
+
+ let cleaned = instructions.replace(TOTAL_TOKENS_RE, "");
+ cleaned = cleaned.replace(TASKCREATE_NUDGE_RE, "");
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
+
+ const noticeParts: string[] = [];
+ if (latestTotal) noticeParts.push(latestTotal);
+ if (latestNudge) noticeParts.push(latestNudge);
+ const dynamicNotice = noticeParts.length > 0 ? noticeParts.join("\n\n") : null;
+
+ return { instructions: cleaned, dynamicNotice };
+}
diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts
index c2e3ded9b2..57336fca03 100644
--- a/src/claude/inbound.ts
+++ b/src/claude/inbound.ts
@@ -17,6 +17,7 @@ export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, e
import { AnthropicRequestError, isRec, type Rec } from "./inbound-records";
import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options";
import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options";
+import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize";
import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget";
@@ -345,7 +346,23 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef
stream: raw.stream === true,
};
- if (systemParts.length > 0) body.instructions = systemParts.join("\n\n");
+ let stabilizedInstructions = "";
+ if (systemParts.length > 0) {
+ // Claude Code appends growing footers (and occasional
+ // TaskCreate nudges) into system text. That churn breaks Muse/Go prefix
+ // cache on the Responses instructions prefix even when tools stay stable.
+ // Strip dynamics from instructions; surface the latest notice on input.
+ const stabilized = stabilizeClaudeInstructionsForPromptCache(systemParts.join("\n\n"));
+ if (stabilized.instructions) body.instructions = stabilized.instructions;
+ if (stabilized.dynamicNotice) {
+ input.push({
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: stabilized.dynamicNotice }],
+ });
+ }
+ stabilizedInstructions = stabilized.instructions;
+ }
const tools = toolsToResponses(raw.tools);
if (tools) body.tools = tools;
@@ -383,11 +400,12 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef
// Exact-prefix matching still isolates content; the key only steers routing
// affinity. Callers must NOT synthesize a session_id header from this fallback
// (audit 133 R2#3).
+ // Claude Code uses metadata.user_id session key; Desktop fallback must hash stabilized instructions so key tracks the cacheable prefix.
body.prompt_cache_key = createHash("sha256")
.update(canonicalJson({
version: 2,
model: body.model,
- system: systemParts,
+ system: stabilizedInstructions,
tools: Array.isArray(body.tools) ? body.tools : [],
}))
.digest("hex").slice(0, 32);
diff --git a/tests/claude-integration/claude-inbound-cache-stabilize.test.ts b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts
new file mode 100644
index 0000000000..d6f32ece69
--- /dev/null
+++ b/tests/claude-integration/claude-inbound-cache-stabilize.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, test } from "bun:test";
+import { stabilizeClaudeInstructionsForPromptCache } from "../../src/claude/inbound-cache-stabilize";
+import { anthropicToResponsesTranslation } from "../../src/claude/inbound";
+
+const TASKCREATE_NUDGE = [
+ "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them.",
+ "Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable.",
+].join(" ");
+
+function footer(used: number): string {
+ return `${used}`;
+}
+
+describe("stabilizeClaudeInstructionsForPromptCache", () => {
+ test("empty input is a no-op", () => {
+ expect(stabilizeClaudeInstructionsForPromptCache("")).toEqual({
+ instructions: "",
+ dynamicNotice: null,
+ });
+ });
+
+ test("stable instructions without dynamics pass through", () => {
+ const instructions = "You are Claude Code.\n\nPrefer terse answers.";
+ expect(stabilizeClaudeInstructionsForPromptCache(instructions)).toEqual({
+ instructions,
+ dynamicNotice: null,
+ });
+ });
+
+ test("three total_tokens footers keep only the latest in the notice", () => {
+ const stable = "You are Claude Code.";
+ const first = footer(1000);
+ const second = footer(4000);
+ const third = footer(8000);
+ const result = stabilizeClaudeInstructionsForPromptCache(
+ [stable, first, second, third].join("\n\n"),
+ );
+ expect(result.instructions).toBe(stable);
+ expect(result.instructions).not.toContain("");
+ expect(result.dynamicNotice).toBe(third);
+ });
+
+ test("instructions contain zero total_tokens after stripping", () => {
+ const result = stabilizeClaudeInstructionsForPromptCache(
+ `System.\n${footer(1)}\nMore system.\n${footer(3)}`,
+ );
+ expect(result.instructions).not.toMatch(//);
+ expect(result.instructions).toContain("System.");
+ expect(result.instructions).toContain("More system.");
+ expect(result.dynamicNotice).toBe(footer(3));
+ });
+
+ test("TaskCreate nudge is stripped from instructions and kept in the notice", () => {
+ const stable = "You are Claude Code.";
+ const result = stabilizeClaudeInstructionsForPromptCache(
+ `${stable}\n\n${TASKCREATE_NUDGE}`,
+ );
+ expect(result.instructions).toBe(stable);
+ expect(result.instructions).not.toContain("TaskCreate");
+ expect(result.dynamicNotice).toBe(TASKCREATE_NUDGE);
+ });
+
+ test("latest footer and latest nudge both surface in the notice", () => {
+ const stable = "Stay stable.";
+ const older = footer(10);
+ const latest = footer(50);
+ const result = stabilizeClaudeInstructionsForPromptCache(
+ [stable, older, TASKCREATE_NUDGE, latest].join("\n\n"),
+ );
+ expect(result.instructions).toBe(stable);
+ expect(result.dynamicNotice).toBe(`${latest}\n\n${TASKCREATE_NUDGE}`);
+ });
+
+ test("inline documentation of total_tokens tags stays in instructions", () => {
+ const docs = "The harness may emit a 123 footer; do not invent one.";
+ const result = stabilizeClaudeInstructionsForPromptCache(docs);
+ expect(result.instructions).toBe(docs);
+ expect(result.dynamicNotice).toBeNull();
+ });
+
+ test("TaskCreate mentioned in docs is not treated as the harness nudge", () => {
+ const docs = "The task tools haven't been used recently. You may mention TaskCreate in docs without the reminder.";
+ const result = stabilizeClaudeInstructionsForPromptCache(docs);
+ expect(result.instructions).toBe(docs);
+ expect(result.dynamicNotice).toBeNull();
+ });
+
+ test("docs plus a real footer keep the docs and move only the latest footer", () => {
+ const docs = "Describe 0 in the protocol guide.";
+ const latest = footer(8000);
+ const result = stabilizeClaudeInstructionsForPromptCache(
+ [docs, footer(1), latest].join("\n\n"),
+ );
+ expect(result.instructions).toBe(docs);
+ expect(result.dynamicNotice).toBe(latest);
+ });
+});
+
+describe("anthropicToResponsesTranslation cache-stabilize wire-in", () => {
+ test("moves the latest total_tokens footer onto a trailing input user message", () => {
+ const first = footer(1000);
+ const latest = footer(8000);
+ const { body } = anthropicToResponsesTranslation({
+ model: "m",
+ max_tokens: 1,
+ system: ["You are Claude Code.", first, latest].join("\n\n"),
+ messages: [{ role: "user", content: "hi" }],
+ });
+ expect(body.instructions).toBe("You are Claude Code.");
+ expect(String(body.instructions)).not.toContain("");
+ const input = body.input as Array>;
+ const last = input[input.length - 1]!;
+ expect(last).toEqual({
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: latest }],
+ });
+ expect(input.some(item => item.role === "user" && item !== last)).toBe(true);
+ });
+
+ test("Desktop prompt_cache_key fallback hashes stabilized instructions, not total_tokens footers", () => {
+ const stable = "You are Claude Code.";
+ const keyOf = (system: string) =>
+ anthropicToResponsesTranslation({
+ model: "m",
+ max_tokens: 1,
+ system,
+ messages: [{ role: "user", content: "hi" }],
+ }).body.prompt_cache_key as string;
+ const stableKey = keyOf(stable);
+ expect(stableKey).toMatch(/^[0-9a-f]{32}$/);
+ expect(keyOf([stable, footer(1000), footer(8000)].join("\n\n"))).toBe(stableKey);
+ expect(keyOf([stable, footer(99999)].join("\n\n"))).toBe(stableKey);
+ });
+});
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 98907bd26a..a213efebf0 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -139,6 +139,7 @@
"claude-desktop-remote-hub.test.ts": "claude-integration",
"claude-dotenv-provenance-transport.test.ts": "claude-integration",
"claude-gateway-cache.test.ts": "claude-integration",
+ "claude-inbound-cache-stabilize.test.ts": "claude-integration",
"claude-inbound-debug.test.ts": "claude-integration",
"claude-inbound.test.ts": "claude-integration",
"claude-management-api.test.ts": "claude-integration",