claude-code-infinite 1.0.20 → 1.0.22

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 (103) hide show
  1. package/README.md +165 -30
  2. package/dist/auto-update-runner.d.ts +52 -0
  3. package/dist/auto-update-runner.d.ts.map +1 -0
  4. package/dist/auto-update-runner.js +307 -0
  5. package/dist/auto-update-runner.js.map +1 -0
  6. package/dist/auto-update-setup.d.ts +38 -0
  7. package/dist/auto-update-setup.d.ts.map +1 -0
  8. package/dist/auto-update-setup.js +72 -0
  9. package/dist/auto-update-setup.js.map +1 -0
  10. package/dist/auto-update.d.ts +170 -0
  11. package/dist/auto-update.d.ts.map +1 -0
  12. package/dist/auto-update.js +401 -0
  13. package/dist/auto-update.js.map +1 -0
  14. package/dist/claude-env.d.ts +14 -1
  15. package/dist/claude-env.d.ts.map +1 -1
  16. package/dist/claude-env.js +17 -1
  17. package/dist/claude-env.js.map +1 -1
  18. package/dist/cli-lifecycle.d.ts +4 -0
  19. package/dist/cli-lifecycle.d.ts.map +1 -1
  20. package/dist/cli-lifecycle.js +18 -0
  21. package/dist/cli-lifecycle.js.map +1 -1
  22. package/dist/cli-main.js +139 -19
  23. package/dist/cli-main.js.map +1 -1
  24. package/dist/config.d.ts +4 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js.map +1 -1
  27. package/dist/count-tokens.d.ts +38 -3
  28. package/dist/count-tokens.d.ts.map +1 -1
  29. package/dist/count-tokens.js +94 -19
  30. package/dist/count-tokens.js.map +1 -1
  31. package/dist/diagnostics-schema.d.ts +44 -0
  32. package/dist/diagnostics-schema.d.ts.map +1 -0
  33. package/dist/diagnostics-schema.js +162 -0
  34. package/dist/diagnostics-schema.js.map +1 -0
  35. package/dist/diagnostics.d.ts +116 -0
  36. package/dist/diagnostics.d.ts.map +1 -0
  37. package/dist/diagnostics.js +316 -0
  38. package/dist/diagnostics.js.map +1 -0
  39. package/dist/first-tree.d.ts +11 -0
  40. package/dist/first-tree.d.ts.map +1 -0
  41. package/dist/first-tree.js +45 -0
  42. package/dist/first-tree.js.map +1 -0
  43. package/dist/hooks.d.ts +37 -0
  44. package/dist/hooks.d.ts.map +1 -1
  45. package/dist/hooks.js +69 -9
  46. package/dist/hooks.js.map +1 -1
  47. package/dist/memtree-finder.d.ts +11 -0
  48. package/dist/memtree-finder.d.ts.map +1 -1
  49. package/dist/memtree-finder.js +18 -0
  50. package/dist/memtree-finder.js.map +1 -1
  51. package/dist/memtree-links.d.ts +2 -0
  52. package/dist/memtree-links.d.ts.map +1 -1
  53. package/dist/memtree-links.js +4 -0
  54. package/dist/memtree-links.js.map +1 -1
  55. package/dist/memtree-mcp-config.d.ts.map +1 -1
  56. package/dist/memtree-mcp-config.js +4 -2
  57. package/dist/memtree-mcp-config.js.map +1 -1
  58. package/dist/memtree-mcp.d.ts +24 -4
  59. package/dist/memtree-mcp.d.ts.map +1 -1
  60. package/dist/memtree-mcp.js +47 -14
  61. package/dist/memtree-mcp.js.map +1 -1
  62. package/dist/memtree-tools.d.ts +17 -1
  63. package/dist/memtree-tools.d.ts.map +1 -1
  64. package/dist/memtree-tools.js +50 -2
  65. package/dist/memtree-tools.js.map +1 -1
  66. package/dist/memtree.d.ts +17 -1
  67. package/dist/memtree.d.ts.map +1 -1
  68. package/dist/memtree.js +62 -19
  69. package/dist/memtree.js.map +1 -1
  70. package/dist/notices.d.ts +6 -1
  71. package/dist/notices.d.ts.map +1 -1
  72. package/dist/notices.js +13 -1
  73. package/dist/notices.js.map +1 -1
  74. package/dist/prefix-store.js +3 -0
  75. package/dist/prefix-store.js.map +1 -1
  76. package/dist/proxy.d.ts +45 -3
  77. package/dist/proxy.d.ts.map +1 -1
  78. package/dist/proxy.js +193 -54
  79. package/dist/proxy.js.map +1 -1
  80. package/dist/reqlog.d.ts +27 -1
  81. package/dist/reqlog.d.ts.map +1 -1
  82. package/dist/reqlog.js.map +1 -1
  83. package/dist/request-sizing.d.ts +21 -2
  84. package/dist/request-sizing.d.ts.map +1 -1
  85. package/dist/request-sizing.js +60 -7
  86. package/dist/request-sizing.js.map +1 -1
  87. package/dist/resume-hint.d.ts +25 -0
  88. package/dist/resume-hint.d.ts.map +1 -0
  89. package/dist/resume-hint.js +36 -0
  90. package/dist/resume-hint.js.map +1 -0
  91. package/dist/token-estimate.d.ts +35 -0
  92. package/dist/token-estimate.d.ts.map +1 -0
  93. package/dist/token-estimate.js +97 -0
  94. package/dist/token-estimate.js.map +1 -0
  95. package/dist/update-check.d.ts +4 -3
  96. package/dist/update-check.d.ts.map +1 -1
  97. package/dist/update-check.js +4 -3
  98. package/dist/update-check.js.map +1 -1
  99. package/dist/update-lock.d.ts +68 -0
  100. package/dist/update-lock.d.ts.map +1 -0
  101. package/dist/update-lock.js +176 -0
  102. package/dist/update-lock.js.map +1 -0
  103. package/package.json +3 -3
package/dist/proxy.js CHANGED
@@ -59,6 +59,8 @@ import { hyperlink } from "./payment-gate.js";
59
59
  import { PaymentNoticeThrottle, paymentNoticeText } from "./payment-notice.js";
60
60
  import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
61
61
  import { describeClaudeCodeRequest, inspectMonitorTranscript, knownSideRequestKind, transcriptWarning, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
62
+ import { isResumableSessionId } from "./resume-hint.js";
63
+ import { conservativeBodyTokens } from "./token-estimate.js";
62
64
  import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
63
65
  const DEFAULT_UPSTREAM = "https://api.anthropic.com";
64
66
  const HOOK_BODY_LIMIT = 64 * 1024;
@@ -91,10 +93,23 @@ const SKIP_RESPONSE_HEADERS = new Set([
91
93
  ]);
92
94
  /**
93
95
  * Budget fallback until the server reports `model_budget_tokens`: this share
94
- * of the model's context window (800k of Opus 5.5's 1M, matching the server's
95
- * large-context threshold).
96
+ * of the model's context window. 0.8 by default; NEAR_WINDOW_FALLBACK_RATIO
97
+ * (900k of 1M) for the 1M-window models the server budgets at 900k for ccc
98
+ * (NEAR_WINDOW_MODEL_RE). Plain Claude Code 2.1.302 auto-compacts a 1M window
99
+ * at 967k, so MemTree compresses no earlier than ~70k before it would; ccc
100
+ * lifts Claude Code's own 977k refusal (claude-env.ts) so a tool turn that
101
+ * jumps past the budget reaches the proxy and is compressed here.
96
102
  */
97
103
  export const FALLBACK_BUDGET_WINDOW_RATIO = 0.8;
104
+ export const NEAR_WINDOW_FALLBACK_RATIO = 0.9;
105
+ /** Opus 5.5, Opus 5, Opus 4.8 and Fable 5 / 5.1 (polychat LARGE_CONTEXT_MODELS). */
106
+ const NEAR_WINDOW_MODEL_RE = /claude-(?:opus-(?:4-8|5-5|5)|fable-5(?:-1)?)(?![0-9])/i;
107
+ const ONE_MILLION_TOKENS = 1_000_000;
108
+ /** The fallback share of the window for `model` (see FALLBACK_BUDGET_WINDOW_RATIO). */
109
+ export function fallbackBudgetRatio(model, modelContextLimit) {
110
+ return typeof model === "string" && modelContextLimit >= ONE_MILLION_TOKENS &&
111
+ NEAR_WINDOW_MODEL_RE.test(model) ? NEAR_WINDOW_FALLBACK_RATIO : FALLBACK_BUDGET_WINDOW_RATIO;
112
+ }
98
113
  /**
99
114
  * An explicit target is capped at this share of the budget: a compaction to
100
115
  * the budget itself would be over budget again on the next turn and
@@ -267,6 +282,8 @@ export function startProxy(opts) {
267
282
  paymentNotice: new PaymentNoticeThrottle(opts.paymentNoticeIntervalMs),
268
283
  unpaidDetail: () => opts.memtree.paymentRequiredDetail,
269
284
  usageUrl: opts.memtree.usageDashboardUrl,
285
+ sessionStartNotice: opts.sessionStartNotice,
286
+ diagnostics: opts.diagnostics,
270
287
  notices: new NoticeDeliveryQueue(),
271
288
  mainNoticePending: false,
272
289
  mainPromptGeneration: 0,
@@ -303,6 +320,7 @@ export function startProxy(opts) {
303
320
  laneSizes: new Map(),
304
321
  };
305
322
  installMemtreeLink(state, state.memtreeLinkPlacement);
323
+ state.notices.setFirstTreeIntro(opts.firstTreeIntro ?? null);
306
324
  const server = http.createServer((req, res) => {
307
325
  const accepted = { req, res };
308
326
  acceptedRequests.add(accepted);
@@ -385,6 +403,7 @@ export function startProxy(opts) {
385
403
  void beginClose();
386
404
  },
387
405
  drain,
406
+ mainSessionId: () => state.resumableMainSession,
388
407
  });
389
408
  });
390
409
  });
@@ -615,6 +634,30 @@ export function memtreePageId(pageUrl) {
615
634
  return undefined;
616
635
  }
617
636
  }
637
+ const SESSION_START_SOURCES = new Set(["startup", "resume", "clear", "compact"]);
638
+ /** A main-agent SessionStart, for diagnostics; never fails the hook. */
639
+ function recordSessionStart(state, source) {
640
+ try {
641
+ const known = source !== undefined && SESSION_START_SOURCES.has(source);
642
+ state.diagnostics?.record("session_start", { source: known ? source : "other" });
643
+ }
644
+ catch {
645
+ // Diagnostics are best effort.
646
+ }
647
+ }
648
+ /**
649
+ * A resumed or forked session can be resumed again at once; a fresh or
650
+ * cleared one has nothing to resume until its first turn. Compaction keeps
651
+ * the same session.
652
+ */
653
+ function noteMainSessionStart(state, source, sessionId) {
654
+ if (source === "resume" || source === "fork") {
655
+ state.resumableMainSession = isResumableSessionId(sessionId) ? sessionId : undefined;
656
+ }
657
+ else if (source === "startup" || source === "clear") {
658
+ state.resumableMainSession = undefined;
659
+ }
660
+ }
618
661
  /** Serve only validated Claude hook POSTs on the randomized localhost path. */
619
662
  async function handleNoticeHook(req, res, state, reqlog,
620
663
  /** A main-lane turn ended: the session may be over (its final index). */
@@ -653,11 +696,15 @@ onMainStop) {
653
696
  return;
654
697
  }
655
698
  if (parsed.hook_event_name === "SessionStart") {
699
+ if (parsed.agent_id === undefined) {
700
+ recordSessionStart(state, parsed.source);
701
+ noteMainSessionStart(state, parsed.source, parsed.session_id);
702
+ }
656
703
  // A resume waits briefly for the newest tree; a fresh session has none to wait for.
657
704
  if (parsed.agent_id === undefined && parsed.source === "resume") {
658
705
  await state.newestTrees?.settle(parsed.session_id);
659
706
  }
660
- const line = joinNoticeLines(resumeLinkLine(state, parsed), resumePaymentNotice(state, parsed));
707
+ const line = joinNoticeLines(resumeLinkLine(state, parsed), resumePaymentNotice(state, parsed), extraSessionStartLine(state.sessionStartNotice, parsed));
661
708
  if (!line) {
662
709
  res.writeHead(204);
663
710
  res.end();
@@ -950,10 +997,11 @@ async function handleMessages(req, res, opts, upstream, state) {
950
997
  upstream, headers: forwardableRequestHeaders(req), requestUrl: req.url,
951
998
  signal: state.shutdownSignal,
952
999
  onAttempt: (result) => {
953
- (rec.countTokens ??= []).push({ statusClass: result.outcome, ms: result.ms });
1000
+ (rec.countTokens ??= []).push({ statusClass: result.outcome, ms: result.ms,
1001
+ bytes: result.bytes ?? 0, timeoutMs: result.timeoutMs ?? 0 });
954
1002
  },
955
1003
  }, state.countTokensCooldowns);
956
- const requestSizing = new RequestSizing(countSession, opts.countTokens === true);
1004
+ const requestSizing = new RequestSizing(countSession, opts.countTokens === true, typeof body.model === "string" ? body.model : undefined);
957
1005
  if (carriesServerTools(body))
958
1006
  serverToolRecords.add(rec);
959
1007
  const sizeSlot = wholeRequestSizeSlot(state, isMainRequest, requestSessionKey, requestSizeKey, body.model);
@@ -1052,6 +1100,8 @@ async function handleMessages(req, res, opts, upstream, state) {
1052
1100
  // reply the session ends with (scheduleFinalIndex, on Stop).
1053
1101
  const mainSessionId = isMainRequest ? requestSessionId(req) : undefined;
1054
1102
  if (mainSessionId !== undefined) {
1103
+ if (isResumableSessionId(mainSessionId))
1104
+ state.resumableMainSession = mainSessionId;
1055
1105
  opts.memtree.noteMainConversation(mainSessionId, {
1056
1106
  messages: msgsForMemtree,
1057
1107
  modelContextLimit,
@@ -1170,7 +1220,7 @@ async function handleMessages(req, res, opts, upstream, state) {
1170
1220
  // prefix, else whole) with no compress call; at it, compress once to
1171
1221
  // the target (planToolCompaction). A "replay" is a client retry of the
1172
1222
  // request that installed the route, forwarded verbatim.
1173
- const plan = planToolCompaction({
1223
+ const plan = await planToolCompaction({
1174
1224
  opts,
1175
1225
  state,
1176
1226
  body,
@@ -1207,11 +1257,15 @@ async function handleMessages(req, res, opts, upstream, state) {
1207
1257
  // A concurrent request of this lane is already compressing.
1208
1258
  rec.routeRecovery = { outcome: "in-flight" };
1209
1259
  }
1210
- else if (prior?.awaitingIndex && !plan.overWindow) {
1260
+ else if (prior?.awaitingIndex && (!plan.overWindow || plan.uncountedOverWindowOnly)) {
1211
1261
  // The last attempt found no tree for this conversation, so a
1212
1262
  // compress call could only pass everything through again. Forward
1213
1263
  // now; the page check lets the next tool turn try once it exists.
1264
+ // Over the window only by its uncounted bound, delivery may still
1265
+ // count it; if it refuses, that is an index wait, not a strike.
1214
1266
  rec.routeRecovery = { outcome: "awaiting-index" };
1267
+ if (plan.uncountedOverWindowOnly)
1268
+ rec.indexBehind = true;
1215
1269
  checkAwaitedIndex(opts, prior, state.shutdownSignal);
1216
1270
  }
1217
1271
  else if (prior?.retryAtTokens !== undefined &&
@@ -1350,7 +1404,7 @@ async function handleMessages(req, res, opts, upstream, state) {
1350
1404
  // Stable-prefix (edge) compaction: the main thread's human turns only. The
1351
1405
  // recap rides the main route above; subagents keep per-turn compression.
1352
1406
  const edge = isMainRequest && sessionId !== undefined
1353
- ? planEdgeCompaction({
1407
+ ? await planEdgeCompaction({
1354
1408
  sizing: requestSizing,
1355
1409
  opts,
1356
1410
  state,
@@ -1641,7 +1695,10 @@ async function handleMessages(req, res, opts, upstream, state) {
1641
1695
  const paymentDetail = opts.memtree.paymentRequiredDetail;
1642
1696
  const mayQueueNotice = displayForThisTurn && state.mainPromptGeneration === noticePromptGeneration;
1643
1697
  if (mayQueueNotice && paymentDetail !== null && state.paymentNotice.due()) {
1644
- state.notices.queueSuffix(paymentNoticeText(paymentDetail, recapLink(state, sessionId)?.link), () => state.paymentNotice.markShown(), noticePromptId);
1698
+ state.notices.queueSuffix(paymentNoticeText(paymentDetail, recapLink(state, sessionId)?.link), () => {
1699
+ state.paymentNotice.markShown();
1700
+ state.diagnostics?.record("paused_notice", { trigger: "turn" });
1701
+ }, noticePromptId);
1645
1702
  }
1646
1703
  else if (mayQueueNotice && paymentDetail === null) {
1647
1704
  state.notices.queueSuffix(DEGRADED_NOTICE, undefined, noticePromptId);
@@ -1875,8 +1932,20 @@ function resumePaymentNotice(state, input) {
1875
1932
  if (detail === null)
1876
1933
  return undefined;
1877
1934
  state.paymentNotice.markShown();
1935
+ state.diagnostics?.record("paused_notice", { trigger: "resume" });
1878
1936
  return state.notices.warningText(paymentNoticeText(detail));
1879
1937
  }
1938
+ /** The caller's extra SessionStart line; a throwing provider shows nothing. */
1939
+ function extraSessionStartLine(provider, input) {
1940
+ if (!provider || input.agent_id !== undefined || input.source === "compact")
1941
+ return undefined;
1942
+ try {
1943
+ return provider();
1944
+ }
1945
+ catch {
1946
+ return undefined;
1947
+ }
1948
+ }
1880
1949
  function joinNoticeLines(...parts) {
1881
1950
  const present = parts.filter((part) => !!part);
1882
1951
  return present.length ? present.join("\n") : undefined;
@@ -2381,7 +2450,7 @@ function serverBudgetKey(model, modelContextLimit) {
2381
2450
  /**
2382
2451
  * The session's whole-request budget: CCC_BUDGET_TOKENS when set (tests),
2383
2452
  * else the model budget the server last reported for this model and window,
2384
- * else the window times FALLBACK_BUDGET_WINDOW_RATIO (servers that predate
2453
+ * else the window times fallbackBudgetRatio (servers that predate
2385
2454
  * `model_budget_tokens`, and the first request before any response).
2386
2455
  */
2387
2456
  function resolveBudget(opts, state, model, modelContextLimit) {
@@ -2392,7 +2461,7 @@ function resolveBudget(opts, state, model, modelContextLimit) {
2392
2461
  if (reported !== undefined)
2393
2462
  return { tokens: reported, source: "server" };
2394
2463
  return {
2395
- tokens: Math.floor(modelContextLimit * FALLBACK_BUDGET_WINDOW_RATIO),
2464
+ tokens: Math.floor(modelContextLimit * fallbackBudgetRatio(model, modelContextLimit)),
2396
2465
  source: "window-ratio",
2397
2466
  };
2398
2467
  }
@@ -2442,10 +2511,11 @@ function laneCompaction(opts, state, sessionId, model, modelContextLimit) {
2442
2511
  }
2443
2512
  /** Calibrated input must leave room for the requested output as well. */
2444
2513
  function calibratedSizeExceedsWindow(record, body, modelContextLimit) {
2445
- // A planning estimate, so the full max_tokens (the floor needs an exact count).
2446
- const outputTokens = bodyOutputReservation(body, false);
2447
- return record.sizeSource === "reported" &&
2448
- (record.estimatedTokens ?? 0) + outputTokens > modelContextLimit;
2514
+ // An exact count reserves the model's output floor, as delivery does; an
2515
+ // estimate reserves the full max_tokens.
2516
+ const outputTokens = bodyOutputReservation(body, exactSizedRecords.has(record));
2517
+ return record.uncountedOverWindow === true || (record.sizeSource === "reported" &&
2518
+ (record.estimatedTokens ?? 0) + outputTokens > modelContextLimit);
2449
2519
  }
2450
2520
  function calibratedSizeExceedsLimits(record, body, modelContextLimit) {
2451
2521
  return (record.sizeSource === "reported" &&
@@ -2515,16 +2585,24 @@ function usedServerTools(rec) {
2515
2585
  * is dropped and the previous sample kept.
2516
2586
  */
2517
2587
  function reportedSample(rec, bytes) {
2518
- if (changedDeliveryRecords.has(rec))
2519
- return undefined;
2520
- if (serverToolRecords.has(rec) || usedServerTools(rec))
2521
- return undefined;
2522
- const tokens = reportedInputTokens(rec);
2588
+ const tokens = reportedBodyTokens(rec);
2523
2589
  if (tokens === undefined)
2524
2590
  return undefined;
2525
2591
  const sample = { tokens, forwardedBytes: bytes };
2526
2592
  return plausibleSample(sample) ? sample : undefined;
2527
2593
  }
2594
+ /**
2595
+ * Anthropic's input tokens for the body this request forwarded, when its usage
2596
+ * describes that body (not a changed delivery, no server tools). Not filtered
2597
+ * by plausibility: a body that is mostly images is truly cheap per byte.
2598
+ */
2599
+ function reportedBodyTokens(rec) {
2600
+ if (changedDeliveryRecords.has(rec))
2601
+ return undefined;
2602
+ if (serverToolRecords.has(rec) || usedServerTools(rec))
2603
+ return undefined;
2604
+ return reportedInputTokens(rec);
2605
+ }
2528
2606
  /**
2529
2607
  * Key of a non-main request's size sample (ProxyState.laneSizes): its lane,
2530
2608
  * its own agent id (helpers that carry only a parent id stay apart from the
@@ -2592,7 +2670,7 @@ function boundedSet(map, key, value, limit = STABLE_PREFIX_MAX_SESSIONS) {
2592
2670
  *
2593
2671
  * Fills rec.compaction with the budget, estimate and reason.
2594
2672
  */
2595
- function planEdgeCompaction(args) {
2673
+ async function planEdgeCompaction(args) {
2596
2674
  const { opts, state, body, messages, sessionId, modelContextLimit, forwardBody, rec } = args;
2597
2675
  const mode = compactionMode(opts, state, sessionId);
2598
2676
  const budget = resolveBudget(opts, state, body.model, modelContextLimit);
@@ -2644,12 +2722,16 @@ function planEdgeCompaction(args) {
2644
2722
  }
2645
2723
  const prefixCandidate = args.sizing.register(routed.raw, "ride", prefix.lastSize);
2646
2724
  candidateSizeOwners.set(prefixCandidate, prefix);
2725
+ await countIfOverBound(args.sizing, prefixCandidate, body, modelContextLimit);
2647
2726
  const size = args.sizing.plan(prefixCandidate);
2648
2727
  compaction.estimatedTokens = size.tokens;
2649
2728
  compaction.estimatedBytes = routed.raw.length;
2650
2729
  compaction.sizeSource = size.source;
2651
- const overWindow = routedBodyExceedsContext(body, routed.raw, modelContextLimit) ||
2652
- calibratedSizeExceedsWindow(compaction, body, modelContextLimit);
2730
+ noteUncountedBound(compaction, args.sizing, prefixCandidate, body, modelContextLimit);
2731
+ // Request-local sizing (an exact count, a calibrated sample, or the
2732
+ // image-aware bound), not bytes: a flatten that kept images is mostly
2733
+ // base64 that costs a fixed amount per image.
2734
+ const overWindow = calibratedSizeExceedsWindow(compaction, body, modelContextLimit);
2653
2735
  const fallback = overWindow ? undefined : { prefix, routed };
2654
2736
  if (state.compactNow.has(sessionId))
2655
2737
  return forced("manual", fallback);
@@ -2663,10 +2745,13 @@ function planEdgeCompaction(args) {
2663
2745
  }
2664
2746
  if (state.compactNow.has(sessionId))
2665
2747
  return forced("manual");
2666
- const size = args.sizing.plan(args.sizing.register(forwardBody, "original"));
2748
+ const originalCandidate = args.sizing.register(forwardBody, "original");
2749
+ await countIfOverBound(args.sizing, originalCandidate, body, modelContextLimit);
2750
+ const size = args.sizing.plan(originalCandidate);
2667
2751
  compaction.estimatedTokens = size.tokens;
2668
2752
  compaction.estimatedBytes = forwardBody.length;
2669
2753
  compaction.sizeSource = size.source;
2754
+ noteUncountedBound(compaction, args.sizing, originalCandidate, body, modelContextLimit);
2670
2755
  if (calibratedSizeExceedsLimits(compaction, body, modelContextLimit)) {
2671
2756
  return forced(compaction.prefixMiss ? "prefix-mismatch" : "budget");
2672
2757
  }
@@ -2697,7 +2782,7 @@ function planEdgeCompaction(args) {
2697
2782
  *
2698
2783
  * Fills rec.compaction.
2699
2784
  */
2700
- function planToolCompaction(args) {
2785
+ async function planToolCompaction(args) {
2701
2786
  const { opts, state, body, messages, sessionId, modelContextLimit, forwardBody, rec } = args;
2702
2787
  const mode = compactionMode(opts, state, sessionId);
2703
2788
  const budget = resolveBudget(opts, state, body.model, modelContextLimit);
@@ -2714,23 +2799,16 @@ function planToolCompaction(args) {
2714
2799
  const stableLane = args.isMainRequest && sessionId !== undefined;
2715
2800
  const current = stableLane ? state.stablePrefixes.get(sessionId) : undefined;
2716
2801
  let ride = args.ride;
2717
- let overWindow = false;
2718
2802
  if (!ride && current && !prefixMismatch(body, messages, current, sessionId, stablePrefixMessageHash)) {
2719
2803
  const routed = prefixRoutedBody(body, messages, current);
2720
2804
  if (routed) {
2721
2805
  const prefixCandidate = args.sizing.register(routed.raw, "ride", current.lastSize);
2722
2806
  candidateSizeOwners.set(prefixCandidate, current);
2723
- if (routedBodyExceedsContext(body, routed.raw, modelContextLimit))
2724
- overWindow = true;
2725
- else
2726
- ride = { raw: routed.raw, turnType: "tool-prefix", sizeHolder: current };
2807
+ // Sized below with the request-local sizing; an over-window ride is not
2808
+ // kept as a fallback.
2809
+ ride = { raw: routed.raw, turnType: "tool-prefix", sizeHolder: current };
2727
2810
  }
2728
2811
  }
2729
- if (!ride) {
2730
- const original = args.sizing.register(forwardBody, "original");
2731
- overWindow ||= args.sizing.plan(original).tokens +
2732
- bodyOutputReservation(body, args.sizing.isExact(original)) > modelContextLimit;
2733
- }
2734
2812
  const sample = ride
2735
2813
  ? ride.sizeHolder.lastSize
2736
2814
  : stableLane
@@ -2739,12 +2817,18 @@ function planToolCompaction(args) {
2739
2817
  const candidate = args.sizing.register(ride?.raw ?? forwardBody, ride ? "ride" : "original", sample);
2740
2818
  if (ride)
2741
2819
  candidateSizeOwners.set(candidate, ride.sizeHolder);
2820
+ await countIfOverBound(args.sizing, candidate, body, modelContextLimit);
2742
2821
  const size = args.sizing.plan(candidate);
2743
2822
  compaction.estimatedTokens = size.tokens;
2744
2823
  compaction.estimatedBytes = (ride?.raw ?? forwardBody).length;
2745
2824
  compaction.sizeSource = size.source;
2746
- overWindow ||= !fitsNative(size.tokens, { budget: budget.tokens, context: modelContextLimit,
2825
+ noteUncountedBound(compaction, args.sizing, candidate, body, modelContextLimit);
2826
+ // Judged after any planning count, so a fitting exact count is not
2827
+ // overridden by the estimate it replaced.
2828
+ let overWindow = !fitsNative(size.tokens, { budget: budget.tokens, context: modelContextLimit,
2747
2829
  output: bodyOutputReservation(body, args.sizing.isExact(candidate)) });
2830
+ const uncountedOnly = !overWindow && compaction.uncountedOverWindow === true;
2831
+ overWindow ||= uncountedOnly;
2748
2832
  if (!args.canCompress) {
2749
2833
  if (overWindow)
2750
2834
  return { kind: "pass", original: true };
@@ -2762,6 +2846,7 @@ function planToolCompaction(args) {
2762
2846
  estimateTokens: size.tokens,
2763
2847
  budgetTokens: budget.tokens,
2764
2848
  overWindow,
2849
+ ...(uncountedOnly ? { uncountedOverWindowOnly: true } : {}),
2765
2850
  ...(ride && !overWindow ? { fallback: ride } : {}),
2766
2851
  ...(stableLane ? { replaces: current ?? null } : {}),
2767
2852
  };
@@ -2806,12 +2891,46 @@ function memoryRoutedToolBody(body, messages, route, routeEpoch, sessionId) {
2806
2891
  return null;
2807
2892
  return Buffer.from(JSON.stringify(routed), "utf-8");
2808
2893
  }
2809
- /** Size the assembled input (including system/tools) and reserve output room. */
2810
- function routedBodyExceedsContext(body, routedBody, modelContextLimit) {
2811
- const outputTokens = bodyOutputReservation(body, false);
2812
- // This is the existing local byte estimate, not a tokenizer or a hard cap.
2813
- // Final forwarding checks can replace this estimate with Count Tokens.
2814
- return approxTokensFromBytes(routedBody.length) + outputTokens > modelContextLimit;
2894
+ /**
2895
+ * Count the candidate a planner is about to judge (ride or original, sampled
2896
+ * or not) when only a count could admit it: no exact count, and its per-model
2897
+ * conservative bound plus max_tokens exceeds the window. Delivery reuses the
2898
+ * result.
2899
+ */
2900
+ async function countIfOverBound(sizing, candidate, body, modelContextLimit) {
2901
+ await sizing.countIfOverBound(candidate, { budget: modelContextLimit, context: modelContextLimit, output: bodyOutputReservation(body, false) });
2902
+ }
2903
+ /** Compaction records whose estimatedTokens is an exact count (noteUncountedBound). */
2904
+ const exactSizedRecords = new WeakSet();
2905
+ /**
2906
+ * Note an exactly counted candidate (exactSizedRecords), or mark a planned
2907
+ * candidate still without an exact count (counting off, or its
2908
+ * planning count failed: countIfOverBound ran first) whose conservative bound
2909
+ * plus max_tokens exceeds the window: delivery would refuse it uncounted, so
2910
+ * it is over the window for planning and takes the normal gated compression.
2911
+ */
2912
+ function noteUncountedBound(record, sizing, candidate, body, modelContextLimit) {
2913
+ if (sizing.isExact(candidate)) {
2914
+ exactSizedRecords.add(record);
2915
+ return;
2916
+ }
2917
+ if (sizing.exceedsRequestLimit(candidate) ||
2918
+ candidate.conservativeTokens + bodyOutputReservation(body, false) > modelContextLimit) {
2919
+ record.uncountedOverWindow = true;
2920
+ }
2921
+ }
2922
+ /**
2923
+ * Whether a forwarded compressed body still overflows the window: its exact
2924
+ * count (`exactTokens`, made for delivery), else its reported input tokens,
2925
+ * both Anthropic's own count, so the output floor applies; else its
2926
+ * conservative image-aware bound (token-estimate.ts) with full max_tokens,
2927
+ * never bytes/4, which reads base64 images as text.
2928
+ */
2929
+ function compressedResultExceedsWindow(body, rec, compressedRaw, modelContextLimit, exactTokens) {
2930
+ const counted = exactTokens ?? reportedBodyTokens(rec);
2931
+ const tokens = counted ??
2932
+ conservativeBodyTokens(compressedRaw, typeof body.model === "string" ? body.model : undefined);
2933
+ return tokens + bodyOutputReservation(body, counted !== undefined) > modelContextLimit;
2815
2934
  }
2816
2935
  /** Output room a native-fit check reserves: the model's floor for an exact
2817
2936
  * count, else (an estimate) the full max_tokens. */
@@ -2998,12 +3117,18 @@ function withFlattenCacheBreakpoint(body, ttl) {
2998
3117
  if (messages.length !== 1)
2999
3118
  return messages;
3000
3119
  const only = messages[0];
3001
- const text = typeof only.content === "string" ? only.content : undefined;
3002
- if (text === undefined)
3003
- return messages;
3004
3120
  const cacheControl = { type: "ephemeral" };
3005
3121
  if (ttl !== undefined)
3006
3122
  cacheControl.ttl = ttl;
3123
+ if (Array.isArray(only.content) && only.content.length > 0) {
3124
+ // A flatten that kept images: the breakpoint goes on its last block.
3125
+ const blocks = only.content.map((block) => ({ ...block }));
3126
+ blocks[blocks.length - 1].cache_control = cacheControl;
3127
+ return [{ ...only, content: blocks }];
3128
+ }
3129
+ const text = typeof only.content === "string" ? only.content : undefined;
3130
+ if (text === undefined)
3131
+ return messages;
3007
3132
  return [{ ...only, content: [{ type: "text", text, cache_control: cacheControl }] }];
3008
3133
  }
3009
3134
  /**
@@ -3147,7 +3272,9 @@ async function runBlockingCompression(args) {
3147
3272
  // whatever the fallback gate would refuse to send uncompressed; a bytes/4
3148
3273
  // guess is not sent, as it would force needless compression.
3149
3274
  ...(args.inputCandidate && args.sizing.clientInputTokens(args.inputCandidate) !== undefined
3150
- ? { clientInputTokens: args.sizing.clientInputTokens(args.inputCandidate) }
3275
+ ? { clientInputTokens: args.sizing.clientInputTokens(args.inputCandidate),
3276
+ // Only an exact count of these bytes may loosen the server's sizing.
3277
+ clientInputTokensExact: args.sizing.isExact(args.inputCandidate) }
3151
3278
  : {}),
3152
3279
  ...(messageUsage && Object.keys(messageUsage).length ? { messageUsage } : {}),
3153
3280
  ...(messageTimes && Object.keys(messageTimes).length ? { messageTimes } : {}),
@@ -3592,8 +3719,11 @@ async function recoverToolRouteMiss(args) {
3592
3719
  // Nothing to ride next turn, or a result that still does not fit the
3593
3720
  // window (the next turn would reject it and compress again): wait for
3594
3721
  // growth before the lane tries again.
3595
- if (!sizeHolder || routedBodyExceedsContext(body, compressedRaw, modelContextLimit))
3722
+ const exactTokens = args.sizing.exactTokensFor(compressedRaw);
3723
+ if (!sizeHolder ||
3724
+ compressedResultExceedsWindow(body, rec, compressedRaw, modelContextLimit, exactTokens)) {
3596
3725
  backOff();
3726
+ }
3597
3727
  // A reservation that lost its race (stale) or never reached
3598
3728
  // protocol-complete (upstream 5xx, truncated stream) installed nothing, so
3599
3729
  // it must stop suppressing whoever is still trying to install.
@@ -3766,13 +3896,16 @@ async function selectForwardCandidate(args) {
3766
3896
  return changed ? candidate.body : intended;
3767
3897
  }
3768
3898
  /**
3769
- * Whether delivery would refuse `candidate` sent whole: it is planned over the
3770
- * window, and the measurement selection would use (an exact count, else the
3771
- * same estimate or bytes/4 fallback) does not fit either. Only then is there
3772
- * no fallback, and only then may a compress take the long budget.
3899
+ * Whether delivery would refuse `candidate` sent whole, by delivery's own
3900
+ * admission: it is sendable only when its plan fits and either an exact count
3901
+ * or its conservative per-model bound (with the full max_tokens) fits too;
3902
+ * otherwise the measurement delivery would use (a count, else that bound)
3903
+ * decides. Only when it is refused is there no fallback, and only then may a
3904
+ * compress take the long budget.
3773
3905
  */
3774
3906
  async function wholeRequestRefused(sizing, candidate, limits) {
3775
- if (fitsNative(sizing.plan(candidate).tokens, limits))
3907
+ if (fitsNative(sizing.plan(candidate).tokens, limits) &&
3908
+ (sizing.isExact(candidate) || sizing.boundFits(candidate, limits)))
3776
3909
  return false;
3777
3910
  return !fitsMeasured(await sizing.measure(candidate, limits), limits);
3778
3911
  }
@@ -3781,12 +3914,17 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
3781
3914
  const failure = args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true });
3782
3915
  args.rec.forwardedBytes = 0;
3783
3916
  args.rec.approxInputTokens = 0;
3784
- console.error("[ccc proxy] request exceeds native context window; recovery failed", {
3917
+ args.state.diagnostics?.record("refusal", {
3918
+ status: failure.status, attempt: failure.attempt,
3919
+ input_ktokens: Math.round(inputTokens / 1000), budget_ktokens: Math.round(budgetTokens / 1000),
3920
+ });
3921
+ // To requests.jsonl, never the console: Claude Code's TUI shows stderr inline.
3922
+ args.rec.refusal = {
3785
3923
  lane: args.routeKey, attempt: failure.attempt, status: failure.status,
3786
3924
  inputTokens, outputTokens: args.body.max_tokens ?? 0,
3787
3925
  outputReservation: bodyOutputReservation(args.body), indexBehind: args.rec.indexBehind === true,
3788
3926
  budgetTokens, modelContextLimit: args.modelContextLimit,
3789
- });
3927
+ };
3790
3928
  if (args.res.headersSent || args.res.destroyed)
3791
3929
  return false;
3792
3930
  args.res.writeHead(failure.status, { "content-type": "application/json", ...failure.headers });
@@ -4529,6 +4667,7 @@ function readAll(stream) {
4529
4667
  /** Test seam. */
4530
4668
  export const __testCapCacheBreakpoints = capCacheBreakpoints;
4531
4669
  export const __testEstimateRequestTokens = estimateRequestTokens;
4670
+ export const __testCompressedResultExceedsWindow = compressedResultExceedsWindow;
4532
4671
  /** Reads message times from the session's transcript, when there is one. */
4533
4672
  function transcriptTimesFor(opts, sessionId, agentId) {
4534
4673
  const source = opts.transcriptUsage;