claude-code-infinite 1.0.33 → 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 (87) hide show
  1. package/README.md +9 -2
  2. package/dist/cli-main.js +18 -9
  3. package/dist/cli-main.js.map +1 -1
  4. package/dist/hooks.d.ts +20 -0
  5. package/dist/hooks.d.ts.map +1 -1
  6. package/dist/hooks.js +34 -4
  7. package/dist/hooks.js.map +1 -1
  8. package/dist/host-session.d.ts +12 -0
  9. package/dist/host-session.d.ts.map +1 -0
  10. package/dist/host-session.js +12 -0
  11. package/dist/host-session.js.map +1 -0
  12. package/dist/image-refs.d.ts +24 -2
  13. package/dist/image-refs.d.ts.map +1 -1
  14. package/dist/image-refs.js +67 -20
  15. package/dist/image-refs.js.map +1 -1
  16. package/dist/index-wait-413.d.ts +26 -0
  17. package/dist/index-wait-413.d.ts.map +1 -0
  18. package/dist/index-wait-413.js +113 -0
  19. package/dist/index-wait-413.js.map +1 -0
  20. package/dist/memtree-finder.d.ts +70 -6
  21. package/dist/memtree-finder.d.ts.map +1 -1
  22. package/dist/memtree-finder.js +150 -20
  23. package/dist/memtree-finder.js.map +1 -1
  24. package/dist/memtree-handover.d.ts +13 -0
  25. package/dist/memtree-handover.d.ts.map +1 -0
  26. package/dist/memtree-handover.js +231 -0
  27. package/dist/memtree-handover.js.map +1 -0
  28. package/dist/memtree-mcp-config.d.ts +2 -2
  29. package/dist/memtree-mcp-config.d.ts.map +1 -1
  30. package/dist/memtree-mcp-config.js +8 -5
  31. package/dist/memtree-mcp-config.js.map +1 -1
  32. package/dist/memtree-mcp.d.ts +182 -2
  33. package/dist/memtree-mcp.d.ts.map +1 -1
  34. package/dist/memtree-mcp.js +357 -38
  35. package/dist/memtree-mcp.js.map +1 -1
  36. package/dist/memtree-newest.d.ts +21 -3
  37. package/dist/memtree-newest.d.ts.map +1 -1
  38. package/dist/memtree-newest.js +43 -11
  39. package/dist/memtree-newest.js.map +1 -1
  40. package/dist/memtree-times.d.ts +33 -0
  41. package/dist/memtree-times.d.ts.map +1 -1
  42. package/dist/memtree-times.js +77 -0
  43. package/dist/memtree-times.js.map +1 -1
  44. package/dist/memtree-tools.d.ts +19 -5
  45. package/dist/memtree-tools.d.ts.map +1 -1
  46. package/dist/memtree-tools.js +97 -23
  47. package/dist/memtree-tools.js.map +1 -1
  48. package/dist/memtree.d.ts +23 -0
  49. package/dist/memtree.d.ts.map +1 -1
  50. package/dist/memtree.js +78 -4
  51. package/dist/memtree.js.map +1 -1
  52. package/dist/network-failure.d.ts +28 -0
  53. package/dist/network-failure.d.ts.map +1 -0
  54. package/dist/network-failure.js +67 -0
  55. package/dist/network-failure.js.map +1 -0
  56. package/dist/prefix-store.d.ts +4 -0
  57. package/dist/prefix-store.d.ts.map +1 -1
  58. package/dist/prefix-store.js +8 -4
  59. package/dist/prefix-store.js.map +1 -1
  60. package/dist/project-trees.d.ts +15 -7
  61. package/dist/project-trees.d.ts.map +1 -1
  62. package/dist/project-trees.js +20 -9
  63. package/dist/project-trees.js.map +1 -1
  64. package/dist/proxy.d.ts +69 -1
  65. package/dist/proxy.d.ts.map +1 -1
  66. package/dist/proxy.js +467 -39
  67. package/dist/proxy.js.map +1 -1
  68. package/dist/reqlog.d.ts +24 -0
  69. package/dist/reqlog.d.ts.map +1 -1
  70. package/dist/reqlog.js.map +1 -1
  71. package/dist/resume-guidance.d.ts +9 -3
  72. package/dist/resume-guidance.d.ts.map +1 -1
  73. package/dist/resume-guidance.js +9 -3
  74. package/dist/resume-guidance.js.map +1 -1
  75. package/dist/route-fallback.d.ts +77 -11
  76. package/dist/route-fallback.d.ts.map +1 -1
  77. package/dist/route-fallback.js +157 -33
  78. package/dist/route-fallback.js.map +1 -1
  79. package/dist/route-identity.d.ts +6 -2
  80. package/dist/route-identity.d.ts.map +1 -1
  81. package/dist/route-identity.js +209 -16
  82. package/dist/route-identity.js.map +1 -1
  83. package/dist/subagent-hint.d.ts +28 -0
  84. package/dist/subagent-hint.d.ts.map +1 -1
  85. package/dist/subagent-hint.js +37 -0
  86. package/dist/subagent-hint.js.map +1 -1
  87. 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
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, 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
@@ -346,6 +354,8 @@ export function startProxy(opts) {
346
354
  pausedDetail: () => opts.memtree.pausedDetail,
347
355
  pausedReason: () => opts.memtree.pausedReason,
348
356
  memtreeAnswering: () => opts.memtree.answering,
357
+ memtreeUnreachable: () => opts.memtree.unreachable,
358
+ anthropicUnreachable: () => upstream.unreachable,
349
359
  modelNotices: new ModelContextNotices(),
350
360
  usageUrl: opts.memtree.usageDashboardUrl,
351
361
  sessionStartNotice: opts.sessionStartNotice,
@@ -353,13 +363,14 @@ export function startProxy(opts) {
353
363
  openUrl: opts.openUrl,
354
364
  projectTrees: opts.projectTrees,
355
365
  subagentHint: opts.subagentHint,
366
+ projectTreesGiven: new Set(),
356
367
  resumeGuidance: opts.resumeGuidance,
357
368
  diagnostics: opts.diagnostics,
358
369
  notices: new NoticeDeliveryQueue(),
359
370
  mainNoticePending: false,
360
371
  mainPromptGeneration: 0,
361
372
  promptAccounting: new PromptAccounting(),
362
- fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs),
373
+ fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs, opts.indexWaitMaxMs),
363
374
  refusalStreaks: new RefusalStreaks(),
364
375
  activeSubagents: new Set(),
365
376
  deferredAgentIndexes: new Map(),
@@ -376,6 +387,7 @@ export function startProxy(opts) {
376
387
  routeInstallFault: opts.routeInstallFault,
377
388
  memtreePages: new Map(),
378
389
  memorySourcePages: new Map(),
390
+ hostToken: randomBytes(24).toString("hex"),
379
391
  memtreeCallSeq: 0,
380
392
  memtreeLinkStore: opts.memtreeLinkStore,
381
393
  stablePrefixStore: opts.stablePrefixStore,
@@ -486,6 +498,8 @@ export function startProxy(opts) {
486
498
  },
487
499
  drain,
488
500
  mainSessionId: () => state.resumableMainSession,
501
+ setHostPid: (pid) => { state.hostPid = pid; },
502
+ hostToken: state.hostToken,
489
503
  });
490
504
  });
491
505
  });
@@ -513,13 +527,22 @@ async function handleRequest(req, res, opts, upstream, state, hookPath) {
513
527
  return handleNoticeHook(req, res, state, opts.reqlog, undefined, "PreCompact");
514
528
  }
515
529
  if (url.pathname === `${hookPath}${RESUME_GUIDANCE_HOOK_SUFFIX}`) {
516
- 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);
517
537
  }
518
538
  if (url.pathname === hookPath) {
519
539
  return handleNoticeHook(req, res, state, opts.reqlog, (sessionId) => opts.memtree.scheduleFinalIndex(sessionId, (signal) => opts.transcriptUsage?.finalReplyAsync
520
540
  ? opts.transcriptUsage.finalReplyAsync(sessionId, signal)
521
541
  : opts.transcriptUsage?.finalReply?.(sessionId)));
522
542
  }
543
+ if (req.method === "GET" && url.pathname === MEMTREE_HOST_SESSION_PATH) {
544
+ return handleMemTreeHostSession(req, res, state, url);
545
+ }
523
546
  if (req.method === "GET" &&
524
547
  (url.pathname === MEMTREE_CURRENT_PATH || url.pathname === `${MEMTREE_CURRENT_PATH}.json`)) {
525
548
  return handleMemTreeCurrent(req, res, opts, state, url);
@@ -584,12 +607,14 @@ const MEMTREE_PASSTHROUGH_PREFIX = "/memtree/";
584
607
  * `<request id>`, `<request id>.json`, `<request id>/session.json` (the
585
608
  * page's session pane), `<request id>/search` (the server's term search over
586
609
  * that tree, `?q=&limit=`), `<request id>/messages` (the un-indexed messages
587
- * 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
588
613
  * page from one session, newest first); nothing that could walk the upstream
589
614
  * path. The id is the request UUID or the server's short form of it (leading
590
615
  * hex, as in the `/m/<id>` links it hands out) — the server accepts both.
591
616
  */
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)$/;
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)$/;
593
618
  /**
594
619
  * `GET /memtree/<id>[.json][?share=…]` on the loopback: read the user's own
595
620
  * MemTree page with their key. Claude Code's child env already carries this
@@ -606,7 +631,7 @@ async function handleMemTreePassthrough(req, res, opts, url) {
606
631
  return;
607
632
  }
608
633
  try {
609
- 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));
610
635
  res.writeHead(upstream.status, { "content-type": upstream.contentType });
611
636
  res.end(upstream.body);
612
637
  }
@@ -633,15 +658,29 @@ const MEMTREE_FINDER_RELAY = new Map([
633
658
  [`${MEMTREE_PASSTHROUGH_PREFIX}search`, "/v1/memtree/search"],
634
659
  ]);
635
660
  const SESSION_ID_HEADER_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
636
- 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) {
637
669
  const headers = {};
638
- const meta = memtreeClientMeta({ project: opts.projectMeta });
639
- if (Object.keys(meta).length)
640
- 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;
641
673
  const sessionId = firstNonEmptyHeader(req, "x-claude-code-session-id");
642
674
  if (sessionId && SESSION_ID_HEADER_VALUE.test(sessionId)) {
643
675
  headers["x-claude-code-session-id"] = sessionId;
644
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);
645
684
  try {
646
685
  const upstream = await opts.memtree.fetchMemTree(`${upstreamPath}${url.search}`, req.headers.accept ?? "application/json", undefined, headers);
647
686
  res.writeHead(upstream.status, { "content-type": upstream.contentType });
@@ -658,18 +697,23 @@ async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
658
697
  * reads (memtree-mcp.ts). Bare `current` answers the pointer
659
698
  * `{id, url, index, session_id, compressed}` without an upstream call, so the
660
699
  * MCP server can keep its cached tree until the page changes; `current.json`
661
- * 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.
662
704
  *
663
705
  * Every pointer is scoped to the caller's exact session id. Missing session
664
706
  * ids never fall back to another conversation served by the same proxy.
665
707
  */
666
708
  async function handleMemTreeCurrent(req, res, opts, state, url) {
667
709
  const sessionId = url.searchParams.get("session") || undefined;
668
- const page = currentMemorySourcePage(state, sessionId);
710
+ const found = await currentPageOrServerTree(state, sessionId);
711
+ const page = "page" in found ? found.page : undefined;
669
712
  const id = page ? memtreePageId(page.url) : undefined;
670
713
  if (!page || !id) {
671
- res.writeHead(404, { "content-type": "application/json" });
672
- 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" }));
673
717
  return;
674
718
  }
675
719
  if (!url.pathname.endsWith(".json")) {
@@ -684,7 +728,7 @@ async function handleMemTreeCurrent(req, res, opts, state, url) {
684
728
  return;
685
729
  }
686
730
  try {
687
- 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));
688
732
  res.writeHead(upstream.status, {
689
733
  "content-type": upstream.contentType,
690
734
  "x-memtree-page": page.url,
@@ -697,6 +741,60 @@ async function handleMemTreeCurrent(req, res, opts, state, url) {
697
741
  }
698
742
  }
699
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
+ }
700
798
  /** The page whose memory is in the prompt, else (none adopted yet) the newest page. */
701
799
  function currentMemorySourcePage(state, sessionId) {
702
800
  if (!sessionId)
@@ -746,12 +844,95 @@ function noteMainSessionStart(state, source, sessionId) {
746
844
  state.resumableMainSession = undefined;
747
845
  }
748
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
+ }
749
930
  /**
750
931
  * SessionStart on its own hook (resume-guidance.ts): only the current MemTree
751
932
  * guidance, so Claude Code sees it as one context of its own and adds an
752
933
  * identical one only once. Never fails the hook: anything unexpected is 204.
753
934
  */
754
- async function handleResumeGuidanceHook(req, res, state) {
935
+ async function handleSessionStartContextHook(req, res, contextFor) {
755
936
  const raw = req.method === "POST" ? await readBodyWithin(req, HOOK_BODY_LIMIT) : undefined;
756
937
  if (req.method !== "POST")
757
938
  req.resume();
@@ -765,7 +946,7 @@ async function handleResumeGuidanceHook(req, res, state) {
765
946
  input = null;
766
947
  }
767
948
  const context = input?.hook_event_name === "SessionStart"
768
- ? await resumeGuidanceContext(state, input) : undefined;
949
+ ? await contextFor(input) : undefined;
769
950
  if (!context) {
770
951
  res.writeHead(204);
771
952
  res.end();
@@ -827,6 +1008,12 @@ onlyEvent) {
827
1008
  if (parsed.agent_id === undefined) {
828
1009
  recordSessionStart(state, parsed.source);
829
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
+ }
830
1017
  // Claude Code's compaction dropped any MemTree notice from the transcript.
831
1018
  if (parsed.source === "compact")
832
1019
  state.modelNotices.forget(parsed.session_id);
@@ -1047,6 +1234,8 @@ async function handleMessages(req, res, opts, upstream, state) {
1047
1234
  };
1048
1235
  // The prompt this request was made for, before any await (Codex r2 P2).
1049
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) });
1050
1239
  const logged = async (forward) => {
1051
1240
  try {
1052
1241
  await forward;
@@ -1173,6 +1362,14 @@ async function handleMessages(req, res, opts, upstream, state) {
1173
1362
  const isSubagentRequest = requestRouteLane === "agent";
1174
1363
  const isMainRequest = requestRouteLane === "main";
1175
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
+ }
1176
1373
  // Extended 1M context arrives as the `context-1m` beta header (Claude Code
1177
1374
  // strips the `[1m]` model suffix on the wire). Current native-1M models send
1178
1375
  // neither, so contextLimitForModel also needs the launcher's native setting.
@@ -1491,12 +1688,16 @@ async function handleMessages(req, res, opts, upstream, state) {
1491
1688
  }
1492
1689
  else if (prior?.retryAtTokens !== undefined &&
1493
1690
  plan.estimateTokens < prior.retryAtTokens &&
1494
- Date.now() < (prior.retryDeadline ?? 0)) {
1691
+ Date.now() < (prior.retryDeadline ?? 0) && !outageOver(state, prior.offline)) {
1495
1692
  // This lane's last attempt this human turn produced nothing; wait
1496
1693
  // for growth instead of paying a blocking call on every tool turn.
1497
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);
1498
1698
  }
1499
1699
  else if (Date.now() < state.toolRecoveryCooldownUntil &&
1700
+ !outageOver(state, state.toolRecoveryCooldownOffline) &&
1500
1701
  !(plan.overWindow && !plan.fallback && (!plan.uncountedOverWindowOnly ||
1501
1702
  await wholeRequestRefused(requestSizing, originalCandidate, sizingLimits())))) {
1502
1703
  // A recent attempt burned the full compress budget and still
@@ -2644,6 +2845,7 @@ function noteMemtreeHealth(state, compression) {
2644
2845
  return;
2645
2846
  state.toolRecoveryCooldownUntil =
2646
2847
  Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
2848
+ state.toolRecoveryCooldownOffline = state.memtreeUnreachable() !== undefined;
2647
2849
  return;
2648
2850
  }
2649
2851
  if (compression.liveHealth === "success") {
@@ -2797,6 +2999,8 @@ function persistStablePrefix(state, sessionId, prefix) {
2797
2999
  originalSystemHash: prefix.originalSystemHash,
2798
3000
  originalPrefixHashes: prefix.originalPrefixHashes,
2799
3001
  originalMediaFreeHashes: prefix.originalMediaFreeHashes,
3002
+ originalPortableHashes: prefix.originalPortableHashes,
3003
+ originalPortableMediaFreeHashes: prefix.originalPortableMediaFreeHashes,
2800
3004
  compressedMessages: prefix.compressedMessages,
2801
3005
  compressedSystem: prefix.compressedSystem,
2802
3006
  hasCompressedSystem: prefix.hasCompressedSystem,
@@ -2837,6 +3041,7 @@ function adoptStoredPrefix(opts, state, rec, sessionId, body, messages, modelCon
2837
3041
  if (verdict === "discard") {
2838
3042
  store.delete(sessionId, owner);
2839
3043
  rec.storedPrefix = "discarded";
3044
+ rec.storedPrefixMissAt = firstPrefixMiss(messages, prefix, "stable");
2840
3045
  return;
2841
3046
  }
2842
3047
  boundedSet(state.stablePrefixes, sessionId, prefix);
@@ -2885,15 +3090,19 @@ function prefixMismatch(body, messages, prefix, sessionId, kind = "route") {
2885
3090
  if (routeValueHash(normalizeRouteSystem(body.system)) !== prefix.originalSystemHash) {
2886
3091
  return "system";
2887
3092
  }
2888
- const prefixLength = prefix.originalPrefixHashes.length;
2889
- if (messages.length < prefixLength)
3093
+ if (messages.length < prefix.originalPrefixHashes.length)
2890
3094
  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";
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;
2894
3103
  }
2895
3104
  }
2896
- return null;
3105
+ return undefined;
2897
3106
  }
2898
3107
  /**
2899
3108
  * Message identity for a stable prefix: routeMessageHash without assistant
@@ -3824,7 +4033,7 @@ function memoryRoutedToolBody(body, messages, route, routeEpoch, sessionId) {
3824
4033
  if (messages.length < prefixLength)
3825
4034
  return null;
3826
4035
  for (let i = 0; i < prefixLength; i++) {
3827
- 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])) {
3828
4037
  return null;
3829
4038
  }
3830
4039
  }
@@ -4188,6 +4397,154 @@ function cloneJson(value) {
4188
4397
  return value;
4189
4398
  return JSON.parse(JSON.stringify(value));
4190
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
+ }
4191
4548
  /**
4192
4549
  * The blocking-compression step, shared by the main followup path and
4193
4550
  * tool-route miss recovery: the compress call plus the `compress` telemetry
@@ -4198,7 +4555,7 @@ function cloneJson(value) {
4198
4555
  * `checkCompressedHistory(result, msgsForMemtree).usable` must hold before
4199
4556
  * the result may be forwarded or become a route.
4200
4557
  */
4201
- async function runBlockingCompression(args) {
4558
+ async function runBlockingCompressionOnce(args) {
4202
4559
  const { opts, state, body, msgsForMemtree, hash, modelContextLimit, rec } = args;
4203
4560
  const compaction = args.compaction ??
4204
4561
  laneCompaction(opts, state, args.sessionId, body.model, modelContextLimit);
@@ -4210,10 +4567,10 @@ async function runBlockingCompression(args) {
4210
4567
  // The upload is about the request's size: the budget grows with it (the long one
4211
4568
  // too, never below the short one's).
4212
4569
  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);
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);
4214
4571
  // 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;
4572
+ const carry = args.carry && args.capMs === undefined &&
4573
+ carriedBudgetMs(opts, scaledMs) >= budgetMs + CARRY_MIN_EXTRA_MS ? args.carry : undefined;
4217
4574
  const compressMeta = {
4218
4575
  // Model + tools drive the server's model-based memory budget
4219
4576
  // (e.g. 800k whole-request target for Fable / Opus 5). Omitting
@@ -4274,6 +4631,8 @@ async function runBlockingCompression(args) {
4274
4631
  opts.memtree.lastCompressFailureArming(hash, modelContextLimit, compressMeta);
4275
4632
  const failureStatus = result === null
4276
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;
4277
4636
  const hadLiveSuccess = !cached && result !== null;
4278
4637
  // Only arming-class live failures (network error, timeout, 5xx, 402) are
4279
4638
  // fuse evidence. A responsive server's other 4xx failed THIS call but says
@@ -4296,7 +4655,9 @@ async function runBlockingCompression(args) {
4296
4655
  ok: result !== null,
4297
4656
  timedOut,
4298
4657
  ...(carried ? { carriedOver: true } : {}),
4299
- ...(!cached && failureStatus === OVER_CONTEXT_STATUS ? { refused: 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 } : {}),
4300
4661
  };
4301
4662
  return {
4302
4663
  result,
@@ -4306,6 +4667,7 @@ async function runBlockingCompression(args) {
4306
4667
  : hadLiveFailure
4307
4668
  ? "failure"
4308
4669
  : "none",
4670
+ ...(indexWaitSeconds !== undefined ? { indexWaitSeconds } : {}),
4309
4671
  };
4310
4672
  }
4311
4673
  /**
@@ -4420,6 +4782,10 @@ async function recoverToolRouteMiss(args) {
4420
4782
  if (args.retryAtTokens !== undefined) {
4421
4783
  recoveryAttempt.retryAtTokens = args.retryAtTokens;
4422
4784
  recoveryAttempt.retryDeadline = Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
4785
+ if (state.memtreeUnreachable())
4786
+ recoveryAttempt.offline = true;
4787
+ else
4788
+ delete recoveryAttempt.offline;
4423
4789
  }
4424
4790
  };
4425
4791
  const sessionId = requestSessionId(req);
@@ -4487,7 +4853,10 @@ async function recoverToolRouteMiss(args) {
4487
4853
  modelContextLimit, rec, targetTokens: stable.targetTokens, explicitTarget: stable.explicitTarget,
4488
4854
  linkSeq }) : undefined;
4489
4855
  let compression;
4856
+ // Nothing else could be sent if this compress fails: the long budget, and an index hold.
4857
+ let longBudget = false;
4490
4858
  try {
4859
+ longBudget = args.wholeRefused ? await args.wholeRefused() : false;
4491
4860
  compression = await runBlockingCompression({
4492
4861
  opts,
4493
4862
  state,
@@ -4502,7 +4871,7 @@ async function recoverToolRouteMiss(args) {
4502
4871
  sizing: args.sizing,
4503
4872
  inputCandidate: args.inputCandidate,
4504
4873
  ...(args.compaction ? { compaction: args.compaction } : {}),
4505
- longBudget: args.wholeRefused ? await args.wholeRefused() : false,
4874
+ longBudget,
4506
4875
  ...(toolCarry ? { carry: toolCarry } : {}),
4507
4876
  });
4508
4877
  }
@@ -4545,6 +4914,11 @@ async function recoverToolRouteMiss(args) {
4545
4914
  if (!result) {
4546
4915
  rec.routeRecovery = { conversationBytes, outcome: "failed" };
4547
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);
4548
4922
  // An ordinary server/network failure or timeout retains the background
4549
4923
  // submission: its longer independent budget can still warm the index for
4550
4924
  // a later turn. An unpaid key (402, possibly set by this very compress)
@@ -5082,6 +5456,9 @@ async function truncatedToFit(rec, sizing, preferred, limits, store) {
5082
5456
  * compression summarize instead.
5083
5457
  */
5084
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;
5085
5462
  if (rec.indexBehind === true)
5086
5463
  return false;
5087
5464
  if (rec.compress?.refused === true || rec.compaction?.prepared === "used")
@@ -5245,9 +5622,13 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
5245
5622
  // A refused key or a paused one: a retry cannot fix either, so it is final at once.
5246
5623
  const keyInvalid = args.state.keyInvalid();
5247
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);
5248
5627
  const failure = keyInvalid ? pausedRefusal(keyInvalidRefusalMessage(args.state.keyPageUrl))
5249
5628
  : unpaid !== null ? pausedRefusal(pausedRefusalMessage(unpaid))
5250
- : 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) });
5251
5632
  args.rec.forwardedBytes = 0;
5252
5633
  args.rec.approxInputTokens = 0;
5253
5634
  // Nothing went out uncompressed: Claude Code retries it (2026-10-28).
@@ -5259,7 +5640,8 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
5259
5640
  delete args.rec.compaction?.keptPrefix;
5260
5641
  }
5261
5642
  delete args.rec.degradedNotice;
5262
- if (unpaid === null && !keyInvalid)
5643
+ // An unreachable MemTree's 503 says why itself: not "slow to answer".
5644
+ if (unpaid === null && !keyInvalid && !offline)
5263
5645
  noteSlowRetry(args, failure);
5264
5646
  args.state.diagnostics?.record("refusal", {
5265
5647
  status: failure.status, attempt: failure.attempt,
@@ -5272,6 +5654,7 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
5272
5654
  outputReservation: bodyOutputReservation(args.body), indexBehind: args.rec.indexBehind === true,
5273
5655
  budgetTokens, modelContextLimit: args.modelContextLimit, ...(unpaid !== null ? { paused: true } : {}),
5274
5656
  ...(keyInvalid ? { keyInvalid: true } : {}),
5657
+ ...(offline ? { unreachable: offline.what } : {}),
5275
5658
  };
5276
5659
  if (unpaid !== null || keyInvalid)
5277
5660
  cancelPausedNotice(args.state, args.rec);
@@ -5287,6 +5670,37 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
5287
5670
  args.state.keyNotice.markShown();
5288
5671
  return false;
5289
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
+ }
5290
5704
  /**
5291
5705
  * A repeated retryable refusal on the main thread: say once per streak that
5292
5706
  * MemTree is slow and Claude Code is retrying, unless another notice waits.
@@ -5563,6 +5977,7 @@ function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, r
5563
5977
  path: req.url, // path + query string verbatim (?beta=true etc.)
5564
5978
  headers,
5565
5979
  }, (upstreamRes) => {
5980
+ upstream.unreachable = undefined;
5566
5981
  activeUpstreamRes = upstreamRes;
5567
5982
  const contentType = String(upstreamRes.headers["content-type"] ?? "");
5568
5983
  const isSse = contentType.includes("text/event-stream");
@@ -5821,7 +6236,7 @@ function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, r
5821
6236
  });
5822
6237
  upstreamReq.on("error", (err) => {
5823
6238
  if (!shutdownCancelled) {
5824
- sendAnthropicError(res, `upstream connection failed: ${err.message}`);
6239
+ sendAnthropicError(res, upstreamFailureMessage(upstream, err, !activeUpstreamRes));
5825
6240
  }
5826
6241
  settle(false);
5827
6242
  });
@@ -5973,6 +6388,7 @@ function passThroughStreaming(req, res, upstream, shutdownSignal) {
5973
6388
  upstreamRes.destroy();
5974
6389
  return;
5975
6390
  }
6391
+ upstream.unreachable = undefined;
5976
6392
  activeUpstreamRes = upstreamRes;
5977
6393
  const onUpstreamResponseEnd = () => {
5978
6394
  upstreamResponseEnded = true;
@@ -6018,7 +6434,7 @@ function passThroughStreaming(req, res, upstream, shutdownSignal) {
6018
6434
  if (upstreamReq)
6019
6435
  req.unpipe(upstreamReq);
6020
6436
  activeUpstreamRes?.unpipe(res);
6021
- sendAnthropicError(res, `upstream connection failed: ${err.message}`);
6437
+ sendAnthropicError(res, upstreamFailureMessage(upstream, err, !activeUpstreamRes));
6022
6438
  if (res.writableFinished)
6023
6439
  onDownstreamFinish();
6024
6440
  });
@@ -6052,6 +6468,18 @@ function forwardableResponseHeaders(upstreamRes) {
6052
6468
  }
6053
6469
  return out;
6054
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
+ }
6055
6483
  /** Well-formed Anthropic-shaped error body so Claude Code fails fast, not weird. */
6056
6484
  function sendAnthropicError(res, message) {
6057
6485
  if (res.headersSent) {