claude-code-infinite 1.0.31 → 1.0.32

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 (63) hide show
  1. package/README.md +46 -47
  2. package/dist/claude-auth-status.d.ts +50 -0
  3. package/dist/claude-auth-status.d.ts.map +1 -0
  4. package/dist/claude-auth-status.js +213 -0
  5. package/dist/claude-auth-status.js.map +1 -0
  6. package/dist/claude-auth.d.ts +2 -1
  7. package/dist/claude-auth.d.ts.map +1 -1
  8. package/dist/claude-auth.js +2 -1
  9. package/dist/claude-auth.js.map +1 -1
  10. package/dist/claude-plan-warning.d.ts +13 -40
  11. package/dist/claude-plan-warning.d.ts.map +1 -1
  12. package/dist/claude-plan-warning.js +24 -75
  13. package/dist/claude-plan-warning.js.map +1 -1
  14. package/dist/cli-main.js +107 -16
  15. package/dist/cli-main.js.map +1 -1
  16. package/dist/diagnostics-schema.d.ts +5 -2
  17. package/dist/diagnostics-schema.d.ts.map +1 -1
  18. package/dist/diagnostics-schema.js +26 -6
  19. package/dist/diagnostics-schema.js.map +1 -1
  20. package/dist/diagnostics.d.ts +5 -1
  21. package/dist/diagnostics.d.ts.map +1 -1
  22. package/dist/diagnostics.js +7 -2
  23. package/dist/diagnostics.js.map +1 -1
  24. package/dist/hooks.d.ts +17 -0
  25. package/dist/hooks.d.ts.map +1 -1
  26. package/dist/hooks.js +55 -2
  27. package/dist/hooks.js.map +1 -1
  28. package/dist/memtree-mcp.d.ts +7 -0
  29. package/dist/memtree-mcp.d.ts.map +1 -1
  30. package/dist/memtree-mcp.js +12 -1
  31. package/dist/memtree-mcp.js.map +1 -1
  32. package/dist/notices.d.ts +2 -0
  33. package/dist/notices.d.ts.map +1 -1
  34. package/dist/notices.js +2 -0
  35. package/dist/notices.js.map +1 -1
  36. package/dist/proxy.d.ts +21 -1
  37. package/dist/proxy.d.ts.map +1 -1
  38. package/dist/proxy.js +243 -38
  39. package/dist/proxy.js.map +1 -1
  40. package/dist/reqlog.d.ts +11 -2
  41. package/dist/reqlog.d.ts.map +1 -1
  42. package/dist/reqlog.js.map +1 -1
  43. package/dist/request-sizing.d.ts +2 -0
  44. package/dist/request-sizing.d.ts.map +1 -1
  45. package/dist/request-sizing.js +5 -0
  46. package/dist/request-sizing.js.map +1 -1
  47. package/dist/resume-guidance.d.ts +102 -0
  48. package/dist/resume-guidance.d.ts.map +1 -0
  49. package/dist/resume-guidance.js +182 -0
  50. package/dist/resume-guidance.js.map +1 -0
  51. package/dist/route-fallback.d.ts +23 -0
  52. package/dist/route-fallback.d.ts.map +1 -1
  53. package/dist/route-fallback.js +44 -0
  54. package/dist/route-fallback.js.map +1 -1
  55. package/dist/sign-in-notice.d.ts +137 -0
  56. package/dist/sign-in-notice.d.ts.map +1 -0
  57. package/dist/sign-in-notice.js +300 -0
  58. package/dist/sign-in-notice.js.map +1 -0
  59. package/dist/subagent-hint.d.ts +31 -0
  60. package/dist/subagent-hint.d.ts.map +1 -0
  61. package/dist/subagent-hint.js +47 -0
  62. package/dist/subagent-hint.js.map +1 -0
  63. package/package.json +1 -1
package/dist/proxy.js CHANGED
@@ -40,7 +40,7 @@
40
40
  */
41
41
  import { capCacheBreakpoints } from "./route-cache.js";
42
42
  import { PromptAccounting } from "./prompt-accounting.js";
43
- import { INDEX_WAIT_RETRY_AFTER_SECONDS, RouteFallbackFailures, pausedRefusal } from "./route-fallback.js";
43
+ import { INDEX_WAIT_RETRY_AFTER_SECONDS, RefusalStreaks, RouteFallbackFailures, pausedRefusal } from "./route-fallback.js";
44
44
  import { createCountTokensSession, CountTokensCooldowns, plausibleSample, } from "./count-tokens.js";
45
45
  import { RequestSizing, estimateRequestTokens, fitsMeasured, fitsNative, outputReservation } from "./request-sizing.js";
46
46
  import { extendsHistory, mediaFreeMessageHash, prefixIdentity, prefixMessageMatches, } from "./route-identity.js";
@@ -54,7 +54,7 @@ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
54
54
  import { join } from "node:path";
55
55
  import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, indexLagEvidence, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
56
56
  import { contextLimitForModel, hasEarlierNonToolUserMessage, isAwaySummaryUserMessage, isLocalBashCommandTurn, isNonToolUserMessage, isToolResultUserMessage, lastNonSystemMessage, messagesWithSystem, modelForMemtree, stripSystemReminderText, } from "./turns.js";
57
- import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, recapLinkText, SseNoticeRewriter, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
57
+ import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, SLOW_RETRY_NOTICE, recapLinkText, SseNoticeRewriter, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
58
58
  import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, urlLine, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, PRECOMPACT_HOOK_PATH, } from "./hooks.js";
59
59
  import { USAGE_LABEL, USAGE_SIGN_IN_NOTE } from "./usage-link.js";
60
60
  import { hyperlink } from "./payment-gate.js";
@@ -64,6 +64,8 @@ import { PaymentNoticeThrottle, pausedCompactionText, pausedRefusalMessage, paym
64
64
  import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
65
65
  import { describeClaudeCodeRequest, inspectMonitorTranscript, knownSideRequestKind, transcriptWarning, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
66
66
  import { isResumableSessionId } from "./resume-hint.js";
67
+ import { subagentMemoryHintText, subagentStartOutput, } from "./subagent-hint.js";
68
+ import { RESUME_GUIDANCE_HOOK_SUFFIX } from "./hooks.js";
67
69
  import { conservativeBodyTokens } from "./token-estimate.js";
68
70
  import { bodyImages } from "./image-tokens.js";
69
71
  import { claudeAuthFromHeaders, upstreamErrorCode } from "./claude-auth.js";
@@ -71,6 +73,8 @@ import { rateLimitFromResponse } from "./rate-limit.js";
71
73
  import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
72
74
  const DEFAULT_UPSTREAM = "https://api.anthropic.com";
73
75
  const HOOK_BODY_LIMIT = 64 * 1024;
76
+ /** A hook body still arriving after this is dropped (the hook itself waits 5 s). */
77
+ const HOOK_BODY_TIMEOUT_MS = 3_000;
74
78
  // After a recovery attempt returns null (MemTree down, 5xx, or a burned
75
79
  // timeout budget), suppress the blocking attempt for this long, across every
76
80
  // lane. A lane's own growth backoff alone would let each over-budget tool turn
@@ -344,12 +348,15 @@ export function startProxy(opts) {
344
348
  sessionStartNotice: opts.sessionStartNotice,
345
349
  promptSubmitNotice: opts.promptSubmitNotice,
346
350
  projectTrees: opts.projectTrees,
351
+ subagentHint: opts.subagentHint,
352
+ resumeGuidance: opts.resumeGuidance,
347
353
  diagnostics: opts.diagnostics,
348
354
  notices: new NoticeDeliveryQueue(),
349
355
  mainNoticePending: false,
350
356
  mainPromptGeneration: 0,
351
357
  promptAccounting: new PromptAccounting(),
352
358
  fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs),
359
+ refusalStreaks: new RefusalStreaks(),
353
360
  activeSubagents: new Set(),
354
361
  memoryRoutes: new Map(),
355
362
  mainRouteEpoch: 0,
@@ -496,6 +503,9 @@ async function handleRequest(req, res, opts, upstream, state, hookPath) {
496
503
  if (url.pathname === PRECOMPACT_HOOK_PATH) {
497
504
  return handleNoticeHook(req, res, state, opts.reqlog, undefined, "PreCompact");
498
505
  }
506
+ if (url.pathname === `${hookPath}${RESUME_GUIDANCE_HOOK_SUFFIX}`) {
507
+ return handleResumeGuidanceHook(req, res, state);
508
+ }
499
509
  if (url.pathname === hookPath) {
500
510
  return handleNoticeHook(req, res, state, opts.reqlog, (sessionId) => opts.memtree.scheduleFinalIndex(sessionId, (signal) => opts.transcriptUsage?.finalReplyAsync
501
511
  ? opts.transcriptUsage.finalReplyAsync(sessionId, signal)
@@ -727,6 +737,39 @@ function noteMainSessionStart(state, source, sessionId) {
727
737
  state.resumableMainSession = undefined;
728
738
  }
729
739
  }
740
+ /**
741
+ * SessionStart on its own hook (resume-guidance.ts): only the current MemTree
742
+ * guidance, so Claude Code sees it as one context of its own and adds an
743
+ * identical one only once. Never fails the hook: anything unexpected is 204.
744
+ */
745
+ async function handleResumeGuidanceHook(req, res, state) {
746
+ const raw = req.method === "POST" ? await readBodyWithin(req, HOOK_BODY_LIMIT) : undefined;
747
+ if (req.method !== "POST")
748
+ req.resume();
749
+ let input = null;
750
+ try {
751
+ if (raw !== undefined && raw.length > 0) {
752
+ input = parseNoticeHookInput(JSON.parse(raw.toString("utf-8")));
753
+ }
754
+ }
755
+ catch {
756
+ input = null;
757
+ }
758
+ const context = input?.hook_event_name === "SessionStart"
759
+ ? await resumeGuidanceContext(state, input) : undefined;
760
+ if (!context) {
761
+ res.writeHead(204);
762
+ res.end();
763
+ return;
764
+ }
765
+ const body = Buffer.from(JSON.stringify(sessionStartOutput(undefined, context)), "utf-8");
766
+ res.writeHead(200, {
767
+ "content-type": "application/json",
768
+ "content-length": String(body.length),
769
+ "cache-control": "no-store",
770
+ });
771
+ res.end(body);
772
+ }
730
773
  /** Serve only validated Claude hook POSTs on the randomized localhost path. */
731
774
  async function handleNoticeHook(req, res, state, reqlog,
732
775
  /** A main-lane turn ended: the session may be over (its final index). */
@@ -882,8 +925,19 @@ onlyEvent) {
882
925
  }
883
926
  if (parsed.hook_event_name === "SubagentStart") {
884
927
  state.activeSubagents.add(parsed.agent_id);
885
- res.writeHead(204);
886
- res.end();
928
+ const hint = await subagentHintContext(state, parsed.session_id);
929
+ if (hint === undefined) {
930
+ res.writeHead(204);
931
+ res.end();
932
+ return;
933
+ }
934
+ const body = Buffer.from(JSON.stringify(subagentStartOutput(hint)), "utf-8");
935
+ res.writeHead(200, {
936
+ "content-type": "application/json",
937
+ "content-length": String(body.length),
938
+ "cache-control": "no-store",
939
+ });
940
+ res.end(body);
887
941
  return;
888
942
  }
889
943
  if (parsed.hook_event_name === "SubagentStop") {
@@ -981,6 +1035,8 @@ async function handleMessages(req, res, opts, upstream, state) {
981
1035
  requestBytes: rawBody.length,
982
1036
  claudeAuth: claudeAuthFromHeaders(req.headers),
983
1037
  };
1038
+ // The prompt this request was made for, before any await (Codex r2 P2).
1039
+ noticeContexts.set(rec, { promptId: state.mainPromptId, generation: state.mainPromptGeneration });
984
1040
  const logged = async (forward) => {
985
1041
  try {
986
1042
  await forward;
@@ -1220,7 +1276,11 @@ async function handleMessages(req, res, opts, upstream, state) {
1220
1276
  lane: isAwaySummary ? "away" : requestRouteLane === "main" ? "main" : "other" },
1221
1277
  ...(target !== undefined ? { target } : {}) });
1222
1278
  },
1223
- delivered: () => { state.fallbackFailures.succeeded(requestRouteKey); toolForwarding.settle(true); },
1279
+ delivered: () => {
1280
+ state.fallbackFailures.succeeded(requestRouteKey);
1281
+ state.refusalStreaks.delivered(requestRouteKey);
1282
+ toolForwarding.settle(true);
1283
+ },
1224
1284
  });
1225
1285
  const rawMsgsForMemtree = messagesWithSystem(messages, body.system);
1226
1286
  const msgsForMemtree = normalizeMessagesForMemtree(rawMsgsForMemtree);
@@ -1809,7 +1869,7 @@ async function handleMessages(req, res, opts, upstream, state) {
1809
1869
  : {}),
1810
1870
  // Riding the old prefix, or sending a raw request that fits, keeps the
1811
1871
  // short budget; with neither, a timeout could only end in a refusal.
1812
- longBudget: !(edge?.kind === "compress" && edge.fallback) &&
1872
+ longBudget: !(edge?.kind === "compress" && edge.fallback && !edge.fallback.uncounted) &&
1813
1873
  await wholeRequestRefused(requestSizing, forwardCandidate, sizingLimits()),
1814
1874
  });
1815
1875
  }
@@ -1858,6 +1918,28 @@ async function handleMessages(req, res, opts, upstream, state) {
1858
1918
  // Each forwarder installs its own close listener synchronously, so this is
1859
1919
  // an event-loop-atomic handoff of downstream-close ownership.
1860
1920
  if (!result) {
1921
+ const paymentDetail = opts.memtree.paymentRequiredDetail;
1922
+ const mayQueueNotice = displayForThisTurn && state.mainPromptGeneration === noticePromptGeneration;
1923
+ if (mayQueueNotice && paymentDetail === null && !state.keyInvalid()) {
1924
+ // Under the budget the whole request is what compression would have
1925
+ // sent: the turn is not degraded, so the failure stays in the logs.
1926
+ // Delivery decides the rest (selectForwardCandidate): only a whole
1927
+ // history actually sent warns. A refusal (Claude Code retries it) or a
1928
+ // ride on a compressed prefix did not run uncompressed (2026-10-28).
1929
+ const eager = compressionNeeded(rec.compaction);
1930
+ // Delivery sizes the request again (an exact count, else a
1931
+ // conservative bound): a planned fit it finds over the budget warns then.
1932
+ if (!eager)
1933
+ rec.degradedNotice = "quiet-under-budget";
1934
+ degradedNotices.set(rec, { eager, warn: () => {
1935
+ // A 402 or refused key meanwhile keeps its own, actionable notice (Codex r2 P2).
1936
+ if (state.mainPromptGeneration !== noticePromptGeneration ||
1937
+ opts.memtree.paymentRequiredDetail !== null || state.keyInvalid())
1938
+ return;
1939
+ state.notices.queueSuffix(DEGRADED_NOTICE, undefined, noticePromptId);
1940
+ rec.degradedNotice = "shown";
1941
+ } });
1942
+ }
1861
1943
  const kept = rideOldPrefix(false);
1862
1944
  if (kept)
1863
1945
  return kept;
@@ -1872,8 +1954,6 @@ async function handleMessages(req, res, opts, upstream, state) {
1872
1954
  releaseForOriginalFallback();
1873
1955
  sendingWhole();
1874
1956
  capture(opts, "anthropic-request", forwardBody);
1875
- const paymentDetail = opts.memtree.paymentRequiredDetail;
1876
- const mayQueueNotice = displayForThisTurn && state.mainPromptGeneration === noticePromptGeneration;
1877
1957
  const overBudget = reachedBudget(rec.compaction);
1878
1958
  if (state.keyInvalid()) {
1879
1959
  // A refused key (401/403) says why MemTree is off and how to fix it;
@@ -1897,26 +1977,6 @@ async function handleMessages(req, res, opts, upstream, state) {
1897
1977
  if (overBudget)
1898
1978
  state.nearWindowNotice = handle;
1899
1979
  }
1900
- else if (mayQueueNotice && paymentDetail === null) {
1901
- // Under the budget the whole request is what compression would have
1902
- // sent: the turn is not degraded, so the failure stays in the logs.
1903
- const warn = () => {
1904
- state.notices.queueSuffix(DEGRADED_NOTICE, undefined, noticePromptId);
1905
- rec.degradedNotice = "shown";
1906
- };
1907
- if (compressionNeeded(rec.compaction)) {
1908
- warn();
1909
- }
1910
- else {
1911
- // Delivery sizes the request again (an exact count, else a
1912
- // conservative bound): a turn it finds over the budget warns then.
1913
- rec.degradedNotice = "quiet-under-budget";
1914
- quietDegrades.set(rec, () => {
1915
- if (state.mainPromptGeneration === noticePromptGeneration)
1916
- warn();
1917
- });
1918
- }
1919
- }
1920
1980
  return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
1921
1981
  noteWholeSize();
1922
1982
  if (delivered)
@@ -2224,6 +2284,48 @@ async function projectTreesContext(hint, input) {
2224
2284
  return undefined;
2225
2285
  }
2226
2286
  }
2287
+ /**
2288
+ * The parent's memory hint for a starting subagent: only while MemTree works
2289
+ * and the session has the tools, naming the parent's newest tree (a bounded
2290
+ * wait when no answer is cached). Undefined otherwise; never throws.
2291
+ */
2292
+ async function subagentHintContext(state, sessionId) {
2293
+ if (!state.subagentHint?.enabled || !memtreeWorking(state))
2294
+ return undefined;
2295
+ try {
2296
+ // A cached tree will do (peek refreshes it in the background); wait only for none.
2297
+ const tree = state.newestTrees?.peek(sessionId) ?? await state.newestTrees?.settle(sessionId);
2298
+ // MemTree may have paused or failed while the lookup was on its way.
2299
+ if (!tree || !state.subagentHint.enabled || !memtreeWorking(state))
2300
+ return undefined;
2301
+ return subagentMemoryHintText(sessionId, tree.key);
2302
+ }
2303
+ catch {
2304
+ return undefined;
2305
+ }
2306
+ }
2307
+ /** The current MemTree guidance for a resumed main session (resume-guidance.ts). */
2308
+ async function resumeGuidanceContext(state, input) {
2309
+ try {
2310
+ return await state.resumeGuidance?.contextFor(input, {
2311
+ working: () => resumeMemtreeWorking(state),
2312
+ compressed: () => currentMemtreePage(state, input.session_id)?.compressed,
2313
+ });
2314
+ }
2315
+ catch {
2316
+ return undefined;
2317
+ }
2318
+ }
2319
+ /**
2320
+ * Whether MemTree works as a session resumes: not paused, key not refused,
2321
+ * and its latest answer, if any, a success. A new `ccc --resume` process has
2322
+ * no answer yet; its startup key check already turned a refused or unpaid
2323
+ * key into no guidance (resumeGuidanceFor).
2324
+ */
2325
+ function resumeMemtreeWorking(state) {
2326
+ return state.memtreeAnswering() !== false && state.unpaidDetail() === null &&
2327
+ !state.keyInvalid();
2328
+ }
2227
2329
  /**
2228
2330
  * The SessionStart hook's answer: the banner line for the user
2229
2331
  * (`systemMessage`), model context (`additionalContext`), or both.
@@ -2830,6 +2932,15 @@ function calibratedSizeExceedsWindow(record, body, modelContextLimit) {
2830
2932
  return record.uncountedOverWindow === true || (record.sizeSource === "reported" &&
2831
2933
  (record.estimatedTokens ?? 0) + outputTokens > modelContextLimit);
2832
2934
  }
2935
+ /**
2936
+ * Over the window (calibratedSizeExceedsWindow) only by the uncounted
2937
+ * conservative bound: a calibrated estimate that fits the window with the
2938
+ * full max_tokens says otherwise.
2939
+ */
2940
+ function uncountedOnlyOverWindow(record, body, modelContextLimit) {
2941
+ return record.uncountedOverWindow === true && record.sizeSource === "reported" &&
2942
+ (record.estimatedTokens ?? 0) + bodyOutputReservation(body, false) <= modelContextLimit;
2943
+ }
2833
2944
  function calibratedSizeExceedsLimits(record, body, modelContextLimit) {
2834
2945
  return (record.sizeSource === "reported" &&
2835
2946
  (record.estimatedTokens ?? 0) >= record.budgetTokens) ||
@@ -3050,7 +3161,14 @@ async function planEdgeCompaction(args) {
3050
3161
  // image-aware bound), not bytes: a flatten that kept images is mostly
3051
3162
  // base64 that costs a fixed amount per image.
3052
3163
  const overWindow = calibratedSizeExceedsWindow(compaction, body, modelContextLimit);
3053
- const fallback = overWindow ? undefined : { prefix, routed };
3164
+ // Over the window only by the conservative bound of a ride whose planning
3165
+ // count timed out: delivery counts it again before sending it, so it stays
3166
+ // the fallback a failed compress rides (2026-10-28: a 528k ride whose
3167
+ // count timed out was dropped, and the failed compress refused the turn).
3168
+ const uncounted = overWindow && uncountedOnlyOverWindow(compaction, body, modelContextLimit) &&
3169
+ args.sizing.canRetryCount(prefixCandidate);
3170
+ const fallback = overWindow && !uncounted
3171
+ ? undefined : { prefix, routed, ...(uncounted ? { uncounted: true } : {}) };
3054
3172
  if (state.compactNow.has(sessionId))
3055
3173
  return forced("manual", fallback);
3056
3174
  if (prefix.modelContextLimit !== modelContextLimit ||
@@ -4566,8 +4684,8 @@ async function selectForwardCandidate(args) {
4566
4684
  (args.rec.routeLane === "main" && args.opts.toolOutputs
4567
4685
  ? await truncatedToFit(args.rec, policy.sizing, target, args.limits, args.opts.toolOutputs)
4568
4686
  : undefined);
4569
- const quietDegrade = quietDegrades.get(args.rec);
4570
- quietDegrades.delete(args.rec);
4687
+ const degraded = degradedNotices.get(args.rec);
4688
+ degradedNotices.delete(args.rec);
4571
4689
  const lateInstall = lateRouteInstalls.get(args.rec);
4572
4690
  if (lateInstall && (!selection || selection.candidate !== target || target === preferred)) {
4573
4691
  lateRouteInstalls.delete(args.rec);
@@ -4595,14 +4713,29 @@ async function selectForwardCandidate(args) {
4595
4713
  args.rec.lateCountRecovery.outcome =
4596
4714
  changed && candidate.kind === "replacement" ? "compressed" : "forwarded";
4597
4715
  delete args.rec.overBudgetForward;
4598
- if (measurement.tokens > args.limits.budget ||
4716
+ const overBudget = measurement.tokens > args.limits.budget ||
4599
4717
  (measurement.source !== "exact" && candidate.kind === "original" &&
4600
- measurement.estimatedTokens > args.limits.budget)) {
4718
+ measurement.estimatedTokens > args.limits.budget);
4719
+ if (overBudget) {
4601
4720
  args.rec.overBudgetForward = { reason: measurement.source === "exact"
4602
4721
  ? "exact-count-fits-window" : measurement.source === "bytes"
4603
4722
  ? "bytes-fallback-fits-window" : "estimate-fits-window" };
4604
4723
  warnPausedNearWindow(args.state, args.rec, requestSessionId(args.req));
4605
- quietDegrade?.();
4724
+ }
4725
+ // Uncompressed only when the whole history goes out (or a truncation of
4726
+ // it), not a compressed ride.
4727
+ const whole = candidate.kind === "original" || wholeHistoryCandidates.has(candidate);
4728
+ if (degraded && whole) {
4729
+ // A failed compress's ride that delivery could not send went out whole.
4730
+ if (args.rec.turnType === "followup-prefix") {
4731
+ args.rec.turnType = "followup-degraded";
4732
+ delete args.rec.compaction?.keptPrefix;
4733
+ }
4734
+ if (degraded.eager || overBudget)
4735
+ degraded.warn();
4736
+ }
4737
+ else if (degraded) {
4738
+ delete args.rec.degradedNotice;
4606
4739
  }
4607
4740
  return changed ? candidate.body : intended;
4608
4741
  }
@@ -4670,6 +4803,9 @@ async function truncateCandidate(rec, sizing, base, limits, store) {
4670
4803
  if (!plan)
4671
4804
  return undefined;
4672
4805
  const candidate = sizing.register(Buffer.from(JSON.stringify(plan.body), "utf-8"), "replacement");
4806
+ if (base.kind === "original" || wholeHistoryCandidates.has(base)) {
4807
+ wholeHistoryCandidates.add(candidate);
4808
+ }
4673
4809
  const measurement = await sizing.measure(candidate, limits);
4674
4810
  if (fitsMeasured(measurement, limits) && measurement.tokens <= goal) {
4675
4811
  rec.truncatedToolResult = { originalChars: plan.originalChars,
@@ -4778,6 +4914,17 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
4778
4914
  : args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true });
4779
4915
  args.rec.forwardedBytes = 0;
4780
4916
  args.rec.approxInputTokens = 0;
4917
+ // Nothing went out uncompressed: Claude Code retries it (2026-10-28).
4918
+ // A failed compress's ride that could not be sent either (review r2 P3).
4919
+ const refusedRide = args.rec.turnType === "followup-prefix" &&
4920
+ args.rec.compaction?.keptPrefix === true;
4921
+ if (args.rec.turnType === "followup-degraded" || refusedRide) {
4922
+ args.rec.turnType = "followup-refused";
4923
+ delete args.rec.compaction?.keptPrefix;
4924
+ }
4925
+ delete args.rec.degradedNotice;
4926
+ if (unpaid === null && !keyInvalid)
4927
+ noteSlowRetry(args, failure);
4781
4928
  args.state.diagnostics?.record("refusal", {
4782
4929
  status: failure.status, attempt: failure.attempt,
4783
4930
  input_ktokens: Math.round(inputTokens / 1000), budget_ktokens: Math.round(budgetTokens / 1000),
@@ -4804,6 +4951,24 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
4804
4951
  args.state.keyNotice.markShown();
4805
4952
  return false;
4806
4953
  }
4954
+ /**
4955
+ * A repeated retryable refusal on the main thread: say once per streak that
4956
+ * MemTree is slow and Claude Code is retrying, unless another notice waits.
4957
+ */
4958
+ function noteSlowRetry(args, failure) {
4959
+ const { state, rec } = args;
4960
+ const prompt = noticeContexts.get(rec);
4961
+ // Not for a stale prompt or a closed response, nor an index wait (its 503
4962
+ // says why), nor a final refusal (its own message shows).
4963
+ const canShow = failure.status === 503 && rec.routeLane === "main" &&
4964
+ rec.indexBehind !== true && prompt !== undefined &&
4965
+ prompt.generation === state.mainPromptGeneration && !args.res.destroyed &&
4966
+ !state.notices.hasPending();
4967
+ if (!state.refusalStreaks.refused(args.routeKey, Date.now(), canShow))
4968
+ return;
4969
+ state.notices.queueSuffix(SLOW_RETRY_NOTICE, undefined, prompt.promptId);
4970
+ rec.slowRetryNotice = true;
4971
+ }
4807
4972
  /**
4808
4973
  * The refusal text already says MemTree is paused and how to resume, so it
4809
4974
  * counts as the paused notice: the throttle restarts, and this request's
@@ -4879,7 +5044,7 @@ function reachedBudget(compaction) {
4879
5044
  * forced compaction (budget, window, `/memtree-compact`, a rebuilt prefix), a
4880
5045
  * size at or over the budget, or a size nobody measured. A planned fit is
4881
5046
  * provisional: delivery's own sizing can still find the turn over the budget
4882
- * (quietDegrades), since a plan can undercount.
5047
+ * (degradedNotices), since a plan can undercount.
4883
5048
  */
4884
5049
  function compressionNeeded(compaction) {
4885
5050
  if (compaction === undefined || compaction.reason !== undefined)
@@ -4889,10 +5054,15 @@ function compressionNeeded(compaction) {
4889
5054
  return compaction.estimatedTokens >= compaction.budgetTokens;
4890
5055
  }
4891
5056
  /**
4892
- * A degraded turn whose plan fit the budget, so it queued no notice: the
4893
- * notice to queue if delivery's sizing finds it over the budget after all.
5057
+ * A degraded turn's notice, queued by delivery only when the whole request
5058
+ * goes out: at once when planning said it needed compression (`eager`), else
5059
+ * when delivery's sizing finds it over the budget after all.
4894
5060
  */
4895
- const quietDegrades = new WeakMap();
5061
+ const degradedNotices = new WeakMap();
5062
+ /** Truncations (truncatedToFit) of the whole history: they still run uncompressed. */
5063
+ const wholeHistoryCandidates = new WeakSet();
5064
+ /** The prompt a request was made for, for notices its refusal may queue. */
5065
+ const noticeContexts = new WeakMap();
4896
5066
  /** The paused notice a degraded request queued, until it is refused or settles. */
4897
5067
  const pausedNotices = new WeakMap();
4898
5068
  const changedDeliveryRecords = new WeakSet();
@@ -5580,6 +5750,41 @@ function capture(opts, kind, body) {
5580
5750
  // diagnostics only — never break the proxy path
5581
5751
  }
5582
5752
  }
5753
+ /**
5754
+ * The body, or undefined once it passes `limit` bytes (declared or streamed)
5755
+ * or takes longer than HOOK_BODY_TIMEOUT_MS: the rest is drained unkept.
5756
+ */
5757
+ function readBodyWithin(req, limit) {
5758
+ return new Promise((resolve) => {
5759
+ const chunks = [];
5760
+ let size = 0;
5761
+ let done = false;
5762
+ const finish = (body) => {
5763
+ if (done)
5764
+ return;
5765
+ done = true;
5766
+ clearTimeout(timer);
5767
+ if (body === undefined) {
5768
+ chunks.length = 0;
5769
+ req.removeAllListeners("data");
5770
+ req.resume();
5771
+ }
5772
+ resolve(body);
5773
+ };
5774
+ const timer = setTimeout(() => finish(undefined), HOOK_BODY_TIMEOUT_MS);
5775
+ if (Number(req.headers["content-length"] ?? 0) > limit)
5776
+ return finish(undefined);
5777
+ req.on("data", (chunk) => {
5778
+ size += chunk.length;
5779
+ if (size > limit)
5780
+ finish(undefined);
5781
+ else
5782
+ chunks.push(chunk);
5783
+ });
5784
+ req.on("end", () => finish(Buffer.concat(chunks)));
5785
+ req.on("error", () => finish(undefined));
5786
+ });
5787
+ }
5583
5788
  function readBody(req) {
5584
5789
  return readAll(req);
5585
5790
  }