claude-code-infinite 1.0.30 → 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 (79) hide show
  1. package/README.md +54 -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-args.d.ts +10 -0
  15. package/dist/cli-args.d.ts.map +1 -1
  16. package/dist/cli-args.js +30 -0
  17. package/dist/cli-args.js.map +1 -1
  18. package/dist/cli-main.js +119 -17
  19. package/dist/cli-main.js.map +1 -1
  20. package/dist/diagnostics-schema.d.ts +5 -2
  21. package/dist/diagnostics-schema.d.ts.map +1 -1
  22. package/dist/diagnostics-schema.js +26 -6
  23. package/dist/diagnostics-schema.js.map +1 -1
  24. package/dist/diagnostics.d.ts +5 -1
  25. package/dist/diagnostics.d.ts.map +1 -1
  26. package/dist/diagnostics.js +7 -2
  27. package/dist/diagnostics.js.map +1 -1
  28. package/dist/hooks.d.ts +41 -1
  29. package/dist/hooks.d.ts.map +1 -1
  30. package/dist/hooks.js +79 -4
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/memtree-mcp.d.ts +7 -0
  33. package/dist/memtree-mcp.d.ts.map +1 -1
  34. package/dist/memtree-mcp.js +16 -1
  35. package/dist/memtree-mcp.js.map +1 -1
  36. package/dist/memtree.d.ts +9 -0
  37. package/dist/memtree.d.ts.map +1 -1
  38. package/dist/memtree.js +16 -0
  39. package/dist/memtree.js.map +1 -1
  40. package/dist/model-context.d.ts +80 -0
  41. package/dist/model-context.d.ts.map +1 -0
  42. package/dist/model-context.js +192 -0
  43. package/dist/model-context.js.map +1 -0
  44. package/dist/notices.d.ts +2 -0
  45. package/dist/notices.d.ts.map +1 -1
  46. package/dist/notices.js +2 -0
  47. package/dist/notices.js.map +1 -1
  48. package/dist/payment-gate.d.ts +2 -0
  49. package/dist/payment-gate.d.ts.map +1 -1
  50. package/dist/payment-gate.js +1 -1
  51. package/dist/payment-gate.js.map +1 -1
  52. package/dist/proxy.d.ts +28 -1
  53. package/dist/proxy.d.ts.map +1 -1
  54. package/dist/proxy.js +332 -42
  55. package/dist/proxy.js.map +1 -1
  56. package/dist/reqlog.d.ts +11 -2
  57. package/dist/reqlog.d.ts.map +1 -1
  58. package/dist/reqlog.js.map +1 -1
  59. package/dist/request-sizing.d.ts +2 -0
  60. package/dist/request-sizing.d.ts.map +1 -1
  61. package/dist/request-sizing.js +5 -0
  62. package/dist/request-sizing.js.map +1 -1
  63. package/dist/resume-guidance.d.ts +102 -0
  64. package/dist/resume-guidance.d.ts.map +1 -0
  65. package/dist/resume-guidance.js +182 -0
  66. package/dist/resume-guidance.js.map +1 -0
  67. package/dist/route-fallback.d.ts +23 -0
  68. package/dist/route-fallback.d.ts.map +1 -1
  69. package/dist/route-fallback.js +44 -0
  70. package/dist/route-fallback.js.map +1 -1
  71. package/dist/sign-in-notice.d.ts +137 -0
  72. package/dist/sign-in-notice.d.ts.map +1 -0
  73. package/dist/sign-in-notice.js +300 -0
  74. package/dist/sign-in-notice.js.map +1 -0
  75. package/dist/subagent-hint.d.ts +31 -0
  76. package/dist/subagent-hint.d.ts.map +1 -0
  77. package/dist/subagent-hint.js +47 -0
  78. package/dist/subagent-hint.js.map +1 -0
  79. 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,15 +54,18 @@ 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";
58
- import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, urlLine, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, } from "./hooks.js";
57
+ import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, SLOW_RETRY_NOTICE, recapLinkText, SseNoticeRewriter, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
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";
61
61
  import { keyInvalidNoticeText, keyInvalidRefusalMessage } from "./key-status.js";
62
+ import { KEY_INVALID_KIND, ModelContextNotices, PAUSED_KIND, keyInvalidModelContext, lastToldKindInFile, pausedModelContext } from "./model-context.js";
62
63
  import { PaymentNoticeThrottle, pausedCompactionText, pausedRefusalMessage, paymentNoticeText, subscribeUrlFromDetail } from "./payment-notice.js";
63
64
  import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
64
65
  import { describeClaudeCodeRequest, inspectMonitorTranscript, knownSideRequestKind, transcriptWarning, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
65
66
  import { isResumableSessionId } from "./resume-hint.js";
67
+ import { subagentMemoryHintText, subagentStartOutput, } from "./subagent-hint.js";
68
+ import { RESUME_GUIDANCE_HOOK_SUFFIX } from "./hooks.js";
66
69
  import { conservativeBodyTokens } from "./token-estimate.js";
67
70
  import { bodyImages } from "./image-tokens.js";
68
71
  import { claudeAuthFromHeaders, upstreamErrorCode } from "./claude-auth.js";
@@ -70,6 +73,8 @@ import { rateLimitFromResponse } from "./rate-limit.js";
70
73
  import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
71
74
  const DEFAULT_UPSTREAM = "https://api.anthropic.com";
72
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;
73
78
  // After a recovery attempt returns null (MemTree down, 5xx, or a burned
74
79
  // timeout budget), suppress the blocking attempt for this long, across every
75
80
  // lane. A lane's own growth backoff alone would let each over-budget tool turn
@@ -336,16 +341,22 @@ export function startProxy(opts) {
336
341
  pausedNearWindowWarned: new Set(),
337
342
  unpaidDetail: () => opts.memtree.paymentRequiredDetail,
338
343
  pausedDetail: () => opts.memtree.pausedDetail,
344
+ pausedReason: () => opts.memtree.pausedReason,
345
+ memtreeAnswering: () => opts.memtree.answering,
346
+ modelNotices: new ModelContextNotices(),
339
347
  usageUrl: opts.memtree.usageDashboardUrl,
340
348
  sessionStartNotice: opts.sessionStartNotice,
341
349
  promptSubmitNotice: opts.promptSubmitNotice,
342
350
  projectTrees: opts.projectTrees,
351
+ subagentHint: opts.subagentHint,
352
+ resumeGuidance: opts.resumeGuidance,
343
353
  diagnostics: opts.diagnostics,
344
354
  notices: new NoticeDeliveryQueue(),
345
355
  mainNoticePending: false,
346
356
  mainPromptGeneration: 0,
347
357
  promptAccounting: new PromptAccounting(),
348
358
  fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs),
359
+ refusalStreaks: new RefusalStreaks(),
349
360
  activeSubagents: new Set(),
350
361
  memoryRoutes: new Map(),
351
362
  mainRouteEpoch: 0,
@@ -489,6 +500,12 @@ async function handleRequest(req, res, opts, upstream, state, hookPath) {
489
500
  res.end(JSON.stringify({ detail: "The ccc proxy only serves programs on this machine" }));
490
501
  return;
491
502
  }
503
+ if (url.pathname === PRECOMPACT_HOOK_PATH) {
504
+ return handleNoticeHook(req, res, state, opts.reqlog, undefined, "PreCompact");
505
+ }
506
+ if (url.pathname === `${hookPath}${RESUME_GUIDANCE_HOOK_SUFFIX}`) {
507
+ return handleResumeGuidanceHook(req, res, state);
508
+ }
492
509
  if (url.pathname === hookPath) {
493
510
  return handleNoticeHook(req, res, state, opts.reqlog, (sessionId) => opts.memtree.scheduleFinalIndex(sessionId, (signal) => opts.transcriptUsage?.finalReplyAsync
494
511
  ? opts.transcriptUsage.finalReplyAsync(sessionId, signal)
@@ -720,10 +737,45 @@ function noteMainSessionStart(state, source, sessionId) {
720
737
  state.resumableMainSession = undefined;
721
738
  }
722
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
+ }
723
773
  /** Serve only validated Claude hook POSTs on the randomized localhost path. */
724
774
  async function handleNoticeHook(req, res, state, reqlog,
725
775
  /** A main-lane turn ended: the session may be over (its final index). */
726
- onMainStop) {
776
+ onMainStop,
777
+ /** The only event this path takes (the token-free PreCompact path). */
778
+ onlyEvent) {
727
779
  if (req.method !== "POST") {
728
780
  res.writeHead(405, { allow: "POST" });
729
781
  res.end();
@@ -757,19 +809,32 @@ onMainStop) {
757
809
  res.end();
758
810
  return;
759
811
  }
812
+ if (onlyEvent !== undefined && parsed.hook_event_name !== onlyEvent) {
813
+ res.writeHead(403);
814
+ res.end();
815
+ return;
816
+ }
760
817
  if (parsed.hook_event_name === "SessionStart") {
761
818
  if (parsed.agent_id === undefined) {
762
819
  recordSessionStart(state, parsed.source);
763
820
  noteMainSessionStart(state, parsed.source, parsed.session_id);
821
+ // Claude Code's compaction dropped any MemTree notice from the transcript.
822
+ if (parsed.source === "compact")
823
+ state.modelNotices.forget(parsed.session_id);
764
824
  }
765
825
  // Earlier trees in this project: asked now, so its wait overlaps the resume's.
766
826
  const contextPromise = projectTreesContext(state.projectTrees, parsed);
827
+ // A resumed or forked history may carry notices an earlier process gave.
828
+ const restored = parsed.agent_id === undefined &&
829
+ (parsed.source === "resume" || parsed.source === "fork")
830
+ ? restoreModelNotices(state, parsed) : Promise.resolve();
767
831
  // A resume waits briefly for the newest tree; a fresh session has none to wait for.
768
832
  if (parsed.agent_id === undefined && parsed.source === "resume") {
769
833
  await state.newestTrees?.settle(parsed.session_id);
770
834
  }
771
835
  const line = joinNoticeLines(resumeLinkLine(state, parsed), resumePaymentNotice(state, parsed), extraSessionStartLine(state.sessionStartNotice, parsed));
772
836
  const context = await contextPromise;
837
+ await restored;
773
838
  if (!line && !context) {
774
839
  res.writeHead(204);
775
840
  res.end();
@@ -784,6 +849,25 @@ onMainStop) {
784
849
  res.end(body);
785
850
  return;
786
851
  }
852
+ if (parsed.hook_event_name === "PreCompact") {
853
+ // A typed /compact while MemTree compresses: say why it isn't needed, once.
854
+ const reason = parsed.agent_id === undefined && parsed.trigger === "manual"
855
+ ? state.modelNotices.blockCompact(parsed.session_id, memtreeWorking(state))
856
+ : undefined;
857
+ if (reason === undefined) {
858
+ res.writeHead(204);
859
+ res.end();
860
+ return;
861
+ }
862
+ const body = Buffer.from(JSON.stringify({ decision: "block", reason }), "utf-8");
863
+ res.writeHead(200, {
864
+ "content-type": "application/json",
865
+ "content-length": String(body.length),
866
+ "cache-control": "no-store",
867
+ });
868
+ res.end(body);
869
+ return;
870
+ }
787
871
  if (parsed.hook_event_name === "UserPromptSubmit") {
788
872
  // `/memtree-view`: answered here and blocked, so no model turn runs and
789
873
  // the prompt never enters the conversation. Not a human turn either, so
@@ -822,8 +906,11 @@ onMainStop) {
822
906
  recordMainPromptNotice(state, parsed.prompt_id, parsed.prompt);
823
907
  }
824
908
  const line = parsed.agent_id === undefined ? promptSubmitLine(state) : undefined;
825
- if (line) {
826
- const body = Buffer.from(JSON.stringify({ systemMessage: line }), "utf-8");
909
+ const context = parsed.agent_id === undefined
910
+ ? state.modelNotices.next(parsed.session_id, currentModelNotice(state))
911
+ : undefined;
912
+ if (line || context) {
913
+ const body = Buffer.from(JSON.stringify(promptSubmitOutput(line, context)), "utf-8");
827
914
  res.writeHead(200, {
828
915
  "content-type": "application/json",
829
916
  "content-length": String(body.length),
@@ -838,8 +925,19 @@ onMainStop) {
838
925
  }
839
926
  if (parsed.hook_event_name === "SubagentStart") {
840
927
  state.activeSubagents.add(parsed.agent_id);
841
- res.writeHead(204);
842
- 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);
843
941
  return;
844
942
  }
845
943
  if (parsed.hook_event_name === "SubagentStop") {
@@ -937,6 +1035,8 @@ async function handleMessages(req, res, opts, upstream, state) {
937
1035
  requestBytes: rawBody.length,
938
1036
  claudeAuth: claudeAuthFromHeaders(req.headers),
939
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 });
940
1040
  const logged = async (forward) => {
941
1041
  try {
942
1042
  await forward;
@@ -1176,7 +1276,11 @@ async function handleMessages(req, res, opts, upstream, state) {
1176
1276
  lane: isAwaySummary ? "away" : requestRouteLane === "main" ? "main" : "other" },
1177
1277
  ...(target !== undefined ? { target } : {}) });
1178
1278
  },
1179
- 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
+ },
1180
1284
  });
1181
1285
  const rawMsgsForMemtree = messagesWithSystem(messages, body.system);
1182
1286
  const msgsForMemtree = normalizeMessagesForMemtree(rawMsgsForMemtree);
@@ -1765,7 +1869,7 @@ async function handleMessages(req, res, opts, upstream, state) {
1765
1869
  : {}),
1766
1870
  // Riding the old prefix, or sending a raw request that fits, keeps the
1767
1871
  // short budget; with neither, a timeout could only end in a refusal.
1768
- longBudget: !(edge?.kind === "compress" && edge.fallback) &&
1872
+ longBudget: !(edge?.kind === "compress" && edge.fallback && !edge.fallback.uncounted) &&
1769
1873
  await wholeRequestRefused(requestSizing, forwardCandidate, sizingLimits()),
1770
1874
  });
1771
1875
  }
@@ -1814,6 +1918,28 @@ async function handleMessages(req, res, opts, upstream, state) {
1814
1918
  // Each forwarder installs its own close listener synchronously, so this is
1815
1919
  // an event-loop-atomic handoff of downstream-close ownership.
1816
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
+ }
1817
1943
  const kept = rideOldPrefix(false);
1818
1944
  if (kept)
1819
1945
  return kept;
@@ -1828,8 +1954,6 @@ async function handleMessages(req, res, opts, upstream, state) {
1828
1954
  releaseForOriginalFallback();
1829
1955
  sendingWhole();
1830
1956
  capture(opts, "anthropic-request", forwardBody);
1831
- const paymentDetail = opts.memtree.paymentRequiredDetail;
1832
- const mayQueueNotice = displayForThisTurn && state.mainPromptGeneration === noticePromptGeneration;
1833
1957
  const overBudget = reachedBudget(rec.compaction);
1834
1958
  if (state.keyInvalid()) {
1835
1959
  // A refused key (401/403) says why MemTree is off and how to fix it;
@@ -1853,26 +1977,6 @@ async function handleMessages(req, res, opts, upstream, state) {
1853
1977
  if (overBudget)
1854
1978
  state.nearWindowNotice = handle;
1855
1979
  }
1856
- else if (mayQueueNotice && paymentDetail === null) {
1857
- // Under the budget the whole request is what compression would have
1858
- // sent: the turn is not degraded, so the failure stays in the logs.
1859
- const warn = () => {
1860
- state.notices.queueSuffix(DEGRADED_NOTICE, undefined, noticePromptId);
1861
- rec.degradedNotice = "shown";
1862
- };
1863
- if (compressionNeeded(rec.compaction)) {
1864
- warn();
1865
- }
1866
- else {
1867
- // Delivery sizes the request again (an exact count, else a
1868
- // conservative bound): a turn it finds over the budget warns then.
1869
- rec.degradedNotice = "quiet-under-budget";
1870
- quietDegrades.set(rec, () => {
1871
- if (state.mainPromptGeneration === noticePromptGeneration)
1872
- warn();
1873
- });
1874
- }
1875
- }
1876
1980
  return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
1877
1981
  noteWholeSize();
1878
1982
  if (delivered)
@@ -2114,6 +2218,47 @@ function promptSubmitLine(state) {
2114
2218
  return undefined;
2115
2219
  }
2116
2220
  }
2221
+ /**
2222
+ * Whether MemTree is known to be compressing: its latest answer a success and
2223
+ * no pause (402) remembered. Before any answer, or after an outage, it is not.
2224
+ */
2225
+ function memtreeWorking(state) {
2226
+ return state.memtreeAnswering() === true && state.unpaidDetail() === null;
2227
+ }
2228
+ /**
2229
+ * What the model should know about MemTree right now: a notice, undefined
2230
+ * when it is known to work, null when neither is known (no answer, an outage).
2231
+ */
2232
+ function currentModelNotice(state) {
2233
+ if (state.keyInvalid()) {
2234
+ return { kind: KEY_INVALID_KIND, text: keyInvalidModelContext(state.keyPageUrl) };
2235
+ }
2236
+ const detail = state.unpaidDetail();
2237
+ if (detail !== null) {
2238
+ return { kind: PAUSED_KIND, text: pausedModelContext(detail, state.pausedReason()) };
2239
+ }
2240
+ return memtreeWorking(state) ? undefined : null;
2241
+ }
2242
+ /**
2243
+ * A resumed session's transcript may hold a notice from an earlier ccc process
2244
+ * (paused, then paid in between): start from it, so the change is announced.
2245
+ * A missing or unreadable transcript, or a scan past its time box, starts from
2246
+ * nothing. Never throws.
2247
+ */
2248
+ async function restoreModelNotices(state, input) {
2249
+ if (!input.transcript_path)
2250
+ return;
2251
+ state.modelNotices.restore(input.session_id, await lastToldKindInFile(input.transcript_path));
2252
+ }
2253
+ /** UserPromptSubmit's answer: a line for the user, context for the model, or both. */
2254
+ function promptSubmitOutput(line, context) {
2255
+ return {
2256
+ ...(line ? { systemMessage: line } : {}),
2257
+ ...(context
2258
+ ? { hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: context } }
2259
+ : {}),
2260
+ };
2261
+ }
2117
2262
  /** The caller's extra SessionStart line; a throwing provider shows nothing. */
2118
2263
  function extraSessionStartLine(provider, input) {
2119
2264
  if (!provider || input.agent_id !== undefined || input.source === "compact")
@@ -2139,6 +2284,48 @@ async function projectTreesContext(hint, input) {
2139
2284
  return undefined;
2140
2285
  }
2141
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
+ }
2142
2329
  /**
2143
2330
  * The SessionStart hook's answer: the banner line for the user
2144
2331
  * (`systemMessage`), model context (`additionalContext`), or both.
@@ -2745,6 +2932,15 @@ function calibratedSizeExceedsWindow(record, body, modelContextLimit) {
2745
2932
  return record.uncountedOverWindow === true || (record.sizeSource === "reported" &&
2746
2933
  (record.estimatedTokens ?? 0) + outputTokens > modelContextLimit);
2747
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
+ }
2748
2944
  function calibratedSizeExceedsLimits(record, body, modelContextLimit) {
2749
2945
  return (record.sizeSource === "reported" &&
2750
2946
  (record.estimatedTokens ?? 0) >= record.budgetTokens) ||
@@ -2965,7 +3161,14 @@ async function planEdgeCompaction(args) {
2965
3161
  // image-aware bound), not bytes: a flatten that kept images is mostly
2966
3162
  // base64 that costs a fixed amount per image.
2967
3163
  const overWindow = calibratedSizeExceedsWindow(compaction, body, modelContextLimit);
2968
- 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 } : {}) };
2969
3172
  if (state.compactNow.has(sessionId))
2970
3173
  return forced("manual", fallback);
2971
3174
  if (prefix.modelContextLimit !== modelContextLimit ||
@@ -4481,8 +4684,8 @@ async function selectForwardCandidate(args) {
4481
4684
  (args.rec.routeLane === "main" && args.opts.toolOutputs
4482
4685
  ? await truncatedToFit(args.rec, policy.sizing, target, args.limits, args.opts.toolOutputs)
4483
4686
  : undefined);
4484
- const quietDegrade = quietDegrades.get(args.rec);
4485
- quietDegrades.delete(args.rec);
4687
+ const degraded = degradedNotices.get(args.rec);
4688
+ degradedNotices.delete(args.rec);
4486
4689
  const lateInstall = lateRouteInstalls.get(args.rec);
4487
4690
  if (lateInstall && (!selection || selection.candidate !== target || target === preferred)) {
4488
4691
  lateRouteInstalls.delete(args.rec);
@@ -4510,14 +4713,29 @@ async function selectForwardCandidate(args) {
4510
4713
  args.rec.lateCountRecovery.outcome =
4511
4714
  changed && candidate.kind === "replacement" ? "compressed" : "forwarded";
4512
4715
  delete args.rec.overBudgetForward;
4513
- if (measurement.tokens > args.limits.budget ||
4716
+ const overBudget = measurement.tokens > args.limits.budget ||
4514
4717
  (measurement.source !== "exact" && candidate.kind === "original" &&
4515
- measurement.estimatedTokens > args.limits.budget)) {
4718
+ measurement.estimatedTokens > args.limits.budget);
4719
+ if (overBudget) {
4516
4720
  args.rec.overBudgetForward = { reason: measurement.source === "exact"
4517
4721
  ? "exact-count-fits-window" : measurement.source === "bytes"
4518
4722
  ? "bytes-fallback-fits-window" : "estimate-fits-window" };
4519
4723
  warnPausedNearWindow(args.state, args.rec, requestSessionId(args.req));
4520
- 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;
4521
4739
  }
4522
4740
  return changed ? candidate.body : intended;
4523
4741
  }
@@ -4585,6 +4803,9 @@ async function truncateCandidate(rec, sizing, base, limits, store) {
4585
4803
  if (!plan)
4586
4804
  return undefined;
4587
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
+ }
4588
4809
  const measurement = await sizing.measure(candidate, limits);
4589
4810
  if (fitsMeasured(measurement, limits) && measurement.tokens <= goal) {
4590
4811
  rec.truncatedToolResult = { originalChars: plan.originalChars,
@@ -4693,6 +4914,17 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
4693
4914
  : args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true });
4694
4915
  args.rec.forwardedBytes = 0;
4695
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);
4696
4928
  args.state.diagnostics?.record("refusal", {
4697
4929
  status: failure.status, attempt: failure.attempt,
4698
4930
  input_ktokens: Math.round(inputTokens / 1000), budget_ktokens: Math.round(budgetTokens / 1000),
@@ -4719,6 +4951,24 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
4719
4951
  args.state.keyNotice.markShown();
4720
4952
  return false;
4721
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
+ }
4722
4972
  /**
4723
4973
  * The refusal text already says MemTree is paused and how to resume, so it
4724
4974
  * counts as the paused notice: the throttle restarts, and this request's
@@ -4794,7 +5044,7 @@ function reachedBudget(compaction) {
4794
5044
  * forced compaction (budget, window, `/memtree-compact`, a rebuilt prefix), a
4795
5045
  * size at or over the budget, or a size nobody measured. A planned fit is
4796
5046
  * provisional: delivery's own sizing can still find the turn over the budget
4797
- * (quietDegrades), since a plan can undercount.
5047
+ * (degradedNotices), since a plan can undercount.
4798
5048
  */
4799
5049
  function compressionNeeded(compaction) {
4800
5050
  if (compaction === undefined || compaction.reason !== undefined)
@@ -4804,10 +5054,15 @@ function compressionNeeded(compaction) {
4804
5054
  return compaction.estimatedTokens >= compaction.budgetTokens;
4805
5055
  }
4806
5056
  /**
4807
- * A degraded turn whose plan fit the budget, so it queued no notice: the
4808
- * 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.
4809
5060
  */
4810
- 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();
4811
5066
  /** The paused notice a degraded request queued, until it is refused or settles. */
4812
5067
  const pausedNotices = new WeakMap();
4813
5068
  const changedDeliveryRecords = new WeakSet();
@@ -5495,6 +5750,41 @@ function capture(opts, kind, body) {
5495
5750
  // diagnostics only — never break the proxy path
5496
5751
  }
5497
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
+ }
5498
5788
  function readBody(req) {
5499
5789
  return readAll(req);
5500
5790
  }