claude-code-infinite 1.0.33 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/cli-main.js +18 -9
- package/dist/cli-main.js.map +1 -1
- package/dist/hooks.d.ts +20 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +34 -4
- package/dist/hooks.js.map +1 -1
- package/dist/host-session.d.ts +12 -0
- package/dist/host-session.d.ts.map +1 -0
- package/dist/host-session.js +12 -0
- package/dist/host-session.js.map +1 -0
- package/dist/image-refs.d.ts +24 -2
- package/dist/image-refs.d.ts.map +1 -1
- package/dist/image-refs.js +67 -20
- package/dist/image-refs.js.map +1 -1
- package/dist/index-wait-413.d.ts +26 -0
- package/dist/index-wait-413.d.ts.map +1 -0
- package/dist/index-wait-413.js +113 -0
- package/dist/index-wait-413.js.map +1 -0
- package/dist/memtree-finder.d.ts +70 -6
- package/dist/memtree-finder.d.ts.map +1 -1
- package/dist/memtree-finder.js +150 -20
- package/dist/memtree-finder.js.map +1 -1
- package/dist/memtree-handover.d.ts +13 -0
- package/dist/memtree-handover.d.ts.map +1 -0
- package/dist/memtree-handover.js +231 -0
- package/dist/memtree-handover.js.map +1 -0
- package/dist/memtree-mcp-config.d.ts +2 -2
- package/dist/memtree-mcp-config.d.ts.map +1 -1
- package/dist/memtree-mcp-config.js +8 -5
- package/dist/memtree-mcp-config.js.map +1 -1
- package/dist/memtree-mcp.d.ts +182 -2
- package/dist/memtree-mcp.d.ts.map +1 -1
- package/dist/memtree-mcp.js +357 -38
- package/dist/memtree-mcp.js.map +1 -1
- package/dist/memtree-newest.d.ts +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 +23 -0
- package/dist/memtree.d.ts.map +1 -1
- package/dist/memtree.js +78 -4
- package/dist/memtree.js.map +1 -1
- package/dist/network-failure.d.ts +28 -0
- package/dist/network-failure.d.ts.map +1 -0
- package/dist/network-failure.js +67 -0
- package/dist/network-failure.js.map +1 -0
- package/dist/prefix-store.d.ts +4 -0
- package/dist/prefix-store.d.ts.map +1 -1
- package/dist/prefix-store.js +8 -4
- package/dist/prefix-store.js.map +1 -1
- package/dist/project-trees.d.ts +15 -7
- package/dist/project-trees.d.ts.map +1 -1
- package/dist/project-trees.js +20 -9
- package/dist/project-trees.js.map +1 -1
- package/dist/proxy.d.ts +69 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +467 -39
- package/dist/proxy.js.map +1 -1
- package/dist/reqlog.d.ts +24 -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
58
|
import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, indexLagEvidence, losesMedia, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
|
|
56
59
|
import { contextLimitForModel, hasEarlierNonToolUserMessage, isAwaySummaryUserMessage, isLocalBashCommandTurn, isNonToolUserMessage, isToolResultUserMessage, lastNonSystemMessage, messagesWithSystem, modelForMemtree, stripSystemReminderText, } from "./turns.js";
|
|
57
60
|
import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, SLOW_RETRY_NOTICE, recapLinkText, SseNoticeRewriter, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
|
|
58
|
-
import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, PRECOMPACT_HOOK_PATH
|
|
61
|
+
import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, PRECOMPACT_HOOK_PATH } from "./hooks.js";
|
|
59
62
|
import { USAGE_LABEL, USAGE_SIGN_IN_NOTE } from "./usage-link.js";
|
|
60
63
|
import { hyperlink } from "./payment-gate.js";
|
|
61
64
|
import { keyInvalidNoticeText, keyInvalidRefusalMessage } from "./key-status.js";
|
|
@@ -64,8 +67,8 @@ import { PaymentNoticeThrottle, pausedCompactionText, pausedRefusalMessage, paym
|
|
|
64
67
|
import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
|
|
65
68
|
import { describeClaudeCodeRequest, inspectMonitorTranscript, knownSideRequestKind, transcriptWarning, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
|
|
66
69
|
import { isResumableSessionId } from "./resume-hint.js";
|
|
67
|
-
import { subagentMemoryHintText, subagentStartOutput, } from "./subagent-hint.js";
|
|
68
|
-
import { RESUME_GUIDANCE_HOOK_SUFFIX } from "./hooks.js";
|
|
70
|
+
import { isSubagentCall, postToolUseOutput, SUBAGENT_RESULT_HINT, subagentMemoryHintText, subagentStartOutput, } from "./subagent-hint.js";
|
|
71
|
+
import { PROJECT_TREES_HOOK_SUFFIX, RESUME_GUIDANCE_HOOK_SUFFIX, SUBAGENT_RESULT_HOOK_SUFFIX, } from "./hooks.js";
|
|
69
72
|
import { conservativeBodyTokens } from "./token-estimate.js";
|
|
70
73
|
import { bodyImages } from "./image-tokens.js";
|
|
71
74
|
import { claudeAuthFromHeaders, upstreamErrorCode } from "./claude-auth.js";
|
|
@@ -73,6 +76,11 @@ import { rateLimitFromResponse } from "./rate-limit.js";
|
|
|
73
76
|
import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
|
|
74
77
|
const DEFAULT_UPSTREAM = "https://api.anthropic.com";
|
|
75
78
|
const HOOK_BODY_LIMIT = 64 * 1024;
|
|
79
|
+
/**
|
|
80
|
+
* Hook bodies that carry a whole prompt (UserPromptSubmit) or a subagent's prompt and
|
|
81
|
+
* full result (PostToolUse on Agent|Task).
|
|
82
|
+
*/
|
|
83
|
+
const LARGE_HOOK_BODY_LIMIT = 16 * 1024 * 1024;
|
|
76
84
|
/** A hook body still arriving after this is dropped (the hook itself waits 5 s). */
|
|
77
85
|
const HOOK_BODY_TIMEOUT_MS = 3_000;
|
|
78
86
|
// After a recovery attempt returns null (MemTree down, 5xx, or a burned
|
|
@@ -346,6 +354,8 @@ export function startProxy(opts) {
|
|
|
346
354
|
pausedDetail: () => opts.memtree.pausedDetail,
|
|
347
355
|
pausedReason: () => opts.memtree.pausedReason,
|
|
348
356
|
memtreeAnswering: () => opts.memtree.answering,
|
|
357
|
+
memtreeUnreachable: () => opts.memtree.unreachable,
|
|
358
|
+
anthropicUnreachable: () => upstream.unreachable,
|
|
349
359
|
modelNotices: new ModelContextNotices(),
|
|
350
360
|
usageUrl: opts.memtree.usageDashboardUrl,
|
|
351
361
|
sessionStartNotice: opts.sessionStartNotice,
|
|
@@ -353,13 +363,14 @@ export function startProxy(opts) {
|
|
|
353
363
|
openUrl: opts.openUrl,
|
|
354
364
|
projectTrees: opts.projectTrees,
|
|
355
365
|
subagentHint: opts.subagentHint,
|
|
366
|
+
projectTreesGiven: new Set(),
|
|
356
367
|
resumeGuidance: opts.resumeGuidance,
|
|
357
368
|
diagnostics: opts.diagnostics,
|
|
358
369
|
notices: new NoticeDeliveryQueue(),
|
|
359
370
|
mainNoticePending: false,
|
|
360
371
|
mainPromptGeneration: 0,
|
|
361
372
|
promptAccounting: new PromptAccounting(),
|
|
362
|
-
fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs),
|
|
373
|
+
fallbackFailures: new RouteFallbackFailures(undefined, opts.indexWaitDeadlineMs, opts.indexWaitMaxMs),
|
|
363
374
|
refusalStreaks: new RefusalStreaks(),
|
|
364
375
|
activeSubagents: new Set(),
|
|
365
376
|
deferredAgentIndexes: new Map(),
|
|
@@ -376,6 +387,7 @@ export function startProxy(opts) {
|
|
|
376
387
|
routeInstallFault: opts.routeInstallFault,
|
|
377
388
|
memtreePages: new Map(),
|
|
378
389
|
memorySourcePages: new Map(),
|
|
390
|
+
hostToken: randomBytes(24).toString("hex"),
|
|
379
391
|
memtreeCallSeq: 0,
|
|
380
392
|
memtreeLinkStore: opts.memtreeLinkStore,
|
|
381
393
|
stablePrefixStore: opts.stablePrefixStore,
|
|
@@ -486,6 +498,8 @@ export function startProxy(opts) {
|
|
|
486
498
|
},
|
|
487
499
|
drain,
|
|
488
500
|
mainSessionId: () => state.resumableMainSession,
|
|
501
|
+
setHostPid: (pid) => { state.hostPid = pid; },
|
|
502
|
+
hostToken: state.hostToken,
|
|
489
503
|
});
|
|
490
504
|
});
|
|
491
505
|
});
|
|
@@ -513,13 +527,22 @@ async function handleRequest(req, res, opts, upstream, state, hookPath) {
|
|
|
513
527
|
return handleNoticeHook(req, res, state, opts.reqlog, undefined, "PreCompact");
|
|
514
528
|
}
|
|
515
529
|
if (url.pathname === `${hookPath}${RESUME_GUIDANCE_HOOK_SUFFIX}`) {
|
|
516
|
-
return
|
|
530
|
+
return handleSessionStartContextHook(req, res, (input) => resumeGuidanceContext(state, input));
|
|
531
|
+
}
|
|
532
|
+
if (url.pathname === `${hookPath}${SUBAGENT_RESULT_HOOK_SUFFIX}`) {
|
|
533
|
+
return handleSubagentResultHook(req, res, state);
|
|
534
|
+
}
|
|
535
|
+
if (url.pathname === `${hookPath}${PROJECT_TREES_HOOK_SUFFIX}`) {
|
|
536
|
+
return handlePrintProjectTreesHook(req, res, state);
|
|
517
537
|
}
|
|
518
538
|
if (url.pathname === hookPath) {
|
|
519
539
|
return handleNoticeHook(req, res, state, opts.reqlog, (sessionId) => opts.memtree.scheduleFinalIndex(sessionId, (signal) => opts.transcriptUsage?.finalReplyAsync
|
|
520
540
|
? opts.transcriptUsage.finalReplyAsync(sessionId, signal)
|
|
521
541
|
: opts.transcriptUsage?.finalReply?.(sessionId)));
|
|
522
542
|
}
|
|
543
|
+
if (req.method === "GET" && url.pathname === MEMTREE_HOST_SESSION_PATH) {
|
|
544
|
+
return handleMemTreeHostSession(req, res, state, url);
|
|
545
|
+
}
|
|
523
546
|
if (req.method === "GET" &&
|
|
524
547
|
(url.pathname === MEMTREE_CURRENT_PATH || url.pathname === `${MEMTREE_CURRENT_PATH}.json`)) {
|
|
525
548
|
return handleMemTreeCurrent(req, res, opts, state, url);
|
|
@@ -584,12 +607,14 @@ const MEMTREE_PASSTHROUGH_PREFIX = "/memtree/";
|
|
|
584
607
|
* `<request id>`, `<request id>.json`, `<request id>/session.json` (the
|
|
585
608
|
* page's session pane), `<request id>/search` (the server's term search over
|
|
586
609
|
* that tree, `?q=&limit=`), `<request id>/messages` (the un-indexed messages
|
|
587
|
-
* after it, `?start=&end=`),
|
|
610
|
+
* after it, `?start=&end=`), `<request id>/user_messages` (the person's own
|
|
611
|
+
* messages, `?since=&until=&q=&cursor=`), `<request id>/handover` (the handover
|
|
612
|
+
* pack), or `sessions/<Claude Code session id>.json` (every
|
|
588
613
|
* page from one session, newest first); nothing that could walk the upstream
|
|
589
614
|
* path. The id is the request UUID or the server's short form of it (leading
|
|
590
615
|
* hex, as in the `/m/<id>` links it hands out) — the server accepts both.
|
|
591
616
|
*/
|
|
592
|
-
const MEMTREE_PASSTHROUGH_TARGET_RE = /^(?:[A-Za-z0-9-]+(\.json|\/session\.json|\/search|\/messages)?|sessions\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\.json)$/;
|
|
617
|
+
const MEMTREE_PASSTHROUGH_TARGET_RE = /^(?:[A-Za-z0-9-]+(\.json|\/session\.json|\/search|\/messages|\/user_messages|\/handover)?|sessions\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\.json)$/;
|
|
593
618
|
/**
|
|
594
619
|
* `GET /memtree/<id>[.json][?share=…]` on the loopback: read the user's own
|
|
595
620
|
* MemTree page with their key. Claude Code's child env already carries this
|
|
@@ -606,7 +631,7 @@ async function handleMemTreePassthrough(req, res, opts, url) {
|
|
|
606
631
|
return;
|
|
607
632
|
}
|
|
608
633
|
try {
|
|
609
|
-
const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${target}${url.search}`, req.headers.accept ?? "application/json");
|
|
634
|
+
const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${target}${url.search}`, req.headers.accept ?? "application/json", undefined, memtreeToolHeaders(req));
|
|
610
635
|
res.writeHead(upstream.status, { "content-type": upstream.contentType });
|
|
611
636
|
res.end(upstream.body);
|
|
612
637
|
}
|
|
@@ -633,15 +658,29 @@ const MEMTREE_FINDER_RELAY = new Map([
|
|
|
633
658
|
[`${MEMTREE_PASSTHROUGH_PREFIX}search`, "/v1/memtree/search"],
|
|
634
659
|
]);
|
|
635
660
|
const SESSION_ID_HEADER_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
636
|
-
|
|
661
|
+
/** `x-memtree-tool` as the memtree MCP server sends it: a tool name. */
|
|
662
|
+
const MEMTREE_TOOL_HEADER_VALUE = /^[a-z_]{1,20}$/;
|
|
663
|
+
/**
|
|
664
|
+
* What a MemTree tool's read carries on to the server: the tool (`x-memtree-tool`)
|
|
665
|
+
* and the calling Claude Code session, when well formed. The server logs one
|
|
666
|
+
* content-free line per tool call with them (polychat memtree_tool_log.py).
|
|
667
|
+
*/
|
|
668
|
+
export function memtreeToolHeaders(req) {
|
|
637
669
|
const headers = {};
|
|
638
|
-
const
|
|
639
|
-
if (
|
|
640
|
-
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;
|
|
641
673
|
const sessionId = firstNonEmptyHeader(req, "x-claude-code-session-id");
|
|
642
674
|
if (sessionId && SESSION_ID_HEADER_VALUE.test(sessionId)) {
|
|
643
675
|
headers["x-claude-code-session-id"] = sessionId;
|
|
644
676
|
}
|
|
677
|
+
return headers;
|
|
678
|
+
}
|
|
679
|
+
async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
|
|
680
|
+
const headers = memtreeToolHeaders(req);
|
|
681
|
+
const meta = memtreeClientMeta({ project: opts.projectMeta });
|
|
682
|
+
if (Object.keys(meta).length)
|
|
683
|
+
headers["x-client-meta"] = JSON.stringify(meta);
|
|
645
684
|
try {
|
|
646
685
|
const upstream = await opts.memtree.fetchMemTree(`${upstreamPath}${url.search}`, req.headers.accept ?? "application/json", undefined, headers);
|
|
647
686
|
res.writeHead(upstream.status, { "content-type": upstream.contentType });
|
|
@@ -658,18 +697,23 @@ async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
|
|
|
658
697
|
* reads (memtree-mcp.ts). Bare `current` answers the pointer
|
|
659
698
|
* `{id, url, index, session_id, compressed}` without an upstream call, so the
|
|
660
699
|
* MCP server can keep its cached tree until the page changes; `current.json`
|
|
661
|
-
* relays the page JSON itself, like `/memtree/<id>.json`.
|
|
700
|
+
* relays the page JSON itself, like `/memtree/<id>.json`. With no page here,
|
|
701
|
+
* the server's newest tree for the session (currentPageOrServerTree): that
|
|
702
|
+
* waits a bounded time (NewestTreeLookup.current), cached; 503 when the
|
|
703
|
+
* server could not be asked.
|
|
662
704
|
*
|
|
663
705
|
* Every pointer is scoped to the caller's exact session id. Missing session
|
|
664
706
|
* ids never fall back to another conversation served by the same proxy.
|
|
665
707
|
*/
|
|
666
708
|
async function handleMemTreeCurrent(req, res, opts, state, url) {
|
|
667
709
|
const sessionId = url.searchParams.get("session") || undefined;
|
|
668
|
-
const
|
|
710
|
+
const found = await currentPageOrServerTree(state, sessionId);
|
|
711
|
+
const page = "page" in found ? found.page : undefined;
|
|
669
712
|
const id = page ? memtreePageId(page.url) : undefined;
|
|
670
713
|
if (!page || !id) {
|
|
671
|
-
|
|
672
|
-
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" }));
|
|
673
717
|
return;
|
|
674
718
|
}
|
|
675
719
|
if (!url.pathname.endsWith(".json")) {
|
|
@@ -684,7 +728,7 @@ async function handleMemTreeCurrent(req, res, opts, state, url) {
|
|
|
684
728
|
return;
|
|
685
729
|
}
|
|
686
730
|
try {
|
|
687
|
-
const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${id}.json`, req.headers.accept ?? "application/json");
|
|
731
|
+
const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${id}.json`, req.headers.accept ?? "application/json", undefined, memtreeToolHeaders(req));
|
|
688
732
|
res.writeHead(upstream.status, {
|
|
689
733
|
"content-type": upstream.contentType,
|
|
690
734
|
"x-memtree-page": page.url,
|
|
@@ -697,6 +741,60 @@ async function handleMemTreeCurrent(req, res, opts, state, url) {
|
|
|
697
741
|
}
|
|
698
742
|
}
|
|
699
743
|
const MEMTREE_CURRENT_PATH = `${MEMTREE_PASSTHROUGH_PREFIX}current`;
|
|
744
|
+
const MEMTREE_HOST_SESSION_PATH = `${MEMTREE_PASSTHROUGH_PREFIX}host-session`;
|
|
745
|
+
/**
|
|
746
|
+
* `GET /memtree/host-session?host_pid=<pid>`: the session ccc's own Claude Code process
|
|
747
|
+
* is on, for that process's memtree MCP server (memtree-mcp.ts: refreshHostSession),
|
|
748
|
+
* which is started with the launch session id and not restarted when the process
|
|
749
|
+
* resumes or clears. Answered only to a caller that names that process's pid (the
|
|
750
|
+
* server's parent) and carries the token from this ccc's MCP config, and only once a
|
|
751
|
+
* SessionStart hook from that process has named the session; 404 otherwise. The pid is
|
|
752
|
+
* not a secret and the token alone is not enough: a teammate Claude Code replays the
|
|
753
|
+
* same MCP config, with another parent. So neither a Claude Code started from inside
|
|
754
|
+
* the session nor a teammate is handed this process's conversation.
|
|
755
|
+
*/
|
|
756
|
+
function handleMemTreeHostSession(req, res, state, url) {
|
|
757
|
+
const known = isHostPid(state, url.searchParams.get("host_pid")) &&
|
|
758
|
+
sameSecret(req.headers[HOST_TOKEN_HEADER], state.hostToken);
|
|
759
|
+
res.writeHead(known && state.hostSession ? 200 : 404, { "content-type": "application/json" });
|
|
760
|
+
res.end(JSON.stringify(known && state.hostSession
|
|
761
|
+
? { session_id: state.hostSession } : { detail: "No session for this process" }));
|
|
762
|
+
}
|
|
763
|
+
/** Whether `pid` (a header or query value) is the Claude Code process ccc launched. */
|
|
764
|
+
function isHostPid(state, pid) {
|
|
765
|
+
return state.hostPid !== undefined && typeof pid === "string" && /^\d+$/.test(pid) &&
|
|
766
|
+
Number(pid) === state.hostPid;
|
|
767
|
+
}
|
|
768
|
+
function sameSecret(given, secret) {
|
|
769
|
+
if (typeof given !== "string")
|
|
770
|
+
return false;
|
|
771
|
+
const a = Buffer.from(given), b = Buffer.from(secret);
|
|
772
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
773
|
+
}
|
|
774
|
+
const CURRENT_LOOKUP_FAILED = "This process has no MemTree page for this session and could not ask the MemTree server for one";
|
|
775
|
+
/**
|
|
776
|
+
* The session's page here (currentMemorySourcePage), else its newest completed
|
|
777
|
+
* tree on the server (`/v1/memtree/sessions`, this account's own sessions,
|
|
778
|
+
* exact session id only; cached by NewestTreeLookup and superseded as soon as
|
|
779
|
+
* a served index is noted). After a restart, a session whose history goes as
|
|
780
|
+
* index-only calls or rides a stored prefix gets no served index here, while
|
|
781
|
+
* the server has its whole tree. `unknown`: the server could not be asked.
|
|
782
|
+
*/
|
|
783
|
+
async function currentPageOrServerTree(state, sessionId) {
|
|
784
|
+
const page = currentMemorySourcePage(state, sessionId);
|
|
785
|
+
if (page || !sessionId || !state.newestTrees || !SESSION_ID_HEADER_VALUE.test(sessionId)) {
|
|
786
|
+
return { page };
|
|
787
|
+
}
|
|
788
|
+
const answer = await state.newestTrees.current(sessionId);
|
|
789
|
+
// A served index noted during the wait takes precedence.
|
|
790
|
+
const served = currentMemorySourcePage(state, sessionId);
|
|
791
|
+
if (served)
|
|
792
|
+
return { page: served };
|
|
793
|
+
if (answer.tree) {
|
|
794
|
+
return { page: { url: answer.tree.url, index: answer.tree.key, compressed: false, sessionId } };
|
|
795
|
+
}
|
|
796
|
+
return answer.answered ? {} : { unknown: true };
|
|
797
|
+
}
|
|
700
798
|
/** The page whose memory is in the prompt, else (none adopted yet) the newest page. */
|
|
701
799
|
function currentMemorySourcePage(state, sessionId) {
|
|
702
800
|
if (!sessionId)
|
|
@@ -746,12 +844,95 @@ function noteMainSessionStart(state, source, sessionId) {
|
|
|
746
844
|
state.resumableMainSession = undefined;
|
|
747
845
|
}
|
|
748
846
|
}
|
|
847
|
+
/**
|
|
848
|
+
* PostToolUse after an Agent/Task call: point the model at the subagent's own tree
|
|
849
|
+
* (subagent-hint.ts), unless MemTree is known not to work, when the session has the tools.
|
|
850
|
+
*/
|
|
851
|
+
async function handleSubagentResultHook(req, res, state) {
|
|
852
|
+
// The body carries the subagent's whole prompt and result: allow a large one.
|
|
853
|
+
const raw = req.method === "POST"
|
|
854
|
+
? await readBodyWithin(req, LARGE_HOOK_BODY_LIMIT) : undefined;
|
|
855
|
+
if (req.method !== "POST")
|
|
856
|
+
req.resume();
|
|
857
|
+
let input = null;
|
|
858
|
+
try {
|
|
859
|
+
if (raw !== undefined && raw.length > 0)
|
|
860
|
+
input = JSON.parse(raw.toString("utf-8"));
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
input = null;
|
|
864
|
+
}
|
|
865
|
+
// Not known to be broken is enough: a short print run's first requests can finish
|
|
866
|
+
// before MemTree has answered any (non-blocking first turn), and the subagent is
|
|
867
|
+
// indexed either way.
|
|
868
|
+
const say = state.subagentHint?.enabled === true && resumeMemtreeWorking(state) &&
|
|
869
|
+
isSubagentCall(input);
|
|
870
|
+
if (!say) {
|
|
871
|
+
res.writeHead(204);
|
|
872
|
+
res.end();
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const body = Buffer.from(JSON.stringify(postToolUseOutput(SUBAGENT_RESULT_HINT)), "utf-8");
|
|
876
|
+
res.writeHead(200, {
|
|
877
|
+
"content-type": "application/json",
|
|
878
|
+
"content-length": String(body.length),
|
|
879
|
+
"cache-control": "no-store",
|
|
880
|
+
});
|
|
881
|
+
res.end(body);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Print runs: the earlier-sessions block (project-trees.ts) with a session's first
|
|
885
|
+
* prompt, as UserPromptSubmit context. Main sessions only, once per session per
|
|
886
|
+
* process; anything unexpected is 204.
|
|
887
|
+
*/
|
|
888
|
+
async function handlePrintProjectTreesHook(req, res, state) {
|
|
889
|
+
// The body carries the whole prompt.
|
|
890
|
+
const raw = req.method === "POST" ? await readBodyWithin(req, LARGE_HOOK_BODY_LIMIT) : undefined;
|
|
891
|
+
if (req.method !== "POST")
|
|
892
|
+
req.resume();
|
|
893
|
+
let input = null;
|
|
894
|
+
try {
|
|
895
|
+
if (raw !== undefined && raw.length > 0) {
|
|
896
|
+
input = parseNoticeHookInput(JSON.parse(raw.toString("utf-8")));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
input = null;
|
|
901
|
+
}
|
|
902
|
+
const first = input?.hook_event_name === "UserPromptSubmit" && input.agent_id === undefined &&
|
|
903
|
+
!state.projectTreesGiven.has(input.session_id);
|
|
904
|
+
const context = first && state.projectTrees
|
|
905
|
+
? await safeContext(() => state.projectTrees.contextFor(input.session_id)) : undefined;
|
|
906
|
+
if (!context) {
|
|
907
|
+
res.writeHead(204);
|
|
908
|
+
res.end();
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
state.projectTreesGiven.add(input.session_id);
|
|
912
|
+
const body = Buffer.from(JSON.stringify({ hookSpecificOutput: {
|
|
913
|
+
hookEventName: "UserPromptSubmit", additionalContext: context
|
|
914
|
+
} }), "utf-8");
|
|
915
|
+
res.writeHead(200, {
|
|
916
|
+
"content-type": "application/json",
|
|
917
|
+
"content-length": String(body.length),
|
|
918
|
+
"cache-control": "no-store",
|
|
919
|
+
});
|
|
920
|
+
res.end(body);
|
|
921
|
+
}
|
|
922
|
+
async function safeContext(get) {
|
|
923
|
+
try {
|
|
924
|
+
return await get();
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
return undefined;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
749
930
|
/**
|
|
750
931
|
* SessionStart on its own hook (resume-guidance.ts): only the current MemTree
|
|
751
932
|
* guidance, so Claude Code sees it as one context of its own and adds an
|
|
752
933
|
* identical one only once. Never fails the hook: anything unexpected is 204.
|
|
753
934
|
*/
|
|
754
|
-
async function
|
|
935
|
+
async function handleSessionStartContextHook(req, res, contextFor) {
|
|
755
936
|
const raw = req.method === "POST" ? await readBodyWithin(req, HOOK_BODY_LIMIT) : undefined;
|
|
756
937
|
if (req.method !== "POST")
|
|
757
938
|
req.resume();
|
|
@@ -765,7 +946,7 @@ async function handleResumeGuidanceHook(req, res, state) {
|
|
|
765
946
|
input = null;
|
|
766
947
|
}
|
|
767
948
|
const context = input?.hook_event_name === "SessionStart"
|
|
768
|
-
? await
|
|
949
|
+
? await contextFor(input) : undefined;
|
|
769
950
|
if (!context) {
|
|
770
951
|
res.writeHead(204);
|
|
771
952
|
res.end();
|
|
@@ -827,6 +1008,12 @@ onlyEvent) {
|
|
|
827
1008
|
if (parsed.agent_id === undefined) {
|
|
828
1009
|
recordSessionStart(state, parsed.source);
|
|
829
1010
|
noteMainSessionStart(state, parsed.source, parsed.session_id);
|
|
1011
|
+
// Only ccc's own Claude Code moves the host session: a teammate process loads
|
|
1012
|
+
// the same plugin and posts its own session starts here.
|
|
1013
|
+
if (isHostPid(state, req.headers[CLAUDE_PID_HEADER]) &&
|
|
1014
|
+
typeof parsed.session_id === "string" && parsed.session_id) {
|
|
1015
|
+
state.hostSession = parsed.session_id;
|
|
1016
|
+
}
|
|
830
1017
|
// Claude Code's compaction dropped any MemTree notice from the transcript.
|
|
831
1018
|
if (parsed.source === "compact")
|
|
832
1019
|
state.modelNotices.forget(parsed.session_id);
|
|
@@ -1047,6 +1234,8 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1047
1234
|
};
|
|
1048
1235
|
// The prompt this request was made for, before any await (Codex r2 P2).
|
|
1049
1236
|
noticeContexts.set(rec, { promptId: state.mainPromptId, generation: state.mainPromptGeneration });
|
|
1237
|
+
// An index wait may hold this request (runBlockingCompression) until Claude Code leaves.
|
|
1238
|
+
indexHolds.set(rec, { started: received, closed: closedSignal(res) });
|
|
1050
1239
|
const logged = async (forward) => {
|
|
1051
1240
|
try {
|
|
1052
1241
|
await forward;
|
|
@@ -1173,6 +1362,14 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1173
1362
|
const isSubagentRequest = requestRouteLane === "agent";
|
|
1174
1363
|
const isMainRequest = requestRouteLane === "main";
|
|
1175
1364
|
const requestSizeKey = laneSizeKey(req, requestRouteKey, body.model);
|
|
1365
|
+
const hold = indexHolds.get(rec);
|
|
1366
|
+
if (hold) {
|
|
1367
|
+
hold.lane = requestRouteKey;
|
|
1368
|
+
// Arriving while the lane waits for its index: this request's asks stay within its
|
|
1369
|
+
// own hold even if that wait ends meanwhile (laneHeldCap).
|
|
1370
|
+
if (state.fallbackFailures.indexWaiting(requestRouteKey))
|
|
1371
|
+
hold.underIndexWait = true;
|
|
1372
|
+
}
|
|
1176
1373
|
// Extended 1M context arrives as the `context-1m` beta header (Claude Code
|
|
1177
1374
|
// strips the `[1m]` model suffix on the wire). Current native-1M models send
|
|
1178
1375
|
// neither, so contextLimitForModel also needs the launcher's native setting.
|
|
@@ -1491,12 +1688,16 @@ async function handleMessages(req, res, opts, upstream, state) {
|
|
|
1491
1688
|
}
|
|
1492
1689
|
else if (prior?.retryAtTokens !== undefined &&
|
|
1493
1690
|
plan.estimateTokens < prior.retryAtTokens &&
|
|
1494
|
-
Date.now() < (prior.retryDeadline ?? 0)) {
|
|
1691
|
+
Date.now() < (prior.retryDeadline ?? 0) && !outageOver(state, prior.offline)) {
|
|
1495
1692
|
// This lane's last attempt this human turn produced nothing; wait
|
|
1496
1693
|
// for growth instead of paying a blocking call on every tool turn.
|
|
1497
1694
|
rec.routeRecovery = { outcome: "backoff" };
|
|
1695
|
+
// MemTree answered that attempt: a refusal now is about the fit (Codex r1 P2).
|
|
1696
|
+
if (!prior.offline)
|
|
1697
|
+
answeredBackoffs.add(rec);
|
|
1498
1698
|
}
|
|
1499
1699
|
else if (Date.now() < state.toolRecoveryCooldownUntil &&
|
|
1700
|
+
!outageOver(state, state.toolRecoveryCooldownOffline) &&
|
|
1500
1701
|
!(plan.overWindow && !plan.fallback && (!plan.uncountedOverWindowOnly ||
|
|
1501
1702
|
await wholeRequestRefused(requestSizing, originalCandidate, sizingLimits())))) {
|
|
1502
1703
|
// A recent attempt burned the full compress budget and still
|
|
@@ -2644,6 +2845,7 @@ function noteMemtreeHealth(state, compression) {
|
|
|
2644
2845
|
return;
|
|
2645
2846
|
state.toolRecoveryCooldownUntil =
|
|
2646
2847
|
Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
|
|
2848
|
+
state.toolRecoveryCooldownOffline = state.memtreeUnreachable() !== undefined;
|
|
2647
2849
|
return;
|
|
2648
2850
|
}
|
|
2649
2851
|
if (compression.liveHealth === "success") {
|
|
@@ -2797,6 +2999,8 @@ function persistStablePrefix(state, sessionId, prefix) {
|
|
|
2797
2999
|
originalSystemHash: prefix.originalSystemHash,
|
|
2798
3000
|
originalPrefixHashes: prefix.originalPrefixHashes,
|
|
2799
3001
|
originalMediaFreeHashes: prefix.originalMediaFreeHashes,
|
|
3002
|
+
originalPortableHashes: prefix.originalPortableHashes,
|
|
3003
|
+
originalPortableMediaFreeHashes: prefix.originalPortableMediaFreeHashes,
|
|
2800
3004
|
compressedMessages: prefix.compressedMessages,
|
|
2801
3005
|
compressedSystem: prefix.compressedSystem,
|
|
2802
3006
|
hasCompressedSystem: prefix.hasCompressedSystem,
|
|
@@ -2837,6 +3041,7 @@ function adoptStoredPrefix(opts, state, rec, sessionId, body, messages, modelCon
|
|
|
2837
3041
|
if (verdict === "discard") {
|
|
2838
3042
|
store.delete(sessionId, owner);
|
|
2839
3043
|
rec.storedPrefix = "discarded";
|
|
3044
|
+
rec.storedPrefixMissAt = firstPrefixMiss(messages, prefix, "stable");
|
|
2840
3045
|
return;
|
|
2841
3046
|
}
|
|
2842
3047
|
boundedSet(state.stablePrefixes, sessionId, prefix);
|
|
@@ -2885,15 +3090,19 @@ function prefixMismatch(body, messages, prefix, sessionId, kind = "route") {
|
|
|
2885
3090
|
if (routeValueHash(normalizeRouteSystem(body.system)) !== prefix.originalSystemHash) {
|
|
2886
3091
|
return "system";
|
|
2887
3092
|
}
|
|
2888
|
-
|
|
2889
|
-
if (messages.length < prefixLength)
|
|
3093
|
+
if (messages.length < prefix.originalPrefixHashes.length)
|
|
2890
3094
|
return "prefix";
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
3095
|
+
return firstPrefixMiss(messages, prefix, kind) === undefined ? null : "prefix";
|
|
3096
|
+
}
|
|
3097
|
+
/** The first message the prefix covers that no longer matches it, if any. */
|
|
3098
|
+
function firstPrefixMiss(messages, prefix, kind) {
|
|
3099
|
+
const length = Math.min(messages.length, prefix.originalPrefixHashes.length);
|
|
3100
|
+
for (let i = 0; i < length; i++) {
|
|
3101
|
+
if (!prefixMessageMatches(messages[i], kind, prefix.originalPrefixHashes[i], prefix.originalMediaFreeHashes?.[i], prefix.originalPortableHashes?.[i], prefix.originalPortableMediaFreeHashes?.[i])) {
|
|
3102
|
+
return i;
|
|
2894
3103
|
}
|
|
2895
3104
|
}
|
|
2896
|
-
return
|
|
3105
|
+
return undefined;
|
|
2897
3106
|
}
|
|
2898
3107
|
/**
|
|
2899
3108
|
* Message identity for a stable prefix: routeMessageHash without assistant
|
|
@@ -3824,7 +4033,7 @@ function memoryRoutedToolBody(body, messages, route, routeEpoch, sessionId) {
|
|
|
3824
4033
|
if (messages.length < prefixLength)
|
|
3825
4034
|
return null;
|
|
3826
4035
|
for (let i = 0; i < prefixLength; i++) {
|
|
3827
|
-
if (!prefixMessageMatches(messages[i], "route", route.originalPrefixHashes[i], route.originalMediaFreeHashes?.[i])) {
|
|
4036
|
+
if (!prefixMessageMatches(messages[i], "route", route.originalPrefixHashes[i], route.originalMediaFreeHashes?.[i], route.originalPortableHashes?.[i], route.originalPortableMediaFreeHashes?.[i])) {
|
|
3828
4037
|
return null;
|
|
3829
4038
|
}
|
|
3830
4039
|
}
|
|
@@ -4188,6 +4397,154 @@ function cloneJson(value) {
|
|
|
4188
4397
|
return value;
|
|
4189
4398
|
return JSON.parse(JSON.stringify(value));
|
|
4190
4399
|
}
|
|
4400
|
+
/**
|
|
4401
|
+
* A compress call that, while MemTree answers that its index is still catching up on
|
|
4402
|
+
* this conversation (an indexing 413, index-wait-413.ts), holds the request and asks
|
|
4403
|
+
* again at the server's pace instead of refusing at once: up to INDEX_HOLD_MS per
|
|
4404
|
+
* request, below Claude Code's wait for a first byte. A wait it cannot finish here is
|
|
4405
|
+
* left to the refusal (rec.indexBehind), whose 503 says how long is left and has Claude
|
|
4406
|
+
* Code retry, until the lane's deadline (route-fallback.ts). Only when nothing else could
|
|
4407
|
+
* be sent (longBudget): a ride on an earlier prefix, or a request that fits, goes at once.
|
|
4408
|
+
* Never for a background call (its record is a copy) or a 413 that waiting cannot fix.
|
|
4409
|
+
*/
|
|
4410
|
+
async function runBlockingCompression(args) {
|
|
4411
|
+
let capMs = laneHeldCap(args);
|
|
4412
|
+
for (;;) {
|
|
4413
|
+
const outcome = await runBlockingCompressionOnce(capMs === undefined ? args
|
|
4414
|
+
: { ...args, capMs });
|
|
4415
|
+
// An ask cut short by the hold's end is no evidence of an outage; and while the
|
|
4416
|
+
// lane waits for its index it is that wait, not a strike (a later answer ends it).
|
|
4417
|
+
if (capMs !== undefined && args.rec.compress?.timedOut) {
|
|
4418
|
+
outcome.liveHealth = "none";
|
|
4419
|
+
if (outcome.indexWaitSeconds === undefined && !activeIndexWaits.has(args.rec)) {
|
|
4420
|
+
const lane = indexHolds.get(args.rec)?.lane;
|
|
4421
|
+
noteIndexWait413(args.rec, (lane !== undefined
|
|
4422
|
+
? args.state.fallbackFailures.indexSecondsLeft(lane) : undefined) ??
|
|
4423
|
+
UNKNOWN_INDEX_WAIT_SECONDS);
|
|
4424
|
+
return outcome;
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
if (outcome.indexWaitSeconds === undefined) {
|
|
4428
|
+
// Settled otherwise (an answer, or a failure of another kind): the wait is over.
|
|
4429
|
+
// A timed-out ask said nothing new, so a refusal still waits.
|
|
4430
|
+
if (outcome.result !== null || !args.rec.compress?.timedOut)
|
|
4431
|
+
endIndexWait(args.rec);
|
|
4432
|
+
return outcome;
|
|
4433
|
+
}
|
|
4434
|
+
noteIndexWait413(args.rec, outcome.indexWaitSeconds);
|
|
4435
|
+
capMs = await holdForIndex(args, outcome.indexWaitSeconds);
|
|
4436
|
+
if (capMs === undefined)
|
|
4437
|
+
return outcome;
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
/**
|
|
4441
|
+
* While the lane waits for its index (another request held it, likely this one's wait
|
|
4442
|
+
* too), a request's first ask gets only what is left of its own hold: its time waiting
|
|
4443
|
+
* for the lane already counts toward Claude Code's first-byte wait.
|
|
4444
|
+
*/
|
|
4445
|
+
function laneHeldCap(args) {
|
|
4446
|
+
const hold = indexHolds.get(args.rec);
|
|
4447
|
+
if (!hold || hold.lane === undefined)
|
|
4448
|
+
return undefined;
|
|
4449
|
+
// Waiting now, or at any time since this request arrived (it may have queued behind a
|
|
4450
|
+
// hold that has since delivered).
|
|
4451
|
+
if (!hold.underIndexWait &&
|
|
4452
|
+
!args.state.fallbackFailures.indexWaitSince(hold.lane, hold.started)) {
|
|
4453
|
+
return undefined;
|
|
4454
|
+
}
|
|
4455
|
+
hold.underIndexWait = true;
|
|
4456
|
+
return Math.max(1, (args.opts.indexHoldMs ?? INDEX_HOLD_MS) - (Date.now() - hold.started));
|
|
4457
|
+
}
|
|
4458
|
+
/** One request's index hold: when it arrived, its lane, and a signal for Claude Code leaving. */
|
|
4459
|
+
const indexHolds = new WeakMap();
|
|
4460
|
+
/**
|
|
4461
|
+
* Requests whose latest compress answer was an indexing 413: the seconds it stated. Set
|
|
4462
|
+
* only by runBlockingCompression, and cleared when a later ask settles otherwise.
|
|
4463
|
+
*/
|
|
4464
|
+
const activeIndexWaits = new WeakMap();
|
|
4465
|
+
/** The most one request is held for an index (Claude Code 2.1.319 waits 180 s for headers). */
|
|
4466
|
+
const INDEX_HOLD_MS = 120_000;
|
|
4467
|
+
/** The longest pause between asks while held (the server's Retry-After, when shorter). */
|
|
4468
|
+
const INDEX_POLL_MAX_MS = 30_000;
|
|
4469
|
+
/**
|
|
4470
|
+
* A held ask gets at least this long (MemTree itself holds an indexing 413 for up to
|
|
4471
|
+
* 90 s); with less left, the request is not held.
|
|
4472
|
+
*/
|
|
4473
|
+
const INDEX_MIN_ASK_MS = 30_000;
|
|
4474
|
+
/** An indexing 413: refusing this request waits for the index, saying how long is left. */
|
|
4475
|
+
function noteIndexWait413(rec, seconds) {
|
|
4476
|
+
if (!activeIndexWaits.has(rec) && rec.indexBehind !== true)
|
|
4477
|
+
indexBehindSetByWait.add(rec);
|
|
4478
|
+
activeIndexWaits.set(rec, seconds);
|
|
4479
|
+
rec.indexBehind = true;
|
|
4480
|
+
rec.indexWait = { secondsLeft: seconds, asks: (rec.indexWait?.asks ?? 0) + 1,
|
|
4481
|
+
heldMs: rec.indexWait?.heldMs ?? 0 };
|
|
4482
|
+
}
|
|
4483
|
+
/** Requests whose indexBehind only an indexing 413 set (endIndexWait clears it). */
|
|
4484
|
+
const indexBehindSetByWait = new WeakSet();
|
|
4485
|
+
/** A later ask settled otherwise: the request no longer waits (rec.indexWait stays as history). */
|
|
4486
|
+
function endIndexWait(rec) {
|
|
4487
|
+
if (!activeIndexWaits.delete(rec))
|
|
4488
|
+
return;
|
|
4489
|
+
if (indexBehindSetByWait.delete(rec))
|
|
4490
|
+
delete rec.indexBehind;
|
|
4491
|
+
if (rec.indexWait)
|
|
4492
|
+
rec.indexWait.ended = true;
|
|
4493
|
+
}
|
|
4494
|
+
/**
|
|
4495
|
+
* Pause before asking MemTree again, when another ask fits this request's hold and its
|
|
4496
|
+
* lane's wait is still open: the time left of the hold for that ask (its cap). Undefined
|
|
4497
|
+
* when not (nothing else could be sent is false, the estimate is past the cap, the lane's
|
|
4498
|
+
* deadline passed, or Claude Code or the proxy left).
|
|
4499
|
+
*/
|
|
4500
|
+
async function holdForIndex(args, seconds) {
|
|
4501
|
+
const hold = indexHolds.get(args.rec);
|
|
4502
|
+
if (!hold || args.longBudget !== true)
|
|
4503
|
+
return undefined;
|
|
4504
|
+
const laneEnds = hold.lane === undefined ? Infinity
|
|
4505
|
+
: args.state.fallbackFailures.indexWaitDeadline(hold.lane, seconds);
|
|
4506
|
+
if (laneEnds === undefined || args.state.fallbackFailures.hopeless(seconds))
|
|
4507
|
+
return undefined;
|
|
4508
|
+
const pause = Math.min(seconds * 1000, args.opts.indexPollMaxMs ?? INDEX_POLL_MAX_MS);
|
|
4509
|
+
const holdMs = args.opts.indexHoldMs ?? INDEX_HOLD_MS;
|
|
4510
|
+
// The earlier of the request's hold and the lane's wait bounds the pause and the ask.
|
|
4511
|
+
const left = () => Math.min(holdMs - (Date.now() - hold.started), laneEnds - Date.now());
|
|
4512
|
+
if (left() - pause < Math.min(INDEX_MIN_ASK_MS, holdMs / 4))
|
|
4513
|
+
return undefined;
|
|
4514
|
+
const signals = [hold.closed, args.state.shutdownSignal, args.signal]
|
|
4515
|
+
.filter((signal) => signal !== undefined);
|
|
4516
|
+
const paused = Date.now();
|
|
4517
|
+
const waited = await abortableDelay(pause, signals);
|
|
4518
|
+
if (args.rec.indexWait)
|
|
4519
|
+
args.rec.indexWait.heldMs += Date.now() - paused;
|
|
4520
|
+
return waited ? Math.max(1, left()) : undefined;
|
|
4521
|
+
}
|
|
4522
|
+
/** Wait `ms`; false at once if any signal aborts first (or already has). */
|
|
4523
|
+
function abortableDelay(ms, signals) {
|
|
4524
|
+
if (signals.some((signal) => signal.aborted))
|
|
4525
|
+
return Promise.resolve(false);
|
|
4526
|
+
return new Promise((resolve) => {
|
|
4527
|
+
const done = (waited) => {
|
|
4528
|
+
clearTimeout(timer);
|
|
4529
|
+
for (const signal of signals)
|
|
4530
|
+
signal.removeEventListener("abort", aborted);
|
|
4531
|
+
resolve(waited);
|
|
4532
|
+
};
|
|
4533
|
+
const aborted = () => done(false);
|
|
4534
|
+
// Unref'd: a hold never keeps the process open.
|
|
4535
|
+
const timer = setTimeout(() => done(true), ms);
|
|
4536
|
+
timer.unref();
|
|
4537
|
+
for (const signal of signals)
|
|
4538
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
4539
|
+
});
|
|
4540
|
+
}
|
|
4541
|
+
/** Aborted when Claude Code closes this response before it finished. */
|
|
4542
|
+
function closedSignal(res) {
|
|
4543
|
+
const controller = new AbortController();
|
|
4544
|
+
res.once("close", () => { if (!res.writableFinished)
|
|
4545
|
+
controller.abort(); });
|
|
4546
|
+
return controller.signal;
|
|
4547
|
+
}
|
|
4191
4548
|
/**
|
|
4192
4549
|
* The blocking-compression step, shared by the main followup path and
|
|
4193
4550
|
* tool-route miss recovery: the compress call plus the `compress` telemetry
|
|
@@ -4198,7 +4555,7 @@ function cloneJson(value) {
|
|
|
4198
4555
|
* `checkCompressedHistory(result, msgsForMemtree).usable` must hold before
|
|
4199
4556
|
* the result may be forwarded or become a route.
|
|
4200
4557
|
*/
|
|
4201
|
-
async function
|
|
4558
|
+
async function runBlockingCompressionOnce(args) {
|
|
4202
4559
|
const { opts, state, body, msgsForMemtree, hash, modelContextLimit, rec } = args;
|
|
4203
4560
|
const compaction = args.compaction ??
|
|
4204
4561
|
laneCompaction(opts, state, args.sessionId, body.model, modelContextLimit);
|
|
@@ -4210,10 +4567,10 @@ async function runBlockingCompression(args) {
|
|
|
4210
4567
|
// The upload is about the request's size: the budget grows with it (the long one
|
|
4211
4568
|
// too, never below the short one's).
|
|
4212
4569
|
const scaledMs = opts.memtree.compressBudgetForBytes(rec.requestBytes);
|
|
4213
|
-
const budgetMs = Math.min(args.longBudget ? MAX_LONG_COMPRESS_WAIT_MS : MAX_COMPRESS_WAIT_MS, args.longBudget ? Math.max(opts.memtree.longCompressBudgetMs, scaledMs) : scaledMs);
|
|
4570
|
+
const budgetMs = Math.min(args.longBudget ? MAX_LONG_COMPRESS_WAIT_MS : MAX_COMPRESS_WAIT_MS, args.longBudget ? Math.max(opts.memtree.longCompressBudgetMs, scaledMs) : scaledMs, args.capMs ?? Infinity);
|
|
4214
4571
|
// Carried only when running on gives it meaningfully more time than this wait.
|
|
4215
|
-
const carry = args.carry &&
|
|
4216
|
-
? args.carry : undefined;
|
|
4572
|
+
const carry = args.carry && args.capMs === undefined &&
|
|
4573
|
+
carriedBudgetMs(opts, scaledMs) >= budgetMs + CARRY_MIN_EXTRA_MS ? args.carry : undefined;
|
|
4217
4574
|
const compressMeta = {
|
|
4218
4575
|
// Model + tools drive the server's model-based memory budget
|
|
4219
4576
|
// (e.g. 800k whole-request target for Fable / Opus 5). Omitting
|
|
@@ -4274,6 +4631,8 @@ async function runBlockingCompression(args) {
|
|
|
4274
4631
|
opts.memtree.lastCompressFailureArming(hash, modelContextLimit, compressMeta);
|
|
4275
4632
|
const failureStatus = result === null
|
|
4276
4633
|
? opts.memtree.lastCompressFailureStatus?.(hash, modelContextLimit, compressMeta) : undefined;
|
|
4634
|
+
// A joined call's answer counts too: it is this request's answer.
|
|
4635
|
+
const indexWaitSeconds = result === null ? opts.memtree.compressIndexWait?.(call) : undefined;
|
|
4277
4636
|
const hadLiveSuccess = !cached && result !== null;
|
|
4278
4637
|
// Only arming-class live failures (network error, timeout, 5xx, 402) are
|
|
4279
4638
|
// fuse evidence. A responsive server's other 4xx failed THIS call but says
|
|
@@ -4296,7 +4655,9 @@ async function runBlockingCompression(args) {
|
|
|
4296
4655
|
ok: result !== null,
|
|
4297
4656
|
timedOut,
|
|
4298
4657
|
...(carried ? { carriedOver: true } : {}),
|
|
4299
|
-
|
|
4658
|
+
// A joined call's indexing 413 is MemTree's refusal of this request too.
|
|
4659
|
+
...((!cached && failureStatus === OVER_CONTEXT_STATUS) || indexWaitSeconds !== undefined
|
|
4660
|
+
? { refused: true } : {}),
|
|
4300
4661
|
};
|
|
4301
4662
|
return {
|
|
4302
4663
|
result,
|
|
@@ -4306,6 +4667,7 @@ async function runBlockingCompression(args) {
|
|
|
4306
4667
|
: hadLiveFailure
|
|
4307
4668
|
? "failure"
|
|
4308
4669
|
: "none",
|
|
4670
|
+
...(indexWaitSeconds !== undefined ? { indexWaitSeconds } : {}),
|
|
4309
4671
|
};
|
|
4310
4672
|
}
|
|
4311
4673
|
/**
|
|
@@ -4420,6 +4782,10 @@ async function recoverToolRouteMiss(args) {
|
|
|
4420
4782
|
if (args.retryAtTokens !== undefined) {
|
|
4421
4783
|
recoveryAttempt.retryAtTokens = args.retryAtTokens;
|
|
4422
4784
|
recoveryAttempt.retryDeadline = Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
|
|
4785
|
+
if (state.memtreeUnreachable())
|
|
4786
|
+
recoveryAttempt.offline = true;
|
|
4787
|
+
else
|
|
4788
|
+
delete recoveryAttempt.offline;
|
|
4423
4789
|
}
|
|
4424
4790
|
};
|
|
4425
4791
|
const sessionId = requestSessionId(req);
|
|
@@ -4487,7 +4853,10 @@ async function recoverToolRouteMiss(args) {
|
|
|
4487
4853
|
modelContextLimit, rec, targetTokens: stable.targetTokens, explicitTarget: stable.explicitTarget,
|
|
4488
4854
|
linkSeq }) : undefined;
|
|
4489
4855
|
let compression;
|
|
4856
|
+
// Nothing else could be sent if this compress fails: the long budget, and an index hold.
|
|
4857
|
+
let longBudget = false;
|
|
4490
4858
|
try {
|
|
4859
|
+
longBudget = args.wholeRefused ? await args.wholeRefused() : false;
|
|
4491
4860
|
compression = await runBlockingCompression({
|
|
4492
4861
|
opts,
|
|
4493
4862
|
state,
|
|
@@ -4502,7 +4871,7 @@ async function recoverToolRouteMiss(args) {
|
|
|
4502
4871
|
sizing: args.sizing,
|
|
4503
4872
|
inputCandidate: args.inputCandidate,
|
|
4504
4873
|
...(args.compaction ? { compaction: args.compaction } : {}),
|
|
4505
|
-
longBudget
|
|
4874
|
+
longBudget,
|
|
4506
4875
|
...(toolCarry ? { carry: toolCarry } : {}),
|
|
4507
4876
|
});
|
|
4508
4877
|
}
|
|
@@ -4545,6 +4914,11 @@ async function recoverToolRouteMiss(args) {
|
|
|
4545
4914
|
if (!result) {
|
|
4546
4915
|
rec.routeRecovery = { conversationBytes, outcome: "failed" };
|
|
4547
4916
|
releaseOwnReservation();
|
|
4917
|
+
// MemTree's 413 for an index still catching up, with nothing else to send: the
|
|
4918
|
+
// refusal waits for the index (its 503 paces the retries), and growth cannot help,
|
|
4919
|
+
// so the retry asks again. With a ride to send, the usual backoff paces the asks.
|
|
4920
|
+
if (longBudget && activeIndexWaits.has(rec))
|
|
4921
|
+
return forwardOriginal(false);
|
|
4548
4922
|
// An ordinary server/network failure or timeout retains the background
|
|
4549
4923
|
// submission: its longer independent budget can still warm the index for
|
|
4550
4924
|
// a later turn. An unpaid key (402, possibly set by this very compress)
|
|
@@ -5082,6 +5456,9 @@ async function truncatedToFit(rec, sizing, preferred, limits, store) {
|
|
|
5082
5456
|
* compression summarize instead.
|
|
5083
5457
|
*/
|
|
5084
5458
|
function compressionSettled(rec) {
|
|
5459
|
+
// An indexing 413 is MemTree's refusal too: the safety net may still fit the request.
|
|
5460
|
+
if (activeIndexWaits.has(rec))
|
|
5461
|
+
return true;
|
|
5085
5462
|
if (rec.indexBehind === true)
|
|
5086
5463
|
return false;
|
|
5087
5464
|
if (rec.compress?.refused === true || rec.compaction?.prepared === "used")
|
|
@@ -5245,9 +5622,13 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
|
|
|
5245
5622
|
// A refused key or a paused one: a retry cannot fix either, so it is final at once.
|
|
5246
5623
|
const keyInvalid = args.state.keyInvalid();
|
|
5247
5624
|
const unpaid = keyInvalid ? null : args.state.pausedDetail();
|
|
5625
|
+
// MemTree can't be reached: nothing says the request can't fit, so no strike (2026-11-09).
|
|
5626
|
+
const offline = keyInvalid || unpaid !== null ? undefined : unreachableNow(args.state, args.rec);
|
|
5248
5627
|
const failure = keyInvalid ? pausedRefusal(keyInvalidRefusalMessage(args.state.keyPageUrl))
|
|
5249
5628
|
: unpaid !== null ? pausedRefusal(pausedRefusalMessage(unpaid))
|
|
5250
|
-
:
|
|
5629
|
+
: offline ? unreachableRefusal(offline.message)
|
|
5630
|
+
: args.state.fallbackFailures.fail(args.routeKey, { awaitingIndex: args.rec.indexBehind === true,
|
|
5631
|
+
indexSecondsLeft: activeIndexWaits.get(args.rec) });
|
|
5251
5632
|
args.rec.forwardedBytes = 0;
|
|
5252
5633
|
args.rec.approxInputTokens = 0;
|
|
5253
5634
|
// Nothing went out uncompressed: Claude Code retries it (2026-10-28).
|
|
@@ -5259,7 +5640,8 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
|
|
|
5259
5640
|
delete args.rec.compaction?.keptPrefix;
|
|
5260
5641
|
}
|
|
5261
5642
|
delete args.rec.degradedNotice;
|
|
5262
|
-
|
|
5643
|
+
// An unreachable MemTree's 503 says why itself: not "slow to answer".
|
|
5644
|
+
if (unpaid === null && !keyInvalid && !offline)
|
|
5263
5645
|
noteSlowRetry(args, failure);
|
|
5264
5646
|
args.state.diagnostics?.record("refusal", {
|
|
5265
5647
|
status: failure.status, attempt: failure.attempt,
|
|
@@ -5272,6 +5654,7 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
|
|
|
5272
5654
|
outputReservation: bodyOutputReservation(args.body), indexBehind: args.rec.indexBehind === true,
|
|
5273
5655
|
budgetTokens, modelContextLimit: args.modelContextLimit, ...(unpaid !== null ? { paused: true } : {}),
|
|
5274
5656
|
...(keyInvalid ? { keyInvalid: true } : {}),
|
|
5657
|
+
...(offline ? { unreachable: offline.what } : {}),
|
|
5275
5658
|
};
|
|
5276
5659
|
if (unpaid !== null || keyInvalid)
|
|
5277
5660
|
cancelPausedNotice(args.state, args.rec);
|
|
@@ -5287,6 +5670,37 @@ function refuseWholeRequest(args, inputTokens, budgetTokens) {
|
|
|
5287
5670
|
args.state.keyNotice.markShown();
|
|
5288
5671
|
return false;
|
|
5289
5672
|
}
|
|
5673
|
+
/** Requests held back by a lane backoff whose attempt MemTree answered (unreachableNow). */
|
|
5674
|
+
const answeredBackoffs = new WeakSet();
|
|
5675
|
+
/**
|
|
5676
|
+
* Whether a backoff or cooldown armed by a call that could not connect (`offline`) is over:
|
|
5677
|
+
* MemTree has answered since, so the next request asks it again.
|
|
5678
|
+
*/
|
|
5679
|
+
function outageOver(state, offline) {
|
|
5680
|
+
return offline === true && state.memtreeUnreachable() === undefined;
|
|
5681
|
+
}
|
|
5682
|
+
/**
|
|
5683
|
+
* Why a request only MemTree could fit can't go now, when MemTree's latest compress call
|
|
5684
|
+
* could not connect (recently): the internet when Anthropic's latest forward could not
|
|
5685
|
+
* either. Never for a request MemTree answered (live or from the cache), one held back by a
|
|
5686
|
+
* backoff MemTree's answer armed, or one sent on a prepared prefix or waiting for its index:
|
|
5687
|
+
* its refusal is about the fit (review r1, Codex r1).
|
|
5688
|
+
*/
|
|
5689
|
+
function unreachableNow(state, rec) {
|
|
5690
|
+
if (rec.compress?.ok === true || rec.compress?.refused === true ||
|
|
5691
|
+
// The backoff's answer speaks only while this request made no compress call of its
|
|
5692
|
+
// own (a delivery-time one that could not connect is its own evidence: Codex r2).
|
|
5693
|
+
(rec.compress === undefined && answeredBackoffs.has(rec)) ||
|
|
5694
|
+
rec.compaction?.prepared === "used" || rec.indexBehind === true ||
|
|
5695
|
+
activeIndexWaits.has(rec))
|
|
5696
|
+
return undefined;
|
|
5697
|
+
const memtree = freshFailure(state.memtreeUnreachable());
|
|
5698
|
+
if (!memtree)
|
|
5699
|
+
return undefined;
|
|
5700
|
+
const anthropic = freshFailure(state.anthropicUnreachable());
|
|
5701
|
+
return { message: unreachableMessage(memtree, anthropic),
|
|
5702
|
+
what: anthropic ? "internet" : "memtree" };
|
|
5703
|
+
}
|
|
5290
5704
|
/**
|
|
5291
5705
|
* A repeated retryable refusal on the main thread: say once per streak that
|
|
5292
5706
|
* MemTree is slow and Claude Code is retrying, unless another notice waits.
|
|
@@ -5563,6 +5977,7 @@ function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, r
|
|
|
5563
5977
|
path: req.url, // path + query string verbatim (?beta=true etc.)
|
|
5564
5978
|
headers,
|
|
5565
5979
|
}, (upstreamRes) => {
|
|
5980
|
+
upstream.unreachable = undefined;
|
|
5566
5981
|
activeUpstreamRes = upstreamRes;
|
|
5567
5982
|
const contentType = String(upstreamRes.headers["content-type"] ?? "");
|
|
5568
5983
|
const isSse = contentType.includes("text/event-stream");
|
|
@@ -5821,7 +6236,7 @@ function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, r
|
|
|
5821
6236
|
});
|
|
5822
6237
|
upstreamReq.on("error", (err) => {
|
|
5823
6238
|
if (!shutdownCancelled) {
|
|
5824
|
-
sendAnthropicError(res,
|
|
6239
|
+
sendAnthropicError(res, upstreamFailureMessage(upstream, err, !activeUpstreamRes));
|
|
5825
6240
|
}
|
|
5826
6241
|
settle(false);
|
|
5827
6242
|
});
|
|
@@ -5973,6 +6388,7 @@ function passThroughStreaming(req, res, upstream, shutdownSignal) {
|
|
|
5973
6388
|
upstreamRes.destroy();
|
|
5974
6389
|
return;
|
|
5975
6390
|
}
|
|
6391
|
+
upstream.unreachable = undefined;
|
|
5976
6392
|
activeUpstreamRes = upstreamRes;
|
|
5977
6393
|
const onUpstreamResponseEnd = () => {
|
|
5978
6394
|
upstreamResponseEnded = true;
|
|
@@ -6018,7 +6434,7 @@ function passThroughStreaming(req, res, upstream, shutdownSignal) {
|
|
|
6018
6434
|
if (upstreamReq)
|
|
6019
6435
|
req.unpipe(upstreamReq);
|
|
6020
6436
|
activeUpstreamRes?.unpipe(res);
|
|
6021
|
-
sendAnthropicError(res,
|
|
6437
|
+
sendAnthropicError(res, upstreamFailureMessage(upstream, err, !activeUpstreamRes));
|
|
6022
6438
|
if (res.writableFinished)
|
|
6023
6439
|
onDownstreamFinish();
|
|
6024
6440
|
});
|
|
@@ -6052,6 +6468,18 @@ function forwardableResponseHeaders(upstreamRes) {
|
|
|
6052
6468
|
}
|
|
6053
6469
|
return out;
|
|
6054
6470
|
}
|
|
6471
|
+
/**
|
|
6472
|
+
* The 502 text for a forward that got no answer: plainly that ccc can't reach Anthropic
|
|
6473
|
+
* when it could not connect (noted on `upstream`; only before any response: review r1 P3),
|
|
6474
|
+
* else the error as it came.
|
|
6475
|
+
*/
|
|
6476
|
+
function upstreamFailureMessage(upstream, err, unanswered) {
|
|
6477
|
+
const failure = unanswered ? networkFailureOf(err, upstream.host) : undefined;
|
|
6478
|
+
if (!failure)
|
|
6479
|
+
return `upstream connection failed: ${err.message}`;
|
|
6480
|
+
upstream.unreachable = failure;
|
|
6481
|
+
return unreachableMessage(undefined, failure);
|
|
6482
|
+
}
|
|
6055
6483
|
/** Well-formed Anthropic-shaped error body so Claude Code fails fast, not weird. */
|
|
6056
6484
|
function sendAnthropicError(res, message) {
|
|
6057
6485
|
if (res.headersSent) {
|