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.
- package/LICENSE +24 -0
- package/README.md +118 -0
- package/package.json +32 -0
- 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
|
+
};
|