claude-code-cache-fix 1.1.0 → 1.2.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 (3) hide show
  1. package/README.md +25 -0
  2. package/package.json +1 -1
  3. package/preload.mjs +164 -0
package/README.md CHANGED
@@ -92,6 +92,27 @@ This keeps images in the last 3 user messages and replaces older ones with a tex
92
92
 
93
93
  Set to `0` (default) to disable.
94
94
 
95
+ ## Prefix lock (resume cache hit)
96
+
97
+ Even with the block relocation fix, the first API call after `--resume` triggers a full cache rebuild because CC reassembles messages with different system-reminder blocks, changing the prefix bytes. On a 300k token context at Opus rates, that's ~$2.80 per resume.
98
+
99
+ The prefix lock eliminates this by saving the exact `messages[0]` content after all fixes are applied, then replaying it on the next resume to produce a byte-identical prefix.
100
+
101
+ ```bash
102
+ export CACHE_FIX_PREFIX_LOCK=1
103
+ ```
104
+
105
+ Safety guards — the lock only fires when ALL of these match:
106
+ - System prompt hash (same project, no CLAUDE.md changes)
107
+ - Tools hash (no MCP/plugin changes)
108
+ - User message text (same conversation)
109
+ - User content hash (no substantive context changes)
110
+ - Not a post-compaction conversation
111
+
112
+ If any guard fails, the lock skips and falls back to normal behavior. The worst case is a skip — the lock cannot increase costs or cause context loss.
113
+
114
+ Set to `0` (default) to disable.
115
+
95
116
  ## Monitoring
96
117
 
97
118
  The interceptor includes monitoring for several additional issues identified by the community:
@@ -131,6 +152,8 @@ Logs are written to `~/.claude/cache-fix-debug.log`. Look for:
131
152
  - `BUDGET WARNING: tool result chars at N / 200,000 threshold` — approaching budget cap
132
153
  - `FALSE RATE LIMIT: synthetic model detected` — client-side false rate limit
133
154
  - `GROWTHBOOK FLAGS: {...}` — server-controlled feature flags on first call
155
+ - `PREFIX LOCK: APPLIED — replayed saved messages[0]` — resume cache hit achieved
156
+ - `PREFIX LOCK: skipped — <reason>` — guard prevented lock (expected, safe)
134
157
  - `SKIPPED: resume relocation (not a resume or already correct)` — no fix needed
135
158
 
136
159
  ### Prefix diff mode
@@ -150,6 +173,7 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
150
173
  | `CACHE_FIX_DEBUG` | `0` | Enable debug logging to `~/.claude/cache-fix-debug.log` |
151
174
  | `CACHE_FIX_PREFIXDIFF` | `0` | Enable prefix snapshot diffing |
152
175
  | `CACHE_FIX_IMAGE_KEEP_LAST` | `0` | Keep images in last N user messages (0 = disabled) |
176
+ | `CACHE_FIX_PREFIX_LOCK` | `0` | Replay saved messages[0] on resume for cache hit (0 = disabled) |
153
177
 
154
178
  ## Limitations
155
179
 
@@ -170,6 +194,7 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
170
194
  ## Related research
171
195
 
172
196
  - **[@ArkNill/claude-code-hidden-problem-analysis](https://github.com/ArkNill/claude-code-hidden-problem-analysis)** — Systematic proxy-based analysis of 7 bugs including microcompact, budget enforcement, false rate limiter, and extended thinking quota impact. The monitoring features in v1.1.0 are informed by this research.
197
+ - **[@Renvect/X-Ray-Claude-Code-Interceptor](https://github.com/Renvect/X-Ray-Claude-Code-Interceptor)** — Diagnostic HTTPS proxy with real-time dashboard, system prompt section diffing, per-tool stripping thresholds, and multi-stream JSONL logging. Works with any Claude client that supports `ANTHROPIC_BASE_URL` (CLI, VS Code extension, desktop app), complementing this package's CLI-only `NODE_OPTIONS` approach.
173
198
 
174
199
  ## Contributors
175
200
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-cache-fix",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Fixes prompt cache regression in Claude Code that causes up to 20x cost increase on resumed sessions",
5
5
  "type": "module",
6
6
  "exports": "./preload.mjs",
package/preload.mjs CHANGED
@@ -365,6 +365,156 @@ function stripOldToolResultImages(messages, keepLast) {
365
365
  return { messages: strippedCount > 0 ? result : messages, stats };
366
366
  }
367
367
 
368
+ // --------------------------------------------------------------------------
369
+ // Prefix lock — replay saved messages[0] on resume for cache hit
370
+ // --------------------------------------------------------------------------
371
+
372
+ // CACHE_FIX_PREFIX_LOCK=1 — save messages[0] on every call and replay it on
373
+ // resume to avoid a cache rebuild. Disabled by default.
374
+ //
375
+ // On resume, CC reassembles messages with blocks in different positions and
376
+ // injects fresh system-reminders, changing the prefix bytes. Even after our
377
+ // relocation fix corrects the blocks, the prefix differs from what the server
378
+ // cached on the last pre-exit call, causing a full cache rebuild.
379
+ //
380
+ // This feature saves the exact messages[0] content after all fixes are applied.
381
+ // On the first call of a new process (resume), if system prompt hash and tools
382
+ // hash match the saved snapshot, and the real user message text matches, we
383
+ // replay the saved messages[0] to produce a byte-identical prefix → cache hit.
384
+
385
+ const PREFIX_LOCK = process.env.CACHE_FIX_PREFIX_LOCK === "1";
386
+ const PREFIX_LOCK_FILE = join(homedir(), ".claude", "cache-fix-prefix-lock.json");
387
+
388
+ let _prefixLockFirstCall = true;
389
+
390
+ /**
391
+ * Compute hashes for prefix lock comparison.
392
+ */
393
+ function computePrefixHashes(system, tools) {
394
+ const sysHash = system
395
+ ? createHash("sha256").update(JSON.stringify(system)).digest("hex").slice(0, 16)
396
+ : "none";
397
+ const toolHash = tools
398
+ ? createHash("sha256").update(JSON.stringify(tools.map(t => t.name).sort())).digest("hex").slice(0, 16)
399
+ : "none";
400
+ return { sysHash, toolHash };
401
+ }
402
+
403
+ /**
404
+ * Extract the real user message text from messages[0] (skipping system-reminders).
405
+ */
406
+ function extractUserTextFromFirstMsg(msg) {
407
+ if (!msg || !Array.isArray(msg.content)) return "";
408
+ for (const block of msg.content) {
409
+ if (block.type === "text" && typeof block.text === "string" &&
410
+ !block.text.startsWith("<system-reminder>") &&
411
+ !block.text.startsWith("<local-command")) {
412
+ return block.text.slice(0, 200); // enough to identify, not too much to compare
413
+ }
414
+ }
415
+ return "";
416
+ }
417
+
418
+ /**
419
+ * Hash all non-system-reminder user content in messages[0] to detect
420
+ * substantive changes that the userText check (first 200 chars) might miss.
421
+ */
422
+ function hashUserContent(msg) {
423
+ if (!msg || !Array.isArray(msg.content)) return "empty";
424
+ const userBlocks = msg.content.filter(b =>
425
+ b.type === "text" && typeof b.text === "string" &&
426
+ !b.text.startsWith("<system-reminder>") &&
427
+ !b.text.startsWith("<local-command")
428
+ );
429
+ if (userBlocks.length === 0) return "empty";
430
+ return createHash("sha256")
431
+ .update(userBlocks.map(b => b.text).join("\n"))
432
+ .digest("hex").slice(0, 16);
433
+ }
434
+
435
+ /**
436
+ * On resume: try to replay saved messages[0] for cache hit.
437
+ * Returns the locked messages array or the original if lock doesn't apply.
438
+ */
439
+ function applyPrefixLock(messages, system, tools) {
440
+ if (!PREFIX_LOCK || !Array.isArray(messages) || messages.length < 2) return messages;
441
+
442
+ const firstUserIdx = messages.findIndex(m => m.role === "user");
443
+ if (firstUserIdx === -1) return messages;
444
+
445
+ const { sysHash, toolHash } = computePrefixHashes(system, tools);
446
+ const currentUserText = extractUserTextFromFirstMsg(messages[firstUserIdx]);
447
+ const currentContentHash = hashUserContent(messages[firstUserIdx]);
448
+
449
+ // Skip if this looks like a compacted conversation (system-reminder as first block
450
+ // with compaction summary markers)
451
+ const firstBlock = messages[firstUserIdx]?.content?.[0];
452
+ if (firstBlock?.text?.includes("CompactBoundary") || firstBlock?.text?.includes("compacted")) {
453
+ debugLog("PREFIX LOCK: skipped — compacted conversation detected");
454
+ return messages;
455
+ }
456
+
457
+ if (_prefixLockFirstCall) {
458
+ _prefixLockFirstCall = false;
459
+
460
+ // Try to load and apply saved prefix
461
+ try {
462
+ const saved = JSON.parse(readFileSync(PREFIX_LOCK_FILE, "utf8"));
463
+
464
+ if (saved.sysHash !== sysHash) {
465
+ debugLog("PREFIX LOCK: skipped — system prompt changed");
466
+ } else if (saved.toolHash !== toolHash) {
467
+ debugLog("PREFIX LOCK: skipped — tools changed");
468
+ } else if (saved.userText !== currentUserText) {
469
+ debugLog("PREFIX LOCK: skipped — user message text changed");
470
+ } else if (saved.contentHash && saved.contentHash !== currentContentHash) {
471
+ debugLog("PREFIX LOCK: skipped — user content hash changed (substantive context change)");
472
+ } else if (!saved.content || !Array.isArray(saved.content)) {
473
+ debugLog("PREFIX LOCK: skipped — saved content invalid");
474
+ } else {
475
+ // Apply the saved messages[0] content
476
+ const result = [...messages];
477
+ result[firstUserIdx] = { ...result[firstUserIdx], content: saved.content };
478
+ debugLog(`PREFIX LOCK: APPLIED — replayed saved messages[0] (${saved.content.length} blocks)`);
479
+ return result;
480
+ }
481
+ } catch {
482
+ debugLog("PREFIX LOCK: no saved prefix found (first run or file missing)");
483
+ }
484
+ }
485
+
486
+ return messages;
487
+ }
488
+
489
+ /**
490
+ * Save current messages[0] content for future resume replay.
491
+ * Called after all fixes are applied, before the request is sent.
492
+ */
493
+ function savePrefixLock(messages, system, tools) {
494
+ if (!PREFIX_LOCK || !Array.isArray(messages)) return;
495
+
496
+ const firstUserIdx = messages.findIndex(m => m.role === "user");
497
+ if (firstUserIdx === -1) return;
498
+
499
+ const { sysHash, toolHash } = computePrefixHashes(system, tools);
500
+ const userText = extractUserTextFromFirstMsg(messages[firstUserIdx]);
501
+ const contentHash = hashUserContent(messages[firstUserIdx]);
502
+ const content = messages[firstUserIdx].content;
503
+
504
+ try {
505
+ writeFileSync(PREFIX_LOCK_FILE, JSON.stringify({
506
+ timestamp: new Date().toISOString(),
507
+ sysHash,
508
+ toolHash,
509
+ userText,
510
+ contentHash,
511
+ content,
512
+ }));
513
+ } catch (e) {
514
+ debugLog("PREFIX LOCK: failed to save:", e?.message);
515
+ }
516
+ }
517
+
368
518
  // --------------------------------------------------------------------------
369
519
  // Tool schema stabilization (Bug 2 secondary cause)
370
520
  // --------------------------------------------------------------------------
@@ -694,6 +844,15 @@ globalThis.fetch = async function (url, options) {
694
844
  }
695
845
  }
696
846
 
847
+ // Prefix lock: replay saved messages[0] on resume for cache hit
848
+ if (payload.messages && payload.system) {
849
+ const locked = applyPrefixLock(payload.messages, payload.system, payload.tools);
850
+ if (locked !== payload.messages) {
851
+ payload.messages = locked;
852
+ modified = true;
853
+ }
854
+ }
855
+
697
856
  // Bug 2a: Stabilize tool ordering
698
857
  if (payload.tools) {
699
858
  const sorted = stabilizeToolOrder(payload.tools);
@@ -726,6 +885,11 @@ globalThis.fetch = async function (url, options) {
726
885
  debugLog("Request body rewritten");
727
886
  }
728
887
 
888
+ // Save prefix lock after all fixes applied
889
+ if (payload.messages && payload.system) {
890
+ savePrefixLock(payload.messages, payload.system, payload.tools);
891
+ }
892
+
729
893
  // Monitor for microcompact / budget enforcement degradation
730
894
  if (payload.messages) {
731
895
  monitorContextDegradation(payload.messages);