claude-code-cache-fix 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +118 -0
  3. package/package.json +32 -0
  4. package/preload.mjs +464 -0
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Nighswonger, Victor Sun (@VictorSun92), @jmarianski
4
+
5
+ If you contributed to this work and are not listed above, please open an issue
6
+ or pull request — we want to credit everyone properly.
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # claude-code-cache-fix
2
+
3
+ Fixes a prompt cache regression in [Claude Code](https://github.com/anthropics/claude-code) that causes **up to 20x cost increase** on resumed sessions. Confirmed broken through v2.1.92.
4
+
5
+ ## The problem
6
+
7
+ When you use `--resume` or `/resume` in Claude Code, the prompt cache breaks silently. Instead of reading cached tokens (cheap), the API rebuilds them from scratch on every turn (expensive). A session that should cost ~$0.50/hour can burn through $5–10/hour with no visible indication anything is wrong.
8
+
9
+ Three bugs cause this:
10
+
11
+ 1. **Partial block scatter** — Attachment blocks (skills listing, MCP servers, deferred tools, hooks) are supposed to live in `messages[0]`. On resume, some or all of them drift to later messages, changing the cache prefix.
12
+
13
+ 2. **Fingerprint instability** — The `cc_version` fingerprint (e.g. `2.1.92.a3f`) is computed from `messages[0]` content including meta/attachment blocks. When those blocks shift, the fingerprint changes, the system prompt changes, and cache busts.
14
+
15
+ 3. **Non-deterministic tool ordering** — Tool definitions can arrive in different orders between turns, changing request bytes and invalidating the cache key.
16
+
17
+ ## Installation
18
+
19
+ Requires Node.js >= 18 and Claude Code installed via npm (not the standalone binary).
20
+
21
+ ```bash
22
+ npm install -g claude-code-cache-fix
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ The fix works as a Node.js preload module that intercepts API requests before they leave your machine.
28
+
29
+ ### Option A: Wrapper script (recommended)
30
+
31
+ Create a wrapper script (e.g. `~/bin/claude-fixed`):
32
+
33
+ ```bash
34
+ #!/bin/bash
35
+ CLAUDE_NPM_CLI="$HOME/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"
36
+
37
+ if [ ! -f "$CLAUDE_NPM_CLI" ]; then
38
+ echo "Error: Claude Code npm package not found at $CLAUDE_NPM_CLI" >&2
39
+ echo "Install with: npm install -g @anthropic-ai/claude-code" >&2
40
+ exit 1
41
+ fi
42
+
43
+ exec env NODE_OPTIONS="--import claude-code-cache-fix" node "$CLAUDE_NPM_CLI" "$@"
44
+ ```
45
+
46
+ ```bash
47
+ chmod +x ~/bin/claude-fixed
48
+ ```
49
+
50
+ Adjust `CLAUDE_NPM_CLI` if your npm global prefix differs. Find it with:
51
+ ```bash
52
+ npm root -g
53
+ ```
54
+
55
+ ### Option B: Shell alias
56
+
57
+ ```bash
58
+ alias claude='NODE_OPTIONS="--import claude-code-cache-fix" node "$(npm root -g)/@anthropic-ai/claude-code/cli.js"'
59
+ ```
60
+
61
+ ### Option C: Direct invocation
62
+
63
+ ```bash
64
+ NODE_OPTIONS="--import claude-code-cache-fix" claude
65
+ ```
66
+
67
+ > **Note**: This only works if `claude` points to the npm/Node installation. The standalone binary uses a different execution path that bypasses Node.js preloads.
68
+
69
+ ## How it works
70
+
71
+ The module intercepts `globalThis.fetch` before Claude Code makes API calls to `/v1/messages`. On each call it:
72
+
73
+ 1. **Scans all user messages** for relocated attachment blocks (skills, MCP, deferred tools, hooks) and moves the latest version of each back to `messages[0]`, matching fresh session layout
74
+ 2. **Sorts tool definitions** alphabetically by name for deterministic ordering
75
+ 3. **Recomputes the cc_version fingerprint** from the real user message text instead of meta/attachment content
76
+
77
+ All fixes are idempotent — if nothing needs fixing, the request passes through unmodified. The interceptor is read-only with respect to your conversation; it only normalizes the request structure before it hits the API.
78
+
79
+ ## Debug mode
80
+
81
+ Enable debug logging to verify the fix is working:
82
+
83
+ ```bash
84
+ CACHE_FIX_DEBUG=1 claude-fixed
85
+ ```
86
+
87
+ Logs are written to `~/.claude/cache-fix-debug.log`. Look for:
88
+ - `APPLIED: resume message relocation` — block scatter was detected and fixed
89
+ - `APPLIED: tool order stabilization` — tools were reordered
90
+ - `APPLIED: fingerprint stabilized from XXX to YYY` — fingerprint was corrected
91
+ - `SKIPPED: resume relocation (not a resume or already correct)` — no fix needed (fresh session or already correct)
92
+
93
+ ## Limitations
94
+
95
+ - **npm installation only** — The standalone Claude Code binary has Zig-level attestation that bypasses Node.js. This fix only works with the npm package (`npm install -g @anthropic-ai/claude-code`).
96
+ - **Overage TTL downgrade** — Exceeding 100% of the 5-hour quota triggers a server-enforced TTL downgrade from 1h to 5m. This is a server-side decision and cannot be fixed client-side. The interceptor prevents the cache instability that can push you into overage in the first place.
97
+ - **Version coupling** — The fingerprint salt and block detection heuristics are derived from Claude Code internals. A major refactor could require an update to this package.
98
+
99
+ ## Tracked issues
100
+
101
+ - [#34629](https://github.com/anthropics/claude-code/issues/34629) — Original resume cache regression report
102
+ - [#40524](https://github.com/anthropics/claude-code/issues/40524) — Within-session fingerprint invalidation
103
+ - [#42052](https://github.com/anthropics/claude-code/issues/42052) — Community interceptor development and testing
104
+ - [#43044](https://github.com/anthropics/claude-code/issues/43044) — Resume loads 0% context on v2.1.91
105
+ - [#43657](https://github.com/anthropics/claude-code/issues/43657) — Resume cache invalidation confirmed on v2.1.92
106
+ - [#44045](https://github.com/anthropics/claude-code/issues/44045) — SDK-level reproduction with token measurements
107
+
108
+ ## Contributors
109
+
110
+ - **[@VictorSun92](https://github.com/VictorSun92)** — Original monkey-patch fix for v2.1.88, identified partial scatter on v2.1.90, contributed forward-scan detection, correct block ordering, and tighter block matchers
111
+ - **[@jmarianski](https://github.com/jmarianski)** — Root cause analysis via MITM proxy capture and Ghidra reverse engineering, multi-mode cache test script
112
+ - **[@cnighswonger](https://github.com/cnighswonger)** — Fingerprint stabilization, tool ordering fix, debug logging, overage TTL downgrade discovery, package maintainer
113
+
114
+ If you contributed to the community effort on these issues and aren't listed here, please open an issue or PR — we want to credit everyone properly.
115
+
116
+ ## License
117
+
118
+ [MIT](LICENSE)
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "claude-code-cache-fix",
3
+ "version": "1.0.0",
4
+ "description": "Fixes prompt cache regression in Claude Code that causes up to 20x cost increase on resumed sessions",
5
+ "type": "module",
6
+ "exports": "./preload.mjs",
7
+ "main": "./preload.mjs",
8
+ "files": [
9
+ "preload.mjs"
10
+ ],
11
+ "engines": {
12
+ "node": ">=18"
13
+ },
14
+ "keywords": [
15
+ "claude-code",
16
+ "claude",
17
+ "prompt-cache",
18
+ "cache-fix",
19
+ "anthropic",
20
+ "cost-optimization"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/cnighswonger/claude-code-cache-fix.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/cnighswonger/claude-code-cache-fix/issues"
28
+ },
29
+ "homepage": "https://github.com/cnighswonger/claude-code-cache-fix#readme",
30
+ "license": "MIT",
31
+ "author": "Chris Nighswonger"
32
+ }
package/preload.mjs ADDED
@@ -0,0 +1,464 @@
1
+ // claude-code-cache-fix — Node.js fetch interceptor for Claude Code prompt cache bugs.
2
+ //
3
+ // Fixes three bugs that cause prompt cache misses in Claude Code, resulting in
4
+ // up to 20x cost increase on resumed sessions:
5
+ //
6
+ // Bug 1: Partial block scatter on resume
7
+ // On --resume, attachment blocks (hooks, skills, deferred-tools, MCP) land in
8
+ // later user messages instead of messages[0]. This breaks the prompt cache
9
+ // prefix match. Fix: relocate them to messages[0] on every API call.
10
+ // (github.com/anthropics/claude-code/issues/34629)
11
+ // (github.com/anthropics/claude-code/issues/43657)
12
+ // (github.com/anthropics/claude-code/issues/44045)
13
+ //
14
+ // Bug 2: Fingerprint instability
15
+ // The cc_version fingerprint in the attribution header is computed from
16
+ // messages[0] content INCLUDING meta/attachment blocks. When those blocks
17
+ // change between turns, the fingerprint changes -> system prompt bytes
18
+ // change -> cache bust. Fix: recompute fingerprint from real user text.
19
+ // (github.com/anthropics/claude-code/issues/40524)
20
+ //
21
+ // Bug 3: Non-deterministic tool schema ordering
22
+ // Tool definitions can arrive in different orders between turns, changing
23
+ // request bytes and busting cache. Fix: sort tools alphabetically by name.
24
+ //
25
+ // Based on community work by @VictorSun92 (original monkey-patch + partial
26
+ // scatter fixes) and @jmarianski (MITM proxy root cause analysis).
27
+ //
28
+ // Usage: NODE_OPTIONS="--import claude-code-cache-fix" claude
29
+
30
+ import { createHash } from "node:crypto";
31
+ import { appendFileSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Debug logging (writes to ~/.claude/cache-fix-debug.log)
37
+ // Set CACHE_FIX_DEBUG=1 to enable
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const DEBUG = process.env.CACHE_FIX_DEBUG === "1";
41
+ const LOG_PATH = join(homedir(), ".claude", "cache-fix-debug.log");
42
+
43
+ function debugLog(...args) {
44
+ if (!DEBUG) return;
45
+ const line = `[${new Date().toISOString()}] ${args.join(" ")}\n`;
46
+ try {
47
+ appendFileSync(LOG_PATH, line);
48
+ } catch {}
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Fingerprint stabilization (Bug 2)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ // Must match Claude Code src/utils/fingerprint.ts exactly.
56
+ const FINGERPRINT_SALT = "59cf53e54c78";
57
+ const FINGERPRINT_INDICES = [4, 7, 20];
58
+
59
+ /**
60
+ * Recompute the 3-char hex fingerprint the same way the source does:
61
+ * SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3]
62
+ * but using the REAL user message text, not the first (possibly meta) message.
63
+ */
64
+ function computeFingerprint(messageText, version) {
65
+ const chars = FINGERPRINT_INDICES.map((i) => messageText[i] || "0").join("");
66
+ const input = `${FINGERPRINT_SALT}${chars}${version}`;
67
+ return createHash("sha256").update(input).digest("hex").slice(0, 3);
68
+ }
69
+
70
+ /**
71
+ * Find the first REAL user message text (not a <system-reminder> meta block).
72
+ * The original bug: extractFirstMessageText() grabs content from messages[0]
73
+ * which may be a synthetic attachment message, not the actual user prompt.
74
+ */
75
+ function extractRealUserMessageText(messages) {
76
+ for (const msg of messages) {
77
+ if (msg.role !== "user") continue;
78
+ const content = msg.content;
79
+ if (!Array.isArray(content)) {
80
+ if (
81
+ typeof content === "string" &&
82
+ !content.startsWith("<system-reminder>")
83
+ ) {
84
+ return content;
85
+ }
86
+ continue;
87
+ }
88
+ for (const block of content) {
89
+ if (
90
+ block.type === "text" &&
91
+ typeof block.text === "string" &&
92
+ !block.text.startsWith("<system-reminder>")
93
+ ) {
94
+ return block.text;
95
+ }
96
+ }
97
+ }
98
+ return "";
99
+ }
100
+
101
+ /**
102
+ * Extract current cc_version from system prompt blocks and recompute with
103
+ * stable fingerprint. Returns { attrIdx, newText, oldFingerprint, stableFingerprint }
104
+ * or null if no fix needed.
105
+ */
106
+ function stabilizeFingerprint(system, messages) {
107
+ if (!Array.isArray(system)) return null;
108
+
109
+ const attrIdx = system.findIndex(
110
+ (b) =>
111
+ b.type === "text" &&
112
+ typeof b.text === "string" &&
113
+ b.text.includes("x-anthropic-billing-header:")
114
+ );
115
+ if (attrIdx === -1) return null;
116
+
117
+ const attrBlock = system[attrIdx];
118
+ const versionMatch = attrBlock.text.match(/cc_version=([^;]+)/);
119
+ if (!versionMatch) return null;
120
+
121
+ const fullVersion = versionMatch[1]; // e.g. "2.1.92.a3f"
122
+ const dotParts = fullVersion.split(".");
123
+ if (dotParts.length < 4) return null;
124
+
125
+ const baseVersion = dotParts.slice(0, 3).join("."); // "2.1.92"
126
+ const oldFingerprint = dotParts[3]; // "a3f"
127
+
128
+ const realText = extractRealUserMessageText(messages);
129
+ const stableFingerprint = computeFingerprint(realText, baseVersion);
130
+
131
+ if (stableFingerprint === oldFingerprint) return null; // already correct
132
+
133
+ const newVersion = `${baseVersion}.${stableFingerprint}`;
134
+ const newText = attrBlock.text.replace(
135
+ `cc_version=${fullVersion}`,
136
+ `cc_version=${newVersion}`
137
+ );
138
+
139
+ return { attrIdx, newText, oldFingerprint, stableFingerprint };
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Resume message relocation (Bug 1)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ function isSystemReminder(text) {
147
+ return typeof text === "string" && text.startsWith("<system-reminder>");
148
+ }
149
+
150
+ const SR = "<system-reminder>\n";
151
+
152
+ function isHooksBlock(text) {
153
+ return (
154
+ isSystemReminder(text) && text.substring(0, 200).includes("hook success")
155
+ );
156
+ }
157
+ function isSkillsBlock(text) {
158
+ return (
159
+ typeof text === "string" &&
160
+ text.startsWith(SR + "The following skills are available")
161
+ );
162
+ }
163
+ function isDeferredToolsBlock(text) {
164
+ return (
165
+ typeof text === "string" &&
166
+ text.startsWith(SR + "The following deferred tools are now available")
167
+ );
168
+ }
169
+ function isMcpBlock(text) {
170
+ return (
171
+ typeof text === "string" &&
172
+ text.startsWith(SR + "# MCP Server Instructions")
173
+ );
174
+ }
175
+ function isRelocatableBlock(text) {
176
+ return (
177
+ isHooksBlock(text) ||
178
+ isSkillsBlock(text) ||
179
+ isDeferredToolsBlock(text) ||
180
+ isMcpBlock(text)
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Sort skill listing entries for deterministic ordering (prevents cache bust
186
+ * from non-deterministic iteration order).
187
+ */
188
+ function sortSkillsBlock(text) {
189
+ const match = text.match(
190
+ /^([\s\S]*?\n\n)(- [\s\S]+?)(\n<\/system-reminder>\s*)$/
191
+ );
192
+ if (!match) return text;
193
+ const [, header, entriesText, footer] = match;
194
+ const entries = entriesText.split(/\n(?=- )/);
195
+ entries.sort();
196
+ return header + entries.join("\n") + footer;
197
+ }
198
+
199
+ /**
200
+ * Strip session_knowledge from hooks blocks — ephemeral content that differs
201
+ * between sessions and would bust cache.
202
+ */
203
+ function stripSessionKnowledge(text) {
204
+ return text.replace(
205
+ /\n<session_knowledge[^>]*>[\s\S]*?<\/session_knowledge>/g,
206
+ ""
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Core fix: on EVERY API call, scan the entire message array for the LATEST
212
+ * relocatable blocks (skills, MCP, deferred tools, hooks) and ensure they
213
+ * are in messages[0]. This matches fresh session behavior where attachments
214
+ * are always prepended to messages[0].
215
+ *
216
+ * The v2.1.90 native fix has a remaining detection gap: it bails early if
217
+ * it sees *some* relocatable blocks in messages[0], missing the case where
218
+ * others have scattered elsewhere (partial scatter).
219
+ *
220
+ * This version scans backwards to find the latest instance of each
221
+ * relocatable block type, removes them from wherever they are, and
222
+ * prepends them to messages[0] in fresh-session order. Idempotent.
223
+ */
224
+ function normalizeResumeMessages(messages) {
225
+ if (!Array.isArray(messages) || messages.length < 2) return messages;
226
+
227
+ let firstUserIdx = -1;
228
+ for (let i = 0; i < messages.length; i++) {
229
+ if (messages[i].role === "user") {
230
+ firstUserIdx = i;
231
+ break;
232
+ }
233
+ }
234
+ if (firstUserIdx === -1) return messages;
235
+
236
+ const firstMsg = messages[firstUserIdx];
237
+ if (!Array.isArray(firstMsg?.content)) return messages;
238
+
239
+ // Check if ANY relocatable blocks are scattered outside first user msg.
240
+ let hasScatteredBlocks = false;
241
+ for (
242
+ let i = firstUserIdx + 1;
243
+ i < messages.length && !hasScatteredBlocks;
244
+ i++
245
+ ) {
246
+ const msg = messages[i];
247
+ if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
248
+ for (const block of msg.content) {
249
+ if (isRelocatableBlock(block.text || "")) {
250
+ hasScatteredBlocks = true;
251
+ break;
252
+ }
253
+ }
254
+ }
255
+ if (!hasScatteredBlocks) return messages;
256
+
257
+ // Scan ALL user messages in reverse to collect the LATEST version of each
258
+ // block type. This handles both full and partial scatter.
259
+ const found = new Map();
260
+
261
+ for (let i = messages.length - 1; i >= firstUserIdx; i--) {
262
+ const msg = messages[i];
263
+ if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
264
+
265
+ for (let j = msg.content.length - 1; j >= 0; j--) {
266
+ const block = msg.content[j];
267
+ const text = block.text || "";
268
+ if (!isRelocatableBlock(text)) continue;
269
+
270
+ let blockType;
271
+ if (isSkillsBlock(text)) blockType = "skills";
272
+ else if (isMcpBlock(text)) blockType = "mcp";
273
+ else if (isDeferredToolsBlock(text)) blockType = "deferred";
274
+ else if (isHooksBlock(text)) blockType = "hooks";
275
+ else continue;
276
+
277
+ if (!found.has(blockType)) {
278
+ let fixedText = text;
279
+ if (blockType === "hooks") fixedText = stripSessionKnowledge(text);
280
+ if (blockType === "skills") fixedText = sortSkillsBlock(text);
281
+
282
+ const { cache_control, ...rest } = block;
283
+ found.set(blockType, { ...rest, text: fixedText });
284
+ }
285
+ }
286
+ }
287
+
288
+ if (found.size === 0) return messages;
289
+
290
+ // Remove ALL relocatable blocks from ALL user messages
291
+ const result = messages.map((msg) => {
292
+ if (msg.role !== "user" || !Array.isArray(msg.content)) return msg;
293
+ const filtered = msg.content.filter(
294
+ (b) => !isRelocatableBlock(b.text || "")
295
+ );
296
+ if (filtered.length === msg.content.length) return msg;
297
+ return { ...msg, content: filtered };
298
+ });
299
+
300
+ // Order must match fresh session layout: deferred -> mcp -> skills -> hooks
301
+ const ORDER = ["deferred", "mcp", "skills", "hooks"];
302
+ const toRelocate = ORDER.filter((t) => found.has(t)).map((t) => found.get(t));
303
+
304
+ result[firstUserIdx] = {
305
+ ...result[firstUserIdx],
306
+ content: [...toRelocate, ...result[firstUserIdx].content],
307
+ };
308
+
309
+ return result;
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Tool schema stabilization (Bug 3)
314
+ // ---------------------------------------------------------------------------
315
+
316
+ /**
317
+ * Sort tool definitions by name for deterministic ordering.
318
+ */
319
+ function stabilizeToolOrder(tools) {
320
+ if (!Array.isArray(tools) || tools.length === 0) return tools;
321
+ return [...tools].sort((a, b) => {
322
+ const nameA = a.name || "";
323
+ const nameB = b.name || "";
324
+ return nameA.localeCompare(nameB);
325
+ });
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Fetch interceptor
330
+ // ---------------------------------------------------------------------------
331
+
332
+ const _origFetch = globalThis.fetch;
333
+
334
+ globalThis.fetch = async function (url, options) {
335
+ const urlStr = typeof url === "string" ? url : url?.url || String(url);
336
+
337
+ const isMessagesEndpoint =
338
+ urlStr.includes("/v1/messages") &&
339
+ !urlStr.includes("batches") &&
340
+ !urlStr.includes("count_tokens");
341
+
342
+ if (
343
+ isMessagesEndpoint &&
344
+ options?.body &&
345
+ typeof options.body === "string"
346
+ ) {
347
+ try {
348
+ const payload = JSON.parse(options.body);
349
+ let modified = false;
350
+
351
+ debugLog("--- API call to", urlStr);
352
+ debugLog("message count:", payload.messages?.length);
353
+
354
+ // Bug 1: Relocate scattered attachment blocks
355
+ if (payload.messages) {
356
+ if (DEBUG) {
357
+ let firstUserIdx = -1;
358
+ let lastUserIdx = -1;
359
+ for (let i = 0; i < payload.messages.length; i++) {
360
+ if (payload.messages[i].role === "user") {
361
+ if (firstUserIdx === -1) firstUserIdx = i;
362
+ lastUserIdx = i;
363
+ }
364
+ }
365
+ if (firstUserIdx !== -1) {
366
+ const firstContent = payload.messages[firstUserIdx].content;
367
+ const lastContent = payload.messages[lastUserIdx].content;
368
+ debugLog(
369
+ "firstUserIdx:",
370
+ firstUserIdx,
371
+ "lastUserIdx:",
372
+ lastUserIdx
373
+ );
374
+ debugLog(
375
+ "first user msg blocks:",
376
+ Array.isArray(firstContent) ? firstContent.length : "string"
377
+ );
378
+ if (Array.isArray(firstContent)) {
379
+ for (const b of firstContent) {
380
+ const t = (b.text || "").substring(0, 80);
381
+ debugLog(
382
+ " first[block]:",
383
+ isRelocatableBlock(b.text) ? "RELOCATABLE" : "keep",
384
+ JSON.stringify(t)
385
+ );
386
+ }
387
+ }
388
+ if (firstUserIdx !== lastUserIdx) {
389
+ debugLog(
390
+ "last user msg blocks:",
391
+ Array.isArray(lastContent) ? lastContent.length : "string"
392
+ );
393
+ if (Array.isArray(lastContent)) {
394
+ for (const b of lastContent) {
395
+ const t = (b.text || "").substring(0, 80);
396
+ debugLog(
397
+ " last[block]:",
398
+ isRelocatableBlock(b.text) ? "RELOCATABLE" : "keep",
399
+ JSON.stringify(t)
400
+ );
401
+ }
402
+ }
403
+ } else {
404
+ debugLog("single user message (fresh session)");
405
+ }
406
+ }
407
+ }
408
+
409
+ const normalized = normalizeResumeMessages(payload.messages);
410
+ if (normalized !== payload.messages) {
411
+ payload.messages = normalized;
412
+ modified = true;
413
+ debugLog("APPLIED: resume message relocation");
414
+ } else {
415
+ debugLog(
416
+ "SKIPPED: resume relocation (not a resume or already correct)"
417
+ );
418
+ }
419
+ }
420
+
421
+ // Bug 3: Stabilize tool ordering
422
+ if (payload.tools) {
423
+ const sorted = stabilizeToolOrder(payload.tools);
424
+ const changed = sorted.some(
425
+ (t, i) => t.name !== payload.tools[i]?.name
426
+ );
427
+ if (changed) {
428
+ payload.tools = sorted;
429
+ modified = true;
430
+ debugLog("APPLIED: tool order stabilization");
431
+ }
432
+ }
433
+
434
+ // Bug 2: Stabilize fingerprint in attribution header
435
+ if (payload.system && payload.messages) {
436
+ const fix = stabilizeFingerprint(payload.system, payload.messages);
437
+ if (fix) {
438
+ payload.system = [...payload.system];
439
+ payload.system[fix.attrIdx] = {
440
+ ...payload.system[fix.attrIdx],
441
+ text: fix.newText,
442
+ };
443
+ modified = true;
444
+ debugLog(
445
+ "APPLIED: fingerprint stabilized from",
446
+ fix.oldFingerprint,
447
+ "to",
448
+ fix.stableFingerprint
449
+ );
450
+ }
451
+ }
452
+
453
+ if (modified) {
454
+ options = { ...options, body: JSON.stringify(payload) };
455
+ debugLog("Request body rewritten");
456
+ }
457
+ } catch (e) {
458
+ debugLog("ERROR in interceptor:", e?.message);
459
+ // Parse failure — pass through unmodified
460
+ }
461
+ }
462
+
463
+ return _origFetch.apply(this, [url, options]);
464
+ };