claude-code-infinite 1.0.31 → 1.0.33
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 +71 -56
- package/dist/claude-auth-status.d.ts +50 -0
- package/dist/claude-auth-status.d.ts.map +1 -0
- package/dist/claude-auth-status.js +213 -0
- package/dist/claude-auth-status.js.map +1 -0
- package/dist/claude-auth.d.ts +2 -1
- package/dist/claude-auth.d.ts.map +1 -1
- package/dist/claude-auth.js +2 -1
- package/dist/claude-auth.js.map +1 -1
- package/dist/claude-plan-warning.d.ts +13 -40
- package/dist/claude-plan-warning.d.ts.map +1 -1
- package/dist/claude-plan-warning.js +24 -75
- package/dist/claude-plan-warning.js.map +1 -1
- package/dist/cli-args.d.ts +7 -0
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +19 -0
- package/dist/cli-args.js.map +1 -1
- package/dist/cli-main.js +117 -17
- package/dist/cli-main.js.map +1 -1
- package/dist/count-tokens.d.ts +7 -3
- package/dist/count-tokens.d.ts.map +1 -1
- package/dist/count-tokens.js +6 -2
- package/dist/count-tokens.js.map +1 -1
- package/dist/diagnostics-schema.d.ts +5 -2
- package/dist/diagnostics-schema.d.ts.map +1 -1
- package/dist/diagnostics-schema.js +26 -6
- package/dist/diagnostics-schema.js.map +1 -1
- package/dist/diagnostics.d.ts +5 -1
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +7 -2
- package/dist/diagnostics.js.map +1 -1
- package/dist/hooks.d.ts +18 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +57 -4
- package/dist/hooks.js.map +1 -1
- package/dist/memtree-mcp.d.ts +7 -0
- package/dist/memtree-mcp.d.ts.map +1 -1
- package/dist/memtree-mcp.js +12 -1
- package/dist/memtree-mcp.js.map +1 -1
- package/dist/memtree.d.ts +86 -3
- package/dist/memtree.d.ts.map +1 -1
- package/dist/memtree.js +305 -18
- package/dist/memtree.js.map +1 -1
- package/dist/notices.d.ts +2 -0
- package/dist/notices.d.ts.map +1 -1
- package/dist/notices.js +2 -0
- package/dist/notices.js.map +1 -1
- package/dist/open-url.d.ts +49 -0
- package/dist/open-url.d.ts.map +1 -0
- package/dist/open-url.js +100 -0
- package/dist/open-url.js.map +1 -0
- package/dist/proxy.d.ts +77 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +618 -77
- package/dist/proxy.js.map +1 -1
- package/dist/reqlog.d.ts +21 -2
- package/dist/reqlog.d.ts.map +1 -1
- package/dist/reqlog.js.map +1 -1
- package/dist/request-sizing.d.ts +2 -0
- package/dist/request-sizing.d.ts.map +1 -1
- package/dist/request-sizing.js +5 -0
- package/dist/request-sizing.js.map +1 -1
- package/dist/resume-guidance.d.ts +102 -0
- package/dist/resume-guidance.d.ts.map +1 -0
- package/dist/resume-guidance.js +182 -0
- package/dist/resume-guidance.js.map +1 -0
- package/dist/route-fallback.d.ts +23 -0
- package/dist/route-fallback.d.ts.map +1 -1
- package/dist/route-fallback.js +44 -0
- package/dist/route-fallback.js.map +1 -1
- package/dist/sign-in-notice.d.ts +137 -0
- package/dist/sign-in-notice.d.ts.map +1 -0
- package/dist/sign-in-notice.js +300 -0
- package/dist/sign-in-notice.js.map +1 -0
- package/dist/subagent-hint.d.ts +31 -0
- package/dist/subagent-hint.d.ts.map +1 -0
- package/dist/subagent-hint.js +47 -0
- package/dist/subagent-hint.js.map +1 -0
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { capCacheBreakpoints } from "./route-cache.js";
|
|
42
42
|
import { PromptAccounting } from "./prompt-accounting.js";
|
|
43
|
-
import { INDEX_WAIT_RETRY_AFTER_SECONDS, RouteFallbackFailures, pausedRefusal } from "./route-fallback.js";
|
|
43
|
+
import { INDEX_WAIT_RETRY_AFTER_SECONDS, RefusalStreaks, RouteFallbackFailures, pausedRefusal } from "./route-fallback.js";
|
|
44
44
|
import { createCountTokensSession, CountTokensCooldowns, plausibleSample, } from "./count-tokens.js";
|
|
45
45
|
import { RequestSizing, estimateRequestTokens, fitsMeasured, fitsNative, outputReservation } from "./request-sizing.js";
|
|
46
46
|
import { extendsHistory, mediaFreeMessageHash, prefixIdentity, prefixMessageMatches, } from "./route-identity.js";
|
|
@@ -52,10 +52,10 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
52
52
|
import { brotliDecompressSync, createBrotliDecompress, createGunzip, createInflate, gunzipSync, inflateSync, } from "node:zlib";
|
|
53
53
|
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
54
54
|
import { join } from "node:path";
|
|
55
|
-
import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, indexLagEvidence, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
|
|
55
|
+
import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, indexLagEvidence, losesMedia, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
|
|
56
56
|
import { contextLimitForModel, hasEarlierNonToolUserMessage, isAwaySummaryUserMessage, isLocalBashCommandTurn, isNonToolUserMessage, isToolResultUserMessage, lastNonSystemMessage, messagesWithSystem, modelForMemtree, stripSystemReminderText, } from "./turns.js";
|
|
57
|
-
import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, recapLinkText, SseNoticeRewriter, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
|
|
58
|
-
import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines,
|
|
57
|
+
import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, SLOW_RETRY_NOTICE, recapLinkText, SseNoticeRewriter, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
|
|
58
|
+
import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, PRECOMPACT_HOOK_PATH, } from "./hooks.js";
|
|
59
59
|
import { USAGE_LABEL, USAGE_SIGN_IN_NOTE } from "./usage-link.js";
|
|
60
60
|
import { hyperlink } from "./payment-gate.js";
|
|
61
61
|
import { keyInvalidNoticeText, keyInvalidRefusalMessage } from "./key-status.js";
|
|
@@ -64,6 +64,8 @@ import { PaymentNoticeThrottle, pausedCompactionText, pausedRefusalMessage, paym
|
|
|
64
64
|
import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
|
|
65
65
|
import { describeClaudeCodeRequest, inspectMonitorTranscript, knownSideRequestKind, transcriptWarning, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
|
|
66
66
|
import { isResumableSessionId } from "./resume-hint.js";
|
|
67
|
+
import { subagentMemoryHintText, subagentStartOutput, } from "./subagent-hint.js";
|
|
68
|
+
import { RESUME_GUIDANCE_HOOK_SUFFIX } from "./hooks.js";
|
|
67
69
|
import { conservativeBodyTokens } from "./token-estimate.js";
|
|
68
70
|
import { bodyImages } from "./image-tokens.js";
|
|
69
71
|
import { claudeAuthFromHeaders, upstreamErrorCode } from "./claude-auth.js";
|
|
@@ -71,6 +73,8 @@ import { rateLimitFromResponse } from "./rate-limit.js";
|
|
|
71
73
|
import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
|
|
72
74
|
const DEFAULT_UPSTREAM = "https://api.anthropic.com";
|
|
73
75
|
const HOOK_BODY_LIMIT = 64 * 1024;
|
|
76
|
+
/** A hook body still arriving after this is dropped (the hook itself waits 5 s). */
|
|
77
|
+
const HOOK_BODY_TIMEOUT_MS = 3_000;
|
|
74
78
|
// After a recovery attempt returns null (MemTree down, 5xx, or a burned
|
|
75
79
|
// timeout budget), suppress the blocking attempt for this long, across every
|
|
76
80
|
// lane. A lane's own growth backoff alone would let each over-budget tool turn
|
|
@@ -82,8 +86,11 @@ const HOOK_BODY_LIMIT = 64 * 1024;
|
|
|
82
86
|
const TOOL_RECOVERY_FAILURE_COOLDOWN_MS = 60_000;
|
|
83
87
|
/** Safety cap on a long-budget compress, should a transport ignore its abort. */
|
|
84
88
|
const MAX_LONG_COMPRESS_WAIT_MS = 10 * 60_000;
|
|
85
|
-
/**
|
|
86
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The most a blocking compress with a fallback may wait (its budget grows with its upload):
|
|
91
|
+
* a 30 MB upload's scaled budget (15 s + 2.5 ms/KiB, ~90 s) fits under it.
|
|
92
|
+
*/
|
|
93
|
+
const MAX_COMPRESS_WAIT_MS = 120_000;
|
|
87
94
|
const SKIP_REQUEST_HEADERS = new Set([
|
|
88
95
|
"host",
|
|
89
96
|
"connection",
|
|
@@ -343,14 +350,20 @@ export function startProxy(opts) {
|
|
|
343
350
|
usageUrl: opts.memtree.usageDashboardUrl,
|
|
344
351
|
sessionStartNotice: opts.sessionStartNotice,
|
|
345
352
|
promptSubmitNotice: opts.promptSubmitNotice,
|
|
353
|
+
openUrl: opts.openUrl,
|
|
346
354
|
projectTrees: opts.projectTrees,
|
|
355
|
+
subagentHint: opts.subagentHint,
|
|
356
|
+
resumeGuidance: opts.resumeGuidance,
|
|
347
357
|
diagnostics: opts.diagnostics,
|
|
348
358
|
notices: new NoticeDeliveryQueue(),
|
|
349
359
|
mainNoticePending: false,
|
|
350
360
|
mainPromptGeneration: 0,
|
|
351
361
|
promptAccounting: new PromptAccounting(),
|
|
352
362
|
fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs),
|
|
363
|
+
refusalStreaks: new RefusalStreaks(),
|
|
353
364
|
activeSubagents: new Set(),
|
|
365
|
+
deferredAgentIndexes: new Map(),
|
|
366
|
+
adoptingPrepared: new Map(),
|
|
354
367
|
memoryRoutes: new Map(),
|
|
355
368
|
mainRouteEpoch: 0,
|
|
356
369
|
mainRouteDecisionGeneration: 0,
|
|
@@ -446,8 +459,10 @@ export function startProxy(opts) {
|
|
|
446
459
|
]);
|
|
447
460
|
if (timer)
|
|
448
461
|
clearTimeout(timer);
|
|
449
|
-
if (completed)
|
|
462
|
+
if (completed) {
|
|
463
|
+
sendDeferredAgentIndexes(state);
|
|
450
464
|
return true;
|
|
465
|
+
}
|
|
451
466
|
// The grace period protects useful in-flight delivery. Once it
|
|
452
467
|
// expires, signal every forwarding path first so each can
|
|
453
468
|
// classify the close as proxy-owned, then tear down any request that was
|
|
@@ -456,6 +471,7 @@ export function startProxy(opts) {
|
|
|
456
471
|
for (const accepted of acceptedRequests)
|
|
457
472
|
cancelAcceptedRequest(accepted);
|
|
458
473
|
await quiesced;
|
|
474
|
+
sendDeferredAgentIndexes(state);
|
|
459
475
|
return false;
|
|
460
476
|
};
|
|
461
477
|
return new Promise((resolve, reject) => {
|
|
@@ -496,6 +512,9 @@ async function handleRequest(req, res, opts, upstream, state, hookPath) {
|
|
|
496
512
|
if (url.pathname === PRECOMPACT_HOOK_PATH) {
|
|
497
513
|
return handleNoticeHook(req, res, state, opts.reqlog, undefined, "PreCompact");
|
|
498
514
|
}
|
|
515
|
+
if (url.pathname === `${hookPath}${RESUME_GUIDANCE_HOOK_SUFFIX}`) {
|
|
516
|
+
return handleResumeGuidanceHook(req, res, state);
|
|
517
|
+
}
|
|
499
518
|
if (url.pathname === hookPath) {
|
|
500
519
|
return handleNoticeHook(req, res, state, opts.reqlog, (sessionId) => opts.memtree.scheduleFinalIndex(sessionId, (signal) => opts.transcriptUsage?.finalReplyAsync
|
|
501
520
|
? opts.transcriptUsage.finalReplyAsync(sessionId, signal)
|
|
@@ -727,6 +746,39 @@ function noteMainSessionStart(state, source, sessionId) {
|
|
|
727
746
|
state.resumableMainSession = undefined;
|
|
728
747
|
}
|
|
729
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* SessionStart on its own hook (resume-guidance.ts): only the current MemTree
|
|
751
|
+
* guidance, so Claude Code sees it as one context of its own and adds an
|
|
752
|
+
* identical one only once. Never fails the hook: anything unexpected is 204.
|
|
753
|
+
*/
|
|
754
|
+
async function handleResumeGuidanceHook(req, res, state) {
|
|
755
|
+
const raw = req.method === "POST" ? await readBodyWithin(req, HOOK_BODY_LIMIT) : undefined;
|
|
756
|
+
if (req.method !== "POST")
|
|
757
|
+
req.resume();
|
|
758
|
+
let input = null;
|
|
759
|
+
try {
|
|
760
|
+
if (raw !== undefined && raw.length > 0) {
|
|
761
|
+
input = parseNoticeHookInput(JSON.parse(raw.toString("utf-8")));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
input = null;
|
|
766
|
+
}
|
|
767
|
+
const context = input?.hook_event_name === "SessionStart"
|
|
768
|
+
? await resumeGuidanceContext(state, input) : undefined;
|
|
769
|
+
if (!context) {
|
|
770
|
+
res.writeHead(204);
|
|
771
|
+
res.end();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const body = Buffer.from(JSON.stringify(sessionStartOutput(undefined, context)), "utf-8");
|
|
775
|
+
res.writeHead(200, {
|
|
776
|
+
"content-type": "application/json",
|
|
777
|
+
"content-length": String(body.length),
|
|
778
|
+
"cache-control": "no-store",
|
|
779
|
+
});
|
|
780
|
+
res.end(body);
|
|
781
|
+
}
|
|
730
782
|
/** Serve only validated Claude hook POSTs on the randomized localhost path. */
|
|
731
783
|
async function handleNoticeHook(req, res, state, reqlog,
|
|
732
784
|
/** A main-lane turn ended: the session may be over (its final index). */
|
|
@@ -882,12 +934,24 @@ onlyEvent) {
|
|
|
882
934
|
}
|
|
883
935
|
if (parsed.hook_event_name === "SubagentStart") {
|
|
884
936
|
state.activeSubagents.add(parsed.agent_id);
|
|
885
|
-
|
|
886
|
-
|
|
937
|
+
const hint = await subagentHintContext(state, parsed.session_id);
|
|
938
|
+
if (hint === undefined) {
|
|
939
|
+
res.writeHead(204);
|
|
940
|
+
res.end();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const body = Buffer.from(JSON.stringify(subagentStartOutput(hint)), "utf-8");
|
|
944
|
+
res.writeHead(200, {
|
|
945
|
+
"content-type": "application/json",
|
|
946
|
+
"content-length": String(body.length),
|
|
947
|
+
"cache-control": "no-store",
|
|
948
|
+
});
|
|
949
|
+
res.end(body);
|
|
887
950
|
return;
|
|
888
951
|
}
|
|
889
952
|
if (parsed.hook_event_name === "SubagentStop") {
|
|
890
953
|
state.activeSubagents.delete(parsed.agent_id);
|
|
954
|
+
sendDeferredAgentIndex(state, parsed.agent_id);
|
|
891
955
|
res.writeHead(204);
|
|
892
956
|
res.end();
|
|
893
957
|
return;
|
|
@@ -981,6 +1045,8 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
981
1045
|
requestBytes: rawBody.length,
|
|
982
1046
|
claudeAuth: claudeAuthFromHeaders(req.headers),
|
|
983
1047
|
};
|
|
1048
|
+
// The prompt this request was made for, before any await (Codex r2 P2).
|
|
1049
|
+
noticeContexts.set(rec, { promptId: state.mainPromptId, generation: state.mainPromptGeneration });
|
|
984
1050
|
const logged = async (forward) => {
|
|
985
1051
|
try {
|
|
986
1052
|
await forward;
|
|
@@ -1220,7 +1286,11 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1220
1286
|
lane: isAwaySummary ? "away" : requestRouteLane === "main" ? "main" : "other" },
|
|
1221
1287
|
...(target !== undefined ? { target } : {}) });
|
|
1222
1288
|
},
|
|
1223
|
-
delivered: () => {
|
|
1289
|
+
delivered: () => {
|
|
1290
|
+
state.fallbackFailures.succeeded(requestRouteKey);
|
|
1291
|
+
state.refusalStreaks.delivered(requestRouteKey);
|
|
1292
|
+
toolForwarding.settle(true);
|
|
1293
|
+
},
|
|
1224
1294
|
});
|
|
1225
1295
|
const rawMsgsForMemtree = messagesWithSystem(messages, body.system);
|
|
1226
1296
|
const msgsForMemtree = normalizeMessagesForMemtree(rawMsgsForMemtree);
|
|
@@ -1349,7 +1419,7 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1349
1419
|
// prefix, else whole) with no compress call; at it, compress once to
|
|
1350
1420
|
// the target (planToolCompaction). A "replay" is a client retry of the
|
|
1351
1421
|
// request that installed the route, forwarded verbatim.
|
|
1352
|
-
const
|
|
1422
|
+
const planArgs = {
|
|
1353
1423
|
opts,
|
|
1354
1424
|
state,
|
|
1355
1425
|
body,
|
|
@@ -1364,7 +1434,24 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1364
1434
|
rec,
|
|
1365
1435
|
ride: toolRide,
|
|
1366
1436
|
canCompress,
|
|
1367
|
-
}
|
|
1437
|
+
};
|
|
1438
|
+
let plan = await planToolCompaction(planArgs);
|
|
1439
|
+
// Over the window with nothing to ride while the lane compresses: going without it
|
|
1440
|
+
// can only be refused (staging 2026-10-30: a 120 s compress, every tool turn meanwhile
|
|
1441
|
+
// refused). Wait for it, then plan again on what it installed.
|
|
1442
|
+
if (plan.kind === "compress" && plan.overWindow && !plan.fallback &&
|
|
1443
|
+
state.toolRecoveryAttemptedLanes.get(requestRouteKey)?.inFlight) {
|
|
1444
|
+
// Stops early if Claude Code drops this request meanwhile.
|
|
1445
|
+
const clientGone = new AbortController();
|
|
1446
|
+
const onClose = () => clientGone.abort();
|
|
1447
|
+
res.once("close", onClose);
|
|
1448
|
+
rec.laneWaitMs = await waitForLaneIdle(state, requestRouteKey, Math.min(MAX_LONG_COMPRESS_WAIT_MS, opts.memtree.longCompressBudgetMs), clientGone.signal);
|
|
1449
|
+
res.off("close", onClose);
|
|
1450
|
+
// Plan on what the lane holds now: the route its compression installed (or, on the
|
|
1451
|
+
// main lane, the session's new stable prefix), never the ride read before the wait.
|
|
1452
|
+
plan = await planToolCompaction({ ...planArgs,
|
|
1453
|
+
ride: currentLaneRide(state, requestRouteKey, body, messages, toolSessionId, isMainRequest) });
|
|
1454
|
+
}
|
|
1368
1455
|
if (isMainRequest && toolSessionId !== undefined && canCompress &&
|
|
1369
1456
|
(plan.kind === "ride" || plan.kind === "pass")) {
|
|
1370
1457
|
maybePrepareCompression({ opts, state, body, messages, msgsForMemtree, hash,
|
|
@@ -1422,6 +1509,10 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1422
1509
|
// the lane's backoff.
|
|
1423
1510
|
rec.routeRecovery = { outcome: "cooldown" };
|
|
1424
1511
|
}
|
|
1512
|
+
else if (state.toolRecoveryAttemptedLanes.get(requestRouteKey)?.inFlight) {
|
|
1513
|
+
// Claimed while the cooldown check above awaited a count (a crossing prompt).
|
|
1514
|
+
rec.routeRecovery = { outcome: "in-flight" };
|
|
1515
|
+
}
|
|
1425
1516
|
else {
|
|
1426
1517
|
const recoveryAttempt = { modelContextLimit, inFlight: true };
|
|
1427
1518
|
if (!prior && state.toolRecoveryAttemptedLanes.size >= 128) {
|
|
@@ -1488,7 +1579,8 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1488
1579
|
requestSendPolicies.get(req)?.allowed.add(toolRide.raw);
|
|
1489
1580
|
const sendBody = toolRide?.raw ?? (toolNeedsOriginal ? rawBody : forwardBody);
|
|
1490
1581
|
recordTurn(rec, toolRide ? toolRide.turnType : isUserTurn ? "first-user" : "tool", sendBody);
|
|
1491
|
-
opts
|
|
1582
|
+
indexTurn({ opts, state, req, rec, hash, msgsForMemtree, modelContextLimit,
|
|
1583
|
+
sessionId: toolSessionId, clientMeta });
|
|
1492
1584
|
capture(opts, toolRide ? "anthropic-request-memory-tool" : "anthropic-request", sendBody);
|
|
1493
1585
|
// The size Anthropic reports is the next budget check's anchor: a ride
|
|
1494
1586
|
// reports to its prefix (or route), a whole request to the session's (or
|
|
@@ -1559,6 +1651,32 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1559
1651
|
rec.compaction.prepared ??= prepared;
|
|
1560
1652
|
}
|
|
1561
1653
|
}
|
|
1654
|
+
// Never beside the main lane's compression in flight either (a tool turn's that Claude
|
|
1655
|
+
// Code dropped, or another prompt's, both after the preparation they waited for failed):
|
|
1656
|
+
// wait for it, then plan again on what it left: a prefix to ride, or a preparation.
|
|
1657
|
+
// Bounded; stops early if Claude Code drops this request (release 1.0.33 E2E).
|
|
1658
|
+
const crossing = () => edge?.kind === "compress" && isMainRequest &&
|
|
1659
|
+
isAutoCrossing(rec, body.model, modelContextLimit);
|
|
1660
|
+
for (let i = 0; i < 2 && crossing() &&
|
|
1661
|
+
state.toolRecoveryAttemptedLanes.get(requestRouteKey)?.inFlight; i++) {
|
|
1662
|
+
const clientGone = new AbortController();
|
|
1663
|
+
const onClose = () => clientGone.abort();
|
|
1664
|
+
res.once("close", onClose);
|
|
1665
|
+
if (res.destroyed)
|
|
1666
|
+
clientGone.abort();
|
|
1667
|
+
rec.laneWaitMs = (rec.laneWaitMs ?? 0) + await waitForLaneIdle(state, requestRouteKey, Math.min(MAX_LONG_COMPRESS_WAIT_MS, opts.memtree.longCompressBudgetMs), clientGone.signal);
|
|
1668
|
+
res.off("close", onClose);
|
|
1669
|
+
await awaitPreparation(state, sessionId, opts.memtree.longCompressBudgetMs, rec.compaction);
|
|
1670
|
+
edge = await planEdge();
|
|
1671
|
+
}
|
|
1672
|
+
// Claude Code dropped it while it waited: the lane's compression in flight already carries
|
|
1673
|
+
// this session's crossing (the next turn rides it), so none beside it for nobody (r3 P1).
|
|
1674
|
+
if (crossing() && state.toolRecoveryAttemptedLanes.get(requestRouteKey)?.inFlight &&
|
|
1675
|
+
res.destroyed && !res.writableFinished) {
|
|
1676
|
+
releaseUncommittedRouteDecision();
|
|
1677
|
+
recordTurn(rec, "followup-client-closed", Buffer.alloc(0));
|
|
1678
|
+
return logged(Promise.resolve());
|
|
1679
|
+
}
|
|
1562
1680
|
// Not beside a blocking compress call: the two would overlap on the server.
|
|
1563
1681
|
if (sessionId !== undefined && edge && (edge.kind === "ride" || edge.kind === "pass")) {
|
|
1564
1682
|
maybePrepareCompression({ opts, state, body, messages, msgsForMemtree, hash, sessionId,
|
|
@@ -1787,9 +1905,17 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1787
1905
|
// The hidden away-summary request compresses too, but its page is not the
|
|
1788
1906
|
// conversation the user is looking at.
|
|
1789
1907
|
const linkSeq = isMainRequest && !isAwaySummary ? nextMemtreeCallSeq(state) : undefined;
|
|
1908
|
+
const edgeCarry = edge?.kind === "compress" && !isAwaySummary
|
|
1909
|
+
? carryFor({ state, isMainRequest, sessionId, body, messages, msgsForMemtree,
|
|
1910
|
+
modelContextLimit, rec,
|
|
1911
|
+
targetTokens: edge.targetTokens, explicitTarget: edge.explicitTarget, linkSeq })
|
|
1912
|
+
: undefined;
|
|
1790
1913
|
let compression;
|
|
1914
|
+
// A main-thread compaction holds its lane, claimed with no await since the check above:
|
|
1915
|
+
// a concurrent turn of the lane waits for it or rides its fallback, never compresses beside it.
|
|
1916
|
+
const edgeLane = (run) => crossing() ? holdingLane(state, requestRouteKey, run) : run();
|
|
1791
1917
|
try {
|
|
1792
|
-
compression = await runBlockingCompression({
|
|
1918
|
+
compression = await edgeLane(async () => runBlockingCompression({
|
|
1793
1919
|
opts,
|
|
1794
1920
|
state,
|
|
1795
1921
|
body,
|
|
@@ -1809,9 +1935,10 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1809
1935
|
: {}),
|
|
1810
1936
|
// Riding the old prefix, or sending a raw request that fits, keeps the
|
|
1811
1937
|
// short budget; with neither, a timeout could only end in a refusal.
|
|
1812
|
-
longBudget: !(edge?.kind === "compress" && edge.fallback) &&
|
|
1938
|
+
longBudget: !(edge?.kind === "compress" && edge.fallback && !edge.fallback.uncounted) &&
|
|
1813
1939
|
await wholeRequestRefused(requestSizing, forwardCandidate, sizingLimits()),
|
|
1814
|
-
|
|
1940
|
+
...(edgeCarry ? { carry: edgeCarry } : {}),
|
|
1941
|
+
}));
|
|
1815
1942
|
}
|
|
1816
1943
|
catch {
|
|
1817
1944
|
const kept = rideOldPrefix(false);
|
|
@@ -1851,6 +1978,9 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1851
1978
|
if (downstreamClosedDuringCompression ||
|
|
1852
1979
|
(res.destroyed && !res.writableFinished)) {
|
|
1853
1980
|
releaseUncommittedRouteDecision();
|
|
1981
|
+
// Claude Code gave up on the turn, not on the compaction: the next turn rides it.
|
|
1982
|
+
if (result && edgeCarry)
|
|
1983
|
+
carryOverCompression(state, edgeCarry, Promise.resolve(result));
|
|
1854
1984
|
recordTurn(rec, "followup-client-closed", Buffer.alloc(0));
|
|
1855
1985
|
return logged(Promise.resolve());
|
|
1856
1986
|
}
|
|
@@ -1858,6 +1988,28 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1858
1988
|
// Each forwarder installs its own close listener synchronously, so this is
|
|
1859
1989
|
// an event-loop-atomic handoff of downstream-close ownership.
|
|
1860
1990
|
if (!result) {
|
|
1991
|
+
const paymentDetail = opts.memtree.paymentRequiredDetail;
|
|
1992
|
+
const mayQueueNotice = displayForThisTurn && state.mainPromptGeneration === noticePromptGeneration;
|
|
1993
|
+
if (mayQueueNotice && paymentDetail === null && !state.keyInvalid()) {
|
|
1994
|
+
// Under the budget the whole request is what compression would have
|
|
1995
|
+
// sent: the turn is not degraded, so the failure stays in the logs.
|
|
1996
|
+
// Delivery decides the rest (selectForwardCandidate): only a whole
|
|
1997
|
+
// history actually sent warns. A refusal (Claude Code retries it) or a
|
|
1998
|
+
// ride on a compressed prefix did not run uncompressed (2026-10-28).
|
|
1999
|
+
const eager = compressionNeeded(rec.compaction);
|
|
2000
|
+
// Delivery sizes the request again (an exact count, else a
|
|
2001
|
+
// conservative bound): a planned fit it finds over the budget warns then.
|
|
2002
|
+
if (!eager)
|
|
2003
|
+
rec.degradedNotice = "quiet-under-budget";
|
|
2004
|
+
degradedNotices.set(rec, { eager, warn: () => {
|
|
2005
|
+
// A 402 or refused key meanwhile keeps its own, actionable notice (Codex r2 P2).
|
|
2006
|
+
if (state.mainPromptGeneration !== noticePromptGeneration ||
|
|
2007
|
+
opts.memtree.paymentRequiredDetail !== null || state.keyInvalid())
|
|
2008
|
+
return;
|
|
2009
|
+
state.notices.queueSuffix(DEGRADED_NOTICE, undefined, noticePromptId);
|
|
2010
|
+
rec.degradedNotice = "shown";
|
|
2011
|
+
} });
|
|
2012
|
+
}
|
|
1861
2013
|
const kept = rideOldPrefix(false);
|
|
1862
2014
|
if (kept)
|
|
1863
2015
|
return kept;
|
|
@@ -1872,8 +2024,6 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1872
2024
|
releaseForOriginalFallback();
|
|
1873
2025
|
sendingWhole();
|
|
1874
2026
|
capture(opts, "anthropic-request", forwardBody);
|
|
1875
|
-
const paymentDetail = opts.memtree.paymentRequiredDetail;
|
|
1876
|
-
const mayQueueNotice = displayForThisTurn && state.mainPromptGeneration === noticePromptGeneration;
|
|
1877
2027
|
const overBudget = reachedBudget(rec.compaction);
|
|
1878
2028
|
if (state.keyInvalid()) {
|
|
1879
2029
|
// A refused key (401/403) says why MemTree is off and how to fix it;
|
|
@@ -1897,26 +2047,6 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1897
2047
|
if (overBudget)
|
|
1898
2048
|
state.nearWindowNotice = handle;
|
|
1899
2049
|
}
|
|
1900
|
-
else if (mayQueueNotice && paymentDetail === null) {
|
|
1901
|
-
// Under the budget the whole request is what compression would have
|
|
1902
|
-
// sent: the turn is not degraded, so the failure stays in the logs.
|
|
1903
|
-
const warn = () => {
|
|
1904
|
-
state.notices.queueSuffix(DEGRADED_NOTICE, undefined, noticePromptId);
|
|
1905
|
-
rec.degradedNotice = "shown";
|
|
1906
|
-
};
|
|
1907
|
-
if (compressionNeeded(rec.compaction)) {
|
|
1908
|
-
warn();
|
|
1909
|
-
}
|
|
1910
|
-
else {
|
|
1911
|
-
// Delivery sizes the request again (an exact count, else a
|
|
1912
|
-
// conservative bound): a turn it finds over the budget warns then.
|
|
1913
|
-
rec.degradedNotice = "quiet-under-budget";
|
|
1914
|
-
quietDegrades.set(rec, () => {
|
|
1915
|
-
if (state.mainPromptGeneration === noticePromptGeneration)
|
|
1916
|
-
warn();
|
|
1917
|
-
});
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
2050
|
return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
|
|
1921
2051
|
noteWholeSize();
|
|
1922
2052
|
if (delivered)
|
|
@@ -2224,6 +2354,48 @@ async function projectTreesContext(hint, input) {
|
|
|
2224
2354
|
return undefined;
|
|
2225
2355
|
}
|
|
2226
2356
|
}
|
|
2357
|
+
/**
|
|
2358
|
+
* The parent's memory hint for a starting subagent: only while MemTree works
|
|
2359
|
+
* and the session has the tools, naming the parent's newest tree (a bounded
|
|
2360
|
+
* wait when no answer is cached). Undefined otherwise; never throws.
|
|
2361
|
+
*/
|
|
2362
|
+
async function subagentHintContext(state, sessionId) {
|
|
2363
|
+
if (!state.subagentHint?.enabled || !memtreeWorking(state))
|
|
2364
|
+
return undefined;
|
|
2365
|
+
try {
|
|
2366
|
+
// A cached tree will do (peek refreshes it in the background); wait only for none.
|
|
2367
|
+
const tree = state.newestTrees?.peek(sessionId) ?? await state.newestTrees?.settle(sessionId);
|
|
2368
|
+
// MemTree may have paused or failed while the lookup was on its way.
|
|
2369
|
+
if (!tree || !state.subagentHint.enabled || !memtreeWorking(state))
|
|
2370
|
+
return undefined;
|
|
2371
|
+
return subagentMemoryHintText(sessionId, tree.key);
|
|
2372
|
+
}
|
|
2373
|
+
catch {
|
|
2374
|
+
return undefined;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
/** The current MemTree guidance for a resumed main session (resume-guidance.ts). */
|
|
2378
|
+
async function resumeGuidanceContext(state, input) {
|
|
2379
|
+
try {
|
|
2380
|
+
return await state.resumeGuidance?.contextFor(input, {
|
|
2381
|
+
working: () => resumeMemtreeWorking(state),
|
|
2382
|
+
compressed: () => currentMemtreePage(state, input.session_id)?.compressed,
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
catch {
|
|
2386
|
+
return undefined;
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* Whether MemTree works as a session resumes: not paused, key not refused,
|
|
2391
|
+
* and its latest answer, if any, a success. A new `ccc --resume` process has
|
|
2392
|
+
* no answer yet; its startup key check already turned a refused or unpaid
|
|
2393
|
+
* key into no guidance (resumeGuidanceFor).
|
|
2394
|
+
*/
|
|
2395
|
+
function resumeMemtreeWorking(state) {
|
|
2396
|
+
return state.memtreeAnswering() !== false && state.unpaidDetail() === null &&
|
|
2397
|
+
!state.keyInvalid();
|
|
2398
|
+
}
|
|
2227
2399
|
/**
|
|
2228
2400
|
* The SessionStart hook's answer: the banner line for the user
|
|
2229
2401
|
* (`systemMessage`), model context (`additionalContext`), or both.
|
|
@@ -2251,15 +2423,14 @@ function sessionCommandReply(state, sessionId, prompt) {
|
|
|
2251
2423
|
: pausedCompactionText(viewUrl(subscribeUrlFromDetail(unpaid)));
|
|
2252
2424
|
if (sessionCommandArgs(prompt, MEMTREE_HELP_COMMAND) !== undefined) {
|
|
2253
2425
|
return [
|
|
2254
|
-
"• /memtree-view ·
|
|
2426
|
+
"• /memtree-view · open this session's MemTree page and show its link",
|
|
2255
2427
|
paused !== undefined ? `• ${paused}`
|
|
2256
2428
|
: `• /memtree-compact [tokens | off] · compact this session on your next message (default half the budget, at least ${MEMTREE_COMPACT_MIN_TOKENS / 1000}k)`,
|
|
2257
2429
|
usageLines(state),
|
|
2258
2430
|
].filter((line) => line !== undefined).join("\n");
|
|
2259
2431
|
}
|
|
2260
|
-
if (isMemtreeViewCommand(prompt))
|
|
2261
|
-
return
|
|
2262
|
-
}
|
|
2432
|
+
if (isMemtreeViewCommand(prompt))
|
|
2433
|
+
return memtreeView(state, sessionId);
|
|
2263
2434
|
const args = sessionCommandArgs(prompt, MEMTREE_COMPACT_COMMAND);
|
|
2264
2435
|
if (args === undefined)
|
|
2265
2436
|
return undefined;
|
|
@@ -2303,10 +2474,8 @@ export const SUBAGENT_COMPACT_REPLY = "MemTree compact applies to the main sessi
|
|
|
2303
2474
|
* is refused rather than applied to the main conversation from here.
|
|
2304
2475
|
*/
|
|
2305
2476
|
function subagentCommandReply(state, sessionId, prompt) {
|
|
2306
|
-
if (isMemtreeViewCommand(prompt))
|
|
2307
|
-
|
|
2308
|
-
return joinNoticeLines(view, usageLines(state)) ?? view;
|
|
2309
|
-
}
|
|
2477
|
+
if (isMemtreeViewCommand(prompt))
|
|
2478
|
+
return `${memtreeView(state, sessionId)}\n${SUBAGENT_VIEW_NOTE}`;
|
|
2310
2479
|
if (sessionCommandArgs(prompt, MEMTREE_COMPACT_COMMAND) !== undefined) {
|
|
2311
2480
|
return `${TRAILER_LABEL} ${SUBAGENT_COMPACT_REPLY}`;
|
|
2312
2481
|
}
|
|
@@ -2321,8 +2490,13 @@ export function parseTokenCount(text) {
|
|
|
2321
2490
|
const value = Math.round(Number(match[1]) * scale);
|
|
2322
2491
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
2323
2492
|
}
|
|
2324
|
-
/**
|
|
2325
|
-
|
|
2493
|
+
/**
|
|
2494
|
+
* The `/memtree-view` answer: one link, this session's newest page (the
|
|
2495
|
+
* newest completed tree, which covers the whole session, rather than the
|
|
2496
|
+
* possibly stale page its memory was compressed from), opened in the browser
|
|
2497
|
+
* where the CLI allows it; or why there is none.
|
|
2498
|
+
*/
|
|
2499
|
+
function memtreeView(state, sessionId) {
|
|
2326
2500
|
const inMemory = state.memtreePages.get(sessionId);
|
|
2327
2501
|
const page = inMemory && (inMemory.sessionId === undefined || inMemory.sessionId === sessionId)
|
|
2328
2502
|
? inMemory
|
|
@@ -2331,15 +2505,17 @@ function memtreeViewLine(state, sessionId) {
|
|
|
2331
2505
|
if (!link) {
|
|
2332
2506
|
return `${TRAILER_LABEL} no page yet: this session has not been indexed. The link appears once it has.`;
|
|
2333
2507
|
}
|
|
2508
|
+
try {
|
|
2509
|
+
state.openUrl?.(link.link);
|
|
2510
|
+
}
|
|
2511
|
+
catch {
|
|
2512
|
+
// The printed link below is the fallback.
|
|
2513
|
+
}
|
|
2334
2514
|
// Blocked-prompt reasons need an explicit hyperlink in styled terminals.
|
|
2335
|
-
|
|
2336
|
-
// The memory the agent works from can be an older tree than the newest one.
|
|
2337
|
-
return page?.compressed && page.url !== link.link
|
|
2338
|
-
? `${lines}\n${urlLine(viewUrl(page.url), "memory from ")}`
|
|
2339
|
-
: lines;
|
|
2515
|
+
return linkLines(LINK_LABEL, viewUrl(link.link), link.note);
|
|
2340
2516
|
}
|
|
2341
2517
|
/**
|
|
2342
|
-
* The usage dashboard link that `/memtree`
|
|
2518
|
+
* The usage dashboard link that `/memtree` ends with,
|
|
2343
2519
|
* formatted like the MemTree link; undefined when the environment has none.
|
|
2344
2520
|
*/
|
|
2345
2521
|
function usageLines(state) {
|
|
@@ -2830,6 +3006,15 @@ function calibratedSizeExceedsWindow(record, body, modelContextLimit) {
|
|
|
2830
3006
|
return record.uncountedOverWindow === true || (record.sizeSource === "reported" &&
|
|
2831
3007
|
(record.estimatedTokens ?? 0) + outputTokens > modelContextLimit);
|
|
2832
3008
|
}
|
|
3009
|
+
/**
|
|
3010
|
+
* Over the window (calibratedSizeExceedsWindow) only by the uncounted
|
|
3011
|
+
* conservative bound: a calibrated estimate that fits the window with the
|
|
3012
|
+
* full max_tokens says otherwise.
|
|
3013
|
+
*/
|
|
3014
|
+
function uncountedOnlyOverWindow(record, body, modelContextLimit) {
|
|
3015
|
+
return record.uncountedOverWindow === true && record.sizeSource === "reported" &&
|
|
3016
|
+
(record.estimatedTokens ?? 0) + bodyOutputReservation(body, false) <= modelContextLimit;
|
|
3017
|
+
}
|
|
2833
3018
|
function calibratedSizeExceedsLimits(record, body, modelContextLimit) {
|
|
2834
3019
|
return (record.sizeSource === "reported" &&
|
|
2835
3020
|
(record.estimatedTokens ?? 0) >= record.budgetTokens) ||
|
|
@@ -3050,7 +3235,14 @@ async function planEdgeCompaction(args) {
|
|
|
3050
3235
|
// image-aware bound), not bytes: a flatten that kept images is mostly
|
|
3051
3236
|
// base64 that costs a fixed amount per image.
|
|
3052
3237
|
const overWindow = calibratedSizeExceedsWindow(compaction, body, modelContextLimit);
|
|
3053
|
-
|
|
3238
|
+
// Over the window only by the conservative bound of a ride whose planning
|
|
3239
|
+
// count timed out: delivery counts it again before sending it, so it stays
|
|
3240
|
+
// the fallback a failed compress rides (2026-10-28: a 528k ride whose
|
|
3241
|
+
// count timed out was dropped, and the failed compress refused the turn).
|
|
3242
|
+
const uncounted = overWindow && uncountedOnlyOverWindow(compaction, body, modelContextLimit) &&
|
|
3243
|
+
args.sizing.canRetryCount(prefixCandidate);
|
|
3244
|
+
const fallback = overWindow && !uncounted
|
|
3245
|
+
? undefined : { prefix, routed, ...(uncounted ? { uncounted: true } : {}) };
|
|
3054
3246
|
if (state.compactNow.has(sessionId))
|
|
3055
3247
|
return forced("manual", fallback);
|
|
3056
3248
|
if (prefix.modelContextLimit !== modelContextLimit ||
|
|
@@ -3174,6 +3366,16 @@ async function planToolCompaction(args) {
|
|
|
3174
3366
|
return { kind: "ride",
|
|
3175
3367
|
ride: { raw: adopted.routed.raw, turnType: "tool-prefix", sizeHolder: adopted.prefix } };
|
|
3176
3368
|
}
|
|
3369
|
+
// Another turn that waited for the same preparation installed it meanwhile: plan again on
|
|
3370
|
+
// it (once) rather than compress beside it (Codex r1 P1, release 1.0.33).
|
|
3371
|
+
if (stableLane && !args.replanned && state.stablePrefixes.get(sessionId) !== current) {
|
|
3372
|
+
const waited = compaction.preparedWaitMs;
|
|
3373
|
+
const plan = await planToolCompaction({ ...args, replanned: true,
|
|
3374
|
+
ride: currentLaneRide(state, args.routeKey, body, messages, sessionId, true) });
|
|
3375
|
+
if (waited !== undefined && rec.compaction)
|
|
3376
|
+
rec.compaction.preparedWaitMs = waited;
|
|
3377
|
+
return plan;
|
|
3378
|
+
}
|
|
3177
3379
|
// Settings current after any wait for a preparation (a /memtree-compact N meanwhile).
|
|
3178
3380
|
const modeNow = compactionMode(opts, state, sessionId);
|
|
3179
3381
|
if (modeNow.mode === "off")
|
|
@@ -3233,10 +3435,49 @@ async function adoptPreparedPrefix(args) {
|
|
|
3233
3435
|
const { state, sessionId } = args;
|
|
3234
3436
|
const base = state.stablePrefixes.get(sessionId);
|
|
3235
3437
|
await awaitPreparation(state, sessionId, args.waitMs, args.compaction);
|
|
3438
|
+
// Checked and taken with no await between: two turns resuming together see one owner.
|
|
3439
|
+
while (state.adoptingPrepared.has(sessionId))
|
|
3440
|
+
await awaitAdoption(state, sessionId, args.compaction);
|
|
3236
3441
|
const entry = state.prepared.get(sessionId);
|
|
3237
3442
|
if (!entry)
|
|
3238
3443
|
return undefined;
|
|
3239
3444
|
state.prepared.discard(sessionId);
|
|
3445
|
+
const release = holdAdoption(state, sessionId);
|
|
3446
|
+
try {
|
|
3447
|
+
return await adoptEntry(args, entry, base);
|
|
3448
|
+
}
|
|
3449
|
+
finally {
|
|
3450
|
+
release();
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
/**
|
|
3454
|
+
* Another turn of the session is adopting its prepared prefix (two turns waited for the same
|
|
3455
|
+
* preparation): wait for it to finish, so this turn plans on the prefix it installs instead of
|
|
3456
|
+
* compressing again (Codex r1 P1, release 1.0.33). Counted as preparation wait, so a human
|
|
3457
|
+
* turn plans again after it.
|
|
3458
|
+
*/
|
|
3459
|
+
async function awaitAdoption(state, sessionId, compaction) {
|
|
3460
|
+
const adopting = state.adoptingPrepared.get(sessionId);
|
|
3461
|
+
if (!adopting)
|
|
3462
|
+
return;
|
|
3463
|
+
const started = Date.now();
|
|
3464
|
+
await adopting;
|
|
3465
|
+
compaction.preparedWaitMs = (compaction.preparedWaitMs ?? 0) + (Date.now() - started);
|
|
3466
|
+
}
|
|
3467
|
+
/** Mark the session's prepared prefix as being adopted; the returned function ends it. */
|
|
3468
|
+
function holdAdoption(state, sessionId) {
|
|
3469
|
+
let done;
|
|
3470
|
+
const adopting = new Promise((resolve) => { done = resolve; });
|
|
3471
|
+
state.adoptingPrepared.set(sessionId, adopting);
|
|
3472
|
+
return () => {
|
|
3473
|
+
if (state.adoptingPrepared.get(sessionId) === adopting)
|
|
3474
|
+
state.adoptingPrepared.delete(sessionId);
|
|
3475
|
+
done();
|
|
3476
|
+
};
|
|
3477
|
+
}
|
|
3478
|
+
/** adoptPreparedPrefix's check and install of the entry it took. */
|
|
3479
|
+
async function adoptEntry(args, entry, base) {
|
|
3480
|
+
const { state, sessionId } = args;
|
|
3240
3481
|
const fit = await preparedFit(args, entry);
|
|
3241
3482
|
// Another request installed a prefix, or `/memtree-compact` asked for its
|
|
3242
3483
|
// own target, while this one waited or counted: that wins.
|
|
@@ -3365,17 +3606,98 @@ function maybePrepareCompression(args) {
|
|
|
3365
3606
|
});
|
|
3366
3607
|
boundedSet(state.preparing, sessionId, pending);
|
|
3367
3608
|
}
|
|
3609
|
+
/**
|
|
3610
|
+
* A subagent's share of its budget from which it is indexed on every tool turn, as the
|
|
3611
|
+
* main thread is; below it, only once, when it ends. 2026-10-30: 29 subagents ran ~32
|
|
3612
|
+
* index jobs each ($45.63 a week for one session), and none came near its budget, so no
|
|
3613
|
+
* subagent tree was ever used to compress. Its tree is still built (at SubagentStop, or
|
|
3614
|
+
* at exit), so MemTree search and the page still find it.
|
|
3615
|
+
*/
|
|
3616
|
+
const AGENT_PER_TURN_INDEX_SHARE = 0.5;
|
|
3617
|
+
/** Subagents whose unsent index is kept; the oldest past this is sent at once. */
|
|
3618
|
+
const DEFERRED_AGENT_INDEX_MAX = 64;
|
|
3619
|
+
/**
|
|
3620
|
+
* A tool turn's background index call: now on the main thread and for a subagent past
|
|
3621
|
+
* AGENT_PER_TURN_INDEX_SHARE of its budget; otherwise kept, replacing the subagent's
|
|
3622
|
+
* previous one, until it ends (sendDeferredAgentIndex).
|
|
3623
|
+
*/
|
|
3624
|
+
function indexTurn(args) {
|
|
3625
|
+
const { opts, state, req, sessionId } = args;
|
|
3626
|
+
const send = (mode) => opts.memtree.indexInBackground(args.hash, args.msgsForMemtree, args.modelContextLimit, sessionId, args.clientMeta, transcriptTimesFor(opts, sessionId, agentAttributionId(req)), mode === "final" ? { finalIndex: true } : mode === "flush" ? { force: true } : {});
|
|
3627
|
+
// Only a request with its own agent id (the one SubagentStop names) waits for its end; one
|
|
3628
|
+
// attributed by its parent's id only is indexed per turn as before.
|
|
3629
|
+
const agentId = firstNonEmptyHeader(req, "x-claude-code-agent-id");
|
|
3630
|
+
if (agentId === undefined || sessionId === undefined)
|
|
3631
|
+
return send();
|
|
3632
|
+
const compaction = args.rec.compaction;
|
|
3633
|
+
if (compaction?.estimatedTokens !== undefined &&
|
|
3634
|
+
compaction.estimatedTokens >= compaction.budgetTokens * AGENT_PER_TURN_INDEX_SHARE) {
|
|
3635
|
+
send();
|
|
3636
|
+
// Kept too: its last turns under the text minimum still go at its end (forced).
|
|
3637
|
+
keepDeferredIndex(state, deferredIndexKey(agentId, args.msgsForMemtree), { messages: args.msgsForMemtree, send });
|
|
3638
|
+
return;
|
|
3639
|
+
}
|
|
3640
|
+
// Per agent and conversation: a side request made under the subagent's (or its parent's)
|
|
3641
|
+
// id is another conversation and must not replace the subagent's own.
|
|
3642
|
+
keepDeferredIndex(state, deferredIndexKey(agentId, args.msgsForMemtree), { messages: args.msgsForMemtree, send });
|
|
3643
|
+
}
|
|
3644
|
+
/** Keep `entry` as its key's newest unsent index call (bounded). */
|
|
3645
|
+
function keepDeferredIndex(state, key, entry) {
|
|
3646
|
+
const kept = state.deferredAgentIndexes.get(key);
|
|
3647
|
+
// A rewind or another branch: the kept history is not part of this one, so it goes now.
|
|
3648
|
+
// So does one holding media Claude Code has evicted since (never uploaded otherwise).
|
|
3649
|
+
if (kept && (!extendsHistory(entry.messages, kept.messages) ||
|
|
3650
|
+
losesMedia(kept.messages, entry.messages)))
|
|
3651
|
+
sendDeferredIndex(state, key, "flush");
|
|
3652
|
+
state.deferredAgentIndexes.delete(key);
|
|
3653
|
+
state.deferredAgentIndexes.set(key, entry);
|
|
3654
|
+
while (state.deferredAgentIndexes.size > DEFERRED_AGENT_INDEX_MAX) {
|
|
3655
|
+
const oldest = state.deferredAgentIndexes.keys().next().value;
|
|
3656
|
+
if (oldest === undefined)
|
|
3657
|
+
break;
|
|
3658
|
+
sendDeferredIndex(state, oldest, "flush");
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
/** A deferred index's key: the agent id and its conversation's first message. */
|
|
3662
|
+
function deferredIndexKey(agentId, messages) {
|
|
3663
|
+
const first = messages.find((message) => message.role !== "system");
|
|
3664
|
+
return JSON.stringify([agentId, first ? mediaFreeMessageHash(first) : ""]);
|
|
3665
|
+
}
|
|
3666
|
+
/** A subagent ended: send each of its conversations' kept index call, whatever its size. */
|
|
3667
|
+
function sendDeferredAgentIndex(state, agentId) {
|
|
3668
|
+
for (const key of [...state.deferredAgentIndexes.keys()]) {
|
|
3669
|
+
if (JSON.parse(key)[0] === agentId)
|
|
3670
|
+
sendDeferredIndex(state, key, "final");
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
function sendDeferredIndex(state, key, mode) {
|
|
3674
|
+
const kept = state.deferredAgentIndexes.get(key);
|
|
3675
|
+
state.deferredAgentIndexes.delete(key);
|
|
3676
|
+
kept?.send(mode);
|
|
3677
|
+
}
|
|
3678
|
+
/** At exit: every subagent's kept index calls. */
|
|
3679
|
+
function sendDeferredAgentIndexes(state) {
|
|
3680
|
+
for (const key of [...state.deferredAgentIndexes.keys()])
|
|
3681
|
+
sendDeferredIndex(state, key, "final");
|
|
3682
|
+
}
|
|
3368
3683
|
/** The background prepare call and the prefix it yields (maybePrepareCompression). */
|
|
3369
3684
|
async function prepareInBackground(args) {
|
|
3370
|
-
const { opts, state, body, msgsForMemtree, modelContextLimit
|
|
3685
|
+
const { opts, state, body, msgsForMemtree, modelContextLimit } = args;
|
|
3371
3686
|
const result = await opts.memtree.compress(args.hash, msgsForMemtree, modelContextLimit, state.backgroundSignal, preparedCompressMeta(args));
|
|
3372
3687
|
noteServerBudget(state, body.model, modelContextLimit, result);
|
|
3373
3688
|
if (!result)
|
|
3374
3689
|
return undefined;
|
|
3375
3690
|
state.prepared.noteServerAnswer(result.prepared === true);
|
|
3376
|
-
if (result.prepared !== true
|
|
3377
|
-
|
|
3691
|
+
if (result.prepared !== true)
|
|
3692
|
+
return undefined;
|
|
3693
|
+
return preparedFromResult(args, result);
|
|
3694
|
+
}
|
|
3695
|
+
/** A compressed answer for a session's history, as its prepared prefix (or undefined). */
|
|
3696
|
+
function preparedFromResult(args, result) {
|
|
3697
|
+
const { body, sessionId, modelContextLimit } = args;
|
|
3698
|
+
if (!didMemtreeCompress(result) || !checkCompressedHistory(result, args.msgsForMemtree).usable) {
|
|
3378
3699
|
return undefined;
|
|
3700
|
+
}
|
|
3379
3701
|
const built = buildCompressedBody(body, result);
|
|
3380
3702
|
if (!built)
|
|
3381
3703
|
return undefined;
|
|
@@ -3390,11 +3712,83 @@ async function prepareInBackground(args) {
|
|
|
3390
3712
|
explicitTarget: args.explicitTarget,
|
|
3391
3713
|
modelContextLimit,
|
|
3392
3714
|
};
|
|
3393
|
-
const source = result.memtreeUrl && result.memtreeIndex
|
|
3715
|
+
const source = result.memtreeUrl && result.memtreeIndex && args.linkSeq !== undefined
|
|
3394
3716
|
? { url: result.memtreeUrl, index: result.memtreeIndex, seq: args.linkSeq } : undefined;
|
|
3395
3717
|
return { prefix, atTokens: args.tokens, triggerTokens: args.triggerTokens,
|
|
3396
3718
|
...(source ? { source } : {}) };
|
|
3397
3719
|
}
|
|
3720
|
+
/**
|
|
3721
|
+
* Whether a main-thread compaction is a budget crossing at the automatic target where
|
|
3722
|
+
* preparations exist: the turns that may race one (a dropped crossing turn and the next
|
|
3723
|
+
* prompt) are serialized on its lane, as carryFor carries it.
|
|
3724
|
+
*/
|
|
3725
|
+
function isAutoCrossing(rec, model, modelContextLimit) {
|
|
3726
|
+
const compaction = rec.compaction;
|
|
3727
|
+
return compaction?.mode === "auto" &&
|
|
3728
|
+
(compaction.reason === "budget" || compaction.reason === "prefix-mismatch") &&
|
|
3729
|
+
preparesCompression(model, modelContextLimit);
|
|
3730
|
+
}
|
|
3731
|
+
/** A main-thread compaction's CarryOver, when its plan has the automatic target. */
|
|
3732
|
+
function carryFor(args) {
|
|
3733
|
+
const compaction = args.rec.compaction;
|
|
3734
|
+
// Where prepared prefixes exist (preparesCompression): the turn after rides it. Not a
|
|
3735
|
+
// /memtree-compact (adoption refuses it) nor beside a preparation (the carry would be
|
|
3736
|
+
// refused, its call left running for nothing).
|
|
3737
|
+
if (!args.isMainRequest || args.sessionId === undefined || args.explicitTarget !== false ||
|
|
3738
|
+
args.targetTokens === undefined || compaction?.mode !== "auto" ||
|
|
3739
|
+
// A crossing only: a call the server decides (maybe a passthrough) is not waited for.
|
|
3740
|
+
(compaction.reason !== "budget" && compaction.reason !== "prefix-mismatch") || args.state.compactNow.has(args.sessionId) ||
|
|
3741
|
+
args.state.preparing.has(args.sessionId) || args.state.prepared.isInFlight(args.sessionId) ||
|
|
3742
|
+
args.state.backgroundSignal.aborted ||
|
|
3743
|
+
!preparesCompression(args.body.model, args.modelContextLimit))
|
|
3744
|
+
return undefined;
|
|
3745
|
+
return { body: args.body, messages: args.messages, msgsForMemtree: args.msgsForMemtree,
|
|
3746
|
+
sessionId: args.sessionId, modelContextLimit: args.modelContextLimit,
|
|
3747
|
+
tokens: compaction.estimatedTokens ?? 0, triggerTokens: compaction.budgetTokens,
|
|
3748
|
+
targetTokens: args.targetTokens, explicitTarget: false,
|
|
3749
|
+
...(args.linkSeq !== undefined ? { linkSeq: args.linkSeq } : {}),
|
|
3750
|
+
base: args.state.stablePrefixes.get(args.sessionId) };
|
|
3751
|
+
}
|
|
3752
|
+
/**
|
|
3753
|
+
* A main-thread compaction its turn stopped waiting for (its budget ran out) becomes the
|
|
3754
|
+
* session's preparation: it runs on to the long budget, the session's next turn at the
|
|
3755
|
+
* budget waits for it (awaitPreparation) instead of starting another, and rides its
|
|
3756
|
+
* answer as a prepared prefix. Before, each turn gave up on its call and started a new
|
|
3757
|
+
* one beside it: 2026-10-30, eleven 26 MB compressions in ten minutes, each billed, none
|
|
3758
|
+
* used. False when the session already has a preparation in flight (it serves instead).
|
|
3759
|
+
*/
|
|
3760
|
+
function carryOverCompression(state, carry, pending) {
|
|
3761
|
+
const { sessionId, base } = carry;
|
|
3762
|
+
// A prefix installed since the call started (another turn's) supersedes it already.
|
|
3763
|
+
if (state.backgroundSignal.aborted || state.preparing.has(sessionId) ||
|
|
3764
|
+
state.stablePrefixes.get(sessionId) !== base ||
|
|
3765
|
+
!state.prepared.begin(sessionId, carry.tokens, carry.triggerTokens))
|
|
3766
|
+
return false;
|
|
3767
|
+
const done = pending
|
|
3768
|
+
.then((result) => {
|
|
3769
|
+
// A late answer clears the cooldown; a late failure is no new outage evidence (a
|
|
3770
|
+
// refusal or a slow server's timeout must not arm it, as runBlockingCompression).
|
|
3771
|
+
if (result)
|
|
3772
|
+
noteMemtreeHealth(state, { result, liveHealth: "success" });
|
|
3773
|
+
return result ? preparedFromResult(carry, result) : undefined;
|
|
3774
|
+
})
|
|
3775
|
+
.catch(() => undefined)
|
|
3776
|
+
.then((prepared) => {
|
|
3777
|
+
// A prefix installed meanwhile supersedes it.
|
|
3778
|
+
const current = state.stablePrefixes.get(sessionId) === base;
|
|
3779
|
+
state.prepared.finish(sessionId, current ? prepared : undefined);
|
|
3780
|
+
if (state.preparing.get(sessionId) === done)
|
|
3781
|
+
state.preparing.delete(sessionId);
|
|
3782
|
+
});
|
|
3783
|
+
boundedSet(state.preparing, sessionId, done);
|
|
3784
|
+
return true;
|
|
3785
|
+
}
|
|
3786
|
+
/** How long a carried compaction may run: the long budget, or its upload's if longer. */
|
|
3787
|
+
/** How much longer than its turn's wait a carried call must be allowed to run. */
|
|
3788
|
+
const CARRY_MIN_EXTRA_MS = 5_000;
|
|
3789
|
+
function carriedBudgetMs(opts, scaledMs) {
|
|
3790
|
+
return Math.min(MAX_LONG_COMPRESS_WAIT_MS, Math.max(opts.memtree.longCompressBudgetMs, scaledMs));
|
|
3791
|
+
}
|
|
3398
3792
|
/** What a prepare call sends: a forced compression to the target, no threshold. */
|
|
3399
3793
|
function preparedCompressMeta(args) {
|
|
3400
3794
|
const { opts, body, sessionId, msgsForMemtree, modelContextLimit } = args;
|
|
@@ -3813,9 +4207,13 @@ async function runBlockingCompression(args) {
|
|
|
3813
4207
|
: undefined;
|
|
3814
4208
|
const messageTimes = transcriptTimesFor(opts, args.sessionId, args.agentId)?.(msgsForMemtree);
|
|
3815
4209
|
const compressStarted = Date.now();
|
|
3816
|
-
// The upload is about the request's size: the budget grows with it
|
|
3817
|
-
|
|
3818
|
-
|
|
4210
|
+
// The upload is about the request's size: the budget grows with it (the long one
|
|
4211
|
+
// too, never below the short one's).
|
|
4212
|
+
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);
|
|
4214
|
+
// 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;
|
|
3819
4217
|
const compressMeta = {
|
|
3820
4218
|
// Model + tools drive the server's model-based memory budget
|
|
3821
4219
|
// (e.g. 800k whole-request target for Fable / Opus 5). Omitting
|
|
@@ -3844,8 +4242,8 @@ async function runBlockingCompression(args) {
|
|
|
3844
4242
|
...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
|
|
3845
4243
|
...(args.clientMeta ? { clientMeta: args.clientMeta } : {}),
|
|
3846
4244
|
// The deadline this wait uses (the upload-scaled budget included), so the
|
|
3847
|
-
// call and its waiters end together.
|
|
3848
|
-
timeoutMs: budgetMs,
|
|
4245
|
+
// call and its waiters end together; a carried call runs on to the long one.
|
|
4246
|
+
timeoutMs: carry ? Math.max(budgetMs, carriedBudgetMs(opts, scaledMs)) : budgetMs,
|
|
3849
4247
|
};
|
|
3850
4248
|
// Sampled BEFORE the call, while it still describes this call: after the
|
|
3851
4249
|
// await the hash is in the cache regardless of who put it there.
|
|
@@ -3854,12 +4252,21 @@ async function runBlockingCompression(args) {
|
|
|
3854
4252
|
// Keep foreground work finite even if a transport ignores its abort signal.
|
|
3855
4253
|
let deadline;
|
|
3856
4254
|
const maximumWait = Math.min(args.longBudget ? MAX_LONG_COMPRESS_WAIT_MS : MAX_COMPRESS_WAIT_MS, Math.max(1, Number.isFinite(budgetMs) ? budgetMs : 15_000));
|
|
4255
|
+
let settled = false;
|
|
4256
|
+
// A refused carry is not aborted: the call may be shared (in-flight join) with a
|
|
4257
|
+
// request that carried it.
|
|
4258
|
+
const call = opts.memtree.compress(hash, msgsForMemtree, modelContextLimit, args.signal ?? state.shutdownSignal, compressMeta);
|
|
4259
|
+
const settle = () => { settled = true; };
|
|
4260
|
+
call.then(settle, settle);
|
|
3857
4261
|
const result = await Promise.race([
|
|
3858
|
-
|
|
4262
|
+
call,
|
|
3859
4263
|
new Promise((resolve) => { deadline = setTimeout(() => resolve(null), maximumWait); }),
|
|
3860
4264
|
]).finally(() => { if (deadline)
|
|
3861
4265
|
clearTimeout(deadline); });
|
|
3862
4266
|
const compressMs = Date.now() - compressStarted;
|
|
4267
|
+
// Given up on but still running: the session's next turn waits for it, not a new call.
|
|
4268
|
+
const carried = result === null && !settled && carry !== undefined &&
|
|
4269
|
+
carryOverCompression(state, carry, call);
|
|
3863
4270
|
noteServerBudget(state, body.model, modelContextLimit, result);
|
|
3864
4271
|
// Sampled at this call's settle, before a concurrent same-request failure of
|
|
3865
4272
|
// another class can overwrite the complete-key entry.
|
|
@@ -3875,16 +4282,20 @@ async function runBlockingCompression(args) {
|
|
|
3875
4282
|
// Budget-consumed heuristic: the client maps every failure to null, so a
|
|
3876
4283
|
// null that took (roughly) the whole abort budget was almost certainly
|
|
3877
4284
|
// the AbortSignal timeout, not a fast server error.
|
|
3878
|
-
|
|
4285
|
+
// A few ms of slack: a timer can fire a millisecond early by the wall clock (CI flake).
|
|
4286
|
+
const timedOut = result === null &&
|
|
4287
|
+
compressMs + TIMER_SLACK_MS >= Math.min(budgetMs, maximumWait);
|
|
3879
4288
|
// A timeout while other compress calls answered (one succeeded after this
|
|
3880
4289
|
// one started) is this call's size or the server's slow reply, not an outage: it arms no cooldown (2026-10-23:
|
|
3881
4290
|
// healthy-server timeouts kept the fuse lit and a screenshot turn was refused).
|
|
3882
4291
|
const othersAnswered = timedOut && (opts.memtree.lastCompressSuccessAt ?? 0) >= compressStarted;
|
|
3883
|
-
|
|
4292
|
+
// A carried call has not failed: it is still running (its answer reports its health).
|
|
4293
|
+
const hadLiveFailure = !cached && result === null && failureArming && !othersAnswered && !carried;
|
|
3884
4294
|
rec.compress = {
|
|
3885
4295
|
ms: compressMs,
|
|
3886
4296
|
ok: result !== null,
|
|
3887
4297
|
timedOut,
|
|
4298
|
+
...(carried ? { carriedOver: true } : {}),
|
|
3888
4299
|
...(!cached && failureStatus === OVER_CONTEXT_STATUS ? { refused: true } : {}),
|
|
3889
4300
|
};
|
|
3890
4301
|
return {
|
|
@@ -4071,6 +4482,10 @@ async function recoverToolRouteMiss(args) {
|
|
|
4071
4482
|
};
|
|
4072
4483
|
res.once("close", markDownstreamClosed);
|
|
4073
4484
|
const linkSeq = isMainRequest ? nextMemtreeCallSeq(state) : undefined;
|
|
4485
|
+
const toolCarry = stable ? carryFor({ state, isMainRequest, sessionId, body, messages,
|
|
4486
|
+
msgsForMemtree,
|
|
4487
|
+
modelContextLimit, rec, targetTokens: stable.targetTokens, explicitTarget: stable.explicitTarget,
|
|
4488
|
+
linkSeq }) : undefined;
|
|
4074
4489
|
let compression;
|
|
4075
4490
|
try {
|
|
4076
4491
|
compression = await runBlockingCompression({
|
|
@@ -4088,6 +4503,7 @@ async function recoverToolRouteMiss(args) {
|
|
|
4088
4503
|
inputCandidate: args.inputCandidate,
|
|
4089
4504
|
...(args.compaction ? { compaction: args.compaction } : {}),
|
|
4090
4505
|
longBudget: args.wholeRefused ? await args.wholeRefused() : false,
|
|
4506
|
+
...(toolCarry ? { carry: toolCarry } : {}),
|
|
4091
4507
|
});
|
|
4092
4508
|
}
|
|
4093
4509
|
catch {
|
|
@@ -4109,9 +4525,11 @@ async function recoverToolRouteMiss(args) {
|
|
|
4109
4525
|
if (downstreamClosedDuringCompression ||
|
|
4110
4526
|
(res.destroyed && !res.writableFinished)) {
|
|
4111
4527
|
// The MemTree work keeps its cache/index value, but a dead client gets
|
|
4112
|
-
// no Anthropic request and no route.
|
|
4528
|
+
// no Anthropic request and no route. The session's next turn rides it.
|
|
4113
4529
|
rec.routeRecovery = { conversationBytes, outcome: "client-closed" };
|
|
4114
4530
|
releaseOwnReservation();
|
|
4531
|
+
if (result && toolCarry)
|
|
4532
|
+
carryOverCompression(state, toolCarry, Promise.resolve(result));
|
|
4115
4533
|
// Same hazard class as the post-forward fates "upstream-failed" /
|
|
4116
4534
|
// "client-aborted": no route was installed and the client's identical
|
|
4117
4535
|
// retry is imminent, so no backoff is set and the attempt mark is
|
|
@@ -4131,7 +4549,9 @@ async function recoverToolRouteMiss(args) {
|
|
|
4131
4549
|
// submission: its longer independent budget can still warm the index for
|
|
4132
4550
|
// a later turn. An unpaid key (402, possibly set by this very compress)
|
|
4133
4551
|
// or a shutting-down proxy gets no retry.
|
|
4552
|
+
// A carried compression still runs on this history (and indexes it): no upload beside it.
|
|
4134
4553
|
if (!state.shutdownSignal.aborted &&
|
|
4554
|
+
rec.compress?.carriedOver !== true &&
|
|
4135
4555
|
opts.memtree.paymentRequiredDetail === null &&
|
|
4136
4556
|
!opts.memtree.keyInvalid // a refused key refuses the index too
|
|
4137
4557
|
) {
|
|
@@ -4566,8 +4986,8 @@ async function selectForwardCandidate(args) {
|
|
|
4566
4986
|
(args.rec.routeLane === "main" && args.opts.toolOutputs
|
|
4567
4987
|
? await truncatedToFit(args.rec, policy.sizing, target, args.limits, args.opts.toolOutputs)
|
|
4568
4988
|
: undefined);
|
|
4569
|
-
const
|
|
4570
|
-
|
|
4989
|
+
const degraded = degradedNotices.get(args.rec);
|
|
4990
|
+
degradedNotices.delete(args.rec);
|
|
4571
4991
|
const lateInstall = lateRouteInstalls.get(args.rec);
|
|
4572
4992
|
if (lateInstall && (!selection || selection.candidate !== target || target === preferred)) {
|
|
4573
4993
|
lateRouteInstalls.delete(args.rec);
|
|
@@ -4595,14 +5015,29 @@ async function selectForwardCandidate(args) {
|
|
|
4595
5015
|
args.rec.lateCountRecovery.outcome =
|
|
4596
5016
|
changed && candidate.kind === "replacement" ? "compressed" : "forwarded";
|
|
4597
5017
|
delete args.rec.overBudgetForward;
|
|
4598
|
-
|
|
5018
|
+
const overBudget = measurement.tokens > args.limits.budget ||
|
|
4599
5019
|
(measurement.source !== "exact" && candidate.kind === "original" &&
|
|
4600
|
-
measurement.estimatedTokens > args.limits.budget)
|
|
5020
|
+
measurement.estimatedTokens > args.limits.budget);
|
|
5021
|
+
if (overBudget) {
|
|
4601
5022
|
args.rec.overBudgetForward = { reason: measurement.source === "exact"
|
|
4602
5023
|
? "exact-count-fits-window" : measurement.source === "bytes"
|
|
4603
5024
|
? "bytes-fallback-fits-window" : "estimate-fits-window" };
|
|
4604
5025
|
warnPausedNearWindow(args.state, args.rec, requestSessionId(args.req));
|
|
4605
|
-
|
|
5026
|
+
}
|
|
5027
|
+
// Uncompressed only when the whole history goes out (or a truncation of
|
|
5028
|
+
// it), not a compressed ride.
|
|
5029
|
+
const whole = candidate.kind === "original" || wholeHistoryCandidates.has(candidate);
|
|
5030
|
+
if (degraded && whole) {
|
|
5031
|
+
// A failed compress's ride that delivery could not send went out whole.
|
|
5032
|
+
if (args.rec.turnType === "followup-prefix") {
|
|
5033
|
+
args.rec.turnType = "followup-degraded";
|
|
5034
|
+
delete args.rec.compaction?.keptPrefix;
|
|
5035
|
+
}
|
|
5036
|
+
if (degraded.eager || overBudget)
|
|
5037
|
+
degraded.warn();
|
|
5038
|
+
}
|
|
5039
|
+
else if (degraded) {
|
|
5040
|
+
delete args.rec.degradedNotice;
|
|
4606
5041
|
}
|
|
4607
5042
|
return changed ? candidate.body : intended;
|
|
4608
5043
|
}
|
|
@@ -4670,6 +5105,9 @@ async function truncateCandidate(rec, sizing, base, limits, store) {
|
|
|
4670
5105
|
if (!plan)
|
|
4671
5106
|
return undefined;
|
|
4672
5107
|
const candidate = sizing.register(Buffer.from(JSON.stringify(plan.body), "utf-8"), "replacement");
|
|
5108
|
+
if (base.kind === "original" || wholeHistoryCandidates.has(base)) {
|
|
5109
|
+
wholeHistoryCandidates.add(candidate);
|
|
5110
|
+
}
|
|
4673
5111
|
const measurement = await sizing.measure(candidate, limits);
|
|
4674
5112
|
if (fitsMeasured(measurement, limits) && measurement.tokens <= goal) {
|
|
4675
5113
|
rec.truncatedToolResult = { originalChars: plan.originalChars,
|
|
@@ -4714,6 +5152,40 @@ function lateRecoveryPlan(state, rec, preferred, measured, limits, routeKey) {
|
|
|
4714
5152
|
return preferred.kind === "original" && !fitsMeasured(measured, limits)
|
|
4715
5153
|
? { tokens: measured.tokens } : undefined;
|
|
4716
5154
|
}
|
|
5155
|
+
/**
|
|
5156
|
+
* Wait (up to `maxMs`, or until `signal` aborts) for the lane's in-flight compression to
|
|
5157
|
+
* end; the milliseconds waited.
|
|
5158
|
+
*/
|
|
5159
|
+
async function waitForLaneIdle(state, routeKey, maxMs, signal) {
|
|
5160
|
+
const started = Date.now();
|
|
5161
|
+
while (state.toolRecoveryAttemptedLanes.get(routeKey)?.inFlight && !signal.aborted &&
|
|
5162
|
+
!state.shutdownSignal.aborted && !state.backgroundSignal.aborted &&
|
|
5163
|
+
Date.now() - started < maxMs) {
|
|
5164
|
+
// Unref'd: a wait never holds the process open.
|
|
5165
|
+
await new Promise((resolve) => setTimeout(resolve, LANE_WAIT_POLL_MS).unref());
|
|
5166
|
+
}
|
|
5167
|
+
return Date.now() - started;
|
|
5168
|
+
}
|
|
5169
|
+
/**
|
|
5170
|
+
* The ride the lane's current memory route gives this request, read afresh (after a wait):
|
|
5171
|
+
* undefined when there is none, it does not match this history, or (main lane) a newer
|
|
5172
|
+
* stable prefix superseded it, which the planner then rides itself.
|
|
5173
|
+
*/
|
|
5174
|
+
function currentLaneRide(state, routeKey, body, messages, sessionId, isMainRequest) {
|
|
5175
|
+
const route = getMemoryRoute(state, routeKey);
|
|
5176
|
+
if (!route)
|
|
5177
|
+
return undefined;
|
|
5178
|
+
if (isMainRequest && sessionId !== undefined &&
|
|
5179
|
+
route.stablePrefix !== state.stablePrefixes.get(sessionId))
|
|
5180
|
+
return undefined;
|
|
5181
|
+
const raw = memoryRoutedToolBody(body, messages, route, state.mainRouteEpoch, sessionId);
|
|
5182
|
+
return raw ? { raw, turnType: "tool-memory", sizeHolder: route.stablePrefix ?? route }
|
|
5183
|
+
: undefined;
|
|
5184
|
+
}
|
|
5185
|
+
/** Wall-clock slack when judging whether a call ran its whole budget (timers fire early). */
|
|
5186
|
+
const TIMER_SLACK_MS = 5;
|
|
5187
|
+
/** How often a turn waiting for its lane's compression looks again. */
|
|
5188
|
+
const LANE_WAIT_POLL_MS = 100;
|
|
4717
5189
|
/**
|
|
4718
5190
|
* Run a delivery-time compression as the lane's in-flight operation, so a
|
|
4719
5191
|
* concurrent request of the lane neither starts its own (planning sees
|
|
@@ -4778,6 +5250,17 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
|
|
|
4778
5250
|
: args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true });
|
|
4779
5251
|
args.rec.forwardedBytes = 0;
|
|
4780
5252
|
args.rec.approxInputTokens = 0;
|
|
5253
|
+
// Nothing went out uncompressed: Claude Code retries it (2026-10-28).
|
|
5254
|
+
// A failed compress's ride that could not be sent either (review r2 P3).
|
|
5255
|
+
const refusedRide = args.rec.turnType === "followup-prefix" &&
|
|
5256
|
+
args.rec.compaction?.keptPrefix === true;
|
|
5257
|
+
if (args.rec.turnType === "followup-degraded" || refusedRide) {
|
|
5258
|
+
args.rec.turnType = "followup-refused";
|
|
5259
|
+
delete args.rec.compaction?.keptPrefix;
|
|
5260
|
+
}
|
|
5261
|
+
delete args.rec.degradedNotice;
|
|
5262
|
+
if (unpaid === null && !keyInvalid)
|
|
5263
|
+
noteSlowRetry(args, failure);
|
|
4781
5264
|
args.state.diagnostics?.record("refusal", {
|
|
4782
5265
|
status: failure.status, attempt: failure.attempt,
|
|
4783
5266
|
input_ktokens: Math.round(inputTokens / 1000), budget_ktokens: Math.round(budgetTokens / 1000),
|
|
@@ -4804,6 +5287,24 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
|
|
|
4804
5287
|
args.state.keyNotice.markShown();
|
|
4805
5288
|
return false;
|
|
4806
5289
|
}
|
|
5290
|
+
/**
|
|
5291
|
+
* A repeated retryable refusal on the main thread: say once per streak that
|
|
5292
|
+
* MemTree is slow and Claude Code is retrying, unless another notice waits.
|
|
5293
|
+
*/
|
|
5294
|
+
function noteSlowRetry(args, failure) {
|
|
5295
|
+
const { state, rec } = args;
|
|
5296
|
+
const prompt = noticeContexts.get(rec);
|
|
5297
|
+
// Not for a stale prompt or a closed response, nor an index wait (its 503
|
|
5298
|
+
// says why), nor a final refusal (its own message shows).
|
|
5299
|
+
const canShow = failure.status === 503 && rec.routeLane === "main" &&
|
|
5300
|
+
rec.indexBehind !== true && prompt !== undefined &&
|
|
5301
|
+
prompt.generation === state.mainPromptGeneration && !args.res.destroyed &&
|
|
5302
|
+
!state.notices.hasPending();
|
|
5303
|
+
if (!state.refusalStreaks.refused(args.routeKey, Date.now(), canShow))
|
|
5304
|
+
return;
|
|
5305
|
+
state.notices.queueSuffix(SLOW_RETRY_NOTICE, undefined, prompt.promptId);
|
|
5306
|
+
rec.slowRetryNotice = true;
|
|
5307
|
+
}
|
|
4807
5308
|
/**
|
|
4808
5309
|
* The refusal text already says MemTree is paused and how to resume, so it
|
|
4809
5310
|
* counts as the paused notice: the throttle restarts, and this request's
|
|
@@ -4879,7 +5380,7 @@ function reachedBudget(compaction) {
|
|
|
4879
5380
|
* forced compaction (budget, window, `/memtree-compact`, a rebuilt prefix), a
|
|
4880
5381
|
* size at or over the budget, or a size nobody measured. A planned fit is
|
|
4881
5382
|
* provisional: delivery's own sizing can still find the turn over the budget
|
|
4882
|
-
* (
|
|
5383
|
+
* (degradedNotices), since a plan can undercount.
|
|
4883
5384
|
*/
|
|
4884
5385
|
function compressionNeeded(compaction) {
|
|
4885
5386
|
if (compaction === undefined || compaction.reason !== undefined)
|
|
@@ -4889,10 +5390,15 @@ function compressionNeeded(compaction) {
|
|
|
4889
5390
|
return compaction.estimatedTokens >= compaction.budgetTokens;
|
|
4890
5391
|
}
|
|
4891
5392
|
/**
|
|
4892
|
-
* A degraded turn
|
|
4893
|
-
*
|
|
5393
|
+
* A degraded turn's notice, queued by delivery only when the whole request
|
|
5394
|
+
* goes out: at once when planning said it needed compression (`eager`), else
|
|
5395
|
+
* when delivery's sizing finds it over the budget after all.
|
|
4894
5396
|
*/
|
|
4895
|
-
const
|
|
5397
|
+
const degradedNotices = new WeakMap();
|
|
5398
|
+
/** Truncations (truncatedToFit) of the whole history: they still run uncompressed. */
|
|
5399
|
+
const wholeHistoryCandidates = new WeakSet();
|
|
5400
|
+
/** The prompt a request was made for, for notices its refusal may queue. */
|
|
5401
|
+
const noticeContexts = new WeakMap();
|
|
4896
5402
|
/** The paused notice a degraded request queued, until it is refused or settles. */
|
|
4897
5403
|
const pausedNotices = new WeakMap();
|
|
4898
5404
|
const changedDeliveryRecords = new WeakSet();
|
|
@@ -5580,6 +6086,41 @@ function capture(opts, kind, body) {
|
|
|
5580
6086
|
// diagnostics only — never break the proxy path
|
|
5581
6087
|
}
|
|
5582
6088
|
}
|
|
6089
|
+
/**
|
|
6090
|
+
* The body, or undefined once it passes `limit` bytes (declared or streamed)
|
|
6091
|
+
* or takes longer than HOOK_BODY_TIMEOUT_MS: the rest is drained unkept.
|
|
6092
|
+
*/
|
|
6093
|
+
function readBodyWithin(req, limit) {
|
|
6094
|
+
return new Promise((resolve) => {
|
|
6095
|
+
const chunks = [];
|
|
6096
|
+
let size = 0;
|
|
6097
|
+
let done = false;
|
|
6098
|
+
const finish = (body) => {
|
|
6099
|
+
if (done)
|
|
6100
|
+
return;
|
|
6101
|
+
done = true;
|
|
6102
|
+
clearTimeout(timer);
|
|
6103
|
+
if (body === undefined) {
|
|
6104
|
+
chunks.length = 0;
|
|
6105
|
+
req.removeAllListeners("data");
|
|
6106
|
+
req.resume();
|
|
6107
|
+
}
|
|
6108
|
+
resolve(body);
|
|
6109
|
+
};
|
|
6110
|
+
const timer = setTimeout(() => finish(undefined), HOOK_BODY_TIMEOUT_MS);
|
|
6111
|
+
if (Number(req.headers["content-length"] ?? 0) > limit)
|
|
6112
|
+
return finish(undefined);
|
|
6113
|
+
req.on("data", (chunk) => {
|
|
6114
|
+
size += chunk.length;
|
|
6115
|
+
if (size > limit)
|
|
6116
|
+
finish(undefined);
|
|
6117
|
+
else
|
|
6118
|
+
chunks.push(chunk);
|
|
6119
|
+
});
|
|
6120
|
+
req.on("end", () => finish(Buffer.concat(chunks)));
|
|
6121
|
+
req.on("error", () => finish(undefined));
|
|
6122
|
+
});
|
|
6123
|
+
}
|
|
5583
6124
|
function readBody(req) {
|
|
5584
6125
|
return readAll(req);
|
|
5585
6126
|
}
|