claude-code-infinite 1.0.32 → 1.0.34

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 (99) hide show
  1. package/README.md +34 -11
  2. package/dist/cli-args.d.ts +7 -0
  3. package/dist/cli-args.d.ts.map +1 -1
  4. package/dist/cli-args.js +19 -0
  5. package/dist/cli-args.js.map +1 -1
  6. package/dist/cli-main.js +28 -10
  7. package/dist/cli-main.js.map +1 -1
  8. package/dist/count-tokens.d.ts +7 -3
  9. package/dist/count-tokens.d.ts.map +1 -1
  10. package/dist/count-tokens.js +6 -2
  11. package/dist/count-tokens.js.map +1 -1
  12. package/dist/hooks.d.ts +21 -1
  13. package/dist/hooks.d.ts.map +1 -1
  14. package/dist/hooks.js +36 -6
  15. package/dist/hooks.js.map +1 -1
  16. package/dist/host-session.d.ts +12 -0
  17. package/dist/host-session.d.ts.map +1 -0
  18. package/dist/host-session.js +12 -0
  19. package/dist/host-session.js.map +1 -0
  20. package/dist/image-refs.d.ts +24 -2
  21. package/dist/image-refs.d.ts.map +1 -1
  22. package/dist/image-refs.js +67 -20
  23. package/dist/image-refs.js.map +1 -1
  24. package/dist/index-wait-413.d.ts +26 -0
  25. package/dist/index-wait-413.d.ts.map +1 -0
  26. package/dist/index-wait-413.js +113 -0
  27. package/dist/index-wait-413.js.map +1 -0
  28. package/dist/memtree-finder.d.ts +70 -6
  29. package/dist/memtree-finder.d.ts.map +1 -1
  30. package/dist/memtree-finder.js +150 -20
  31. package/dist/memtree-finder.js.map +1 -1
  32. package/dist/memtree-handover.d.ts +13 -0
  33. package/dist/memtree-handover.d.ts.map +1 -0
  34. package/dist/memtree-handover.js +231 -0
  35. package/dist/memtree-handover.js.map +1 -0
  36. package/dist/memtree-mcp-config.d.ts +2 -2
  37. package/dist/memtree-mcp-config.d.ts.map +1 -1
  38. package/dist/memtree-mcp-config.js +8 -5
  39. package/dist/memtree-mcp-config.js.map +1 -1
  40. package/dist/memtree-mcp.d.ts +182 -2
  41. package/dist/memtree-mcp.d.ts.map +1 -1
  42. package/dist/memtree-mcp.js +357 -38
  43. package/dist/memtree-mcp.js.map +1 -1
  44. package/dist/memtree-newest.d.ts +21 -3
  45. package/dist/memtree-newest.d.ts.map +1 -1
  46. package/dist/memtree-newest.js +43 -11
  47. package/dist/memtree-newest.js.map +1 -1
  48. package/dist/memtree-times.d.ts +33 -0
  49. package/dist/memtree-times.d.ts.map +1 -1
  50. package/dist/memtree-times.js +77 -0
  51. package/dist/memtree-times.js.map +1 -1
  52. package/dist/memtree-tools.d.ts +19 -5
  53. package/dist/memtree-tools.d.ts.map +1 -1
  54. package/dist/memtree-tools.js +97 -23
  55. package/dist/memtree-tools.js.map +1 -1
  56. package/dist/memtree.d.ts +109 -3
  57. package/dist/memtree.d.ts.map +1 -1
  58. package/dist/memtree.js +382 -21
  59. package/dist/memtree.js.map +1 -1
  60. package/dist/network-failure.d.ts +28 -0
  61. package/dist/network-failure.d.ts.map +1 -0
  62. package/dist/network-failure.js +67 -0
  63. package/dist/network-failure.js.map +1 -0
  64. package/dist/open-url.d.ts +49 -0
  65. package/dist/open-url.d.ts.map +1 -0
  66. package/dist/open-url.js +100 -0
  67. package/dist/open-url.js.map +1 -0
  68. package/dist/prefix-store.d.ts +4 -0
  69. package/dist/prefix-store.d.ts.map +1 -1
  70. package/dist/prefix-store.js +8 -4
  71. package/dist/prefix-store.js.map +1 -1
  72. package/dist/project-trees.d.ts +15 -7
  73. package/dist/project-trees.d.ts.map +1 -1
  74. package/dist/project-trees.js +20 -9
  75. package/dist/project-trees.js.map +1 -1
  76. package/dist/proxy.d.ts +125 -1
  77. package/dist/proxy.d.ts.map +1 -1
  78. package/dist/proxy.js +838 -74
  79. package/dist/proxy.js.map +1 -1
  80. package/dist/reqlog.d.ts +34 -0
  81. package/dist/reqlog.d.ts.map +1 -1
  82. package/dist/reqlog.js.map +1 -1
  83. package/dist/resume-guidance.d.ts +9 -3
  84. package/dist/resume-guidance.d.ts.map +1 -1
  85. package/dist/resume-guidance.js +9 -3
  86. package/dist/resume-guidance.js.map +1 -1
  87. package/dist/route-fallback.d.ts +77 -11
  88. package/dist/route-fallback.d.ts.map +1 -1
  89. package/dist/route-fallback.js +157 -33
  90. package/dist/route-fallback.js.map +1 -1
  91. package/dist/route-identity.d.ts +6 -2
  92. package/dist/route-identity.d.ts.map +1 -1
  93. package/dist/route-identity.js +209 -16
  94. package/dist/route-identity.js.map +1 -1
  95. package/dist/subagent-hint.d.ts +28 -0
  96. package/dist/subagent-hint.d.ts.map +1 -1
  97. package/dist/subagent-hint.js +37 -0
  98. package/dist/subagent-hint.js.map +1 -1
  99. package/package.json +1 -1
package/dist/proxy.js CHANGED
@@ -38,9 +38,11 @@
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 { freshFailure, networkFailureOf, unreachableMessage, } from "./network-failure.js";
44
46
  import { createCountTokensSession, CountTokensCooldowns, plausibleSample, } from "./count-tokens.js";
45
47
  import { RequestSizing, estimateRequestTokens, fitsMeasured, fitsNative, outputReservation } from "./request-sizing.js";
46
48
  import { extendsHistory, mediaFreeMessageHash, prefixIdentity, prefixMessageMatches, } from "./route-identity.js";
@@ -48,14 +50,15 @@ import { COMPRESSION_RESERVE_TOKENS, compressionTrigger, PreparedCompressions, p
48
50
  import { truncateToolResults } from "./tool-result-truncation.js";
49
51
  import http from "node:http";
50
52
  import https from "node:https";
51
- import { createHash, randomBytes } from "node:crypto";
53
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
54
+ import { CLAUDE_PID_HEADER, HOST_TOKEN_HEADER } from "./host-session.js";
52
55
  import { brotliDecompressSync, createBrotliDecompress, createGunzip, createInflate, gunzipSync, inflateSync, } from "node:zlib";
53
56
  import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
54
57
  import { join } from "node:path";
55
- import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, indexLagEvidence, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
58
+ import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, indexLagEvidence, losesMedia, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
56
59
  import { contextLimitForModel, hasEarlierNonToolUserMessage, isAwaySummaryUserMessage, isLocalBashCommandTurn, isNonToolUserMessage, isToolResultUserMessage, lastNonSystemMessage, messagesWithSystem, modelForMemtree, stripSystemReminderText, } from "./turns.js";
57
60
  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";
61
+ import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, PRECOMPACT_HOOK_PATH } from "./hooks.js";
59
62
  import { USAGE_LABEL, USAGE_SIGN_IN_NOTE } from "./usage-link.js";
60
63
  import { hyperlink } from "./payment-gate.js";
61
64
  import { keyInvalidNoticeText, keyInvalidRefusalMessage } from "./key-status.js";
@@ -64,8 +67,8 @@ import { PaymentNoticeThrottle, pausedCompactionText, pausedRefusalMessage, paym
64
67
  import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
65
68
  import { describeClaudeCodeRequest, inspectMonitorTranscript, knownSideRequestKind, transcriptWarning, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
66
69
  import { isResumableSessionId } from "./resume-hint.js";
67
- import { subagentMemoryHintText, subagentStartOutput, } from "./subagent-hint.js";
68
- import { RESUME_GUIDANCE_HOOK_SUFFIX } from "./hooks.js";
70
+ import { isSubagentCall, postToolUseOutput, SUBAGENT_RESULT_HINT, subagentMemoryHintText, subagentStartOutput, } from "./subagent-hint.js";
71
+ import { PROJECT_TREES_HOOK_SUFFIX, RESUME_GUIDANCE_HOOK_SUFFIX, SUBAGENT_RESULT_HOOK_SUFFIX, } from "./hooks.js";
69
72
  import { conservativeBodyTokens } from "./token-estimate.js";
70
73
  import { bodyImages } from "./image-tokens.js";
71
74
  import { claudeAuthFromHeaders, upstreamErrorCode } from "./claude-auth.js";
@@ -73,6 +76,11 @@ import { rateLimitFromResponse } from "./rate-limit.js";
73
76
  import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
74
77
  const DEFAULT_UPSTREAM = "https://api.anthropic.com";
75
78
  const HOOK_BODY_LIMIT = 64 * 1024;
79
+ /**
80
+ * Hook bodies that carry a whole prompt (UserPromptSubmit) or a subagent's prompt and
81
+ * full result (PostToolUse on Agent|Task).
82
+ */
83
+ const LARGE_HOOK_BODY_LIMIT = 16 * 1024 * 1024;
76
84
  /** A hook body still arriving after this is dropped (the hook itself waits 5 s). */
77
85
  const HOOK_BODY_TIMEOUT_MS = 3_000;
78
86
  // After a recovery attempt returns null (MemTree down, 5xx, or a burned
@@ -86,8 +94,11 @@ const HOOK_BODY_TIMEOUT_MS = 3_000;
86
94
  const TOOL_RECOVERY_FAILURE_COOLDOWN_MS = 60_000;
87
95
  /** Safety cap on a long-budget compress, should a transport ignore its abort. */
88
96
  const MAX_LONG_COMPRESS_WAIT_MS = 10 * 60_000;
89
- /** The most a blocking compress with a fallback may wait (its budget grows with its upload). */
90
- const MAX_COMPRESS_WAIT_MS = 60_000;
97
+ /**
98
+ * The most a blocking compress with a fallback may wait (its budget grows with its upload):
99
+ * a 30 MB upload's scaled budget (15 s + 2.5 ms/KiB, ~90 s) fits under it.
100
+ */
101
+ const MAX_COMPRESS_WAIT_MS = 120_000;
91
102
  const SKIP_REQUEST_HEADERS = new Set([
92
103
  "host",
93
104
  "connection",
@@ -343,21 +354,27 @@ export function startProxy(opts) {
343
354
  pausedDetail: () => opts.memtree.pausedDetail,
344
355
  pausedReason: () => opts.memtree.pausedReason,
345
356
  memtreeAnswering: () => opts.memtree.answering,
357
+ memtreeUnreachable: () => opts.memtree.unreachable,
358
+ anthropicUnreachable: () => upstream.unreachable,
346
359
  modelNotices: new ModelContextNotices(),
347
360
  usageUrl: opts.memtree.usageDashboardUrl,
348
361
  sessionStartNotice: opts.sessionStartNotice,
349
362
  promptSubmitNotice: opts.promptSubmitNotice,
363
+ openUrl: opts.openUrl,
350
364
  projectTrees: opts.projectTrees,
351
365
  subagentHint: opts.subagentHint,
366
+ projectTreesGiven: new Set(),
352
367
  resumeGuidance: opts.resumeGuidance,
353
368
  diagnostics: opts.diagnostics,
354
369
  notices: new NoticeDeliveryQueue(),
355
370
  mainNoticePending: false,
356
371
  mainPromptGeneration: 0,
357
372
  promptAccounting: new PromptAccounting(),
358
- fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs),
373
+ fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs, opts.indexWaitMaxMs),
359
374
  refusalStreaks: new RefusalStreaks(),
360
375
  activeSubagents: new Set(),
376
+ deferredAgentIndexes: new Map(),
377
+ adoptingPrepared: new Map(),
361
378
  memoryRoutes: new Map(),
362
379
  mainRouteEpoch: 0,
363
380
  mainRouteDecisionGeneration: 0,
@@ -370,6 +387,7 @@ export function startProxy(opts) {
370
387
  routeInstallFault: opts.routeInstallFault,
371
388
  memtreePages: new Map(),
372
389
  memorySourcePages: new Map(),
390
+ hostToken: randomBytes(24).toString("hex"),
373
391
  memtreeCallSeq: 0,
374
392
  memtreeLinkStore: opts.memtreeLinkStore,
375
393
  stablePrefixStore: opts.stablePrefixStore,
@@ -453,8 +471,10 @@ export function startProxy(opts) {
453
471
  ]);
454
472
  if (timer)
455
473
  clearTimeout(timer);
456
- if (completed)
474
+ if (completed) {
475
+ sendDeferredAgentIndexes(state);
457
476
  return true;
477
+ }
458
478
  // The grace period protects useful in-flight delivery. Once it
459
479
  // expires, signal every forwarding path first so each can
460
480
  // classify the close as proxy-owned, then tear down any request that was
@@ -463,6 +483,7 @@ export function startProxy(opts) {
463
483
  for (const accepted of acceptedRequests)
464
484
  cancelAcceptedRequest(accepted);
465
485
  await quiesced;
486
+ sendDeferredAgentIndexes(state);
466
487
  return false;
467
488
  };
468
489
  return new Promise((resolve, reject) => {
@@ -477,6 +498,8 @@ export function startProxy(opts) {
477
498
  },
478
499
  drain,
479
500
  mainSessionId: () => state.resumableMainSession,
501
+ setHostPid: (pid) => { state.hostPid = pid; },
502
+ hostToken: state.hostToken,
480
503
  });
481
504
  });
482
505
  });
@@ -504,13 +527,22 @@ async function handleRequest(req, res, opts, upstream, state, hookPath) {
504
527
  return handleNoticeHook(req, res, state, opts.reqlog, undefined, "PreCompact");
505
528
  }
506
529
  if (url.pathname === `${hookPath}${RESUME_GUIDANCE_HOOK_SUFFIX}`) {
507
- return handleResumeGuidanceHook(req, res, state);
530
+ return handleSessionStartContextHook(req, res, (input) => resumeGuidanceContext(state, input));
531
+ }
532
+ if (url.pathname === `${hookPath}${SUBAGENT_RESULT_HOOK_SUFFIX}`) {
533
+ return handleSubagentResultHook(req, res, state);
534
+ }
535
+ if (url.pathname === `${hookPath}${PROJECT_TREES_HOOK_SUFFIX}`) {
536
+ return handlePrintProjectTreesHook(req, res, state);
508
537
  }
509
538
  if (url.pathname === hookPath) {
510
539
  return handleNoticeHook(req, res, state, opts.reqlog, (sessionId) => opts.memtree.scheduleFinalIndex(sessionId, (signal) => opts.transcriptUsage?.finalReplyAsync
511
540
  ? opts.transcriptUsage.finalReplyAsync(sessionId, signal)
512
541
  : opts.transcriptUsage?.finalReply?.(sessionId)));
513
542
  }
543
+ if (req.method === "GET" && url.pathname === MEMTREE_HOST_SESSION_PATH) {
544
+ return handleMemTreeHostSession(req, res, state, url);
545
+ }
514
546
  if (req.method === "GET" &&
515
547
  (url.pathname === MEMTREE_CURRENT_PATH || url.pathname === `${MEMTREE_CURRENT_PATH}.json`)) {
516
548
  return handleMemTreeCurrent(req, res, opts, state, url);
@@ -575,12 +607,14 @@ const MEMTREE_PASSTHROUGH_PREFIX = "/memtree/";
575
607
  * `<request id>`, `<request id>.json`, `<request id>/session.json` (the
576
608
  * page's session pane), `<request id>/search` (the server's term search over
577
609
  * that tree, `?q=&limit=`), `<request id>/messages` (the un-indexed messages
578
- * after it, `?start=&end=`), or `sessions/<Claude Code session id>.json` (every
610
+ * after it, `?start=&end=`), `<request id>/user_messages` (the person's own
611
+ * messages, `?since=&until=&q=&cursor=`), `<request id>/handover` (the handover
612
+ * pack), or `sessions/<Claude Code session id>.json` (every
579
613
  * page from one session, newest first); nothing that could walk the upstream
580
614
  * path. The id is the request UUID or the server's short form of it (leading
581
615
  * hex, as in the `/m/<id>` links it hands out) — the server accepts both.
582
616
  */
583
- 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)$/;
617
+ 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)$/;
584
618
  /**
585
619
  * `GET /memtree/<id>[.json][?share=…]` on the loopback: read the user's own
586
620
  * MemTree page with their key. Claude Code's child env already carries this
@@ -597,7 +631,7 @@ async function handleMemTreePassthrough(req, res, opts, url) {
597
631
  return;
598
632
  }
599
633
  try {
600
- const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${target}${url.search}`, req.headers.accept ?? "application/json");
634
+ const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${target}${url.search}`, req.headers.accept ?? "application/json", undefined, memtreeToolHeaders(req));
601
635
  res.writeHead(upstream.status, { "content-type": upstream.contentType });
602
636
  res.end(upstream.body);
603
637
  }
@@ -624,15 +658,29 @@ const MEMTREE_FINDER_RELAY = new Map([
624
658
  [`${MEMTREE_PASSTHROUGH_PREFIX}search`, "/v1/memtree/search"],
625
659
  ]);
626
660
  const SESSION_ID_HEADER_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
627
- async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
661
+ /** `x-memtree-tool` as the memtree MCP server sends it: a tool name. */
662
+ const MEMTREE_TOOL_HEADER_VALUE = /^[a-z_]{1,20}$/;
663
+ /**
664
+ * What a MemTree tool's read carries on to the server: the tool (`x-memtree-tool`)
665
+ * and the calling Claude Code session, when well formed. The server logs one
666
+ * content-free line per tool call with them (polychat memtree_tool_log.py).
667
+ */
668
+ export function memtreeToolHeaders(req) {
628
669
  const headers = {};
629
- const meta = memtreeClientMeta({ project: opts.projectMeta });
630
- if (Object.keys(meta).length)
631
- headers["x-client-meta"] = JSON.stringify(meta);
670
+ const tool = firstNonEmptyHeader(req, "x-memtree-tool");
671
+ if (tool && MEMTREE_TOOL_HEADER_VALUE.test(tool))
672
+ headers["x-memtree-tool"] = tool;
632
673
  const sessionId = firstNonEmptyHeader(req, "x-claude-code-session-id");
633
674
  if (sessionId && SESSION_ID_HEADER_VALUE.test(sessionId)) {
634
675
  headers["x-claude-code-session-id"] = sessionId;
635
676
  }
677
+ return headers;
678
+ }
679
+ async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
680
+ const headers = memtreeToolHeaders(req);
681
+ const meta = memtreeClientMeta({ project: opts.projectMeta });
682
+ if (Object.keys(meta).length)
683
+ headers["x-client-meta"] = JSON.stringify(meta);
636
684
  try {
637
685
  const upstream = await opts.memtree.fetchMemTree(`${upstreamPath}${url.search}`, req.headers.accept ?? "application/json", undefined, headers);
638
686
  res.writeHead(upstream.status, { "content-type": upstream.contentType });
@@ -649,18 +697,23 @@ async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
649
697
  * reads (memtree-mcp.ts). Bare `current` answers the pointer
650
698
  * `{id, url, index, session_id, compressed}` without an upstream call, so the
651
699
  * MCP server can keep its cached tree until the page changes; `current.json`
652
- * relays the page JSON itself, like `/memtree/<id>.json`.
700
+ * relays the page JSON itself, like `/memtree/<id>.json`. With no page here,
701
+ * the server's newest tree for the session (currentPageOrServerTree): that
702
+ * waits a bounded time (NewestTreeLookup.current), cached; 503 when the
703
+ * server could not be asked.
653
704
  *
654
705
  * Every pointer is scoped to the caller's exact session id. Missing session
655
706
  * ids never fall back to another conversation served by the same proxy.
656
707
  */
657
708
  async function handleMemTreeCurrent(req, res, opts, state, url) {
658
709
  const sessionId = url.searchParams.get("session") || undefined;
659
- const page = currentMemorySourcePage(state, sessionId);
710
+ const found = await currentPageOrServerTree(state, sessionId);
711
+ const page = "page" in found ? found.page : undefined;
660
712
  const id = page ? memtreePageId(page.url) : undefined;
661
713
  if (!page || !id) {
662
- res.writeHead(404, { "content-type": "application/json" });
663
- res.end(JSON.stringify({ detail: "No MemTree page for this session yet" }));
714
+ const unknown = "unknown" in found;
715
+ res.writeHead(unknown ? 503 : 404, { "content-type": "application/json" });
716
+ res.end(JSON.stringify({ detail: unknown ? CURRENT_LOOKUP_FAILED : "No MemTree page for this session yet" }));
664
717
  return;
665
718
  }
666
719
  if (!url.pathname.endsWith(".json")) {
@@ -675,7 +728,7 @@ async function handleMemTreeCurrent(req, res, opts, state, url) {
675
728
  return;
676
729
  }
677
730
  try {
678
- const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${id}.json`, req.headers.accept ?? "application/json");
731
+ const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${id}.json`, req.headers.accept ?? "application/json", undefined, memtreeToolHeaders(req));
679
732
  res.writeHead(upstream.status, {
680
733
  "content-type": upstream.contentType,
681
734
  "x-memtree-page": page.url,
@@ -688,6 +741,60 @@ async function handleMemTreeCurrent(req, res, opts, state, url) {
688
741
  }
689
742
  }
690
743
  const MEMTREE_CURRENT_PATH = `${MEMTREE_PASSTHROUGH_PREFIX}current`;
744
+ const MEMTREE_HOST_SESSION_PATH = `${MEMTREE_PASSTHROUGH_PREFIX}host-session`;
745
+ /**
746
+ * `GET /memtree/host-session?host_pid=<pid>`: the session ccc's own Claude Code process
747
+ * is on, for that process's memtree MCP server (memtree-mcp.ts: refreshHostSession),
748
+ * which is started with the launch session id and not restarted when the process
749
+ * resumes or clears. Answered only to a caller that names that process's pid (the
750
+ * server's parent) and carries the token from this ccc's MCP config, and only once a
751
+ * SessionStart hook from that process has named the session; 404 otherwise. The pid is
752
+ * not a secret and the token alone is not enough: a teammate Claude Code replays the
753
+ * same MCP config, with another parent. So neither a Claude Code started from inside
754
+ * the session nor a teammate is handed this process's conversation.
755
+ */
756
+ function handleMemTreeHostSession(req, res, state, url) {
757
+ const known = isHostPid(state, url.searchParams.get("host_pid")) &&
758
+ sameSecret(req.headers[HOST_TOKEN_HEADER], state.hostToken);
759
+ res.writeHead(known && state.hostSession ? 200 : 404, { "content-type": "application/json" });
760
+ res.end(JSON.stringify(known && state.hostSession
761
+ ? { session_id: state.hostSession } : { detail: "No session for this process" }));
762
+ }
763
+ /** Whether `pid` (a header or query value) is the Claude Code process ccc launched. */
764
+ function isHostPid(state, pid) {
765
+ return state.hostPid !== undefined && typeof pid === "string" && /^\d+$/.test(pid) &&
766
+ Number(pid) === state.hostPid;
767
+ }
768
+ function sameSecret(given, secret) {
769
+ if (typeof given !== "string")
770
+ return false;
771
+ const a = Buffer.from(given), b = Buffer.from(secret);
772
+ return a.length === b.length && timingSafeEqual(a, b);
773
+ }
774
+ const CURRENT_LOOKUP_FAILED = "This process has no MemTree page for this session and could not ask the MemTree server for one";
775
+ /**
776
+ * The session's page here (currentMemorySourcePage), else its newest completed
777
+ * tree on the server (`/v1/memtree/sessions`, this account's own sessions,
778
+ * exact session id only; cached by NewestTreeLookup and superseded as soon as
779
+ * a served index is noted). After a restart, a session whose history goes as
780
+ * index-only calls or rides a stored prefix gets no served index here, while
781
+ * the server has its whole tree. `unknown`: the server could not be asked.
782
+ */
783
+ async function currentPageOrServerTree(state, sessionId) {
784
+ const page = currentMemorySourcePage(state, sessionId);
785
+ if (page || !sessionId || !state.newestTrees || !SESSION_ID_HEADER_VALUE.test(sessionId)) {
786
+ return { page };
787
+ }
788
+ const answer = await state.newestTrees.current(sessionId);
789
+ // A served index noted during the wait takes precedence.
790
+ const served = currentMemorySourcePage(state, sessionId);
791
+ if (served)
792
+ return { page: served };
793
+ if (answer.tree) {
794
+ return { page: { url: answer.tree.url, index: answer.tree.key, compressed: false, sessionId } };
795
+ }
796
+ return answer.answered ? {} : { unknown: true };
797
+ }
691
798
  /** The page whose memory is in the prompt, else (none adopted yet) the newest page. */
692
799
  function currentMemorySourcePage(state, sessionId) {
693
800
  if (!sessionId)
@@ -737,12 +844,95 @@ function noteMainSessionStart(state, source, sessionId) {
737
844
  state.resumableMainSession = undefined;
738
845
  }
739
846
  }
847
+ /**
848
+ * PostToolUse after an Agent/Task call: point the model at the subagent's own tree
849
+ * (subagent-hint.ts), unless MemTree is known not to work, when the session has the tools.
850
+ */
851
+ async function handleSubagentResultHook(req, res, state) {
852
+ // The body carries the subagent's whole prompt and result: allow a large one.
853
+ const raw = req.method === "POST"
854
+ ? await readBodyWithin(req, LARGE_HOOK_BODY_LIMIT) : undefined;
855
+ if (req.method !== "POST")
856
+ req.resume();
857
+ let input = null;
858
+ try {
859
+ if (raw !== undefined && raw.length > 0)
860
+ input = JSON.parse(raw.toString("utf-8"));
861
+ }
862
+ catch {
863
+ input = null;
864
+ }
865
+ // Not known to be broken is enough: a short print run's first requests can finish
866
+ // before MemTree has answered any (non-blocking first turn), and the subagent is
867
+ // indexed either way.
868
+ const say = state.subagentHint?.enabled === true && resumeMemtreeWorking(state) &&
869
+ isSubagentCall(input);
870
+ if (!say) {
871
+ res.writeHead(204);
872
+ res.end();
873
+ return;
874
+ }
875
+ const body = Buffer.from(JSON.stringify(postToolUseOutput(SUBAGENT_RESULT_HINT)), "utf-8");
876
+ res.writeHead(200, {
877
+ "content-type": "application/json",
878
+ "content-length": String(body.length),
879
+ "cache-control": "no-store",
880
+ });
881
+ res.end(body);
882
+ }
883
+ /**
884
+ * Print runs: the earlier-sessions block (project-trees.ts) with a session's first
885
+ * prompt, as UserPromptSubmit context. Main sessions only, once per session per
886
+ * process; anything unexpected is 204.
887
+ */
888
+ async function handlePrintProjectTreesHook(req, res, state) {
889
+ // The body carries the whole prompt.
890
+ const raw = req.method === "POST" ? await readBodyWithin(req, LARGE_HOOK_BODY_LIMIT) : undefined;
891
+ if (req.method !== "POST")
892
+ req.resume();
893
+ let input = null;
894
+ try {
895
+ if (raw !== undefined && raw.length > 0) {
896
+ input = parseNoticeHookInput(JSON.parse(raw.toString("utf-8")));
897
+ }
898
+ }
899
+ catch {
900
+ input = null;
901
+ }
902
+ const first = input?.hook_event_name === "UserPromptSubmit" && input.agent_id === undefined &&
903
+ !state.projectTreesGiven.has(input.session_id);
904
+ const context = first && state.projectTrees
905
+ ? await safeContext(() => state.projectTrees.contextFor(input.session_id)) : undefined;
906
+ if (!context) {
907
+ res.writeHead(204);
908
+ res.end();
909
+ return;
910
+ }
911
+ state.projectTreesGiven.add(input.session_id);
912
+ const body = Buffer.from(JSON.stringify({ hookSpecificOutput: {
913
+ hookEventName: "UserPromptSubmit", additionalContext: context
914
+ } }), "utf-8");
915
+ res.writeHead(200, {
916
+ "content-type": "application/json",
917
+ "content-length": String(body.length),
918
+ "cache-control": "no-store",
919
+ });
920
+ res.end(body);
921
+ }
922
+ async function safeContext(get) {
923
+ try {
924
+ return await get();
925
+ }
926
+ catch {
927
+ return undefined;
928
+ }
929
+ }
740
930
  /**
741
931
  * SessionStart on its own hook (resume-guidance.ts): only the current MemTree
742
932
  * guidance, so Claude Code sees it as one context of its own and adds an
743
933
  * identical one only once. Never fails the hook: anything unexpected is 204.
744
934
  */
745
- async function handleResumeGuidanceHook(req, res, state) {
935
+ async function handleSessionStartContextHook(req, res, contextFor) {
746
936
  const raw = req.method === "POST" ? await readBodyWithin(req, HOOK_BODY_LIMIT) : undefined;
747
937
  if (req.method !== "POST")
748
938
  req.resume();
@@ -756,7 +946,7 @@ async function handleResumeGuidanceHook(req, res, state) {
756
946
  input = null;
757
947
  }
758
948
  const context = input?.hook_event_name === "SessionStart"
759
- ? await resumeGuidanceContext(state, input) : undefined;
949
+ ? await contextFor(input) : undefined;
760
950
  if (!context) {
761
951
  res.writeHead(204);
762
952
  res.end();
@@ -818,6 +1008,12 @@ onlyEvent) {
818
1008
  if (parsed.agent_id === undefined) {
819
1009
  recordSessionStart(state, parsed.source);
820
1010
  noteMainSessionStart(state, parsed.source, parsed.session_id);
1011
+ // Only ccc's own Claude Code moves the host session: a teammate process loads
1012
+ // the same plugin and posts its own session starts here.
1013
+ if (isHostPid(state, req.headers[CLAUDE_PID_HEADER]) &&
1014
+ typeof parsed.session_id === "string" && parsed.session_id) {
1015
+ state.hostSession = parsed.session_id;
1016
+ }
821
1017
  // Claude Code's compaction dropped any MemTree notice from the transcript.
822
1018
  if (parsed.source === "compact")
823
1019
  state.modelNotices.forget(parsed.session_id);
@@ -942,6 +1138,7 @@ onlyEvent) {
942
1138
  }
943
1139
  if (parsed.hook_event_name === "SubagentStop") {
944
1140
  state.activeSubagents.delete(parsed.agent_id);
1141
+ sendDeferredAgentIndex(state, parsed.agent_id);
945
1142
  res.writeHead(204);
946
1143
  res.end();
947
1144
  return;
@@ -1037,6 +1234,8 @@ async function handleMessages(req, res, opts, upstream, state) {
1037
1234
  };
1038
1235
  // The prompt this request was made for, before any await (Codex r2 P2).
1039
1236
  noticeContexts.set(rec, { promptId: state.mainPromptId, generation: state.mainPromptGeneration });
1237
+ // An index wait may hold this request (runBlockingCompression) until Claude Code leaves.
1238
+ indexHolds.set(rec, { started: received, closed: closedSignal(res) });
1040
1239
  const logged = async (forward) => {
1041
1240
  try {
1042
1241
  await forward;
@@ -1163,6 +1362,14 @@ async function handleMessages(req, res, opts, upstream, state) {
1163
1362
  const isSubagentRequest = requestRouteLane === "agent";
1164
1363
  const isMainRequest = requestRouteLane === "main";
1165
1364
  const requestSizeKey = laneSizeKey(req, requestRouteKey, body.model);
1365
+ const hold = indexHolds.get(rec);
1366
+ if (hold) {
1367
+ hold.lane = requestRouteKey;
1368
+ // Arriving while the lane waits for its index: this request's asks stay within its
1369
+ // own hold even if that wait ends meanwhile (laneHeldCap).
1370
+ if (state.fallbackFailures.indexWaiting(requestRouteKey))
1371
+ hold.underIndexWait = true;
1372
+ }
1166
1373
  // Extended 1M context arrives as the `context-1m` beta header (Claude Code
1167
1374
  // strips the `[1m]` model suffix on the wire). Current native-1M models send
1168
1375
  // neither, so contextLimitForModel also needs the launcher's native setting.
@@ -1409,7 +1616,7 @@ async function handleMessages(req, res, opts, upstream, state) {
1409
1616
  // prefix, else whole) with no compress call; at it, compress once to
1410
1617
  // the target (planToolCompaction). A "replay" is a client retry of the
1411
1618
  // request that installed the route, forwarded verbatim.
1412
- const plan = await planToolCompaction({
1619
+ const planArgs = {
1413
1620
  opts,
1414
1621
  state,
1415
1622
  body,
@@ -1424,7 +1631,24 @@ async function handleMessages(req, res, opts, upstream, state) {
1424
1631
  rec,
1425
1632
  ride: toolRide,
1426
1633
  canCompress,
1427
- });
1634
+ };
1635
+ let plan = await planToolCompaction(planArgs);
1636
+ // Over the window with nothing to ride while the lane compresses: going without it
1637
+ // can only be refused (staging 2026-10-30: a 120 s compress, every tool turn meanwhile
1638
+ // refused). Wait for it, then plan again on what it installed.
1639
+ if (plan.kind === "compress" && plan.overWindow && !plan.fallback &&
1640
+ state.toolRecoveryAttemptedLanes.get(requestRouteKey)?.inFlight) {
1641
+ // Stops early if Claude Code drops this request meanwhile.
1642
+ const clientGone = new AbortController();
1643
+ const onClose = () => clientGone.abort();
1644
+ res.once("close", onClose);
1645
+ rec.laneWaitMs = await waitForLaneIdle(state, requestRouteKey, Math.min(MAX_LONG_COMPRESS_WAIT_MS, opts.memtree.longCompressBudgetMs), clientGone.signal);
1646
+ res.off("close", onClose);
1647
+ // Plan on what the lane holds now: the route its compression installed (or, on the
1648
+ // main lane, the session's new stable prefix), never the ride read before the wait.
1649
+ plan = await planToolCompaction({ ...planArgs,
1650
+ ride: currentLaneRide(state, requestRouteKey, body, messages, toolSessionId, isMainRequest) });
1651
+ }
1428
1652
  if (isMainRequest && toolSessionId !== undefined && canCompress &&
1429
1653
  (plan.kind === "ride" || plan.kind === "pass")) {
1430
1654
  maybePrepareCompression({ opts, state, body, messages, msgsForMemtree, hash,
@@ -1464,12 +1688,16 @@ async function handleMessages(req, res, opts, upstream, state) {
1464
1688
  }
1465
1689
  else if (prior?.retryAtTokens !== undefined &&
1466
1690
  plan.estimateTokens < prior.retryAtTokens &&
1467
- Date.now() < (prior.retryDeadline ?? 0)) {
1691
+ Date.now() < (prior.retryDeadline ?? 0) && !outageOver(state, prior.offline)) {
1468
1692
  // This lane's last attempt this human turn produced nothing; wait
1469
1693
  // for growth instead of paying a blocking call on every tool turn.
1470
1694
  rec.routeRecovery = { outcome: "backoff" };
1695
+ // MemTree answered that attempt: a refusal now is about the fit (Codex r1 P2).
1696
+ if (!prior.offline)
1697
+ answeredBackoffs.add(rec);
1471
1698
  }
1472
1699
  else if (Date.now() < state.toolRecoveryCooldownUntil &&
1700
+ !outageOver(state, state.toolRecoveryCooldownOffline) &&
1473
1701
  !(plan.overWindow && !plan.fallback && (!plan.uncountedOverWindowOnly ||
1474
1702
  await wholeRequestRefused(requestSizing, originalCandidate, sizingLimits())))) {
1475
1703
  // A recent attempt burned the full compress budget and still
@@ -1482,6 +1710,10 @@ async function handleMessages(req, res, opts, upstream, state) {
1482
1710
  // the lane's backoff.
1483
1711
  rec.routeRecovery = { outcome: "cooldown" };
1484
1712
  }
1713
+ else if (state.toolRecoveryAttemptedLanes.get(requestRouteKey)?.inFlight) {
1714
+ // Claimed while the cooldown check above awaited a count (a crossing prompt).
1715
+ rec.routeRecovery = { outcome: "in-flight" };
1716
+ }
1485
1717
  else {
1486
1718
  const recoveryAttempt = { modelContextLimit, inFlight: true };
1487
1719
  if (!prior && state.toolRecoveryAttemptedLanes.size >= 128) {
@@ -1548,7 +1780,8 @@ async function handleMessages(req, res, opts, upstream, state) {
1548
1780
  requestSendPolicies.get(req)?.allowed.add(toolRide.raw);
1549
1781
  const sendBody = toolRide?.raw ?? (toolNeedsOriginal ? rawBody : forwardBody);
1550
1782
  recordTurn(rec, toolRide ? toolRide.turnType : isUserTurn ? "first-user" : "tool", sendBody);
1551
- opts.memtree.indexInBackground(hash, msgsForMemtree, modelContextLimit, toolSessionId, clientMeta, transcriptTimesFor(opts, toolSessionId, agentAttributionId(req)));
1783
+ indexTurn({ opts, state, req, rec, hash, msgsForMemtree, modelContextLimit,
1784
+ sessionId: toolSessionId, clientMeta });
1552
1785
  capture(opts, toolRide ? "anthropic-request-memory-tool" : "anthropic-request", sendBody);
1553
1786
  // The size Anthropic reports is the next budget check's anchor: a ride
1554
1787
  // reports to its prefix (or route), a whole request to the session's (or
@@ -1619,6 +1852,32 @@ async function handleMessages(req, res, opts, upstream, state) {
1619
1852
  rec.compaction.prepared ??= prepared;
1620
1853
  }
1621
1854
  }
1855
+ // Never beside the main lane's compression in flight either (a tool turn's that Claude
1856
+ // Code dropped, or another prompt's, both after the preparation they waited for failed):
1857
+ // wait for it, then plan again on what it left: a prefix to ride, or a preparation.
1858
+ // Bounded; stops early if Claude Code drops this request (release 1.0.33 E2E).
1859
+ const crossing = () => edge?.kind === "compress" && isMainRequest &&
1860
+ isAutoCrossing(rec, body.model, modelContextLimit);
1861
+ for (let i = 0; i < 2 && crossing() &&
1862
+ state.toolRecoveryAttemptedLanes.get(requestRouteKey)?.inFlight; i++) {
1863
+ const clientGone = new AbortController();
1864
+ const onClose = () => clientGone.abort();
1865
+ res.once("close", onClose);
1866
+ if (res.destroyed)
1867
+ clientGone.abort();
1868
+ rec.laneWaitMs = (rec.laneWaitMs ?? 0) + await waitForLaneIdle(state, requestRouteKey, Math.min(MAX_LONG_COMPRESS_WAIT_MS, opts.memtree.longCompressBudgetMs), clientGone.signal);
1869
+ res.off("close", onClose);
1870
+ await awaitPreparation(state, sessionId, opts.memtree.longCompressBudgetMs, rec.compaction);
1871
+ edge = await planEdge();
1872
+ }
1873
+ // Claude Code dropped it while it waited: the lane's compression in flight already carries
1874
+ // this session's crossing (the next turn rides it), so none beside it for nobody (r3 P1).
1875
+ if (crossing() && state.toolRecoveryAttemptedLanes.get(requestRouteKey)?.inFlight &&
1876
+ res.destroyed && !res.writableFinished) {
1877
+ releaseUncommittedRouteDecision();
1878
+ recordTurn(rec, "followup-client-closed", Buffer.alloc(0));
1879
+ return logged(Promise.resolve());
1880
+ }
1622
1881
  // Not beside a blocking compress call: the two would overlap on the server.
1623
1882
  if (sessionId !== undefined && edge && (edge.kind === "ride" || edge.kind === "pass")) {
1624
1883
  maybePrepareCompression({ opts, state, body, messages, msgsForMemtree, hash, sessionId,
@@ -1847,9 +2106,17 @@ async function handleMessages(req, res, opts, upstream, state) {
1847
2106
  // The hidden away-summary request compresses too, but its page is not the
1848
2107
  // conversation the user is looking at.
1849
2108
  const linkSeq = isMainRequest && !isAwaySummary ? nextMemtreeCallSeq(state) : undefined;
2109
+ const edgeCarry = edge?.kind === "compress" && !isAwaySummary
2110
+ ? carryFor({ state, isMainRequest, sessionId, body, messages, msgsForMemtree,
2111
+ modelContextLimit, rec,
2112
+ targetTokens: edge.targetTokens, explicitTarget: edge.explicitTarget, linkSeq })
2113
+ : undefined;
1850
2114
  let compression;
2115
+ // A main-thread compaction holds its lane, claimed with no await since the check above:
2116
+ // a concurrent turn of the lane waits for it or rides its fallback, never compresses beside it.
2117
+ const edgeLane = (run) => crossing() ? holdingLane(state, requestRouteKey, run) : run();
1851
2118
  try {
1852
- compression = await runBlockingCompression({
2119
+ compression = await edgeLane(async () => runBlockingCompression({
1853
2120
  opts,
1854
2121
  state,
1855
2122
  body,
@@ -1871,7 +2138,8 @@ async function handleMessages(req, res, opts, upstream, state) {
1871
2138
  // short budget; with neither, a timeout could only end in a refusal.
1872
2139
  longBudget: !(edge?.kind === "compress" && edge.fallback && !edge.fallback.uncounted) &&
1873
2140
  await wholeRequestRefused(requestSizing, forwardCandidate, sizingLimits()),
1874
- });
2141
+ ...(edgeCarry ? { carry: edgeCarry } : {}),
2142
+ }));
1875
2143
  }
1876
2144
  catch {
1877
2145
  const kept = rideOldPrefix(false);
@@ -1911,6 +2179,9 @@ async function handleMessages(req, res, opts, upstream, state) {
1911
2179
  if (downstreamClosedDuringCompression ||
1912
2180
  (res.destroyed && !res.writableFinished)) {
1913
2181
  releaseUncommittedRouteDecision();
2182
+ // Claude Code gave up on the turn, not on the compaction: the next turn rides it.
2183
+ if (result && edgeCarry)
2184
+ carryOverCompression(state, edgeCarry, Promise.resolve(result));
1914
2185
  recordTurn(rec, "followup-client-closed", Buffer.alloc(0));
1915
2186
  return logged(Promise.resolve());
1916
2187
  }
@@ -2353,15 +2624,14 @@ function sessionCommandReply(state, sessionId, prompt) {
2353
2624
  : pausedCompactionText(viewUrl(subscribeUrlFromDetail(unpaid)));
2354
2625
  if (sessionCommandArgs(prompt, MEMTREE_HELP_COMMAND) !== undefined) {
2355
2626
  return [
2356
- "• /memtree-view · show this session's MemTree page link",
2627
+ "• /memtree-view · open this session's MemTree page and show its link",
2357
2628
  paused !== undefined ? `• ${paused}`
2358
2629
  : `• /memtree-compact [tokens | off] · compact this session on your next message (default half the budget, at least ${MEMTREE_COMPACT_MIN_TOKENS / 1000}k)`,
2359
2630
  usageLines(state),
2360
2631
  ].filter((line) => line !== undefined).join("\n");
2361
2632
  }
2362
- if (isMemtreeViewCommand(prompt)) {
2363
- return joinNoticeLines(memtreeViewLine(state, sessionId), usageLines(state)) ?? "";
2364
- }
2633
+ if (isMemtreeViewCommand(prompt))
2634
+ return memtreeView(state, sessionId);
2365
2635
  const args = sessionCommandArgs(prompt, MEMTREE_COMPACT_COMMAND);
2366
2636
  if (args === undefined)
2367
2637
  return undefined;
@@ -2405,10 +2675,8 @@ export const SUBAGENT_COMPACT_REPLY = "MemTree compact applies to the main sessi
2405
2675
  * is refused rather than applied to the main conversation from here.
2406
2676
  */
2407
2677
  function subagentCommandReply(state, sessionId, prompt) {
2408
- if (isMemtreeViewCommand(prompt)) {
2409
- const view = `${memtreeViewLine(state, sessionId)}\n${SUBAGENT_VIEW_NOTE}`;
2410
- return joinNoticeLines(view, usageLines(state)) ?? view;
2411
- }
2678
+ if (isMemtreeViewCommand(prompt))
2679
+ return `${memtreeView(state, sessionId)}\n${SUBAGENT_VIEW_NOTE}`;
2412
2680
  if (sessionCommandArgs(prompt, MEMTREE_COMPACT_COMMAND) !== undefined) {
2413
2681
  return `${TRAILER_LABEL} ${SUBAGENT_COMPACT_REPLY}`;
2414
2682
  }
@@ -2423,8 +2691,13 @@ export function parseTokenCount(text) {
2423
2691
  const value = Math.round(Number(match[1]) * scale);
2424
2692
  return Number.isFinite(value) && value > 0 ? value : undefined;
2425
2693
  }
2426
- /** The `/memtree-view` answer: this session's newest page, or why there is none. */
2427
- function memtreeViewLine(state, sessionId) {
2694
+ /**
2695
+ * The `/memtree-view` answer: one link, this session's newest page (the
2696
+ * newest completed tree, which covers the whole session, rather than the
2697
+ * possibly stale page its memory was compressed from), opened in the browser
2698
+ * where the CLI allows it; or why there is none.
2699
+ */
2700
+ function memtreeView(state, sessionId) {
2428
2701
  const inMemory = state.memtreePages.get(sessionId);
2429
2702
  const page = inMemory && (inMemory.sessionId === undefined || inMemory.sessionId === sessionId)
2430
2703
  ? inMemory
@@ -2433,15 +2706,17 @@ function memtreeViewLine(state, sessionId) {
2433
2706
  if (!link) {
2434
2707
  return `${TRAILER_LABEL} no page yet: this session has not been indexed. The link appears once it has.`;
2435
2708
  }
2709
+ try {
2710
+ state.openUrl?.(link.link);
2711
+ }
2712
+ catch {
2713
+ // The printed link below is the fallback.
2714
+ }
2436
2715
  // Blocked-prompt reasons need an explicit hyperlink in styled terminals.
2437
- const lines = linkLines(LINK_LABEL, viewUrl(link.link), link.note);
2438
- // The memory the agent works from can be an older tree than the newest one.
2439
- return page?.compressed && page.url !== link.link
2440
- ? `${lines}\n${urlLine(viewUrl(page.url), "memory from ")}`
2441
- : lines;
2716
+ return linkLines(LINK_LABEL, viewUrl(link.link), link.note);
2442
2717
  }
2443
2718
  /**
2444
- * The usage dashboard link that `/memtree` and `/memtree-view` end with,
2719
+ * The usage dashboard link that `/memtree` ends with,
2445
2720
  * formatted like the MemTree link; undefined when the environment has none.
2446
2721
  */
2447
2722
  function usageLines(state) {
@@ -2570,6 +2845,7 @@ function noteMemtreeHealth(state, compression) {
2570
2845
  return;
2571
2846
  state.toolRecoveryCooldownUntil =
2572
2847
  Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
2848
+ state.toolRecoveryCooldownOffline = state.memtreeUnreachable() !== undefined;
2573
2849
  return;
2574
2850
  }
2575
2851
  if (compression.liveHealth === "success") {
@@ -2723,6 +2999,8 @@ function persistStablePrefix(state, sessionId, prefix) {
2723
2999
  originalSystemHash: prefix.originalSystemHash,
2724
3000
  originalPrefixHashes: prefix.originalPrefixHashes,
2725
3001
  originalMediaFreeHashes: prefix.originalMediaFreeHashes,
3002
+ originalPortableHashes: prefix.originalPortableHashes,
3003
+ originalPortableMediaFreeHashes: prefix.originalPortableMediaFreeHashes,
2726
3004
  compressedMessages: prefix.compressedMessages,
2727
3005
  compressedSystem: prefix.compressedSystem,
2728
3006
  hasCompressedSystem: prefix.hasCompressedSystem,
@@ -2763,6 +3041,7 @@ function adoptStoredPrefix(opts, state, rec, sessionId, body, messages, modelCon
2763
3041
  if (verdict === "discard") {
2764
3042
  store.delete(sessionId, owner);
2765
3043
  rec.storedPrefix = "discarded";
3044
+ rec.storedPrefixMissAt = firstPrefixMiss(messages, prefix, "stable");
2766
3045
  return;
2767
3046
  }
2768
3047
  boundedSet(state.stablePrefixes, sessionId, prefix);
@@ -2811,15 +3090,19 @@ function prefixMismatch(body, messages, prefix, sessionId, kind = "route") {
2811
3090
  if (routeValueHash(normalizeRouteSystem(body.system)) !== prefix.originalSystemHash) {
2812
3091
  return "system";
2813
3092
  }
2814
- const prefixLength = prefix.originalPrefixHashes.length;
2815
- if (messages.length < prefixLength)
3093
+ if (messages.length < prefix.originalPrefixHashes.length)
2816
3094
  return "prefix";
2817
- for (let i = 0; i < prefixLength; i++) {
2818
- if (!prefixMessageMatches(messages[i], kind, prefix.originalPrefixHashes[i], prefix.originalMediaFreeHashes?.[i])) {
2819
- return "prefix";
3095
+ return firstPrefixMiss(messages, prefix, kind) === undefined ? null : "prefix";
3096
+ }
3097
+ /** The first message the prefix covers that no longer matches it, if any. */
3098
+ function firstPrefixMiss(messages, prefix, kind) {
3099
+ const length = Math.min(messages.length, prefix.originalPrefixHashes.length);
3100
+ for (let i = 0; i < length; i++) {
3101
+ if (!prefixMessageMatches(messages[i], kind, prefix.originalPrefixHashes[i], prefix.originalMediaFreeHashes?.[i], prefix.originalPortableHashes?.[i], prefix.originalPortableMediaFreeHashes?.[i])) {
3102
+ return i;
2820
3103
  }
2821
3104
  }
2822
- return null;
3105
+ return undefined;
2823
3106
  }
2824
3107
  /**
2825
3108
  * Message identity for a stable prefix: routeMessageHash without assistant
@@ -3292,6 +3575,16 @@ async function planToolCompaction(args) {
3292
3575
  return { kind: "ride",
3293
3576
  ride: { raw: adopted.routed.raw, turnType: "tool-prefix", sizeHolder: adopted.prefix } };
3294
3577
  }
3578
+ // Another turn that waited for the same preparation installed it meanwhile: plan again on
3579
+ // it (once) rather than compress beside it (Codex r1 P1, release 1.0.33).
3580
+ if (stableLane && !args.replanned && state.stablePrefixes.get(sessionId) !== current) {
3581
+ const waited = compaction.preparedWaitMs;
3582
+ const plan = await planToolCompaction({ ...args, replanned: true,
3583
+ ride: currentLaneRide(state, args.routeKey, body, messages, sessionId, true) });
3584
+ if (waited !== undefined && rec.compaction)
3585
+ rec.compaction.preparedWaitMs = waited;
3586
+ return plan;
3587
+ }
3295
3588
  // Settings current after any wait for a preparation (a /memtree-compact N meanwhile).
3296
3589
  const modeNow = compactionMode(opts, state, sessionId);
3297
3590
  if (modeNow.mode === "off")
@@ -3351,10 +3644,49 @@ async function adoptPreparedPrefix(args) {
3351
3644
  const { state, sessionId } = args;
3352
3645
  const base = state.stablePrefixes.get(sessionId);
3353
3646
  await awaitPreparation(state, sessionId, args.waitMs, args.compaction);
3647
+ // Checked and taken with no await between: two turns resuming together see one owner.
3648
+ while (state.adoptingPrepared.has(sessionId))
3649
+ await awaitAdoption(state, sessionId, args.compaction);
3354
3650
  const entry = state.prepared.get(sessionId);
3355
3651
  if (!entry)
3356
3652
  return undefined;
3357
3653
  state.prepared.discard(sessionId);
3654
+ const release = holdAdoption(state, sessionId);
3655
+ try {
3656
+ return await adoptEntry(args, entry, base);
3657
+ }
3658
+ finally {
3659
+ release();
3660
+ }
3661
+ }
3662
+ /**
3663
+ * Another turn of the session is adopting its prepared prefix (two turns waited for the same
3664
+ * preparation): wait for it to finish, so this turn plans on the prefix it installs instead of
3665
+ * compressing again (Codex r1 P1, release 1.0.33). Counted as preparation wait, so a human
3666
+ * turn plans again after it.
3667
+ */
3668
+ async function awaitAdoption(state, sessionId, compaction) {
3669
+ const adopting = state.adoptingPrepared.get(sessionId);
3670
+ if (!adopting)
3671
+ return;
3672
+ const started = Date.now();
3673
+ await adopting;
3674
+ compaction.preparedWaitMs = (compaction.preparedWaitMs ?? 0) + (Date.now() - started);
3675
+ }
3676
+ /** Mark the session's prepared prefix as being adopted; the returned function ends it. */
3677
+ function holdAdoption(state, sessionId) {
3678
+ let done;
3679
+ const adopting = new Promise((resolve) => { done = resolve; });
3680
+ state.adoptingPrepared.set(sessionId, adopting);
3681
+ return () => {
3682
+ if (state.adoptingPrepared.get(sessionId) === adopting)
3683
+ state.adoptingPrepared.delete(sessionId);
3684
+ done();
3685
+ };
3686
+ }
3687
+ /** adoptPreparedPrefix's check and install of the entry it took. */
3688
+ async function adoptEntry(args, entry, base) {
3689
+ const { state, sessionId } = args;
3358
3690
  const fit = await preparedFit(args, entry);
3359
3691
  // Another request installed a prefix, or `/memtree-compact` asked for its
3360
3692
  // own target, while this one waited or counted: that wins.
@@ -3483,17 +3815,98 @@ function maybePrepareCompression(args) {
3483
3815
  });
3484
3816
  boundedSet(state.preparing, sessionId, pending);
3485
3817
  }
3818
+ /**
3819
+ * A subagent's share of its budget from which it is indexed on every tool turn, as the
3820
+ * main thread is; below it, only once, when it ends. 2026-10-30: 29 subagents ran ~32
3821
+ * index jobs each ($45.63 a week for one session), and none came near its budget, so no
3822
+ * subagent tree was ever used to compress. Its tree is still built (at SubagentStop, or
3823
+ * at exit), so MemTree search and the page still find it.
3824
+ */
3825
+ const AGENT_PER_TURN_INDEX_SHARE = 0.5;
3826
+ /** Subagents whose unsent index is kept; the oldest past this is sent at once. */
3827
+ const DEFERRED_AGENT_INDEX_MAX = 64;
3828
+ /**
3829
+ * A tool turn's background index call: now on the main thread and for a subagent past
3830
+ * AGENT_PER_TURN_INDEX_SHARE of its budget; otherwise kept, replacing the subagent's
3831
+ * previous one, until it ends (sendDeferredAgentIndex).
3832
+ */
3833
+ function indexTurn(args) {
3834
+ const { opts, state, req, sessionId } = args;
3835
+ const send = (mode) => opts.memtree.indexInBackground(args.hash, args.msgsForMemtree, args.modelContextLimit, sessionId, args.clientMeta, transcriptTimesFor(opts, sessionId, agentAttributionId(req)), mode === "final" ? { finalIndex: true } : mode === "flush" ? { force: true } : {});
3836
+ // Only a request with its own agent id (the one SubagentStop names) waits for its end; one
3837
+ // attributed by its parent's id only is indexed per turn as before.
3838
+ const agentId = firstNonEmptyHeader(req, "x-claude-code-agent-id");
3839
+ if (agentId === undefined || sessionId === undefined)
3840
+ return send();
3841
+ const compaction = args.rec.compaction;
3842
+ if (compaction?.estimatedTokens !== undefined &&
3843
+ compaction.estimatedTokens >= compaction.budgetTokens * AGENT_PER_TURN_INDEX_SHARE) {
3844
+ send();
3845
+ // Kept too: its last turns under the text minimum still go at its end (forced).
3846
+ keepDeferredIndex(state, deferredIndexKey(agentId, args.msgsForMemtree), { messages: args.msgsForMemtree, send });
3847
+ return;
3848
+ }
3849
+ // Per agent and conversation: a side request made under the subagent's (or its parent's)
3850
+ // id is another conversation and must not replace the subagent's own.
3851
+ keepDeferredIndex(state, deferredIndexKey(agentId, args.msgsForMemtree), { messages: args.msgsForMemtree, send });
3852
+ }
3853
+ /** Keep `entry` as its key's newest unsent index call (bounded). */
3854
+ function keepDeferredIndex(state, key, entry) {
3855
+ const kept = state.deferredAgentIndexes.get(key);
3856
+ // A rewind or another branch: the kept history is not part of this one, so it goes now.
3857
+ // So does one holding media Claude Code has evicted since (never uploaded otherwise).
3858
+ if (kept && (!extendsHistory(entry.messages, kept.messages) ||
3859
+ losesMedia(kept.messages, entry.messages)))
3860
+ sendDeferredIndex(state, key, "flush");
3861
+ state.deferredAgentIndexes.delete(key);
3862
+ state.deferredAgentIndexes.set(key, entry);
3863
+ while (state.deferredAgentIndexes.size > DEFERRED_AGENT_INDEX_MAX) {
3864
+ const oldest = state.deferredAgentIndexes.keys().next().value;
3865
+ if (oldest === undefined)
3866
+ break;
3867
+ sendDeferredIndex(state, oldest, "flush");
3868
+ }
3869
+ }
3870
+ /** A deferred index's key: the agent id and its conversation's first message. */
3871
+ function deferredIndexKey(agentId, messages) {
3872
+ const first = messages.find((message) => message.role !== "system");
3873
+ return JSON.stringify([agentId, first ? mediaFreeMessageHash(first) : ""]);
3874
+ }
3875
+ /** A subagent ended: send each of its conversations' kept index call, whatever its size. */
3876
+ function sendDeferredAgentIndex(state, agentId) {
3877
+ for (const key of [...state.deferredAgentIndexes.keys()]) {
3878
+ if (JSON.parse(key)[0] === agentId)
3879
+ sendDeferredIndex(state, key, "final");
3880
+ }
3881
+ }
3882
+ function sendDeferredIndex(state, key, mode) {
3883
+ const kept = state.deferredAgentIndexes.get(key);
3884
+ state.deferredAgentIndexes.delete(key);
3885
+ kept?.send(mode);
3886
+ }
3887
+ /** At exit: every subagent's kept index calls. */
3888
+ function sendDeferredAgentIndexes(state) {
3889
+ for (const key of [...state.deferredAgentIndexes.keys()])
3890
+ sendDeferredIndex(state, key, "final");
3891
+ }
3486
3892
  /** The background prepare call and the prefix it yields (maybePrepareCompression). */
3487
3893
  async function prepareInBackground(args) {
3488
- const { opts, state, body, msgsForMemtree, modelContextLimit, sessionId } = args;
3894
+ const { opts, state, body, msgsForMemtree, modelContextLimit } = args;
3489
3895
  const result = await opts.memtree.compress(args.hash, msgsForMemtree, modelContextLimit, state.backgroundSignal, preparedCompressMeta(args));
3490
3896
  noteServerBudget(state, body.model, modelContextLimit, result);
3491
3897
  if (!result)
3492
3898
  return undefined;
3493
3899
  state.prepared.noteServerAnswer(result.prepared === true);
3494
- if (result.prepared !== true || !didMemtreeCompress(result) ||
3495
- !checkCompressedHistory(result, msgsForMemtree).usable)
3900
+ if (result.prepared !== true)
3901
+ return undefined;
3902
+ return preparedFromResult(args, result);
3903
+ }
3904
+ /** A compressed answer for a session's history, as its prepared prefix (or undefined). */
3905
+ function preparedFromResult(args, result) {
3906
+ const { body, sessionId, modelContextLimit } = args;
3907
+ if (!didMemtreeCompress(result) || !checkCompressedHistory(result, args.msgsForMemtree).usable) {
3496
3908
  return undefined;
3909
+ }
3497
3910
  const built = buildCompressedBody(body, result);
3498
3911
  if (!built)
3499
3912
  return undefined;
@@ -3508,11 +3921,83 @@ async function prepareInBackground(args) {
3508
3921
  explicitTarget: args.explicitTarget,
3509
3922
  modelContextLimit,
3510
3923
  };
3511
- const source = result.memtreeUrl && result.memtreeIndex
3924
+ const source = result.memtreeUrl && result.memtreeIndex && args.linkSeq !== undefined
3512
3925
  ? { url: result.memtreeUrl, index: result.memtreeIndex, seq: args.linkSeq } : undefined;
3513
3926
  return { prefix, atTokens: args.tokens, triggerTokens: args.triggerTokens,
3514
3927
  ...(source ? { source } : {}) };
3515
3928
  }
3929
+ /**
3930
+ * Whether a main-thread compaction is a budget crossing at the automatic target where
3931
+ * preparations exist: the turns that may race one (a dropped crossing turn and the next
3932
+ * prompt) are serialized on its lane, as carryFor carries it.
3933
+ */
3934
+ function isAutoCrossing(rec, model, modelContextLimit) {
3935
+ const compaction = rec.compaction;
3936
+ return compaction?.mode === "auto" &&
3937
+ (compaction.reason === "budget" || compaction.reason === "prefix-mismatch") &&
3938
+ preparesCompression(model, modelContextLimit);
3939
+ }
3940
+ /** A main-thread compaction's CarryOver, when its plan has the automatic target. */
3941
+ function carryFor(args) {
3942
+ const compaction = args.rec.compaction;
3943
+ // Where prepared prefixes exist (preparesCompression): the turn after rides it. Not a
3944
+ // /memtree-compact (adoption refuses it) nor beside a preparation (the carry would be
3945
+ // refused, its call left running for nothing).
3946
+ if (!args.isMainRequest || args.sessionId === undefined || args.explicitTarget !== false ||
3947
+ args.targetTokens === undefined || compaction?.mode !== "auto" ||
3948
+ // A crossing only: a call the server decides (maybe a passthrough) is not waited for.
3949
+ (compaction.reason !== "budget" && compaction.reason !== "prefix-mismatch") || args.state.compactNow.has(args.sessionId) ||
3950
+ args.state.preparing.has(args.sessionId) || args.state.prepared.isInFlight(args.sessionId) ||
3951
+ args.state.backgroundSignal.aborted ||
3952
+ !preparesCompression(args.body.model, args.modelContextLimit))
3953
+ return undefined;
3954
+ return { body: args.body, messages: args.messages, msgsForMemtree: args.msgsForMemtree,
3955
+ sessionId: args.sessionId, modelContextLimit: args.modelContextLimit,
3956
+ tokens: compaction.estimatedTokens ?? 0, triggerTokens: compaction.budgetTokens,
3957
+ targetTokens: args.targetTokens, explicitTarget: false,
3958
+ ...(args.linkSeq !== undefined ? { linkSeq: args.linkSeq } : {}),
3959
+ base: args.state.stablePrefixes.get(args.sessionId) };
3960
+ }
3961
+ /**
3962
+ * A main-thread compaction its turn stopped waiting for (its budget ran out) becomes the
3963
+ * session's preparation: it runs on to the long budget, the session's next turn at the
3964
+ * budget waits for it (awaitPreparation) instead of starting another, and rides its
3965
+ * answer as a prepared prefix. Before, each turn gave up on its call and started a new
3966
+ * one beside it: 2026-10-30, eleven 26 MB compressions in ten minutes, each billed, none
3967
+ * used. False when the session already has a preparation in flight (it serves instead).
3968
+ */
3969
+ function carryOverCompression(state, carry, pending) {
3970
+ const { sessionId, base } = carry;
3971
+ // A prefix installed since the call started (another turn's) supersedes it already.
3972
+ if (state.backgroundSignal.aborted || state.preparing.has(sessionId) ||
3973
+ state.stablePrefixes.get(sessionId) !== base ||
3974
+ !state.prepared.begin(sessionId, carry.tokens, carry.triggerTokens))
3975
+ return false;
3976
+ const done = pending
3977
+ .then((result) => {
3978
+ // A late answer clears the cooldown; a late failure is no new outage evidence (a
3979
+ // refusal or a slow server's timeout must not arm it, as runBlockingCompression).
3980
+ if (result)
3981
+ noteMemtreeHealth(state, { result, liveHealth: "success" });
3982
+ return result ? preparedFromResult(carry, result) : undefined;
3983
+ })
3984
+ .catch(() => undefined)
3985
+ .then((prepared) => {
3986
+ // A prefix installed meanwhile supersedes it.
3987
+ const current = state.stablePrefixes.get(sessionId) === base;
3988
+ state.prepared.finish(sessionId, current ? prepared : undefined);
3989
+ if (state.preparing.get(sessionId) === done)
3990
+ state.preparing.delete(sessionId);
3991
+ });
3992
+ boundedSet(state.preparing, sessionId, done);
3993
+ return true;
3994
+ }
3995
+ /** How long a carried compaction may run: the long budget, or its upload's if longer. */
3996
+ /** How much longer than its turn's wait a carried call must be allowed to run. */
3997
+ const CARRY_MIN_EXTRA_MS = 5_000;
3998
+ function carriedBudgetMs(opts, scaledMs) {
3999
+ return Math.min(MAX_LONG_COMPRESS_WAIT_MS, Math.max(opts.memtree.longCompressBudgetMs, scaledMs));
4000
+ }
3516
4001
  /** What a prepare call sends: a forced compression to the target, no threshold. */
3517
4002
  function preparedCompressMeta(args) {
3518
4003
  const { opts, body, sessionId, msgsForMemtree, modelContextLimit } = args;
@@ -3548,7 +4033,7 @@ function memoryRoutedToolBody(body, messages, route, routeEpoch, sessionId) {
3548
4033
  if (messages.length < prefixLength)
3549
4034
  return null;
3550
4035
  for (let i = 0; i < prefixLength; i++) {
3551
- if (!prefixMessageMatches(messages[i], "route", route.originalPrefixHashes[i], route.originalMediaFreeHashes?.[i])) {
4036
+ if (!prefixMessageMatches(messages[i], "route", route.originalPrefixHashes[i], route.originalMediaFreeHashes?.[i], route.originalPortableHashes?.[i], route.originalPortableMediaFreeHashes?.[i])) {
3552
4037
  return null;
3553
4038
  }
3554
4039
  }
@@ -3912,6 +4397,154 @@ function cloneJson(value) {
3912
4397
  return value;
3913
4398
  return JSON.parse(JSON.stringify(value));
3914
4399
  }
4400
+ /**
4401
+ * A compress call that, while MemTree answers that its index is still catching up on
4402
+ * this conversation (an indexing 413, index-wait-413.ts), holds the request and asks
4403
+ * again at the server's pace instead of refusing at once: up to INDEX_HOLD_MS per
4404
+ * request, below Claude Code's wait for a first byte. A wait it cannot finish here is
4405
+ * left to the refusal (rec.indexBehind), whose 503 says how long is left and has Claude
4406
+ * Code retry, until the lane's deadline (route-fallback.ts). Only when nothing else could
4407
+ * be sent (longBudget): a ride on an earlier prefix, or a request that fits, goes at once.
4408
+ * Never for a background call (its record is a copy) or a 413 that waiting cannot fix.
4409
+ */
4410
+ async function runBlockingCompression(args) {
4411
+ let capMs = laneHeldCap(args);
4412
+ for (;;) {
4413
+ const outcome = await runBlockingCompressionOnce(capMs === undefined ? args
4414
+ : { ...args, capMs });
4415
+ // An ask cut short by the hold's end is no evidence of an outage; and while the
4416
+ // lane waits for its index it is that wait, not a strike (a later answer ends it).
4417
+ if (capMs !== undefined && args.rec.compress?.timedOut) {
4418
+ outcome.liveHealth = "none";
4419
+ if (outcome.indexWaitSeconds === undefined && !activeIndexWaits.has(args.rec)) {
4420
+ const lane = indexHolds.get(args.rec)?.lane;
4421
+ noteIndexWait413(args.rec, (lane !== undefined
4422
+ ? args.state.fallbackFailures.indexSecondsLeft(lane) : undefined) ??
4423
+ UNKNOWN_INDEX_WAIT_SECONDS);
4424
+ return outcome;
4425
+ }
4426
+ }
4427
+ if (outcome.indexWaitSeconds === undefined) {
4428
+ // Settled otherwise (an answer, or a failure of another kind): the wait is over.
4429
+ // A timed-out ask said nothing new, so a refusal still waits.
4430
+ if (outcome.result !== null || !args.rec.compress?.timedOut)
4431
+ endIndexWait(args.rec);
4432
+ return outcome;
4433
+ }
4434
+ noteIndexWait413(args.rec, outcome.indexWaitSeconds);
4435
+ capMs = await holdForIndex(args, outcome.indexWaitSeconds);
4436
+ if (capMs === undefined)
4437
+ return outcome;
4438
+ }
4439
+ }
4440
+ /**
4441
+ * While the lane waits for its index (another request held it, likely this one's wait
4442
+ * too), a request's first ask gets only what is left of its own hold: its time waiting
4443
+ * for the lane already counts toward Claude Code's first-byte wait.
4444
+ */
4445
+ function laneHeldCap(args) {
4446
+ const hold = indexHolds.get(args.rec);
4447
+ if (!hold || hold.lane === undefined)
4448
+ return undefined;
4449
+ // Waiting now, or at any time since this request arrived (it may have queued behind a
4450
+ // hold that has since delivered).
4451
+ if (!hold.underIndexWait &&
4452
+ !args.state.fallbackFailures.indexWaitSince(hold.lane, hold.started)) {
4453
+ return undefined;
4454
+ }
4455
+ hold.underIndexWait = true;
4456
+ return Math.max(1, (args.opts.indexHoldMs ?? INDEX_HOLD_MS) - (Date.now() - hold.started));
4457
+ }
4458
+ /** One request's index hold: when it arrived, its lane, and a signal for Claude Code leaving. */
4459
+ const indexHolds = new WeakMap();
4460
+ /**
4461
+ * Requests whose latest compress answer was an indexing 413: the seconds it stated. Set
4462
+ * only by runBlockingCompression, and cleared when a later ask settles otherwise.
4463
+ */
4464
+ const activeIndexWaits = new WeakMap();
4465
+ /** The most one request is held for an index (Claude Code 2.1.319 waits 180 s for headers). */
4466
+ const INDEX_HOLD_MS = 120_000;
4467
+ /** The longest pause between asks while held (the server's Retry-After, when shorter). */
4468
+ const INDEX_POLL_MAX_MS = 30_000;
4469
+ /**
4470
+ * A held ask gets at least this long (MemTree itself holds an indexing 413 for up to
4471
+ * 90 s); with less left, the request is not held.
4472
+ */
4473
+ const INDEX_MIN_ASK_MS = 30_000;
4474
+ /** An indexing 413: refusing this request waits for the index, saying how long is left. */
4475
+ function noteIndexWait413(rec, seconds) {
4476
+ if (!activeIndexWaits.has(rec) && rec.indexBehind !== true)
4477
+ indexBehindSetByWait.add(rec);
4478
+ activeIndexWaits.set(rec, seconds);
4479
+ rec.indexBehind = true;
4480
+ rec.indexWait = { secondsLeft: seconds, asks: (rec.indexWait?.asks ?? 0) + 1,
4481
+ heldMs: rec.indexWait?.heldMs ?? 0 };
4482
+ }
4483
+ /** Requests whose indexBehind only an indexing 413 set (endIndexWait clears it). */
4484
+ const indexBehindSetByWait = new WeakSet();
4485
+ /** A later ask settled otherwise: the request no longer waits (rec.indexWait stays as history). */
4486
+ function endIndexWait(rec) {
4487
+ if (!activeIndexWaits.delete(rec))
4488
+ return;
4489
+ if (indexBehindSetByWait.delete(rec))
4490
+ delete rec.indexBehind;
4491
+ if (rec.indexWait)
4492
+ rec.indexWait.ended = true;
4493
+ }
4494
+ /**
4495
+ * Pause before asking MemTree again, when another ask fits this request's hold and its
4496
+ * lane's wait is still open: the time left of the hold for that ask (its cap). Undefined
4497
+ * when not (nothing else could be sent is false, the estimate is past the cap, the lane's
4498
+ * deadline passed, or Claude Code or the proxy left).
4499
+ */
4500
+ async function holdForIndex(args, seconds) {
4501
+ const hold = indexHolds.get(args.rec);
4502
+ if (!hold || args.longBudget !== true)
4503
+ return undefined;
4504
+ const laneEnds = hold.lane === undefined ? Infinity
4505
+ : args.state.fallbackFailures.indexWaitDeadline(hold.lane, seconds);
4506
+ if (laneEnds === undefined || args.state.fallbackFailures.hopeless(seconds))
4507
+ return undefined;
4508
+ const pause = Math.min(seconds * 1000, args.opts.indexPollMaxMs ?? INDEX_POLL_MAX_MS);
4509
+ const holdMs = args.opts.indexHoldMs ?? INDEX_HOLD_MS;
4510
+ // The earlier of the request's hold and the lane's wait bounds the pause and the ask.
4511
+ const left = () => Math.min(holdMs - (Date.now() - hold.started), laneEnds - Date.now());
4512
+ if (left() - pause < Math.min(INDEX_MIN_ASK_MS, holdMs / 4))
4513
+ return undefined;
4514
+ const signals = [hold.closed, args.state.shutdownSignal, args.signal]
4515
+ .filter((signal) => signal !== undefined);
4516
+ const paused = Date.now();
4517
+ const waited = await abortableDelay(pause, signals);
4518
+ if (args.rec.indexWait)
4519
+ args.rec.indexWait.heldMs += Date.now() - paused;
4520
+ return waited ? Math.max(1, left()) : undefined;
4521
+ }
4522
+ /** Wait `ms`; false at once if any signal aborts first (or already has). */
4523
+ function abortableDelay(ms, signals) {
4524
+ if (signals.some((signal) => signal.aborted))
4525
+ return Promise.resolve(false);
4526
+ return new Promise((resolve) => {
4527
+ const done = (waited) => {
4528
+ clearTimeout(timer);
4529
+ for (const signal of signals)
4530
+ signal.removeEventListener("abort", aborted);
4531
+ resolve(waited);
4532
+ };
4533
+ const aborted = () => done(false);
4534
+ // Unref'd: a hold never keeps the process open.
4535
+ const timer = setTimeout(() => done(true), ms);
4536
+ timer.unref();
4537
+ for (const signal of signals)
4538
+ signal.addEventListener("abort", aborted, { once: true });
4539
+ });
4540
+ }
4541
+ /** Aborted when Claude Code closes this response before it finished. */
4542
+ function closedSignal(res) {
4543
+ const controller = new AbortController();
4544
+ res.once("close", () => { if (!res.writableFinished)
4545
+ controller.abort(); });
4546
+ return controller.signal;
4547
+ }
3915
4548
  /**
3916
4549
  * The blocking-compression step, shared by the main followup path and
3917
4550
  * tool-route miss recovery: the compress call plus the `compress` telemetry
@@ -3922,7 +4555,7 @@ function cloneJson(value) {
3922
4555
  * `checkCompressedHistory(result, msgsForMemtree).usable` must hold before
3923
4556
  * the result may be forwarded or become a route.
3924
4557
  */
3925
- async function runBlockingCompression(args) {
4558
+ async function runBlockingCompressionOnce(args) {
3926
4559
  const { opts, state, body, msgsForMemtree, hash, modelContextLimit, rec } = args;
3927
4560
  const compaction = args.compaction ??
3928
4561
  laneCompaction(opts, state, args.sessionId, body.model, modelContextLimit);
@@ -3931,9 +4564,13 @@ async function runBlockingCompression(args) {
3931
4564
  : undefined;
3932
4565
  const messageTimes = transcriptTimesFor(opts, args.sessionId, args.agentId)?.(msgsForMemtree);
3933
4566
  const compressStarted = Date.now();
3934
- // The upload is about the request's size: the budget grows with it.
3935
- const budgetMs = Math.min(args.longBudget ? MAX_LONG_COMPRESS_WAIT_MS : MAX_COMPRESS_WAIT_MS, args.longBudget ? opts.memtree.longCompressBudgetMs
3936
- : opts.memtree.compressBudgetForBytes(rec.requestBytes));
4567
+ // The upload is about the request's size: the budget grows with it (the long one
4568
+ // too, never below the short one's).
4569
+ const scaledMs = opts.memtree.compressBudgetForBytes(rec.requestBytes);
4570
+ const budgetMs = Math.min(args.longBudget ? MAX_LONG_COMPRESS_WAIT_MS : MAX_COMPRESS_WAIT_MS, args.longBudget ? Math.max(opts.memtree.longCompressBudgetMs, scaledMs) : scaledMs, args.capMs ?? Infinity);
4571
+ // Carried only when running on gives it meaningfully more time than this wait.
4572
+ const carry = args.carry && args.capMs === undefined &&
4573
+ carriedBudgetMs(opts, scaledMs) >= budgetMs + CARRY_MIN_EXTRA_MS ? args.carry : undefined;
3937
4574
  const compressMeta = {
3938
4575
  // Model + tools drive the server's model-based memory budget
3939
4576
  // (e.g. 800k whole-request target for Fable / Opus 5). Omitting
@@ -3962,8 +4599,8 @@ async function runBlockingCompression(args) {
3962
4599
  ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
3963
4600
  ...(args.clientMeta ? { clientMeta: args.clientMeta } : {}),
3964
4601
  // The deadline this wait uses (the upload-scaled budget included), so the
3965
- // call and its waiters end together.
3966
- timeoutMs: budgetMs,
4602
+ // call and its waiters end together; a carried call runs on to the long one.
4603
+ timeoutMs: carry ? Math.max(budgetMs, carriedBudgetMs(opts, scaledMs)) : budgetMs,
3967
4604
  };
3968
4605
  // Sampled BEFORE the call, while it still describes this call: after the
3969
4606
  // await the hash is in the cache regardless of who put it there.
@@ -3972,12 +4609,21 @@ async function runBlockingCompression(args) {
3972
4609
  // Keep foreground work finite even if a transport ignores its abort signal.
3973
4610
  let deadline;
3974
4611
  const maximumWait = Math.min(args.longBudget ? MAX_LONG_COMPRESS_WAIT_MS : MAX_COMPRESS_WAIT_MS, Math.max(1, Number.isFinite(budgetMs) ? budgetMs : 15_000));
4612
+ let settled = false;
4613
+ // A refused carry is not aborted: the call may be shared (in-flight join) with a
4614
+ // request that carried it.
4615
+ const call = opts.memtree.compress(hash, msgsForMemtree, modelContextLimit, args.signal ?? state.shutdownSignal, compressMeta);
4616
+ const settle = () => { settled = true; };
4617
+ call.then(settle, settle);
3975
4618
  const result = await Promise.race([
3976
- opts.memtree.compress(hash, msgsForMemtree, modelContextLimit, args.signal ?? state.shutdownSignal, compressMeta),
4619
+ call,
3977
4620
  new Promise((resolve) => { deadline = setTimeout(() => resolve(null), maximumWait); }),
3978
4621
  ]).finally(() => { if (deadline)
3979
4622
  clearTimeout(deadline); });
3980
4623
  const compressMs = Date.now() - compressStarted;
4624
+ // Given up on but still running: the session's next turn waits for it, not a new call.
4625
+ const carried = result === null && !settled && carry !== undefined &&
4626
+ carryOverCompression(state, carry, call);
3981
4627
  noteServerBudget(state, body.model, modelContextLimit, result);
3982
4628
  // Sampled at this call's settle, before a concurrent same-request failure of
3983
4629
  // another class can overwrite the complete-key entry.
@@ -3985,6 +4631,8 @@ async function runBlockingCompression(args) {
3985
4631
  opts.memtree.lastCompressFailureArming(hash, modelContextLimit, compressMeta);
3986
4632
  const failureStatus = result === null
3987
4633
  ? opts.memtree.lastCompressFailureStatus?.(hash, modelContextLimit, compressMeta) : undefined;
4634
+ // A joined call's answer counts too: it is this request's answer.
4635
+ const indexWaitSeconds = result === null ? opts.memtree.compressIndexWait?.(call) : undefined;
3988
4636
  const hadLiveSuccess = !cached && result !== null;
3989
4637
  // Only arming-class live failures (network error, timeout, 5xx, 402) are
3990
4638
  // fuse evidence. A responsive server's other 4xx failed THIS call but says
@@ -3993,17 +4641,23 @@ async function runBlockingCompression(args) {
3993
4641
  // Budget-consumed heuristic: the client maps every failure to null, so a
3994
4642
  // null that took (roughly) the whole abort budget was almost certainly
3995
4643
  // the AbortSignal timeout, not a fast server error.
3996
- const timedOut = result === null && compressMs >= Math.min(budgetMs, maximumWait);
4644
+ // A few ms of slack: a timer can fire a millisecond early by the wall clock (CI flake).
4645
+ const timedOut = result === null &&
4646
+ compressMs + TIMER_SLACK_MS >= Math.min(budgetMs, maximumWait);
3997
4647
  // A timeout while other compress calls answered (one succeeded after this
3998
4648
  // one started) is this call's size or the server's slow reply, not an outage: it arms no cooldown (2026-10-23:
3999
4649
  // healthy-server timeouts kept the fuse lit and a screenshot turn was refused).
4000
4650
  const othersAnswered = timedOut && (opts.memtree.lastCompressSuccessAt ?? 0) >= compressStarted;
4001
- const hadLiveFailure = !cached && result === null && failureArming && !othersAnswered;
4651
+ // A carried call has not failed: it is still running (its answer reports its health).
4652
+ const hadLiveFailure = !cached && result === null && failureArming && !othersAnswered && !carried;
4002
4653
  rec.compress = {
4003
4654
  ms: compressMs,
4004
4655
  ok: result !== null,
4005
4656
  timedOut,
4006
- ...(!cached && failureStatus === OVER_CONTEXT_STATUS ? { refused: true } : {}),
4657
+ ...(carried ? { carriedOver: true } : {}),
4658
+ // A joined call's indexing 413 is MemTree's refusal of this request too.
4659
+ ...((!cached && failureStatus === OVER_CONTEXT_STATUS) || indexWaitSeconds !== undefined
4660
+ ? { refused: true } : {}),
4007
4661
  };
4008
4662
  return {
4009
4663
  result,
@@ -4013,6 +4667,7 @@ async function runBlockingCompression(args) {
4013
4667
  : hadLiveFailure
4014
4668
  ? "failure"
4015
4669
  : "none",
4670
+ ...(indexWaitSeconds !== undefined ? { indexWaitSeconds } : {}),
4016
4671
  };
4017
4672
  }
4018
4673
  /**
@@ -4127,6 +4782,10 @@ async function recoverToolRouteMiss(args) {
4127
4782
  if (args.retryAtTokens !== undefined) {
4128
4783
  recoveryAttempt.retryAtTokens = args.retryAtTokens;
4129
4784
  recoveryAttempt.retryDeadline = Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
4785
+ if (state.memtreeUnreachable())
4786
+ recoveryAttempt.offline = true;
4787
+ else
4788
+ delete recoveryAttempt.offline;
4130
4789
  }
4131
4790
  };
4132
4791
  const sessionId = requestSessionId(req);
@@ -4189,8 +4848,15 @@ async function recoverToolRouteMiss(args) {
4189
4848
  };
4190
4849
  res.once("close", markDownstreamClosed);
4191
4850
  const linkSeq = isMainRequest ? nextMemtreeCallSeq(state) : undefined;
4851
+ const toolCarry = stable ? carryFor({ state, isMainRequest, sessionId, body, messages,
4852
+ msgsForMemtree,
4853
+ modelContextLimit, rec, targetTokens: stable.targetTokens, explicitTarget: stable.explicitTarget,
4854
+ linkSeq }) : undefined;
4192
4855
  let compression;
4856
+ // Nothing else could be sent if this compress fails: the long budget, and an index hold.
4857
+ let longBudget = false;
4193
4858
  try {
4859
+ longBudget = args.wholeRefused ? await args.wholeRefused() : false;
4194
4860
  compression = await runBlockingCompression({
4195
4861
  opts,
4196
4862
  state,
@@ -4205,7 +4871,8 @@ async function recoverToolRouteMiss(args) {
4205
4871
  sizing: args.sizing,
4206
4872
  inputCandidate: args.inputCandidate,
4207
4873
  ...(args.compaction ? { compaction: args.compaction } : {}),
4208
- longBudget: args.wholeRefused ? await args.wholeRefused() : false,
4874
+ longBudget,
4875
+ ...(toolCarry ? { carry: toolCarry } : {}),
4209
4876
  });
4210
4877
  }
4211
4878
  catch {
@@ -4227,9 +4894,11 @@ async function recoverToolRouteMiss(args) {
4227
4894
  if (downstreamClosedDuringCompression ||
4228
4895
  (res.destroyed && !res.writableFinished)) {
4229
4896
  // The MemTree work keeps its cache/index value, but a dead client gets
4230
- // no Anthropic request and no route.
4897
+ // no Anthropic request and no route. The session's next turn rides it.
4231
4898
  rec.routeRecovery = { conversationBytes, outcome: "client-closed" };
4232
4899
  releaseOwnReservation();
4900
+ if (result && toolCarry)
4901
+ carryOverCompression(state, toolCarry, Promise.resolve(result));
4233
4902
  // Same hazard class as the post-forward fates "upstream-failed" /
4234
4903
  // "client-aborted": no route was installed and the client's identical
4235
4904
  // retry is imminent, so no backoff is set and the attempt mark is
@@ -4245,11 +4914,18 @@ async function recoverToolRouteMiss(args) {
4245
4914
  if (!result) {
4246
4915
  rec.routeRecovery = { conversationBytes, outcome: "failed" };
4247
4916
  releaseOwnReservation();
4917
+ // MemTree's 413 for an index still catching up, with nothing else to send: the
4918
+ // refusal waits for the index (its 503 paces the retries), and growth cannot help,
4919
+ // so the retry asks again. With a ride to send, the usual backoff paces the asks.
4920
+ if (longBudget && activeIndexWaits.has(rec))
4921
+ return forwardOriginal(false);
4248
4922
  // An ordinary server/network failure or timeout retains the background
4249
4923
  // submission: its longer independent budget can still warm the index for
4250
4924
  // a later turn. An unpaid key (402, possibly set by this very compress)
4251
4925
  // or a shutting-down proxy gets no retry.
4926
+ // A carried compression still runs on this history (and indexes it): no upload beside it.
4252
4927
  if (!state.shutdownSignal.aborted &&
4928
+ rec.compress?.carriedOver !== true &&
4253
4929
  opts.memtree.paymentRequiredDetail === null &&
4254
4930
  !opts.memtree.keyInvalid // a refused key refuses the index too
4255
4931
  ) {
@@ -4780,6 +5456,9 @@ async function truncatedToFit(rec, sizing, preferred, limits, store) {
4780
5456
  * compression summarize instead.
4781
5457
  */
4782
5458
  function compressionSettled(rec) {
5459
+ // An indexing 413 is MemTree's refusal too: the safety net may still fit the request.
5460
+ if (activeIndexWaits.has(rec))
5461
+ return true;
4783
5462
  if (rec.indexBehind === true)
4784
5463
  return false;
4785
5464
  if (rec.compress?.refused === true || rec.compaction?.prepared === "used")
@@ -4850,6 +5529,40 @@ function lateRecoveryPlan(state, rec, preferred, measured, limits, routeKey) {
4850
5529
  return preferred.kind === "original" && !fitsMeasured(measured, limits)
4851
5530
  ? { tokens: measured.tokens } : undefined;
4852
5531
  }
5532
+ /**
5533
+ * Wait (up to `maxMs`, or until `signal` aborts) for the lane's in-flight compression to
5534
+ * end; the milliseconds waited.
5535
+ */
5536
+ async function waitForLaneIdle(state, routeKey, maxMs, signal) {
5537
+ const started = Date.now();
5538
+ while (state.toolRecoveryAttemptedLanes.get(routeKey)?.inFlight && !signal.aborted &&
5539
+ !state.shutdownSignal.aborted && !state.backgroundSignal.aborted &&
5540
+ Date.now() - started < maxMs) {
5541
+ // Unref'd: a wait never holds the process open.
5542
+ await new Promise((resolve) => setTimeout(resolve, LANE_WAIT_POLL_MS).unref());
5543
+ }
5544
+ return Date.now() - started;
5545
+ }
5546
+ /**
5547
+ * The ride the lane's current memory route gives this request, read afresh (after a wait):
5548
+ * undefined when there is none, it does not match this history, or (main lane) a newer
5549
+ * stable prefix superseded it, which the planner then rides itself.
5550
+ */
5551
+ function currentLaneRide(state, routeKey, body, messages, sessionId, isMainRequest) {
5552
+ const route = getMemoryRoute(state, routeKey);
5553
+ if (!route)
5554
+ return undefined;
5555
+ if (isMainRequest && sessionId !== undefined &&
5556
+ route.stablePrefix !== state.stablePrefixes.get(sessionId))
5557
+ return undefined;
5558
+ const raw = memoryRoutedToolBody(body, messages, route, state.mainRouteEpoch, sessionId);
5559
+ return raw ? { raw, turnType: "tool-memory", sizeHolder: route.stablePrefix ?? route }
5560
+ : undefined;
5561
+ }
5562
+ /** Wall-clock slack when judging whether a call ran its whole budget (timers fire early). */
5563
+ const TIMER_SLACK_MS = 5;
5564
+ /** How often a turn waiting for its lane's compression looks again. */
5565
+ const LANE_WAIT_POLL_MS = 100;
4853
5566
  /**
4854
5567
  * Run a delivery-time compression as the lane's in-flight operation, so a
4855
5568
  * concurrent request of the lane neither starts its own (planning sees
@@ -4909,9 +5622,13 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
4909
5622
  // A refused key or a paused one: a retry cannot fix either, so it is final at once.
4910
5623
  const keyInvalid = args.state.keyInvalid();
4911
5624
  const unpaid = keyInvalid ? null : args.state.pausedDetail();
5625
+ // MemTree can't be reached: nothing says the request can't fit, so no strike (2026-11-09).
5626
+ const offline = keyInvalid || unpaid !== null ? undefined : unreachableNow(args.state, args.rec);
4912
5627
  const failure = keyInvalid ? pausedRefusal(keyInvalidRefusalMessage(args.state.keyPageUrl))
4913
5628
  : unpaid !== null ? pausedRefusal(pausedRefusalMessage(unpaid))
4914
- : args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true });
5629
+ : offline ? unreachableRefusal(offline.message)
5630
+ : args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true,
5631
+ indexSecondsLeft: activeIndexWaits.get(args.rec) });
4915
5632
  args.rec.forwardedBytes = 0;
4916
5633
  args.rec.approxInputTokens = 0;
4917
5634
  // Nothing went out uncompressed: Claude Code retries it (2026-10-28).
@@ -4923,7 +5640,8 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
4923
5640
  delete args.rec.compaction?.keptPrefix;
4924
5641
  }
4925
5642
  delete args.rec.degradedNotice;
4926
- if (unpaid === null && !keyInvalid)
5643
+ // An unreachable MemTree's 503 says why itself: not "slow to answer".
5644
+ if (unpaid === null && !keyInvalid && !offline)
4927
5645
  noteSlowRetry(args, failure);
4928
5646
  args.state.diagnostics?.record("refusal", {
4929
5647
  status: failure.status, attempt: failure.attempt,
@@ -4936,6 +5654,7 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
4936
5654
  outputReservation: bodyOutputReservation(args.body), indexBehind: args.rec.indexBehind === true,
4937
5655
  budgetTokens, modelContextLimit: args.modelContextLimit, ...(unpaid !== null ? { paused: true } : {}),
4938
5656
  ...(keyInvalid ? { keyInvalid: true } : {}),
5657
+ ...(offline ? { unreachable: offline.what } : {}),
4939
5658
  };
4940
5659
  if (unpaid !== null || keyInvalid)
4941
5660
  cancelPausedNotice(args.state, args.rec);
@@ -4951,6 +5670,37 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
4951
5670
  args.state.keyNotice.markShown();
4952
5671
  return false;
4953
5672
  }
5673
+ /** Requests held back by a lane backoff whose attempt MemTree answered (unreachableNow). */
5674
+ const answeredBackoffs = new WeakSet();
5675
+ /**
5676
+ * Whether a backoff or cooldown armed by a call that could not connect (`offline`) is over:
5677
+ * MemTree has answered since, so the next request asks it again.
5678
+ */
5679
+ function outageOver(state, offline) {
5680
+ return offline === true && state.memtreeUnreachable() === undefined;
5681
+ }
5682
+ /**
5683
+ * Why a request only MemTree could fit can't go now, when MemTree's latest compress call
5684
+ * could not connect (recently): the internet when Anthropic's latest forward could not
5685
+ * either. Never for a request MemTree answered (live or from the cache), one held back by a
5686
+ * backoff MemTree's answer armed, or one sent on a prepared prefix or waiting for its index:
5687
+ * its refusal is about the fit (review r1, Codex r1).
5688
+ */
5689
+ function unreachableNow(state, rec) {
5690
+ if (rec.compress?.ok === true || rec.compress?.refused === true ||
5691
+ // The backoff's answer speaks only while this request made no compress call of its
5692
+ // own (a delivery-time one that could not connect is its own evidence: Codex r2).
5693
+ (rec.compress === undefined && answeredBackoffs.has(rec)) ||
5694
+ rec.compaction?.prepared === "used" || rec.indexBehind === true ||
5695
+ activeIndexWaits.has(rec))
5696
+ return undefined;
5697
+ const memtree = freshFailure(state.memtreeUnreachable());
5698
+ if (!memtree)
5699
+ return undefined;
5700
+ const anthropic = freshFailure(state.anthropicUnreachable());
5701
+ return { message: unreachableMessage(memtree, anthropic),
5702
+ what: anthropic ? "internet" : "memtree" };
5703
+ }
4954
5704
  /**
4955
5705
  * A repeated retryable refusal on the main thread: say once per streak that
4956
5706
  * MemTree is slow and Claude Code is retrying, unless another notice waits.
@@ -5227,6 +5977,7 @@ function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, r
5227
5977
  path: req.url, // path + query string verbatim (?beta=true etc.)
5228
5978
  headers,
5229
5979
  }, (upstreamRes) => {
5980
+ upstream.unreachable = undefined;
5230
5981
  activeUpstreamRes = upstreamRes;
5231
5982
  const contentType = String(upstreamRes.headers["content-type"] ?? "");
5232
5983
  const isSse = contentType.includes("text/event-stream");
@@ -5485,7 +6236,7 @@ function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, r
5485
6236
  });
5486
6237
  upstreamReq.on("error", (err) => {
5487
6238
  if (!shutdownCancelled) {
5488
- sendAnthropicError(res, `upstream connection failed: ${err.message}`);
6239
+ sendAnthropicError(res, upstreamFailureMessage(upstream, err, !activeUpstreamRes));
5489
6240
  }
5490
6241
  settle(false);
5491
6242
  });
@@ -5637,6 +6388,7 @@ function passThroughStreaming(req, res, upstream, shutdownSignal) {
5637
6388
  upstreamRes.destroy();
5638
6389
  return;
5639
6390
  }
6391
+ upstream.unreachable = undefined;
5640
6392
  activeUpstreamRes = upstreamRes;
5641
6393
  const onUpstreamResponseEnd = () => {
5642
6394
  upstreamResponseEnded = true;
@@ -5682,7 +6434,7 @@ function passThroughStreaming(req, res, upstream, shutdownSignal) {
5682
6434
  if (upstreamReq)
5683
6435
  req.unpipe(upstreamReq);
5684
6436
  activeUpstreamRes?.unpipe(res);
5685
- sendAnthropicError(res, `upstream connection failed: ${err.message}`);
6437
+ sendAnthropicError(res, upstreamFailureMessage(upstream, err, !activeUpstreamRes));
5686
6438
  if (res.writableFinished)
5687
6439
  onDownstreamFinish();
5688
6440
  });
@@ -5716,6 +6468,18 @@ function forwardableResponseHeaders(upstreamRes) {
5716
6468
  }
5717
6469
  return out;
5718
6470
  }
6471
+ /**
6472
+ * The 502 text for a forward that got no answer: plainly that ccc can't reach Anthropic
6473
+ * when it could not connect (noted on `upstream`; only before any response: review r1 P3),
6474
+ * else the error as it came.
6475
+ */
6476
+ function upstreamFailureMessage(upstream, err, unanswered) {
6477
+ const failure = unanswered ? networkFailureOf(err, upstream.host) : undefined;
6478
+ if (!failure)
6479
+ return `upstream connection failed: ${err.message}`;
6480
+ upstream.unreachable = failure;
6481
+ return unreachableMessage(undefined, failure);
6482
+ }
5719
6483
  /** Well-formed Anthropic-shaped error body so Claude Code fails fast, not weird. */
5720
6484
  function sendAnthropicError(res, message) {
5721
6485
  if (res.headersSent) {