claude-code-infinite 1.0.33 → 1.0.35

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 (105) hide show
  1. package/README.md +60 -2
  2. package/dist/cli-main.js +24 -9
  3. package/dist/cli-main.js.map +1 -1
  4. package/dist/cli.js +23 -10
  5. package/dist/cli.js.map +1 -1
  6. package/dist/hooks.d.ts +20 -0
  7. package/dist/hooks.d.ts.map +1 -1
  8. package/dist/hooks.js +34 -4
  9. package/dist/hooks.js.map +1 -1
  10. package/dist/host-session.d.ts +12 -0
  11. package/dist/host-session.d.ts.map +1 -0
  12. package/dist/host-session.js +12 -0
  13. package/dist/host-session.js.map +1 -0
  14. package/dist/image-refs.d.ts +24 -2
  15. package/dist/image-refs.d.ts.map +1 -1
  16. package/dist/image-refs.js +67 -20
  17. package/dist/image-refs.js.map +1 -1
  18. package/dist/index-wait-413.d.ts +26 -0
  19. package/dist/index-wait-413.d.ts.map +1 -0
  20. package/dist/index-wait-413.js +113 -0
  21. package/dist/index-wait-413.js.map +1 -0
  22. package/dist/memtree-finder.d.ts +70 -6
  23. package/dist/memtree-finder.d.ts.map +1 -1
  24. package/dist/memtree-finder.js +150 -20
  25. package/dist/memtree-finder.js.map +1 -1
  26. package/dist/memtree-handover.d.ts +13 -0
  27. package/dist/memtree-handover.d.ts.map +1 -0
  28. package/dist/memtree-handover.js +231 -0
  29. package/dist/memtree-handover.js.map +1 -0
  30. package/dist/memtree-mcp-config.d.ts +2 -2
  31. package/dist/memtree-mcp-config.d.ts.map +1 -1
  32. package/dist/memtree-mcp-config.js +8 -5
  33. package/dist/memtree-mcp-config.js.map +1 -1
  34. package/dist/memtree-mcp.d.ts +182 -2
  35. package/dist/memtree-mcp.d.ts.map +1 -1
  36. package/dist/memtree-mcp.js +357 -38
  37. package/dist/memtree-mcp.js.map +1 -1
  38. package/dist/memtree-newest.d.ts +31 -5
  39. package/dist/memtree-newest.d.ts.map +1 -1
  40. package/dist/memtree-newest.js +65 -13
  41. package/dist/memtree-newest.js.map +1 -1
  42. package/dist/memtree-times.d.ts +33 -0
  43. package/dist/memtree-times.d.ts.map +1 -1
  44. package/dist/memtree-times.js +77 -0
  45. package/dist/memtree-times.js.map +1 -1
  46. package/dist/memtree-tools.d.ts +19 -5
  47. package/dist/memtree-tools.d.ts.map +1 -1
  48. package/dist/memtree-tools.js +97 -23
  49. package/dist/memtree-tools.js.map +1 -1
  50. package/dist/memtree.d.ts +33 -0
  51. package/dist/memtree.d.ts.map +1 -1
  52. package/dist/memtree.js +92 -4
  53. package/dist/memtree.js.map +1 -1
  54. package/dist/network-failure.d.ts +28 -0
  55. package/dist/network-failure.d.ts.map +1 -0
  56. package/dist/network-failure.js +67 -0
  57. package/dist/network-failure.js.map +1 -0
  58. package/dist/prefix-store.d.ts +4 -0
  59. package/dist/prefix-store.d.ts.map +1 -1
  60. package/dist/prefix-store.js +8 -4
  61. package/dist/prefix-store.js.map +1 -1
  62. package/dist/project-trees.d.ts +15 -7
  63. package/dist/project-trees.d.ts.map +1 -1
  64. package/dist/project-trees.js +20 -9
  65. package/dist/project-trees.js.map +1 -1
  66. package/dist/proxy.d.ts +85 -1
  67. package/dist/proxy.d.ts.map +1 -1
  68. package/dist/proxy.js +552 -44
  69. package/dist/proxy.js.map +1 -1
  70. package/dist/refusal-reason.d.ts +69 -0
  71. package/dist/refusal-reason.d.ts.map +1 -0
  72. package/dist/refusal-reason.js +131 -0
  73. package/dist/refusal-reason.js.map +1 -0
  74. package/dist/reqlog.d.ts +43 -0
  75. package/dist/reqlog.d.ts.map +1 -1
  76. package/dist/reqlog.js.map +1 -1
  77. package/dist/resume-guidance.d.ts +9 -3
  78. package/dist/resume-guidance.d.ts.map +1 -1
  79. package/dist/resume-guidance.js +9 -3
  80. package/dist/resume-guidance.js.map +1 -1
  81. package/dist/route-fallback.d.ts +79 -11
  82. package/dist/route-fallback.d.ts.map +1 -1
  83. package/dist/route-fallback.js +162 -38
  84. package/dist/route-fallback.js.map +1 -1
  85. package/dist/route-identity.d.ts +6 -2
  86. package/dist/route-identity.d.ts.map +1 -1
  87. package/dist/route-identity.js +209 -16
  88. package/dist/route-identity.js.map +1 -1
  89. package/dist/slow-upload.d.ts +40 -0
  90. package/dist/slow-upload.d.ts.map +1 -0
  91. package/dist/slow-upload.js +69 -0
  92. package/dist/slow-upload.js.map +1 -0
  93. package/dist/status-link.d.ts +18 -0
  94. package/dist/status-link.d.ts.map +1 -0
  95. package/dist/status-link.js +103 -0
  96. package/dist/status-link.js.map +1 -0
  97. package/dist/statusline.d.ts +51 -0
  98. package/dist/statusline.d.ts.map +1 -0
  99. package/dist/statusline.js +379 -0
  100. package/dist/statusline.js.map +1 -0
  101. package/dist/subagent-hint.d.ts +28 -0
  102. package/dist/subagent-hint.d.ts.map +1 -1
  103. package/dist/subagent-hint.js +37 -0
  104. package/dist/subagent-hint.js.map +1 -1
  105. package/package.json +1 -1
package/dist/proxy.js CHANGED
@@ -38,9 +38,13 @@
38
38
  * strip pass before hashing/forwarding. Live notices use Claude Code hooks and
39
39
  * upstream response bytes pass through to the client unchanged.
40
40
  */
41
+ import { UNKNOWN_INDEX_WAIT_SECONDS } from "./index-wait-413.js";
41
42
  import { capCacheBreakpoints } from "./route-cache.js";
42
43
  import { PromptAccounting } from "./prompt-accounting.js";
43
- import { INDEX_WAIT_RETRY_AFTER_SECONDS, RefusalStreaks, RouteFallbackFailures, pausedRefusal } from "./route-fallback.js";
44
+ import { INDEX_WAIT_RETRY_AFTER_SECONDS, RefusalStreaks, RouteFallbackFailures, pausedRefusal, unreachableRefusal } from "./route-fallback.js";
45
+ import { refusalCause, refusalMessage } from "./refusal-reason.js";
46
+ import { SlowUploads } from "./slow-upload.js";
47
+ import { freshFailure, networkFailureOf, unreachableMessage, } from "./network-failure.js";
44
48
  import { createCountTokensSession, CountTokensCooldowns, plausibleSample, } from "./count-tokens.js";
45
49
  import { RequestSizing, estimateRequestTokens, fitsMeasured, fitsNative, outputReservation } from "./request-sizing.js";
46
50
  import { extendsHistory, mediaFreeMessageHash, prefixIdentity, prefixMessageMatches, } from "./route-identity.js";
@@ -48,14 +52,15 @@ import { COMPRESSION_RESERVE_TOKENS, compressionTrigger, PreparedCompressions, p
48
52
  import { truncateToolResults } from "./tool-result-truncation.js";
49
53
  import http from "node:http";
50
54
  import https from "node:https";
51
- import { createHash, randomBytes } from "node:crypto";
55
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
56
+ import { CLAUDE_PID_HEADER, HOST_TOKEN_HEADER } from "./host-session.js";
52
57
  import { brotliDecompressSync, createBrotliDecompress, createGunzip, createInflate, gunzipSync, inflateSync, } from "node:zlib";
53
58
  import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
54
59
  import { join } from "node:path";
55
- import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, indexLagEvidence, losesMedia, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
60
+ import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, indexLagEvidence, losesMedia, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, memoryReadySeconds, unindexedTokenCount, } from "./memtree.js";
56
61
  import { contextLimitForModel, hasEarlierNonToolUserMessage, isAwaySummaryUserMessage, isLocalBashCommandTurn, isNonToolUserMessage, isToolResultUserMessage, lastNonSystemMessage, messagesWithSystem, modelForMemtree, stripSystemReminderText, } from "./turns.js";
57
62
  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, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, PRECOMPACT_HOOK_PATH, } from "./hooks.js";
63
+ import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, PRECOMPACT_HOOK_PATH } from "./hooks.js";
59
64
  import { USAGE_LABEL, USAGE_SIGN_IN_NOTE } from "./usage-link.js";
60
65
  import { hyperlink } from "./payment-gate.js";
61
66
  import { keyInvalidNoticeText, keyInvalidRefusalMessage } from "./key-status.js";
@@ -64,8 +69,8 @@ import { PaymentNoticeThrottle, pausedCompactionText, pausedRefusalMessage, paym
64
69
  import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
65
70
  import { describeClaudeCodeRequest, inspectMonitorTranscript, knownSideRequestKind, transcriptWarning, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
66
71
  import { isResumableSessionId } from "./resume-hint.js";
67
- import { subagentMemoryHintText, subagentStartOutput, } from "./subagent-hint.js";
68
- import { RESUME_GUIDANCE_HOOK_SUFFIX } from "./hooks.js";
72
+ import { isSubagentCall, postToolUseOutput, SUBAGENT_RESULT_HINT, subagentMemoryHintText, subagentStartOutput, } from "./subagent-hint.js";
73
+ import { PROJECT_TREES_HOOK_SUFFIX, RESUME_GUIDANCE_HOOK_SUFFIX, SUBAGENT_RESULT_HOOK_SUFFIX, } from "./hooks.js";
69
74
  import { conservativeBodyTokens } from "./token-estimate.js";
70
75
  import { bodyImages } from "./image-tokens.js";
71
76
  import { claudeAuthFromHeaders, upstreamErrorCode } from "./claude-auth.js";
@@ -73,6 +78,11 @@ import { rateLimitFromResponse } from "./rate-limit.js";
73
78
  import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
74
79
  const DEFAULT_UPSTREAM = "https://api.anthropic.com";
75
80
  const HOOK_BODY_LIMIT = 64 * 1024;
81
+ /**
82
+ * Hook bodies that carry a whole prompt (UserPromptSubmit) or a subagent's prompt and
83
+ * full result (PostToolUse on Agent|Task).
84
+ */
85
+ const LARGE_HOOK_BODY_LIMIT = 16 * 1024 * 1024;
76
86
  /** A hook body still arriving after this is dropped (the hook itself waits 5 s). */
77
87
  const HOOK_BODY_TIMEOUT_MS = 3_000;
78
88
  // After a recovery attempt returns null (MemTree down, 5xx, or a burned
@@ -221,6 +231,12 @@ function noteIndexLag(opts, rec, result) {
221
231
  if (!indexLagEvidence(result))
222
232
  return false;
223
233
  rec.indexBehind = true;
234
+ const etaSeconds = memoryReadySeconds(result);
235
+ const unindexedTokens = unindexedTokenCount(result);
236
+ if (etaSeconds !== undefined || unindexedTokens !== undefined) {
237
+ rec.indexLag = { ...(etaSeconds !== undefined ? { etaSeconds } : {}),
238
+ ...(unindexedTokens !== undefined ? { unindexedTokens } : {}) };
239
+ }
224
240
  opts.memtree.expireCompressResult?.(result, opts.indexWaitRefreshMs ?? INDEX_WAIT_RETRY_AFTER_SECONDS * 1000);
225
241
  return true;
226
242
  }
@@ -346,6 +362,8 @@ export function startProxy(opts) {
346
362
  pausedDetail: () => opts.memtree.pausedDetail,
347
363
  pausedReason: () => opts.memtree.pausedReason,
348
364
  memtreeAnswering: () => opts.memtree.answering,
365
+ memtreeUnreachable: () => opts.memtree.unreachable,
366
+ anthropicUnreachable: () => upstream.unreachable,
349
367
  modelNotices: new ModelContextNotices(),
350
368
  usageUrl: opts.memtree.usageDashboardUrl,
351
369
  sessionStartNotice: opts.sessionStartNotice,
@@ -353,13 +371,15 @@ export function startProxy(opts) {
353
371
  openUrl: opts.openUrl,
354
372
  projectTrees: opts.projectTrees,
355
373
  subagentHint: opts.subagentHint,
374
+ projectTreesGiven: new Set(),
356
375
  resumeGuidance: opts.resumeGuidance,
357
376
  diagnostics: opts.diagnostics,
358
377
  notices: new NoticeDeliveryQueue(),
359
378
  mainNoticePending: false,
360
379
  mainPromptGeneration: 0,
361
380
  promptAccounting: new PromptAccounting(),
362
- fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs),
381
+ slowUploads: new SlowUploads(undefined, opts.slowUploadMinBytes),
382
+ fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs, opts.indexWaitMaxMs),
363
383
  refusalStreaks: new RefusalStreaks(),
364
384
  activeSubagents: new Set(),
365
385
  deferredAgentIndexes: new Map(),
@@ -376,6 +396,7 @@ export function startProxy(opts) {
376
396
  routeInstallFault: opts.routeInstallFault,
377
397
  memtreePages: new Map(),
378
398
  memorySourcePages: new Map(),
399
+ hostToken: randomBytes(24).toString("hex"),
379
400
  memtreeCallSeq: 0,
380
401
  memtreeLinkStore: opts.memtreeLinkStore,
381
402
  stablePrefixStore: opts.stablePrefixStore,
@@ -383,6 +404,8 @@ export function startProxy(opts) {
383
404
  storageOwner: typeof opts.memtree.storageIdentity === "string"
384
405
  ? opts.memtree.storageIdentity : undefined,
385
406
  newestTrees: opts.newestTrees,
407
+ statusLinks: opts.statusLinks,
408
+ statusLinkRefresh: opts.statusLinkRefresh === true,
386
409
  memtreeLinkPlacement: opts.memtreeLinkPlacement ?? "turn",
387
410
  compactTargets: new Map(),
388
411
  defaultCompactOff: opts.defaultCompactTarget === null,
@@ -398,6 +421,8 @@ export function startProxy(opts) {
398
421
  laneSizes: new Map(),
399
422
  };
400
423
  installMemtreeLink(state, state.memtreeLinkPlacement);
424
+ // A newer tree from the background lookup reaches the status line without a turn ending.
425
+ opts.newestTrees?.onTree((sessionId) => publishStatusLink(state, sessionId));
401
426
  state.notices.setFirstTreeIntro(opts.firstTreeIntro ?? null);
402
427
  const server = http.createServer((req, res) => {
403
428
  const accepted = { req, res };
@@ -486,6 +511,8 @@ export function startProxy(opts) {
486
511
  },
487
512
  drain,
488
513
  mainSessionId: () => state.resumableMainSession,
514
+ setHostPid: (pid) => { state.hostPid = pid; },
515
+ hostToken: state.hostToken,
489
516
  });
490
517
  });
491
518
  });
@@ -513,13 +540,22 @@ async function handleRequest(req, res, opts, upstream, state, hookPath) {
513
540
  return handleNoticeHook(req, res, state, opts.reqlog, undefined, "PreCompact");
514
541
  }
515
542
  if (url.pathname === `${hookPath}${RESUME_GUIDANCE_HOOK_SUFFIX}`) {
516
- return handleResumeGuidanceHook(req, res, state);
543
+ return handleSessionStartContextHook(req, res, (input) => resumeGuidanceContext(state, input));
544
+ }
545
+ if (url.pathname === `${hookPath}${SUBAGENT_RESULT_HOOK_SUFFIX}`) {
546
+ return handleSubagentResultHook(req, res, state);
547
+ }
548
+ if (url.pathname === `${hookPath}${PROJECT_TREES_HOOK_SUFFIX}`) {
549
+ return handlePrintProjectTreesHook(req, res, state);
517
550
  }
518
551
  if (url.pathname === hookPath) {
519
552
  return handleNoticeHook(req, res, state, opts.reqlog, (sessionId) => opts.memtree.scheduleFinalIndex(sessionId, (signal) => opts.transcriptUsage?.finalReplyAsync
520
553
  ? opts.transcriptUsage.finalReplyAsync(sessionId, signal)
521
554
  : opts.transcriptUsage?.finalReply?.(sessionId)));
522
555
  }
556
+ if (req.method === "GET" && url.pathname === MEMTREE_HOST_SESSION_PATH) {
557
+ return handleMemTreeHostSession(req, res, state, url);
558
+ }
523
559
  if (req.method === "GET" &&
524
560
  (url.pathname === MEMTREE_CURRENT_PATH || url.pathname === `${MEMTREE_CURRENT_PATH}.json`)) {
525
561
  return handleMemTreeCurrent(req, res, opts, state, url);
@@ -584,12 +620,14 @@ const MEMTREE_PASSTHROUGH_PREFIX = "/memtree/";
584
620
  * `<request id>`, `<request id>.json`, `<request id>/session.json` (the
585
621
  * page's session pane), `<request id>/search` (the server's term search over
586
622
  * that tree, `?q=&limit=`), `<request id>/messages` (the un-indexed messages
587
- * after it, `?start=&end=`), or `sessions/<Claude Code session id>.json` (every
623
+ * after it, `?start=&end=`), `<request id>/user_messages` (the person's own
624
+ * messages, `?since=&until=&q=&cursor=`), `<request id>/handover` (the handover
625
+ * pack), or `sessions/<Claude Code session id>.json` (every
588
626
  * page from one session, newest first); nothing that could walk the upstream
589
627
  * path. The id is the request UUID or the server's short form of it (leading
590
628
  * hex, as in the `/m/<id>` links it hands out) — the server accepts both.
591
629
  */
592
- const MEMTREE_PASSTHROUGH_TARGET_RE = /^(?:[A-Za-z0-9-]+(\.json|\/session\.json|\/search|\/messages)?|sessions\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\.json)$/;
630
+ const MEMTREE_PASSTHROUGH_TARGET_RE = /^(?:[A-Za-z0-9-]+(\.json|\/session\.json|\/search|\/messages|\/user_messages|\/handover)?|sessions\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\.json)$/;
593
631
  /**
594
632
  * `GET /memtree/<id>[.json][?share=…]` on the loopback: read the user's own
595
633
  * MemTree page with their key. Claude Code's child env already carries this
@@ -606,7 +644,7 @@ async function handleMemTreePassthrough(req, res, opts, url) {
606
644
  return;
607
645
  }
608
646
  try {
609
- const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${target}${url.search}`, req.headers.accept ?? "application/json");
647
+ const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${target}${url.search}`, req.headers.accept ?? "application/json", undefined, memtreeToolHeaders(req));
610
648
  res.writeHead(upstream.status, { "content-type": upstream.contentType });
611
649
  res.end(upstream.body);
612
650
  }
@@ -633,15 +671,29 @@ const MEMTREE_FINDER_RELAY = new Map([
633
671
  [`${MEMTREE_PASSTHROUGH_PREFIX}search`, "/v1/memtree/search"],
634
672
  ]);
635
673
  const SESSION_ID_HEADER_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
636
- async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
674
+ /** `x-memtree-tool` as the memtree MCP server sends it: a tool name. */
675
+ const MEMTREE_TOOL_HEADER_VALUE = /^[a-z_]{1,20}$/;
676
+ /**
677
+ * What a MemTree tool's read carries on to the server: the tool (`x-memtree-tool`)
678
+ * and the calling Claude Code session, when well formed. The server logs one
679
+ * content-free line per tool call with them (polychat memtree_tool_log.py).
680
+ */
681
+ export function memtreeToolHeaders(req) {
637
682
  const headers = {};
638
- const meta = memtreeClientMeta({ project: opts.projectMeta });
639
- if (Object.keys(meta).length)
640
- headers["x-client-meta"] = JSON.stringify(meta);
683
+ const tool = firstNonEmptyHeader(req, "x-memtree-tool");
684
+ if (tool && MEMTREE_TOOL_HEADER_VALUE.test(tool))
685
+ headers["x-memtree-tool"] = tool;
641
686
  const sessionId = firstNonEmptyHeader(req, "x-claude-code-session-id");
642
687
  if (sessionId && SESSION_ID_HEADER_VALUE.test(sessionId)) {
643
688
  headers["x-claude-code-session-id"] = sessionId;
644
689
  }
690
+ return headers;
691
+ }
692
+ async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
693
+ const headers = memtreeToolHeaders(req);
694
+ const meta = memtreeClientMeta({ project: opts.projectMeta });
695
+ if (Object.keys(meta).length)
696
+ headers["x-client-meta"] = JSON.stringify(meta);
645
697
  try {
646
698
  const upstream = await opts.memtree.fetchMemTree(`${upstreamPath}${url.search}`, req.headers.accept ?? "application/json", undefined, headers);
647
699
  res.writeHead(upstream.status, { "content-type": upstream.contentType });
@@ -658,18 +710,23 @@ async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
658
710
  * reads (memtree-mcp.ts). Bare `current` answers the pointer
659
711
  * `{id, url, index, session_id, compressed}` without an upstream call, so the
660
712
  * MCP server can keep its cached tree until the page changes; `current.json`
661
- * relays the page JSON itself, like `/memtree/<id>.json`.
713
+ * relays the page JSON itself, like `/memtree/<id>.json`. With no page here,
714
+ * the server's newest tree for the session (currentPageOrServerTree): that
715
+ * waits a bounded time (NewestTreeLookup.current), cached; 503 when the
716
+ * server could not be asked.
662
717
  *
663
718
  * Every pointer is scoped to the caller's exact session id. Missing session
664
719
  * ids never fall back to another conversation served by the same proxy.
665
720
  */
666
721
  async function handleMemTreeCurrent(req, res, opts, state, url) {
667
722
  const sessionId = url.searchParams.get("session") || undefined;
668
- const page = currentMemorySourcePage(state, sessionId);
723
+ const found = await currentPageOrServerTree(state, sessionId);
724
+ const page = "page" in found ? found.page : undefined;
669
725
  const id = page ? memtreePageId(page.url) : undefined;
670
726
  if (!page || !id) {
671
- res.writeHead(404, { "content-type": "application/json" });
672
- res.end(JSON.stringify({ detail: "No MemTree page for this session yet" }));
727
+ const unknown = "unknown" in found;
728
+ res.writeHead(unknown ? 503 : 404, { "content-type": "application/json" });
729
+ res.end(JSON.stringify({ detail: unknown ? CURRENT_LOOKUP_FAILED : "No MemTree page for this session yet" }));
673
730
  return;
674
731
  }
675
732
  if (!url.pathname.endsWith(".json")) {
@@ -684,7 +741,7 @@ async function handleMemTreeCurrent(req, res, opts, state, url) {
684
741
  return;
685
742
  }
686
743
  try {
687
- const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${id}.json`, req.headers.accept ?? "application/json");
744
+ const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${id}.json`, req.headers.accept ?? "application/json", undefined, memtreeToolHeaders(req));
688
745
  res.writeHead(upstream.status, {
689
746
  "content-type": upstream.contentType,
690
747
  "x-memtree-page": page.url,
@@ -697,6 +754,60 @@ async function handleMemTreeCurrent(req, res, opts, state, url) {
697
754
  }
698
755
  }
699
756
  const MEMTREE_CURRENT_PATH = `${MEMTREE_PASSTHROUGH_PREFIX}current`;
757
+ const MEMTREE_HOST_SESSION_PATH = `${MEMTREE_PASSTHROUGH_PREFIX}host-session`;
758
+ /**
759
+ * `GET /memtree/host-session?host_pid=<pid>`: the session ccc's own Claude Code process
760
+ * is on, for that process's memtree MCP server (memtree-mcp.ts: refreshHostSession),
761
+ * which is started with the launch session id and not restarted when the process
762
+ * resumes or clears. Answered only to a caller that names that process's pid (the
763
+ * server's parent) and carries the token from this ccc's MCP config, and only once a
764
+ * SessionStart hook from that process has named the session; 404 otherwise. The pid is
765
+ * not a secret and the token alone is not enough: a teammate Claude Code replays the
766
+ * same MCP config, with another parent. So neither a Claude Code started from inside
767
+ * the session nor a teammate is handed this process's conversation.
768
+ */
769
+ function handleMemTreeHostSession(req, res, state, url) {
770
+ const known = isHostPid(state, url.searchParams.get("host_pid")) &&
771
+ sameSecret(req.headers[HOST_TOKEN_HEADER], state.hostToken);
772
+ res.writeHead(known && state.hostSession ? 200 : 404, { "content-type": "application/json" });
773
+ res.end(JSON.stringify(known && state.hostSession
774
+ ? { session_id: state.hostSession } : { detail: "No session for this process" }));
775
+ }
776
+ /** Whether `pid` (a header or query value) is the Claude Code process ccc launched. */
777
+ function isHostPid(state, pid) {
778
+ return state.hostPid !== undefined && typeof pid === "string" && /^\d+$/.test(pid) &&
779
+ Number(pid) === state.hostPid;
780
+ }
781
+ function sameSecret(given, secret) {
782
+ if (typeof given !== "string")
783
+ return false;
784
+ const a = Buffer.from(given), b = Buffer.from(secret);
785
+ return a.length === b.length && timingSafeEqual(a, b);
786
+ }
787
+ const CURRENT_LOOKUP_FAILED = "This process has no MemTree page for this session and could not ask the MemTree server for one";
788
+ /**
789
+ * The session's page here (currentMemorySourcePage), else its newest completed
790
+ * tree on the server (`/v1/memtree/sessions`, this account's own sessions,
791
+ * exact session id only; cached by NewestTreeLookup and superseded as soon as
792
+ * a served index is noted). After a restart, a session whose history goes as
793
+ * index-only calls or rides a stored prefix gets no served index here, while
794
+ * the server has its whole tree. `unknown`: the server could not be asked.
795
+ */
796
+ async function currentPageOrServerTree(state, sessionId) {
797
+ const page = currentMemorySourcePage(state, sessionId);
798
+ if (page || !sessionId || !state.newestTrees || !SESSION_ID_HEADER_VALUE.test(sessionId)) {
799
+ return { page };
800
+ }
801
+ const answer = await state.newestTrees.current(sessionId);
802
+ // A served index noted during the wait takes precedence.
803
+ const served = currentMemorySourcePage(state, sessionId);
804
+ if (served)
805
+ return { page: served };
806
+ if (answer.tree) {
807
+ return { page: { url: answer.tree.url, index: answer.tree.key, compressed: false, sessionId } };
808
+ }
809
+ return answer.answered ? {} : { unknown: true };
810
+ }
700
811
  /** The page whose memory is in the prompt, else (none adopted yet) the newest page. */
701
812
  function currentMemorySourcePage(state, sessionId) {
702
813
  if (!sessionId)
@@ -746,12 +857,95 @@ function noteMainSessionStart(state, source, sessionId) {
746
857
  state.resumableMainSession = undefined;
747
858
  }
748
859
  }
860
+ /**
861
+ * PostToolUse after an Agent/Task call: point the model at the subagent's own tree
862
+ * (subagent-hint.ts), unless MemTree is known not to work, when the session has the tools.
863
+ */
864
+ async function handleSubagentResultHook(req, res, state) {
865
+ // The body carries the subagent's whole prompt and result: allow a large one.
866
+ const raw = req.method === "POST"
867
+ ? await readBodyWithin(req, LARGE_HOOK_BODY_LIMIT) : undefined;
868
+ if (req.method !== "POST")
869
+ req.resume();
870
+ let input = null;
871
+ try {
872
+ if (raw !== undefined && raw.length > 0)
873
+ input = JSON.parse(raw.toString("utf-8"));
874
+ }
875
+ catch {
876
+ input = null;
877
+ }
878
+ // Not known to be broken is enough: a short print run's first requests can finish
879
+ // before MemTree has answered any (non-blocking first turn), and the subagent is
880
+ // indexed either way.
881
+ const say = state.subagentHint?.enabled === true && resumeMemtreeWorking(state) &&
882
+ isSubagentCall(input);
883
+ if (!say) {
884
+ res.writeHead(204);
885
+ res.end();
886
+ return;
887
+ }
888
+ const body = Buffer.from(JSON.stringify(postToolUseOutput(SUBAGENT_RESULT_HINT)), "utf-8");
889
+ res.writeHead(200, {
890
+ "content-type": "application/json",
891
+ "content-length": String(body.length),
892
+ "cache-control": "no-store",
893
+ });
894
+ res.end(body);
895
+ }
896
+ /**
897
+ * Print runs: the earlier-sessions block (project-trees.ts) with a session's first
898
+ * prompt, as UserPromptSubmit context. Main sessions only, once per session per
899
+ * process; anything unexpected is 204.
900
+ */
901
+ async function handlePrintProjectTreesHook(req, res, state) {
902
+ // The body carries the whole prompt.
903
+ const raw = req.method === "POST" ? await readBodyWithin(req, LARGE_HOOK_BODY_LIMIT) : undefined;
904
+ if (req.method !== "POST")
905
+ req.resume();
906
+ let input = null;
907
+ try {
908
+ if (raw !== undefined && raw.length > 0) {
909
+ input = parseNoticeHookInput(JSON.parse(raw.toString("utf-8")));
910
+ }
911
+ }
912
+ catch {
913
+ input = null;
914
+ }
915
+ const first = input?.hook_event_name === "UserPromptSubmit" && input.agent_id === undefined &&
916
+ !state.projectTreesGiven.has(input.session_id);
917
+ const context = first && state.projectTrees
918
+ ? await safeContext(() => state.projectTrees.contextFor(input.session_id)) : undefined;
919
+ if (!context) {
920
+ res.writeHead(204);
921
+ res.end();
922
+ return;
923
+ }
924
+ state.projectTreesGiven.add(input.session_id);
925
+ const body = Buffer.from(JSON.stringify({ hookSpecificOutput: {
926
+ hookEventName: "UserPromptSubmit", additionalContext: context
927
+ } }), "utf-8");
928
+ res.writeHead(200, {
929
+ "content-type": "application/json",
930
+ "content-length": String(body.length),
931
+ "cache-control": "no-store",
932
+ });
933
+ res.end(body);
934
+ }
935
+ async function safeContext(get) {
936
+ try {
937
+ return await get();
938
+ }
939
+ catch {
940
+ return undefined;
941
+ }
942
+ }
749
943
  /**
750
944
  * SessionStart on its own hook (resume-guidance.ts): only the current MemTree
751
945
  * guidance, so Claude Code sees it as one context of its own and adds an
752
946
  * identical one only once. Never fails the hook: anything unexpected is 204.
753
947
  */
754
- async function handleResumeGuidanceHook(req, res, state) {
948
+ async function handleSessionStartContextHook(req, res, contextFor) {
755
949
  const raw = req.method === "POST" ? await readBodyWithin(req, HOOK_BODY_LIMIT) : undefined;
756
950
  if (req.method !== "POST")
757
951
  req.resume();
@@ -765,7 +959,7 @@ async function handleResumeGuidanceHook(req, res, state) {
765
959
  input = null;
766
960
  }
767
961
  const context = input?.hook_event_name === "SessionStart"
768
- ? await resumeGuidanceContext(state, input) : undefined;
962
+ ? await contextFor(input) : undefined;
769
963
  if (!context) {
770
964
  res.writeHead(204);
771
965
  res.end();
@@ -827,6 +1021,12 @@ onlyEvent) {
827
1021
  if (parsed.agent_id === undefined) {
828
1022
  recordSessionStart(state, parsed.source);
829
1023
  noteMainSessionStart(state, parsed.source, parsed.session_id);
1024
+ // Only ccc's own Claude Code moves the host session: a teammate process loads
1025
+ // the same plugin and posts its own session starts here.
1026
+ if (isHostPid(state, req.headers[CLAUDE_PID_HEADER]) &&
1027
+ typeof parsed.session_id === "string" && parsed.session_id) {
1028
+ state.hostSession = parsed.session_id;
1029
+ }
830
1030
  // Claude Code's compaction dropped any MemTree notice from the transcript.
831
1031
  if (parsed.source === "compact")
832
1032
  state.modelNotices.forget(parsed.session_id);
@@ -1047,6 +1247,8 @@ async function handleMessages(req, res, opts, upstream, state) {
1047
1247
  };
1048
1248
  // The prompt this request was made for, before any await (Codex r2 P2).
1049
1249
  noticeContexts.set(rec, { promptId: state.mainPromptId, generation: state.mainPromptGeneration });
1250
+ // An index wait may hold this request (runBlockingCompression) until Claude Code leaves.
1251
+ indexHolds.set(rec, { started: received, closed: closedSignal(res) });
1050
1252
  const logged = async (forward) => {
1051
1253
  try {
1052
1254
  await forward;
@@ -1165,6 +1367,11 @@ async function handleMessages(req, res, opts, upstream, state) {
1165
1367
  return link ? recapLinkText(link.link, streamedTextChars, link.note) : undefined;
1166
1368
  });
1167
1369
  }
1370
+ else if (state.statusLinks && state.statusLinkRefresh) {
1371
+ // Keeps the status line's link fresh through long tool runs: answers from the cache,
1372
+ // refreshing in the background at most once a minute (NEWEST_TREE_TTL_MS).
1373
+ state.newestTrees?.peek(requestSessionId(req));
1374
+ }
1168
1375
  const isLocalBashCommand = isLocalBashCommandTurn(messages);
1169
1376
  // CC 2.1.207 identifies agent API calls explicitly. Use that wire-level
1170
1377
  // attribution before lifecycle-hook state so an agent request cannot claim
@@ -1173,6 +1380,14 @@ async function handleMessages(req, res, opts, upstream, state) {
1173
1380
  const isSubagentRequest = requestRouteLane === "agent";
1174
1381
  const isMainRequest = requestRouteLane === "main";
1175
1382
  const requestSizeKey = laneSizeKey(req, requestRouteKey, body.model);
1383
+ const hold = indexHolds.get(rec);
1384
+ if (hold) {
1385
+ hold.lane = requestRouteKey;
1386
+ // Arriving while the lane waits for its index: this request's asks stay within its
1387
+ // own hold even if that wait ends meanwhile (laneHeldCap).
1388
+ if (state.fallbackFailures.indexWaiting(requestRouteKey))
1389
+ hold.underIndexWait = true;
1390
+ }
1176
1391
  // Extended 1M context arrives as the `context-1m` beta header (Claude Code
1177
1392
  // strips the `[1m]` model suffix on the wire). Current native-1M models send
1178
1393
  // neither, so contextLimitForModel also needs the launcher's native setting.
@@ -1491,12 +1706,16 @@ async function handleMessages(req, res, opts, upstream, state) {
1491
1706
  }
1492
1707
  else if (prior?.retryAtTokens !== undefined &&
1493
1708
  plan.estimateTokens < prior.retryAtTokens &&
1494
- Date.now() < (prior.retryDeadline ?? 0)) {
1709
+ Date.now() < (prior.retryDeadline ?? 0) && !outageOver(state, prior.offline)) {
1495
1710
  // This lane's last attempt this human turn produced nothing; wait
1496
1711
  // for growth instead of paying a blocking call on every tool turn.
1497
1712
  rec.routeRecovery = { outcome: "backoff" };
1713
+ // MemTree answered that attempt: a refusal now is about the fit (Codex r1 P2).
1714
+ if (!prior.offline)
1715
+ answeredBackoffs.add(rec);
1498
1716
  }
1499
1717
  else if (Date.now() < state.toolRecoveryCooldownUntil &&
1718
+ !outageOver(state, state.toolRecoveryCooldownOffline) &&
1500
1719
  !(plan.overWindow && !plan.fallback && (!plan.uncountedOverWindowOnly ||
1501
1720
  await wholeRequestRefused(requestSizing, originalCandidate, sizingLimits())))) {
1502
1721
  // A recent attempt burned the full compress budget and still
@@ -2572,10 +2791,11 @@ function noteMemtreePage(state, seq, sessionId, result) {
2572
2791
  if (latest && latest.seq >= seq)
2573
2792
  return;
2574
2793
  const compressed = didMemtreeCompress(result);
2794
+ // A cached newest tree may predate this page; ask again (first, so the status line's
2795
+ // link is this page until the lookup answers).
2796
+ state.newestTrees?.invalidate(sessionId);
2575
2797
  rememberMemtreePage(state, sessionId, { sessionId, url, index, compressed, seq });
2576
2798
  state.memtreeLinkStore?.put(sessionId, { url, index, compressed });
2577
- // A cached newest tree may predate this page; ask again.
2578
- state.newestTrees?.invalidate(sessionId);
2579
2799
  }
2580
2800
  /**
2581
2801
  * Record a compress answer's page as the source of the memory now forwarded
@@ -2594,6 +2814,13 @@ function noteMemorySource(state, seq, sessionId, result) {
2594
2814
  }
2595
2815
  function rememberMemtreePage(state, sessionId, page) {
2596
2816
  rememberPage(state.memtreePages, sessionId, page);
2817
+ publishStatusLink(state, sessionId);
2818
+ }
2819
+ /** Write the session's current link (as the trailers would show it) for the status line. */
2820
+ function publishStatusLink(state, sessionId) {
2821
+ if (!state.statusLinks)
2822
+ return;
2823
+ state.statusLinks.write(sessionId, currentSessionLink(state, sessionId));
2597
2824
  }
2598
2825
  /** Bound optional page state independently of how many sessions use the proxy. */
2599
2826
  function rememberPage(pages, sessionId, page) {
@@ -2644,6 +2871,7 @@ function noteMemtreeHealth(state, compression) {
2644
2871
  return;
2645
2872
  state.toolRecoveryCooldownUntil =
2646
2873
  Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
2874
+ state.toolRecoveryCooldownOffline = state.memtreeUnreachable() !== undefined;
2647
2875
  return;
2648
2876
  }
2649
2877
  if (compression.liveHealth === "success") {
@@ -2797,6 +3025,8 @@ function persistStablePrefix(state, sessionId, prefix) {
2797
3025
  originalSystemHash: prefix.originalSystemHash,
2798
3026
  originalPrefixHashes: prefix.originalPrefixHashes,
2799
3027
  originalMediaFreeHashes: prefix.originalMediaFreeHashes,
3028
+ originalPortableHashes: prefix.originalPortableHashes,
3029
+ originalPortableMediaFreeHashes: prefix.originalPortableMediaFreeHashes,
2800
3030
  compressedMessages: prefix.compressedMessages,
2801
3031
  compressedSystem: prefix.compressedSystem,
2802
3032
  hasCompressedSystem: prefix.hasCompressedSystem,
@@ -2837,6 +3067,7 @@ function adoptStoredPrefix(opts, state, rec, sessionId, body, messages, modelCon
2837
3067
  if (verdict === "discard") {
2838
3068
  store.delete(sessionId, owner);
2839
3069
  rec.storedPrefix = "discarded";
3070
+ rec.storedPrefixMissAt = firstPrefixMiss(messages, prefix, "stable");
2840
3071
  return;
2841
3072
  }
2842
3073
  boundedSet(state.stablePrefixes, sessionId, prefix);
@@ -2885,15 +3116,19 @@ function prefixMismatch(body, messages, prefix, sessionId, kind = "route") {
2885
3116
  if (routeValueHash(normalizeRouteSystem(body.system)) !== prefix.originalSystemHash) {
2886
3117
  return "system";
2887
3118
  }
2888
- const prefixLength = prefix.originalPrefixHashes.length;
2889
- if (messages.length < prefixLength)
3119
+ if (messages.length < prefix.originalPrefixHashes.length)
2890
3120
  return "prefix";
2891
- for (let i = 0; i < prefixLength; i++) {
2892
- if (!prefixMessageMatches(messages[i], kind, prefix.originalPrefixHashes[i], prefix.originalMediaFreeHashes?.[i])) {
2893
- return "prefix";
3121
+ return firstPrefixMiss(messages, prefix, kind) === undefined ? null : "prefix";
3122
+ }
3123
+ /** The first message the prefix covers that no longer matches it, if any. */
3124
+ function firstPrefixMiss(messages, prefix, kind) {
3125
+ const length = Math.min(messages.length, prefix.originalPrefixHashes.length);
3126
+ for (let i = 0; i < length; i++) {
3127
+ if (!prefixMessageMatches(messages[i], kind, prefix.originalPrefixHashes[i], prefix.originalMediaFreeHashes?.[i], prefix.originalPortableHashes?.[i], prefix.originalPortableMediaFreeHashes?.[i])) {
3128
+ return i;
2894
3129
  }
2895
3130
  }
2896
- return null;
3131
+ return undefined;
2897
3132
  }
2898
3133
  /**
2899
3134
  * Message identity for a stable prefix: routeMessageHash without assistant
@@ -3757,7 +3992,7 @@ function carryFor(args) {
3757
3992
  * one beside it: 2026-10-30, eleven 26 MB compressions in ten minutes, each billed, none
3758
3993
  * used. False when the session already has a preparation in flight (it serves instead).
3759
3994
  */
3760
- function carryOverCompression(state, carry, pending) {
3995
+ function carryOverCompression(state, carry, pending, bytes = 0) {
3761
3996
  const { sessionId, base } = carry;
3762
3997
  // A prefix installed since the call started (another turn's) supersedes it already.
3763
3998
  if (state.backgroundSignal.aborted || state.preparing.has(sessionId) ||
@@ -3770,6 +4005,9 @@ function carryOverCompression(state, carry, pending) {
3770
4005
  // refusal or a slow server's timeout must not arm it, as runBlockingCompression).
3771
4006
  if (result)
3772
4007
  noteMemtreeHealth(state, { result, liveHealth: "success" });
4008
+ // Its upload got through: a slow-upload episode is over (Codex r4 P2).
4009
+ if (result)
4010
+ state.slowUploads.settled(sessionId, bytes, "ok");
3773
4011
  return result ? preparedFromResult(carry, result) : undefined;
3774
4012
  })
3775
4013
  .catch(() => undefined)
@@ -3786,6 +4024,14 @@ function carryOverCompression(state, carry, pending) {
3786
4024
  /** How long a carried compaction may run: the long budget, or its upload's if longer. */
3787
4025
  /** How much longer than its turn's wait a carried call must be allowed to run. */
3788
4026
  const CARRY_MIN_EXTRA_MS = 5_000;
4027
+ /**
4028
+ * The long compress budget for an upload of `bytes`: the long budget or the upload's
4029
+ * scaled one if longer, raised after the session's last large upload timed out.
4030
+ */
4031
+ function longCompressBudgetFor(opts, state, sessionId, bytes) {
4032
+ const longMs = Math.max(opts.memtree.longCompressBudgetMs, opts.memtree.compressBudgetForBytes(bytes));
4033
+ return state.slowUploads.budgetMs(sessionId, bytes, longMs);
4034
+ }
3789
4035
  function carriedBudgetMs(opts, scaledMs) {
3790
4036
  return Math.min(MAX_LONG_COMPRESS_WAIT_MS, Math.max(opts.memtree.longCompressBudgetMs, scaledMs));
3791
4037
  }
@@ -3824,7 +4070,7 @@ function memoryRoutedToolBody(body, messages, route, routeEpoch, sessionId) {
3824
4070
  if (messages.length < prefixLength)
3825
4071
  return null;
3826
4072
  for (let i = 0; i < prefixLength; i++) {
3827
- if (!prefixMessageMatches(messages[i], "route", route.originalPrefixHashes[i], route.originalMediaFreeHashes?.[i])) {
4073
+ if (!prefixMessageMatches(messages[i], "route", route.originalPrefixHashes[i], route.originalMediaFreeHashes?.[i], route.originalPortableHashes?.[i], route.originalPortableMediaFreeHashes?.[i])) {
3828
4074
  return null;
3829
4075
  }
3830
4076
  }
@@ -4188,6 +4434,154 @@ function cloneJson(value) {
4188
4434
  return value;
4189
4435
  return JSON.parse(JSON.stringify(value));
4190
4436
  }
4437
+ /**
4438
+ * A compress call that, while MemTree answers that its index is still catching up on
4439
+ * this conversation (an indexing 413, index-wait-413.ts), holds the request and asks
4440
+ * again at the server's pace instead of refusing at once: up to INDEX_HOLD_MS per
4441
+ * request, below Claude Code's wait for a first byte. A wait it cannot finish here is
4442
+ * left to the refusal (rec.indexBehind), whose 503 says how long is left and has Claude
4443
+ * Code retry, until the lane's deadline (route-fallback.ts). Only when nothing else could
4444
+ * be sent (longBudget): a ride on an earlier prefix, or a request that fits, goes at once.
4445
+ * Never for a background call (its record is a copy) or a 413 that waiting cannot fix.
4446
+ */
4447
+ async function runBlockingCompression(args) {
4448
+ let capMs = laneHeldCap(args);
4449
+ for (;;) {
4450
+ const outcome = await runBlockingCompressionOnce(capMs === undefined ? args
4451
+ : { ...args, capMs });
4452
+ // An ask cut short by the hold's end is no evidence of an outage; and while the
4453
+ // lane waits for its index it is that wait, not a strike (a later answer ends it).
4454
+ if (capMs !== undefined && args.rec.compress?.timedOut) {
4455
+ outcome.liveHealth = "none";
4456
+ if (outcome.indexWaitSeconds === undefined && !activeIndexWaits.has(args.rec)) {
4457
+ const lane = indexHolds.get(args.rec)?.lane;
4458
+ noteIndexWait413(args.rec, (lane !== undefined
4459
+ ? args.state.fallbackFailures.indexSecondsLeft(lane) : undefined) ??
4460
+ UNKNOWN_INDEX_WAIT_SECONDS);
4461
+ return outcome;
4462
+ }
4463
+ }
4464
+ if (outcome.indexWaitSeconds === undefined) {
4465
+ // Settled otherwise (an answer, or a failure of another kind): the wait is over.
4466
+ // A timed-out ask said nothing new, so a refusal still waits.
4467
+ if (outcome.result !== null || !args.rec.compress?.timedOut)
4468
+ endIndexWait(args.rec);
4469
+ return outcome;
4470
+ }
4471
+ noteIndexWait413(args.rec, outcome.indexWaitSeconds);
4472
+ capMs = await holdForIndex(args, outcome.indexWaitSeconds);
4473
+ if (capMs === undefined)
4474
+ return outcome;
4475
+ }
4476
+ }
4477
+ /**
4478
+ * While the lane waits for its index (another request held it, likely this one's wait
4479
+ * too), a request's first ask gets only what is left of its own hold: its time waiting
4480
+ * for the lane already counts toward Claude Code's first-byte wait.
4481
+ */
4482
+ function laneHeldCap(args) {
4483
+ const hold = indexHolds.get(args.rec);
4484
+ if (!hold || hold.lane === undefined)
4485
+ return undefined;
4486
+ // Waiting now, or at any time since this request arrived (it may have queued behind a
4487
+ // hold that has since delivered).
4488
+ if (!hold.underIndexWait &&
4489
+ !args.state.fallbackFailures.indexWaitSince(hold.lane, hold.started)) {
4490
+ return undefined;
4491
+ }
4492
+ hold.underIndexWait = true;
4493
+ return Math.max(1, (args.opts.indexHoldMs ?? INDEX_HOLD_MS) - (Date.now() - hold.started));
4494
+ }
4495
+ /** One request's index hold: when it arrived, its lane, and a signal for Claude Code leaving. */
4496
+ const indexHolds = new WeakMap();
4497
+ /**
4498
+ * Requests whose latest compress answer was an indexing 413: the seconds it stated. Set
4499
+ * only by runBlockingCompression, and cleared when a later ask settles otherwise.
4500
+ */
4501
+ const activeIndexWaits = new WeakMap();
4502
+ /** The most one request is held for an index (Claude Code 2.1.319 waits 180 s for headers). */
4503
+ const INDEX_HOLD_MS = 120_000;
4504
+ /** The longest pause between asks while held (the server's Retry-After, when shorter). */
4505
+ const INDEX_POLL_MAX_MS = 30_000;
4506
+ /**
4507
+ * A held ask gets at least this long (MemTree itself holds an indexing 413 for up to
4508
+ * 90 s); with less left, the request is not held.
4509
+ */
4510
+ const INDEX_MIN_ASK_MS = 30_000;
4511
+ /** An indexing 413: refusing this request waits for the index, saying how long is left. */
4512
+ function noteIndexWait413(rec, seconds) {
4513
+ if (!activeIndexWaits.has(rec) && rec.indexBehind !== true)
4514
+ indexBehindSetByWait.add(rec);
4515
+ activeIndexWaits.set(rec, seconds);
4516
+ rec.indexBehind = true;
4517
+ rec.indexWait = { secondsLeft: seconds, asks: (rec.indexWait?.asks ?? 0) + 1,
4518
+ heldMs: rec.indexWait?.heldMs ?? 0 };
4519
+ }
4520
+ /** Requests whose indexBehind only an indexing 413 set (endIndexWait clears it). */
4521
+ const indexBehindSetByWait = new WeakSet();
4522
+ /** A later ask settled otherwise: the request no longer waits (rec.indexWait stays as history). */
4523
+ function endIndexWait(rec) {
4524
+ if (!activeIndexWaits.delete(rec))
4525
+ return;
4526
+ if (indexBehindSetByWait.delete(rec))
4527
+ delete rec.indexBehind;
4528
+ if (rec.indexWait)
4529
+ rec.indexWait.ended = true;
4530
+ }
4531
+ /**
4532
+ * Pause before asking MemTree again, when another ask fits this request's hold and its
4533
+ * lane's wait is still open: the time left of the hold for that ask (its cap). Undefined
4534
+ * when not (nothing else could be sent is false, the estimate is past the cap, the lane's
4535
+ * deadline passed, or Claude Code or the proxy left).
4536
+ */
4537
+ async function holdForIndex(args, seconds) {
4538
+ const hold = indexHolds.get(args.rec);
4539
+ if (!hold || args.longBudget !== true)
4540
+ return undefined;
4541
+ const laneEnds = hold.lane === undefined ? Infinity
4542
+ : args.state.fallbackFailures.indexWaitDeadline(hold.lane, seconds);
4543
+ if (laneEnds === undefined || args.state.fallbackFailures.hopeless(seconds))
4544
+ return undefined;
4545
+ const pause = Math.min(seconds * 1000, args.opts.indexPollMaxMs ?? INDEX_POLL_MAX_MS);
4546
+ const holdMs = args.opts.indexHoldMs ?? INDEX_HOLD_MS;
4547
+ // The earlier of the request's hold and the lane's wait bounds the pause and the ask.
4548
+ const left = () => Math.min(holdMs - (Date.now() - hold.started), laneEnds - Date.now());
4549
+ if (left() - pause < Math.min(INDEX_MIN_ASK_MS, holdMs / 4))
4550
+ return undefined;
4551
+ const signals = [hold.closed, args.state.shutdownSignal, args.signal]
4552
+ .filter((signal) => signal !== undefined);
4553
+ const paused = Date.now();
4554
+ const waited = await abortableDelay(pause, signals);
4555
+ if (args.rec.indexWait)
4556
+ args.rec.indexWait.heldMs += Date.now() - paused;
4557
+ return waited ? Math.max(1, left()) : undefined;
4558
+ }
4559
+ /** Wait `ms`; false at once if any signal aborts first (or already has). */
4560
+ function abortableDelay(ms, signals) {
4561
+ if (signals.some((signal) => signal.aborted))
4562
+ return Promise.resolve(false);
4563
+ return new Promise((resolve) => {
4564
+ const done = (waited) => {
4565
+ clearTimeout(timer);
4566
+ for (const signal of signals)
4567
+ signal.removeEventListener("abort", aborted);
4568
+ resolve(waited);
4569
+ };
4570
+ const aborted = () => done(false);
4571
+ // Unref'd: a hold never keeps the process open.
4572
+ const timer = setTimeout(() => done(true), ms);
4573
+ timer.unref();
4574
+ for (const signal of signals)
4575
+ signal.addEventListener("abort", aborted, { once: true });
4576
+ });
4577
+ }
4578
+ /** Aborted when Claude Code closes this response before it finished. */
4579
+ function closedSignal(res) {
4580
+ const controller = new AbortController();
4581
+ res.once("close", () => { if (!res.writableFinished)
4582
+ controller.abort(); });
4583
+ return controller.signal;
4584
+ }
4191
4585
  /**
4192
4586
  * The blocking-compression step, shared by the main followup path and
4193
4587
  * tool-route miss recovery: the compress call plus the `compress` telemetry
@@ -4198,7 +4592,7 @@ function cloneJson(value) {
4198
4592
  * `checkCompressedHistory(result, msgsForMemtree).usable` must hold before
4199
4593
  * the result may be forwarded or become a route.
4200
4594
  */
4201
- async function runBlockingCompression(args) {
4595
+ async function runBlockingCompressionOnce(args) {
4202
4596
  const { opts, state, body, msgsForMemtree, hash, modelContextLimit, rec } = args;
4203
4597
  const compaction = args.compaction ??
4204
4598
  laneCompaction(opts, state, args.sessionId, body.model, modelContextLimit);
@@ -4210,10 +4604,14 @@ async function runBlockingCompression(args) {
4210
4604
  // The upload is about the request's size: the budget grows with it (the long one
4211
4605
  // too, never below the short one's).
4212
4606
  const scaledMs = opts.memtree.compressBudgetForBytes(rec.requestBytes);
4213
- const budgetMs = Math.min(args.longBudget ? MAX_LONG_COMPRESS_WAIT_MS : MAX_COMPRESS_WAIT_MS, args.longBudget ? Math.max(opts.memtree.longCompressBudgetMs, scaledMs) : scaledMs);
4607
+ // A session whose last large upload ran out of the long budget gets more (slow-upload.ts).
4608
+ const extendedBudget = args.longBudget === true &&
4609
+ state.slowUploads.extended(args.sessionId, rec.requestBytes);
4610
+ const budgetMs = Math.min(args.longBudget ? MAX_LONG_COMPRESS_WAIT_MS : MAX_COMPRESS_WAIT_MS, args.longBudget ? longCompressBudgetFor(opts, state, args.sessionId, rec.requestBytes)
4611
+ : scaledMs, args.capMs ?? Infinity);
4214
4612
  // Carried only when running on gives it meaningfully more time than this wait.
4215
- const carry = args.carry && carriedBudgetMs(opts, scaledMs) >= budgetMs + CARRY_MIN_EXTRA_MS
4216
- ? args.carry : undefined;
4613
+ const carry = args.carry && args.capMs === undefined &&
4614
+ carriedBudgetMs(opts, scaledMs) >= budgetMs + CARRY_MIN_EXTRA_MS ? args.carry : undefined;
4217
4615
  const compressMeta = {
4218
4616
  // Model + tools drive the server's model-based memory budget
4219
4617
  // (e.g. 800k whole-request target for Fable / Opus 5). Omitting
@@ -4266,7 +4664,7 @@ async function runBlockingCompression(args) {
4266
4664
  const compressMs = Date.now() - compressStarted;
4267
4665
  // Given up on but still running: the session's next turn waits for it, not a new call.
4268
4666
  const carried = result === null && !settled && carry !== undefined &&
4269
- carryOverCompression(state, carry, call);
4667
+ carryOverCompression(state, carry, call, rec.requestBytes);
4270
4668
  noteServerBudget(state, body.model, modelContextLimit, result);
4271
4669
  // Sampled at this call's settle, before a concurrent same-request failure of
4272
4670
  // another class can overwrite the complete-key entry.
@@ -4274,6 +4672,8 @@ async function runBlockingCompression(args) {
4274
4672
  opts.memtree.lastCompressFailureArming(hash, modelContextLimit, compressMeta);
4275
4673
  const failureStatus = result === null
4276
4674
  ? opts.memtree.lastCompressFailureStatus?.(hash, modelContextLimit, compressMeta) : undefined;
4675
+ // A joined call's answer counts too: it is this request's answer.
4676
+ const indexWaitSeconds = result === null ? opts.memtree.compressIndexWait?.(call) : undefined;
4277
4677
  const hadLiveSuccess = !cached && result !== null;
4278
4678
  // Only arming-class live failures (network error, timeout, 5xx, 402) are
4279
4679
  // fuse evidence. A responsive server's other 4xx failed THIS call but says
@@ -4291,12 +4691,25 @@ async function runBlockingCompression(args) {
4291
4691
  const othersAnswered = timedOut && (opts.memtree.lastCompressSuccessAt ?? 0) >= compressStarted;
4292
4692
  // A carried call has not failed: it is still running (its answer reports its health).
4293
4693
  const hadLiveFailure = !cached && result === null && failureArming && !othersAnswered && !carried;
4694
+ // Only a whole long budget spent is the upload's slowness (a hold's cap is not).
4695
+ // A large upload's live answer (any budget, not a cache hit) ends a slow-upload episode.
4696
+ if (result !== null && !cached)
4697
+ state.slowUploads.settled(args.sessionId, rec.requestBytes, "ok");
4698
+ const uploadTimeout = result === null && args.longBudget === true &&
4699
+ args.capMs === undefined && !carried &&
4700
+ state.slowUploads.settled(args.sessionId, rec.requestBytes, timedOut ? "timeout" : "other");
4294
4701
  rec.compress = {
4295
4702
  ms: compressMs,
4296
4703
  ok: result !== null,
4297
4704
  timedOut,
4705
+ budgetMs,
4706
+ ...(extendedBudget ? { extendedBudget: true } : {}),
4707
+ ...(uploadTimeout ? { uploadTimeout: true } : {}),
4708
+ ...(failureStatus !== undefined ? { status: failureStatus } : {}),
4298
4709
  ...(carried ? { carriedOver: true } : {}),
4299
- ...(!cached && failureStatus === OVER_CONTEXT_STATUS ? { refused: true } : {}),
4710
+ // A joined call's indexing 413 is MemTree's refusal of this request too.
4711
+ ...((!cached && failureStatus === OVER_CONTEXT_STATUS) || indexWaitSeconds !== undefined
4712
+ ? { refused: true } : {}),
4300
4713
  };
4301
4714
  return {
4302
4715
  result,
@@ -4306,6 +4719,7 @@ async function runBlockingCompression(args) {
4306
4719
  : hadLiveFailure
4307
4720
  ? "failure"
4308
4721
  : "none",
4722
+ ...(indexWaitSeconds !== undefined ? { indexWaitSeconds } : {}),
4309
4723
  };
4310
4724
  }
4311
4725
  /**
@@ -4420,6 +4834,10 @@ async function recoverToolRouteMiss(args) {
4420
4834
  if (args.retryAtTokens !== undefined) {
4421
4835
  recoveryAttempt.retryAtTokens = args.retryAtTokens;
4422
4836
  recoveryAttempt.retryDeadline = Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
4837
+ if (state.memtreeUnreachable())
4838
+ recoveryAttempt.offline = true;
4839
+ else
4840
+ delete recoveryAttempt.offline;
4423
4841
  }
4424
4842
  };
4425
4843
  const sessionId = requestSessionId(req);
@@ -4487,7 +4905,10 @@ async function recoverToolRouteMiss(args) {
4487
4905
  modelContextLimit, rec, targetTokens: stable.targetTokens, explicitTarget: stable.explicitTarget,
4488
4906
  linkSeq }) : undefined;
4489
4907
  let compression;
4908
+ // Nothing else could be sent if this compress fails: the long budget, and an index hold.
4909
+ let longBudget = false;
4490
4910
  try {
4911
+ longBudget = args.wholeRefused ? await args.wholeRefused() : false;
4491
4912
  compression = await runBlockingCompression({
4492
4913
  opts,
4493
4914
  state,
@@ -4502,7 +4923,7 @@ async function recoverToolRouteMiss(args) {
4502
4923
  sizing: args.sizing,
4503
4924
  inputCandidate: args.inputCandidate,
4504
4925
  ...(args.compaction ? { compaction: args.compaction } : {}),
4505
- longBudget: args.wholeRefused ? await args.wholeRefused() : false,
4926
+ longBudget,
4506
4927
  ...(toolCarry ? { carry: toolCarry } : {}),
4507
4928
  });
4508
4929
  }
@@ -4545,6 +4966,11 @@ async function recoverToolRouteMiss(args) {
4545
4966
  if (!result) {
4546
4967
  rec.routeRecovery = { conversationBytes, outcome: "failed" };
4547
4968
  releaseOwnReservation();
4969
+ // MemTree's 413 for an index still catching up, with nothing else to send: the
4970
+ // refusal waits for the index (its 503 paces the retries), and growth cannot help,
4971
+ // so the retry asks again. With a ride to send, the usual backoff paces the asks.
4972
+ if (longBudget && activeIndexWaits.has(rec))
4973
+ return forwardOriginal(false);
4548
4974
  // An ordinary server/network failure or timeout retains the background
4549
4975
  // submission: its longer independent budget can still warm the index for
4550
4976
  // a later turn. An unpaid key (402, possibly set by this very compress)
@@ -5082,6 +5508,9 @@ async function truncatedToFit(rec, sizing, preferred, limits, store) {
5082
5508
  * compression summarize instead.
5083
5509
  */
5084
5510
  function compressionSettled(rec) {
5511
+ // An indexing 413 is MemTree's refusal too: the safety net may still fit the request.
5512
+ if (activeIndexWaits.has(rec))
5513
+ return true;
5085
5514
  if (rec.indexBehind === true)
5086
5515
  return false;
5087
5516
  if (rec.compress?.refused === true || rec.compaction?.prepared === "used")
@@ -5240,14 +5669,45 @@ async function wholeRequestRefused(sizing, candidate, limits) {
5240
5669
  return false;
5241
5670
  return !fitsMeasured(await sizing.measure(candidate, limits), limits);
5242
5671
  }
5672
+ /** What explains refusing this request (refusal-reason.ts), from its record and the lane. */
5673
+ function refusalCauseFor(args, inputTokens) {
5674
+ const { state, rec } = args;
5675
+ const sessionId = requestSessionId(args.req);
5676
+ const bytes = rec.requestBytes;
5677
+ // After a slow upload: what the session's next long-budget try gets.
5678
+ const nextCompressBudgetMs = rec.compress?.uploadTimeout
5679
+ ? Math.min(MAX_LONG_COMPRESS_WAIT_MS, longCompressBudgetFor(args.opts, state, sessionId, bytes))
5680
+ : undefined;
5681
+ // This request's own 413, then its own answer's ETA, then the lane's last stated time.
5682
+ const known = (seconds) => seconds === UNKNOWN_INDEX_WAIT_SECONDS ? undefined : seconds;
5683
+ return refusalCause(rec, {
5684
+ inputTokens, modelContextLimit: args.modelContextLimit,
5685
+ // The 60 s stand-in for an indexing 413 that stated no time is no estimate.
5686
+ indexSecondsLeft: known(activeIndexWaits.get(rec)) ?? rec.indexLag?.etaSeconds ??
5687
+ known(state.fallbackFailures.indexSecondsLeft(args.routeKey)),
5688
+ nextCompressBudgetMs,
5689
+ });
5690
+ }
5691
+ /** The session's MemTree page (refusal line, status line), as the trailers would show it. */
5692
+ function currentSessionLink(state, sessionId) {
5693
+ return sessionId === undefined ? undefined
5694
+ : sessionLink(state, sessionId, currentMemtreePage(state, sessionId))?.link;
5695
+ }
5243
5696
  /** Write the bounded fallback refusal (route-fallback.ts); always false. */
5244
5697
  function refuseWholeRequest(args, inputTokens, budgetTokens) {
5245
5698
  // A refused key or a paused one: a retry cannot fix either, so it is final at once.
5246
5699
  const keyInvalid = args.state.keyInvalid();
5247
5700
  const unpaid = keyInvalid ? null : args.state.pausedDetail();
5701
+ // MemTree can't be reached: nothing says the request can't fit, so no strike (2026-11-09).
5702
+ const offline = keyInvalid || unpaid !== null ? undefined : unreachableNow(args.state, args.rec);
5703
+ const cause = keyInvalid || unpaid !== null || offline ? undefined
5704
+ : refusalCauseFor(args, inputTokens);
5248
5705
  const failure = keyInvalid ? pausedRefusal(keyInvalidRefusalMessage(args.state.keyPageUrl))
5249
5706
  : unpaid !== null ? pausedRefusal(pausedRefusalMessage(unpaid))
5250
- : args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true });
5707
+ : offline ? unreachableRefusal(offline.message)
5708
+ : args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true,
5709
+ indexSecondsLeft: activeIndexWaits.get(args.rec),
5710
+ refusalMessage: refusalMessage(cause, currentSessionLink(args.state, requestSessionId(args.req))) });
5251
5711
  args.rec.forwardedBytes = 0;
5252
5712
  args.rec.approxInputTokens = 0;
5253
5713
  // Nothing went out uncompressed: Claude Code retries it (2026-10-28).
@@ -5259,7 +5719,8 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
5259
5719
  delete args.rec.compaction?.keptPrefix;
5260
5720
  }
5261
5721
  delete args.rec.degradedNotice;
5262
- if (unpaid === null && !keyInvalid)
5722
+ // An unreachable MemTree's 503 says why itself: not "slow to answer".
5723
+ if (unpaid === null && !keyInvalid && !offline)
5263
5724
  noteSlowRetry(args, failure);
5264
5725
  args.state.diagnostics?.record("refusal", {
5265
5726
  status: failure.status, attempt: failure.attempt,
@@ -5272,6 +5733,8 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
5272
5733
  outputReservation: bodyOutputReservation(args.body), indexBehind: args.rec.indexBehind === true,
5273
5734
  budgetTokens, modelContextLimit: args.modelContextLimit, ...(unpaid !== null ? { paused: true } : {}),
5274
5735
  ...(keyInvalid ? { keyInvalid: true } : {}),
5736
+ ...(offline ? { unreachable: offline.what } : {}),
5737
+ ...(cause && failure.status === 400 ? { cause: cause.kind } : {}),
5275
5738
  };
5276
5739
  if (unpaid !== null || keyInvalid)
5277
5740
  cancelPausedNotice(args.state, args.rec);
@@ -5287,6 +5750,37 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
5287
5750
  args.state.keyNotice.markShown();
5288
5751
  return false;
5289
5752
  }
5753
+ /** Requests held back by a lane backoff whose attempt MemTree answered (unreachableNow). */
5754
+ const answeredBackoffs = new WeakSet();
5755
+ /**
5756
+ * Whether a backoff or cooldown armed by a call that could not connect (`offline`) is over:
5757
+ * MemTree has answered since, so the next request asks it again.
5758
+ */
5759
+ function outageOver(state, offline) {
5760
+ return offline === true && state.memtreeUnreachable() === undefined;
5761
+ }
5762
+ /**
5763
+ * Why a request only MemTree could fit can't go now, when MemTree's latest compress call
5764
+ * could not connect (recently): the internet when Anthropic's latest forward could not
5765
+ * either. Never for a request MemTree answered (live or from the cache), one held back by a
5766
+ * backoff MemTree's answer armed, or one sent on a prepared prefix or waiting for its index:
5767
+ * its refusal is about the fit (review r1, Codex r1).
5768
+ */
5769
+ function unreachableNow(state, rec) {
5770
+ if (rec.compress?.ok === true || rec.compress?.refused === true ||
5771
+ // The backoff's answer speaks only while this request made no compress call of its
5772
+ // own (a delivery-time one that could not connect is its own evidence: Codex r2).
5773
+ (rec.compress === undefined && answeredBackoffs.has(rec)) ||
5774
+ rec.compaction?.prepared === "used" || rec.indexBehind === true ||
5775
+ activeIndexWaits.has(rec))
5776
+ return undefined;
5777
+ const memtree = freshFailure(state.memtreeUnreachable());
5778
+ if (!memtree)
5779
+ return undefined;
5780
+ const anthropic = freshFailure(state.anthropicUnreachable());
5781
+ return { message: unreachableMessage(memtree, anthropic),
5782
+ what: anthropic ? "internet" : "memtree" };
5783
+ }
5290
5784
  /**
5291
5785
  * A repeated retryable refusal on the main thread: say once per streak that
5292
5786
  * MemTree is slow and Claude Code is retrying, unless another notice waits.
@@ -5563,6 +6057,7 @@ function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, r
5563
6057
  path: req.url, // path + query string verbatim (?beta=true etc.)
5564
6058
  headers,
5565
6059
  }, (upstreamRes) => {
6060
+ upstream.unreachable = undefined;
5566
6061
  activeUpstreamRes = upstreamRes;
5567
6062
  const contentType = String(upstreamRes.headers["content-type"] ?? "");
5568
6063
  const isSse = contentType.includes("text/event-stream");
@@ -5821,7 +6316,7 @@ function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, r
5821
6316
  });
5822
6317
  upstreamReq.on("error", (err) => {
5823
6318
  if (!shutdownCancelled) {
5824
- sendAnthropicError(res, `upstream connection failed: ${err.message}`);
6319
+ sendAnthropicError(res, upstreamFailureMessage(upstream, err, !activeUpstreamRes));
5825
6320
  }
5826
6321
  settle(false);
5827
6322
  });
@@ -5973,6 +6468,7 @@ function passThroughStreaming(req, res, upstream, shutdownSignal) {
5973
6468
  upstreamRes.destroy();
5974
6469
  return;
5975
6470
  }
6471
+ upstream.unreachable = undefined;
5976
6472
  activeUpstreamRes = upstreamRes;
5977
6473
  const onUpstreamResponseEnd = () => {
5978
6474
  upstreamResponseEnded = true;
@@ -6018,7 +6514,7 @@ function passThroughStreaming(req, res, upstream, shutdownSignal) {
6018
6514
  if (upstreamReq)
6019
6515
  req.unpipe(upstreamReq);
6020
6516
  activeUpstreamRes?.unpipe(res);
6021
- sendAnthropicError(res, `upstream connection failed: ${err.message}`);
6517
+ sendAnthropicError(res, upstreamFailureMessage(upstream, err, !activeUpstreamRes));
6022
6518
  if (res.writableFinished)
6023
6519
  onDownstreamFinish();
6024
6520
  });
@@ -6052,6 +6548,18 @@ function forwardableResponseHeaders(upstreamRes) {
6052
6548
  }
6053
6549
  return out;
6054
6550
  }
6551
+ /**
6552
+ * The 502 text for a forward that got no answer: plainly that ccc can't reach Anthropic
6553
+ * when it could not connect (noted on `upstream`; only before any response: review r1 P3),
6554
+ * else the error as it came.
6555
+ */
6556
+ function upstreamFailureMessage(upstream, err, unanswered) {
6557
+ const failure = unanswered ? networkFailureOf(err, upstream.host) : undefined;
6558
+ if (!failure)
6559
+ return `upstream connection failed: ${err.message}`;
6560
+ upstream.unreachable = failure;
6561
+ return unreachableMessage(undefined, failure);
6562
+ }
6055
6563
  /** Well-formed Anthropic-shaped error body so Claude Code fails fast, not weird. */
6056
6564
  function sendAnthropicError(res, message) {
6057
6565
  if (res.headersSent) {