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