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