claude-code-infinite 1.0.9 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +230 -37
  2. package/dist/cc-request.d.ts +94 -0
  3. package/dist/cc-request.d.ts.map +1 -0
  4. package/dist/cc-request.js +194 -0
  5. package/dist/cc-request.js.map +1 -0
  6. package/dist/claude-env.d.ts +30 -0
  7. package/dist/claude-env.d.ts.map +1 -0
  8. package/dist/claude-env.js +55 -0
  9. package/dist/claude-env.js.map +1 -0
  10. package/dist/cli-args.d.ts +32 -0
  11. package/dist/cli-args.d.ts.map +1 -0
  12. package/dist/cli-args.js +72 -0
  13. package/dist/cli-args.js.map +1 -0
  14. package/dist/cli-lifecycle.d.ts +17 -0
  15. package/dist/cli-lifecycle.d.ts.map +1 -0
  16. package/dist/cli-lifecycle.js +30 -0
  17. package/dist/cli-lifecycle.js.map +1 -0
  18. package/dist/cli-main.d.ts +15 -0
  19. package/dist/cli-main.d.ts.map +1 -0
  20. package/dist/cli-main.js +464 -0
  21. package/dist/cli-main.js.map +1 -0
  22. package/dist/cli.js +13 -177
  23. package/dist/cli.js.map +1 -1
  24. package/dist/config.d.ts +3 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +29 -3
  27. package/dist/config.js.map +1 -1
  28. package/dist/count-tokens.d.ts +78 -0
  29. package/dist/count-tokens.d.ts.map +1 -0
  30. package/dist/count-tokens.js +277 -0
  31. package/dist/count-tokens.js.map +1 -0
  32. package/dist/final-index.d.ts +10 -0
  33. package/dist/final-index.d.ts.map +1 -0
  34. package/dist/final-index.js +37 -0
  35. package/dist/final-index.js.map +1 -0
  36. package/dist/hooks.d.ts +239 -0
  37. package/dist/hooks.d.ts.map +1 -0
  38. package/dist/hooks.js +618 -0
  39. package/dist/hooks.js.map +1 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +8 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/memtree-fetch.d.ts +24 -0
  45. package/dist/memtree-fetch.d.ts.map +1 -0
  46. package/dist/memtree-fetch.js +121 -0
  47. package/dist/memtree-fetch.js.map +1 -0
  48. package/dist/memtree-finder.d.ts +99 -0
  49. package/dist/memtree-finder.d.ts.map +1 -0
  50. package/dist/memtree-finder.js +235 -0
  51. package/dist/memtree-finder.js.map +1 -0
  52. package/dist/memtree-links.d.ts +16 -0
  53. package/dist/memtree-links.d.ts.map +1 -0
  54. package/dist/memtree-links.js +66 -0
  55. package/dist/memtree-links.js.map +1 -0
  56. package/dist/memtree-mcp-config.d.ts +28 -0
  57. package/dist/memtree-mcp-config.d.ts.map +1 -0
  58. package/dist/memtree-mcp-config.js +121 -0
  59. package/dist/memtree-mcp-config.js.map +1 -0
  60. package/dist/memtree-mcp.d.ts +265 -0
  61. package/dist/memtree-mcp.d.ts.map +1 -0
  62. package/dist/memtree-mcp.js +667 -0
  63. package/dist/memtree-mcp.js.map +1 -0
  64. package/dist/memtree-newest.d.ts +74 -0
  65. package/dist/memtree-newest.d.ts.map +1 -0
  66. package/dist/memtree-newest.js +217 -0
  67. package/dist/memtree-newest.js.map +1 -0
  68. package/dist/memtree-tools.d.ts +105 -0
  69. package/dist/memtree-tools.d.ts.map +1 -0
  70. package/dist/memtree-tools.js +408 -0
  71. package/dist/memtree-tools.js.map +1 -0
  72. package/dist/memtree.d.ts +398 -0
  73. package/dist/memtree.d.ts.map +1 -0
  74. package/dist/memtree.js +1231 -0
  75. package/dist/memtree.js.map +1 -0
  76. package/dist/notices.d.ts +177 -0
  77. package/dist/notices.d.ts.map +1 -0
  78. package/dist/notices.js +469 -0
  79. package/dist/notices.js.map +1 -0
  80. package/dist/payment-gate.d.ts +53 -0
  81. package/dist/payment-gate.d.ts.map +1 -0
  82. package/dist/payment-gate.js +97 -0
  83. package/dist/payment-gate.js.map +1 -0
  84. package/dist/project-meta.d.ts +15 -0
  85. package/dist/project-meta.d.ts.map +1 -0
  86. package/dist/project-meta.js +80 -0
  87. package/dist/project-meta.js.map +1 -0
  88. package/dist/prompt-accounting.d.ts +12 -0
  89. package/dist/prompt-accounting.d.ts.map +1 -0
  90. package/dist/prompt-accounting.js +70 -0
  91. package/dist/prompt-accounting.js.map +1 -0
  92. package/dist/proxy.d.ts +197 -0
  93. package/dist/proxy.d.ts.map +1 -0
  94. package/dist/proxy.js +4247 -0
  95. package/dist/proxy.js.map +1 -0
  96. package/dist/reqlog.d.ts +419 -0
  97. package/dist/reqlog.d.ts.map +1 -0
  98. package/dist/reqlog.js +146 -0
  99. package/dist/reqlog.js.map +1 -0
  100. package/dist/request-sizing.d.ts +52 -0
  101. package/dist/request-sizing.d.ts.map +1 -0
  102. package/dist/request-sizing.js +92 -0
  103. package/dist/request-sizing.js.map +1 -0
  104. package/dist/resume-repair-file.d.ts +17 -0
  105. package/dist/resume-repair-file.d.ts.map +1 -0
  106. package/dist/resume-repair-file.js +100 -0
  107. package/dist/resume-repair-file.js.map +1 -0
  108. package/dist/resume-repair.d.ts +27 -0
  109. package/dist/resume-repair.d.ts.map +1 -0
  110. package/dist/resume-repair.js +198 -0
  111. package/dist/resume-repair.js.map +1 -0
  112. package/dist/route-cache.d.ts +5 -0
  113. package/dist/route-cache.d.ts.map +1 -0
  114. package/dist/route-cache.js +63 -0
  115. package/dist/route-cache.js.map +1 -0
  116. package/dist/route-fallback.d.ts +29 -0
  117. package/dist/route-fallback.d.ts.map +1 -0
  118. package/dist/route-fallback.js +49 -0
  119. package/dist/route-fallback.js.map +1 -0
  120. package/dist/route-identity.d.ts +6 -0
  121. package/dist/route-identity.d.ts.map +1 -0
  122. package/dist/route-identity.js +100 -0
  123. package/dist/route-identity.js.map +1 -0
  124. package/dist/transcript-usage.d.ts +57 -0
  125. package/dist/transcript-usage.d.ts.map +1 -0
  126. package/dist/transcript-usage.js +570 -0
  127. package/dist/transcript-usage.js.map +1 -0
  128. package/dist/turns.d.ts +73 -0
  129. package/dist/turns.d.ts.map +1 -0
  130. package/dist/turns.js +298 -0
  131. package/dist/turns.js.map +1 -0
  132. package/dist/update-check.d.ts +38 -0
  133. package/dist/update-check.d.ts.map +1 -0
  134. package/dist/update-check.js +67 -0
  135. package/dist/update-check.js.map +1 -0
  136. package/package.json +6 -2
package/dist/proxy.js ADDED
@@ -0,0 +1,4247 @@
1
+ /**
2
+ * Claude Code Infinite local proxy (plans/2026-06-09_PLAN_local_proxy_app.md,
3
+ * refined by plans/2026-07-05_PLAN_first_user_turn_nonblocking.md).
4
+ *
5
+ * Claude Code points ANTHROPIC_BASE_URL at this 127.0.0.1 server. We never
6
+ * read, store, or refresh credentials: Claude Code keeps its native login and
7
+ * sends its own OAuth bearer here, and we forward its headers and query string
8
+ * verbatim to api.anthropic.com (the anthropic-beta flag list churns across CC
9
+ * versions — never reconstruct it). Only the `messages` body is ever altered
10
+ * (compression, plus defensive removal of legacy notice markers);
11
+ * auth, identity, and routing are never touched.
12
+ *
13
+ * Turn classification for POST /v1/messages:
14
+ * - Tool turn (last message isn't a real user input): background indexing,
15
+ * forward as-is — riding its lane's memory route (routes live in a small
16
+ * map keyed by request identity: session + main/away/agent-id), on the main
17
+ * thread the session's stable prefix, or verbatim — while the estimated
18
+ * size is under the budget, with no compress call. At the budget it
19
+ * compresses once to the target (planToolCompaction), the same rule human
20
+ * turns follow; on the main thread the result becomes the stable prefix
21
+ * later tool and human turns ride. Failure degrades to the old ride or the
22
+ * verbatim forward. Every identity installs into its own lane — isolation
23
+ * is structural, not defended.
24
+ * - First user turn (no earlier real user input): background indexing, forward
25
+ * as-is — nothing is indexed yet, so blocking would be a guaranteed no-op.
26
+ * - Followup user turn: blocking compress + substitute. The compressed body
27
+ * remains the prefix for that turn's tool loop. On the main thread a
28
+ * compaction also becomes the session's stable prefix (planEdgeCompaction):
29
+ * later human turns ride it byte for byte, with no compress call, until
30
+ * prefix + newer turns reach the budget or the covered messages change.
31
+ * - MemTree failure/timeout degrades to passthrough. A display-only success
32
+ * notice is queued only when the memory response is selected AND MemTree's
33
+ * index coverage grew since the last announcement (unchanged coverage means
34
+ * the turn was appended after an index that learned nothing new); degraded
35
+ * and unpaid states get their own notices.
36
+ *
37
+ * Every /v1/messages and count_tokens body is run through the legacy notice
38
+ * strip pass before hashing/forwarding. Live notices use Claude Code hooks and
39
+ * upstream response bytes pass through to the client unchanged.
40
+ */
41
+ import { capCacheBreakpoints } from "./route-cache.js";
42
+ import { PromptAccounting } from "./prompt-accounting.js";
43
+ import { RouteFallbackFailures } from "./route-fallback.js";
44
+ import { createCountTokensSession, CountTokensCooldowns, plausibleSample, } from "./count-tokens.js";
45
+ import { RequestSizing, estimateRequestTokens, fitsNative } from "./request-sizing.js";
46
+ import { routeMessageHash, stablePrefixMessageHash } from "./route-identity.js";
47
+ import http from "node:http";
48
+ import https from "node:https";
49
+ import { createHash, randomBytes } from "node:crypto";
50
+ import { brotliDecompressSync, createBrotliDecompress, createGunzip, createInflate, gunzipSync, inflateSync, } from "node:zlib";
51
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
52
+ import { join } from "node:path";
53
+ import { cachedPromptTokenCount, checkCompressedHistory, didMemtreeCompress, MemtreeClient, modelBudgetTokens, normalizeMessagesForMemtree, serverFlattenedMessages, rawPromptTokenCount, } from "./memtree.js";
54
+ import { contextLimitForModel, hasEarlierNonToolUserMessage, isAwaySummaryUserMessage, isLocalBashCommandTurn, isNonToolUserMessage, isToolResultUserMessage, lastNonSystemMessage, messagesWithSystem, modelForMemtree, stripSystemReminderText, } from "./turns.js";
55
+ import { COMPRESSED_NOTICE, compressedTotalsText, DEGRADED_NOTICE, NOT_COMPRESSED_NOTE, recapLinkText, PAYMENT_REQUIRED_NOTICE, SseNoticeRewriter, sanitizeNoticeDetail, stripNoticeBlocks, stripNoticeSystem, } from "./notices.js";
56
+ import { NoticeDeliveryQueue, MEMTREE_COMPACT_COMMAND, MEMTREE_HELP_COMMAND, TRAILER_LABEL, LINK_LABEL, linkLines, terminalSupportsHyperlinks, isMemtreeViewCommand, sessionCommandArgs, parseNoticeHookInput, } from "./hooks.js";
57
+ import { hyperlink } from "./payment-gate.js";
58
+ import { MEMTREE_LINKS_MAX_SESSIONS } from "./memtree-links.js";
59
+ import { describeClaudeCodeRequest, inspectMonitorTranscript, isClaudeCodeSideRequest, memtreeClientMeta, sessionTag, } from "./cc-request.js";
60
+ import { approxTokensFromBytes, mergeUsageFromJsonBody, mergeUsageFromSseEvent, } from "./reqlog.js";
61
+ const DEFAULT_UPSTREAM = "https://api.anthropic.com";
62
+ const HOOK_BODY_LIMIT = 64 * 1024;
63
+ // After a recovery attempt returns null (MemTree down, 5xx, or a burned
64
+ // timeout budget), suppress the blocking attempt for this long, across every
65
+ // lane. A lane's own growth backoff alone would let each over-budget tool turn
66
+ // pay the failed call again once its history grew past the retry size, and
67
+ // every other lane would pay it too — history grows every turn, so
68
+ // compress()'s complete-request dedup never absorbs the repeat.
69
+ // Long enough that a real outage costs one stall, short enough that a
70
+ // transient blip does not disable recovery for a working session.
71
+ const TOOL_RECOVERY_FAILURE_COOLDOWN_MS = 60_000;
72
+ const SKIP_REQUEST_HEADERS = new Set([
73
+ "host",
74
+ "connection",
75
+ "keep-alive",
76
+ "transfer-encoding",
77
+ "te",
78
+ "upgrade",
79
+ "proxy-authorization",
80
+ "proxy-connection",
81
+ "content-length", // recomputed for buffered/modified bodies
82
+ ]);
83
+ const SKIP_RESPONSE_HEADERS = new Set([
84
+ "connection",
85
+ "keep-alive",
86
+ "transfer-encoding",
87
+ ]);
88
+ /**
89
+ * Budget fallback until the server reports `model_budget_tokens`: this share
90
+ * of the model's context window (800k of Opus 5.5's 1M, matching the server's
91
+ * large-context threshold).
92
+ */
93
+ export const FALLBACK_BUDGET_WINDOW_RATIO = 0.8;
94
+ /**
95
+ * An explicit target is capped at this share of the budget: a compaction to
96
+ * the budget itself would be over budget again on the next turn and
97
+ * recompress on every turn.
98
+ */
99
+ const MAX_EXPLICIT_TARGET_BUDGET_RATIO = 0.9;
100
+ /** The server rejects smaller compression targets and thresholds. */
101
+ const SERVER_MIN_TARGET_TOKENS = 10_000;
102
+ /** Sessions whose stable prefix / passthrough size is kept (LRU). */
103
+ const STABLE_PREFIX_MAX_SESSIONS = 16;
104
+ /**
105
+ * Background check of whether a lane's awaited tree exists: its page answers
106
+ * 200 once built (202 while building). Any other answer stops the wait, so a
107
+ * page that never resolves falls back to the growth backoff instead of
108
+ * blocking compaction for good.
109
+ */
110
+ function checkAwaitedIndex(opts, attempt, shutdownSignal) {
111
+ const waiting = attempt.awaitingIndex;
112
+ if (!waiting || waiting.checking || shutdownSignal.aborted)
113
+ return;
114
+ waiting.checking = true;
115
+ // Bound both the individual body read and repeated 202 responses. A page
116
+ // that never completes must eventually return the lane to growth backoff.
117
+ const timeoutMs = Math.max(1, Math.min(opts.awaitedIndexProbeTimeoutMs ?? 5_000, waiting.deadline - Date.now()));
118
+ const signal = AbortSignal.any([shutdownSignal, AbortSignal.timeout(timeoutMs)]);
119
+ opts.memtree
120
+ .fetchMemTree(`/usage/memtree/${waiting.pageId}.json`, "application/json", signal)
121
+ .then((page) => {
122
+ if (page.status === 202 && Date.now() < waiting.deadline)
123
+ return;
124
+ if (attempt.awaitingIndex === waiting)
125
+ attempt.awaitingIndex = undefined;
126
+ // A finished tree is exactly what the backoff was waiting for.
127
+ if (page.status === 200)
128
+ attempt.retryAtTokens = undefined;
129
+ })
130
+ .catch(() => {
131
+ if (attempt.awaitingIndex === waiting)
132
+ attempt.awaitingIndex = undefined;
133
+ })
134
+ .finally(() => {
135
+ waiting.checking = false;
136
+ });
137
+ }
138
+ /** Growth (share of the budget) a lane waits for after an attempt that failed. */
139
+ const TOOL_COMPACTION_RETRY_BUDGET_RATIO = 0.05;
140
+ /**
141
+ * Bound on concurrently held route lanes. A route entry is 0.4–4 MB of heap,
142
+ * so 32 lanes is honestly ~128 MB worst case — accepted so a wide agent
143
+ * fan-out (main + away + dozens of live subagents) cannot LRU-evict main's
144
+ * route mid-turn; the cap is enforced by LRU eviction, not assumed.
145
+ */
146
+ const MEMORY_ROUTE_MAX_LANES = 32;
147
+ const DEFERRED_MAIN_PROMPT_LIMIT = 32;
148
+ /**
149
+ * The agent id a request is attributed by, preferring its own id over its
150
+ * parent's. Absence of both is the definition of main-thread attribution, so
151
+ * hasAgentAttribution is this same extraction asked as a yes/no question.
152
+ */
153
+ function agentAttributionId(req) {
154
+ return (firstNonEmptyHeader(req, "x-claude-code-agent-id") ??
155
+ firstNonEmptyHeader(req, "x-claude-code-parent-agent-id"));
156
+ }
157
+ /**
158
+ * Collision-free identity of a memory-route lane: the label the reqlog reports
159
+ * and the key every map is addressed by, classified once from the same headers
160
+ * so the two can never disagree. The tuple tags reserved main/away lanes
161
+ * separately from agent ids, so an agent literally named "main" or "away"
162
+ * cannot alias either reserved lane; JSON array encoding also prevents
163
+ * delimiter collisions between arbitrary session and agent headers.
164
+ * Keying by identity is what makes lane isolation structural: a request can
165
+ * only ever reach its own lane, so foreign-route defense is unnecessary rather
166
+ * than implemented.
167
+ */
168
+ function routeIdentity(req, isAwaySummary) {
169
+ const session = requestSessionId(req) ?? "";
170
+ const agent = agentAttributionId(req);
171
+ if (isAwaySummary) {
172
+ return { lane: "away", key: JSON.stringify([session, "away"]) };
173
+ }
174
+ if (agent) {
175
+ return { lane: "agent", key: JSON.stringify([session, "agent", agent]) };
176
+ }
177
+ return { lane: "main", key: JSON.stringify([session, "main"]) };
178
+ }
179
+ function firstNonEmptyHeader(req, name) {
180
+ const value = req.headers[name];
181
+ const text = Array.isArray(value)
182
+ ? value.find((item) => item.trim() !== "")
183
+ : value;
184
+ return typeof text === "string" && text.trim() ? text.trim() : undefined;
185
+ }
186
+ /** Record notice context without changing any route. */
187
+ function recordMainPromptNotice(state, promptId, prompt) {
188
+ state.mainNoticePending = true;
189
+ state.mainPromptId = promptId;
190
+ state.mainPromptText = prompt;
191
+ state.mainPromptGeneration++;
192
+ state.notices.clearForUserRequest();
193
+ }
194
+ /** Snapshot display accounting before asynchronous work. It never controls forwarding. */
195
+ function capturePromptDelivery(state, isMainRequest, lastMsg, sessionId) {
196
+ return { settle: isMainRequest
197
+ ? state.promptAccounting.capture(sessionId, lastMsg) : () => { } };
198
+ }
199
+ /** Lookup a validated route and update its LRU position. */
200
+ function getMemoryRoute(state, key) {
201
+ const route = state.memoryRoutes.get(key);
202
+ if (!route)
203
+ return undefined;
204
+ if (route.routeEpoch !== state.mainRouteEpoch) {
205
+ state.memoryRoutes.delete(key);
206
+ return undefined;
207
+ }
208
+ state.memoryRoutes.delete(key);
209
+ state.memoryRoutes.set(key, route);
210
+ return route;
211
+ }
212
+ function setMemoryRoute(state, key, route) {
213
+ state.memoryRoutes.delete(key);
214
+ state.memoryRoutes.set(key, route);
215
+ while (state.memoryRoutes.size > MEMORY_ROUTE_MAX_LANES) {
216
+ const oldest = state.memoryRoutes.keys().next().value;
217
+ if (oldest === undefined)
218
+ break;
219
+ state.memoryRoutes.delete(oldest);
220
+ }
221
+ }
222
+ function resolveUpstream(opts) {
223
+ const url = new URL(opts.upstreamOrigin ?? DEFAULT_UPSTREAM);
224
+ const secure = url.protocol === "https:";
225
+ return {
226
+ module: secure ? https : http,
227
+ host: url.hostname,
228
+ port: url.port ? Number(url.port) : secure ? 443 : 80,
229
+ };
230
+ }
231
+ export function startProxy(opts) {
232
+ const upstream = resolveUpstream(opts);
233
+ const hookPath = `/_ccc/hooks/${randomBytes(24).toString("hex")}`;
234
+ const activeRequests = new Set();
235
+ const acceptedRequests = new Set();
236
+ const shutdownAbort = new AbortController();
237
+ const state = {
238
+ paymentNoticeShown: false,
239
+ notices: new NoticeDeliveryQueue(),
240
+ mainNoticePending: false,
241
+ mainPromptGeneration: 0,
242
+ promptAccounting: new PromptAccounting(),
243
+ fallbackFailures: new RouteFallbackFailures(),
244
+ activeSubagents: new Set(),
245
+ memoryRoutes: new Map(),
246
+ mainRouteEpoch: 0,
247
+ mainRouteDecisionGeneration: 0,
248
+ routeDecisionsLive: new Map(),
249
+ retiredRouteDecisionFloor: 0,
250
+ toolRecoveryAttemptedLanes: new Map(),
251
+ toolRecoveryCooldownUntil: 0,
252
+ shutdownSignal: shutdownAbort.signal,
253
+ routeInstallFault: opts.routeInstallFault,
254
+ memtreePages: new Map(),
255
+ memtreeCallSeq: 0,
256
+ memtreeLinkStore: opts.memtreeLinkStore,
257
+ newestTrees: opts.newestTrees,
258
+ memtreeLinkPlacement: opts.memtreeLinkPlacement ?? "turn",
259
+ compactTargets: new Map(),
260
+ defaultCompactOff: opts.defaultCompactTarget === null,
261
+ compactNow: new Set(),
262
+ stablePrefixes: new Map(),
263
+ serverBudgets: new Map(),
264
+ serverReportsBudget: false,
265
+ countTokensCooldowns: new CountTokensCooldowns(),
266
+ passthroughSizes: new Map(),
267
+ laneSizes: new Map(),
268
+ };
269
+ installMemtreeLink(state, state.memtreeLinkPlacement);
270
+ const server = http.createServer((req, res) => {
271
+ const accepted = { req, res };
272
+ acceptedRequests.add(accepted);
273
+ const task = handleRequest(req, res, opts, upstream, state, hookPath).catch((err) => {
274
+ try {
275
+ sendAnthropicError(res, `local proxy error: ${err?.message ?? err}`);
276
+ }
277
+ catch {
278
+ // A failed error response must not strand shutdown bookkeeping.
279
+ }
280
+ });
281
+ activeRequests.add(task);
282
+ void task.then(() => {
283
+ activeRequests.delete(task);
284
+ acceptedRequests.delete(accepted);
285
+ }, () => {
286
+ activeRequests.delete(task);
287
+ acceptedRequests.delete(accepted);
288
+ });
289
+ if (shutdownAbort.signal.aborted)
290
+ cancelAcceptedRequest(accepted);
291
+ });
292
+ // Long-running SSE responses must not be cut by idle timeouts.
293
+ server.requestTimeout = 0;
294
+ server.headersTimeout = 60_000;
295
+ let closePromise;
296
+ const beginClose = () => {
297
+ if (closePromise)
298
+ return closePromise;
299
+ closePromise = new Promise((done) => {
300
+ if (!server.listening) {
301
+ done();
302
+ return;
303
+ }
304
+ server.close(() => done());
305
+ });
306
+ return closePromise;
307
+ };
308
+ const drain = async (timeoutMs = 5_000) => {
309
+ const boundedMs = Number.isFinite(timeoutMs) && timeoutMs >= 0
310
+ ? Math.floor(timeoutMs)
311
+ : 5_000;
312
+ const quiesced = (async () => {
313
+ // Once close completes, every accepted request has entered the tracked
314
+ // set.
315
+ await beginClose();
316
+ while (activeRequests.size > 0) {
317
+ await Promise.allSettled([...activeRequests]);
318
+ }
319
+ })();
320
+ let timer;
321
+ const completed = await Promise.race([
322
+ quiesced.then(() => true),
323
+ new Promise((resolve) => {
324
+ timer = setTimeout(() => resolve(false), boundedMs);
325
+ }),
326
+ ]);
327
+ if (timer)
328
+ clearTimeout(timer);
329
+ if (completed)
330
+ return true;
331
+ // The grace period protects useful in-flight delivery. Once it
332
+ // expires, signal every forwarding path first so each can
333
+ // classify the close as proxy-owned, then tear down any request that was
334
+ // still reading/dispatching before it installed a path-specific listener.
335
+ shutdownAbort.abort();
336
+ for (const accepted of acceptedRequests)
337
+ cancelAcceptedRequest(accepted);
338
+ await quiesced;
339
+ return false;
340
+ };
341
+ return new Promise((resolve, reject) => {
342
+ server.once("error", reject);
343
+ server.listen(0, "127.0.0.1", () => {
344
+ const port = server.address().port;
345
+ resolve({
346
+ port,
347
+ hookUrl: `http://127.0.0.1:${port}${hookPath}`,
348
+ close: () => {
349
+ void beginClose();
350
+ },
351
+ drain,
352
+ });
353
+ });
354
+ });
355
+ }
356
+ /** Final safety net for handlers that have not reached an upstream path yet. */
357
+ function cancelAcceptedRequest(accepted) {
358
+ const { req, res } = accepted;
359
+ if (!req.complete && !req.destroyed) {
360
+ // A pass-through upload may not otherwise have an IncomingMessage error
361
+ // listener; consume this proxy-owned teardown error after body readers see it.
362
+ req.once("error", () => { });
363
+ req.destroy(new Error("proxy shutdown"));
364
+ }
365
+ if (!res.destroyed && !res.writableEnded)
366
+ res.destroy();
367
+ }
368
+ async function handleRequest(req, res, opts, upstream, state, hookPath) {
369
+ const url = new URL(req.url ?? "/", `http://127.0.0.1`);
370
+ if (!isLocalCaller(req)) {
371
+ res.writeHead(403, { "content-type": "application/json" });
372
+ res.end(JSON.stringify({ detail: "The ccc proxy only serves programs on this machine" }));
373
+ return;
374
+ }
375
+ if (url.pathname === hookPath) {
376
+ return handleNoticeHook(req, res, state, opts.reqlog, (sessionId) => opts.memtree.scheduleFinalIndex(sessionId, (signal) => opts.transcriptUsage?.finalReplyAsync
377
+ ? opts.transcriptUsage.finalReplyAsync(sessionId, signal)
378
+ : opts.transcriptUsage?.finalReply?.(sessionId)));
379
+ }
380
+ if (req.method === "GET" &&
381
+ (url.pathname === MEMTREE_CURRENT_PATH || url.pathname === `${MEMTREE_CURRENT_PATH}.json`)) {
382
+ return handleMemTreeCurrent(req, res, opts, state, url);
383
+ }
384
+ const finderPath = req.method === "GET" ? MEMTREE_FINDER_RELAY.get(url.pathname) : undefined;
385
+ if (finderPath) {
386
+ return handleMemTreeFinder(req, res, opts, finderPath, url);
387
+ }
388
+ if (req.method === "GET" && url.pathname.startsWith(MEMTREE_PASSTHROUGH_PREFIX)) {
389
+ return handleMemTreePassthrough(req, res, opts, url);
390
+ }
391
+ if (req.method === "POST" && url.pathname === "/v1/messages") {
392
+ return handleMessages(req, res, opts, upstream, state);
393
+ }
394
+ if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
395
+ return handleCountTokens(req, res, opts, upstream, state);
396
+ }
397
+ return passThroughStreaming(req, res, upstream, state.shutdownSignal);
398
+ }
399
+ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]"]);
400
+ const BROWSER_SAME_SITE = new Set(["same-origin", "none"]);
401
+ /**
402
+ * Every route acts with the user's MemTree key (the `/memtree/*` relay reads
403
+ * their trees; `/v1/messages` indexes and compresses on their account), so only
404
+ * callers on this machine may use the proxy: Claude Code, agents (curl, the MCP
405
+ * server) and the MemTree page itself when opened from the loopback. A web page
406
+ * on another site can reach the loopback too, either directly (its `origin` or
407
+ * `sec-fetch-site` says so) or by rebinding its own hostname to 127.0.0.1 (its
408
+ * `host` then names it). Same-origin means this exact host and port.
409
+ */
410
+ function isLocalCaller(req) {
411
+ const host = req.headers.host;
412
+ if (!host || !isLoopbackHost(host))
413
+ return false;
414
+ const origin = req.headers.origin;
415
+ if (origin !== undefined && !isSameLoopbackOrigin(origin, host))
416
+ return false;
417
+ const site = req.headers["sec-fetch-site"];
418
+ return site === undefined || BROWSER_SAME_SITE.has(String(site));
419
+ }
420
+ function isLoopbackHost(host) {
421
+ try {
422
+ const url = new URL(`http://${host}`);
423
+ return url.host === host.toLowerCase() && LOOPBACK_HOSTNAMES.has(url.hostname);
424
+ }
425
+ catch {
426
+ return false;
427
+ }
428
+ }
429
+ function isSameLoopbackOrigin(origin, host) {
430
+ try {
431
+ const url = new URL(origin);
432
+ return url.protocol === "http:" && url.host === new URL(`http://${host}`).host;
433
+ }
434
+ catch {
435
+ return false;
436
+ }
437
+ }
438
+ /** Loopback prefix for reading the user's own MemTree pages through this proxy. */
439
+ const MEMTREE_PASSTHROUGH_PREFIX = "/memtree/";
440
+ /**
441
+ * `<request id>`, `<request id>.json`, `<request id>/session.json` (the
442
+ * page's session pane), `<request id>/search` (the server's term search over
443
+ * that tree, `?q=&limit=`), `<request id>/messages` (the un-indexed messages
444
+ * after it, `?start=&end=`), or `sessions/<Claude Code session id>.json` (every
445
+ * page from one session, newest first); nothing that could walk the upstream
446
+ * path. The id is the request UUID or the server's short form of it (leading
447
+ * hex, as in the `/m/<id>` links it hands out) — the server accepts both.
448
+ */
449
+ 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)$/;
450
+ /**
451
+ * `GET /memtree/<id>[.json][?share=…]` on the loopback: read the user's own
452
+ * MemTree page with their key. Claude Code's child env already carries this
453
+ * server as ANTHROPIC_BASE_URL, so an agent inside a ccc session needs no key
454
+ * handling — the polychat page's 401 body points here first. The upstream
455
+ * response is relayed as-is (status, content type, body); the key never
456
+ * leaves this process.
457
+ */
458
+ async function handleMemTreePassthrough(req, res, opts, url) {
459
+ const target = url.pathname.slice(MEMTREE_PASSTHROUGH_PREFIX.length);
460
+ if (!MEMTREE_PASSTHROUGH_TARGET_RE.test(target)) {
461
+ res.writeHead(404, { "content-type": "application/json" });
462
+ res.end(JSON.stringify({ detail: "Not Found" }));
463
+ return;
464
+ }
465
+ try {
466
+ const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${target}${url.search}`, req.headers.accept ?? "application/json");
467
+ res.writeHead(upstream.status, { "content-type": upstream.contentType });
468
+ res.end(upstream.body);
469
+ }
470
+ catch (err) {
471
+ res.writeHead(502, { "content-type": "application/json" });
472
+ res.end(JSON.stringify({ detail: `MemTree fetch failed: ${String(err)}` }));
473
+ }
474
+ }
475
+ /**
476
+ * Loopback routes for finding things across the user's own sessions, relayed
477
+ * to the server's owner-only endpoints with the user's key (which never
478
+ * leaves this process): `GET /memtree/sessions?…` lists sessions, `GET
479
+ * /memtree/search?…` searches their trees. Exact paths only; the query string
480
+ * goes on unchanged (the URL parser has already split off any path or
481
+ * fragment). Matched before the page relay, whose id pattern would otherwise
482
+ * take them for page ids.
483
+ *
484
+ * The project meta goes along in `x-client-meta`, and the caller's Claude
485
+ * Code session id when it sends one, so a charged (vector) search's usage row
486
+ * says where it came from.
487
+ */
488
+ const MEMTREE_FINDER_RELAY = new Map([
489
+ [`${MEMTREE_PASSTHROUGH_PREFIX}sessions`, "/v1/memtree/sessions"],
490
+ [`${MEMTREE_PASSTHROUGH_PREFIX}search`, "/v1/memtree/search"],
491
+ ]);
492
+ const SESSION_ID_HEADER_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
493
+ async function handleMemTreeFinder(req, res, opts, upstreamPath, url) {
494
+ const headers = {};
495
+ const meta = memtreeClientMeta({ project: opts.projectMeta });
496
+ if (Object.keys(meta).length)
497
+ headers["x-client-meta"] = JSON.stringify(meta);
498
+ const sessionId = firstNonEmptyHeader(req, "x-claude-code-session-id");
499
+ if (sessionId && SESSION_ID_HEADER_VALUE.test(sessionId)) {
500
+ headers["x-claude-code-session-id"] = sessionId;
501
+ }
502
+ try {
503
+ const upstream = await opts.memtree.fetchMemTree(`${upstreamPath}${url.search}`, req.headers.accept ?? "application/json", undefined, headers);
504
+ res.writeHead(upstream.status, { "content-type": upstream.contentType });
505
+ res.end(upstream.body);
506
+ }
507
+ catch (err) {
508
+ res.writeHead(502, { "content-type": "application/json" });
509
+ res.end(JSON.stringify({ detail: `MemTree fetch failed: ${String(err)}` }));
510
+ }
511
+ }
512
+ /**
513
+ * `GET /memtree/current[.json][?session=<Claude Code session id>]`: the page
514
+ * the session's newest main request was served from — what the `memtree` MCP server
515
+ * reads (memtree-mcp.ts). Bare `current` answers the pointer
516
+ * `{id, url, index, session_id, compressed}` without an upstream call, so the
517
+ * MCP server can keep its cached tree until the page changes; `current.json`
518
+ * relays the page JSON itself, like `/memtree/<id>.json`.
519
+ *
520
+ * Every pointer is scoped to the caller's exact session id. Missing session
521
+ * ids never fall back to another conversation served by the same proxy.
522
+ */
523
+ async function handleMemTreeCurrent(req, res, opts, state, url) {
524
+ const sessionId = url.searchParams.get("session") || undefined;
525
+ const page = currentMemtreePage(state, sessionId);
526
+ const id = page ? memtreePageId(page.url) : undefined;
527
+ if (!page || !id) {
528
+ res.writeHead(404, { "content-type": "application/json" });
529
+ res.end(JSON.stringify({ detail: "No MemTree page for this session yet" }));
530
+ return;
531
+ }
532
+ if (!url.pathname.endsWith(".json")) {
533
+ res.writeHead(200, { "content-type": "application/json" });
534
+ res.end(JSON.stringify({
535
+ id,
536
+ url: page.url,
537
+ index: page.index,
538
+ session_id: page.sessionId ?? null,
539
+ compressed: page.compressed,
540
+ }));
541
+ return;
542
+ }
543
+ try {
544
+ const upstream = await opts.memtree.fetchMemTree(`/usage/memtree/${id}.json`, req.headers.accept ?? "application/json");
545
+ res.writeHead(upstream.status, {
546
+ "content-type": upstream.contentType,
547
+ "x-memtree-page": page.url,
548
+ });
549
+ res.end(upstream.body);
550
+ }
551
+ catch (err) {
552
+ res.writeHead(502, { "content-type": "application/json" });
553
+ res.end(JSON.stringify({ detail: `MemTree fetch failed: ${String(err)}` }));
554
+ }
555
+ }
556
+ const MEMTREE_CURRENT_PATH = `${MEMTREE_PASSTHROUGH_PREFIX}current`;
557
+ function currentMemtreePage(state, sessionId) {
558
+ if (!sessionId)
559
+ return undefined;
560
+ const latest = state.memtreePages.get(sessionId);
561
+ if (latest)
562
+ return latest;
563
+ const stored = state.memtreeLinkStore?.get(sessionId);
564
+ return stored ? { ...stored, sessionId } : undefined;
565
+ }
566
+ /** The page id in a server-stamped link (`…/m/<id>` or `…/usage/memtree/<id>`). */
567
+ export function memtreePageId(pageUrl) {
568
+ try {
569
+ const match = /\/(?:m|usage\/memtree)\/([A-Za-z0-9-]+?)(?:\.json)?$/.exec(new URL(pageUrl).pathname);
570
+ return match?.[1];
571
+ }
572
+ catch {
573
+ return undefined;
574
+ }
575
+ }
576
+ /** Serve only validated Claude hook POSTs on the randomized localhost path. */
577
+ async function handleNoticeHook(req, res, state, reqlog,
578
+ /** A main-lane turn ended: the session may be over (its final index). */
579
+ onMainStop) {
580
+ if (req.method !== "POST") {
581
+ res.writeHead(405, { allow: "POST" });
582
+ res.end();
583
+ return;
584
+ }
585
+ const declaredLength = Number(req.headers["content-length"] ?? 0);
586
+ if (Number.isFinite(declaredLength) && declaredLength > HOOK_BODY_LIMIT) {
587
+ res.writeHead(413);
588
+ res.end();
589
+ req.resume();
590
+ return;
591
+ }
592
+ const raw = await readBody(req);
593
+ if (raw.length > HOOK_BODY_LIMIT) {
594
+ res.writeHead(413);
595
+ res.end();
596
+ return;
597
+ }
598
+ let input;
599
+ try {
600
+ input = JSON.parse(raw.toString("utf-8"));
601
+ }
602
+ catch {
603
+ res.writeHead(400);
604
+ res.end();
605
+ return;
606
+ }
607
+ const parsed = parseNoticeHookInput(input);
608
+ if (!parsed) {
609
+ res.writeHead(400);
610
+ res.end();
611
+ return;
612
+ }
613
+ if (parsed.hook_event_name === "SessionStart") {
614
+ // A resume waits briefly for the newest tree; a fresh session has none to wait for.
615
+ if (parsed.agent_id === undefined && parsed.source === "resume") {
616
+ await state.newestTrees?.settle(parsed.session_id);
617
+ }
618
+ const line = resumeLinkLine(state, parsed);
619
+ if (!line) {
620
+ res.writeHead(204);
621
+ res.end();
622
+ return;
623
+ }
624
+ const body = Buffer.from(JSON.stringify({ systemMessage: line }), "utf-8");
625
+ res.writeHead(200, {
626
+ "content-type": "application/json",
627
+ "content-length": String(body.length),
628
+ "cache-control": "no-store",
629
+ });
630
+ res.end(body);
631
+ return;
632
+ }
633
+ if (parsed.hook_event_name === "UserPromptSubmit") {
634
+ // `/memtree-view`: answered here and blocked, so no model turn runs and
635
+ // the prompt never enters the conversation. Not a human turn either, so
636
+ // none of the turn state below is touched.
637
+ if (parsed.agent_id === undefined) {
638
+ // `/memtree-view` waits briefly for the newest tree; a prompt only warms it.
639
+ if (isMemtreeViewCommand(parsed.prompt))
640
+ await state.newestTrees?.settle(parsed.session_id);
641
+ else
642
+ state.newestTrees?.peek(parsed.session_id);
643
+ }
644
+ const commandReply = parsed.agent_id === undefined
645
+ ? sessionCommandReply(state, parsed.session_id, parsed.prompt)
646
+ : undefined;
647
+ if (commandReply !== undefined) {
648
+ const body = Buffer.from(JSON.stringify({
649
+ decision: "block",
650
+ reason: commandReply,
651
+ // Claude Code otherwise repeats "Original prompt: /ccc:memtree-view"
652
+ // under the answer. Older releases ignore the flag.
653
+ hookSpecificOutput: {
654
+ hookEventName: "UserPromptSubmit",
655
+ suppressOriginalPrompt: true,
656
+ },
657
+ }), "utf-8");
658
+ res.writeHead(200, {
659
+ "content-type": "application/json",
660
+ "content-length": String(body.length),
661
+ "cache-control": "no-store",
662
+ });
663
+ res.end(body);
664
+ return;
665
+ }
666
+ if (parsed.agent_id === undefined) {
667
+ state.promptAccounting.add(parsed.session_id, parsed.prompt_id, parsed.prompt);
668
+ recordMainPromptNotice(state, parsed.prompt_id, parsed.prompt);
669
+ }
670
+ res.writeHead(204);
671
+ res.end();
672
+ return;
673
+ }
674
+ if (parsed.hook_event_name === "SubagentStart") {
675
+ state.activeSubagents.add(parsed.agent_id);
676
+ res.writeHead(204);
677
+ res.end();
678
+ return;
679
+ }
680
+ if (parsed.hook_event_name === "SubagentStop") {
681
+ state.activeSubagents.delete(parsed.agent_id);
682
+ res.writeHead(204);
683
+ res.end();
684
+ return;
685
+ }
686
+ const stopMatchesMainPrompt = parsed.hook_event_name === "Stop" &&
687
+ parsed.agent_id === undefined &&
688
+ (state.mainPromptId === undefined ||
689
+ parsed.prompt_id === undefined ||
690
+ state.mainPromptId === parsed.prompt_id ||
691
+ (parsed.prompt_id !== undefined && state.promptAccounting.hasId(parsed.prompt_id)));
692
+ const output = parsed.hook_event_name === "Stop" && !stopMatchesMainPrompt
693
+ ? null
694
+ : state.notices.claim(parsed);
695
+ if (stopMatchesMainPrompt) {
696
+ onMainStop?.(typeof parsed.session_id === "string" ? parsed.session_id : undefined);
697
+ state.mainNoticePending = false;
698
+ state.mainPromptId = undefined;
699
+ state.mainPromptText = undefined;
700
+ // Invalidate a response still in flight at Stop. Otherwise its late
701
+ // delivery callback could enqueue a notice after Stop returned.
702
+ state.mainPromptGeneration++;
703
+ // Subagent lifetimes are independent; only their own stop events retire them.
704
+ }
705
+ if (!output) {
706
+ res.writeHead(204);
707
+ res.end();
708
+ return;
709
+ }
710
+ if (parsed.hook_event_name === "MessageDisplay" ||
711
+ parsed.hook_event_name === "Stop") {
712
+ try {
713
+ reqlog?.log({
714
+ kind: "notice",
715
+ event: "claimed",
716
+ via: parsed.hook_event_name,
717
+ });
718
+ }
719
+ catch {
720
+ // Custom/test loggers get RequestLogger's never-break-hook policy.
721
+ }
722
+ }
723
+ const body = Buffer.from(JSON.stringify(output), "utf-8");
724
+ res.writeHead(200, {
725
+ "content-type": "application/json",
726
+ "content-length": String(body.length),
727
+ "cache-control": "no-store",
728
+ });
729
+ res.end(body);
730
+ }
731
+ /**
732
+ * Hook-prompt correlation: true only when the armed typed prompt appears as a
733
+ * deliberate top-level text block of the user message — the whole block, or
734
+ * its start/end (Claude Code may append/prepend ambient text around a merged
735
+ * queued prompt). Matching runs on reminder-stripped block text so a
736
+ * coincidental substring inside an appended <system-reminder> (or buried
737
+ * mid-sentence in unrelated text) can never claim or consume the arm.
738
+ */
739
+ function messageCarriesPromptText(message, promptText) {
740
+ const prompt = promptText.trim();
741
+ if (!prompt)
742
+ return false;
743
+ if (!message || message.role !== "user")
744
+ return false;
745
+ const content = message.content;
746
+ const parts = typeof content === "string"
747
+ ? [content]
748
+ : Array.isArray(content)
749
+ ? content.map((part) => {
750
+ if (typeof part === "string")
751
+ return part;
752
+ return part?.type === "text" && typeof part.text === "string"
753
+ ? part.text
754
+ : "";
755
+ })
756
+ : [];
757
+ return parts.some((part) => {
758
+ const text = stripSystemReminderText(part);
759
+ return (text === prompt || text.startsWith(prompt) || text.endsWith(prompt));
760
+ });
761
+ }
762
+ /** Buffer + inspect /v1/messages; classify the turn, strip notices, forward. */
763
+ async function handleMessages(req, res, opts, upstream, state) {
764
+ const received = Date.now();
765
+ const rawBody = await readBody(req);
766
+ // One request-log record per /v1/messages, filled in as the request flows
767
+ // through the forward path and written exactly once when the response is
768
+ // done (success or failure) — always on, so a stalled turn leaves a trace.
769
+ const rec = {
770
+ kind: "messages",
771
+ turnType: "unparseable",
772
+ requestBytes: rawBody.length,
773
+ };
774
+ const logged = async (forward) => {
775
+ try {
776
+ await forward;
777
+ }
778
+ finally {
779
+ if (rec.totalMs === undefined)
780
+ rec.totalMs = Date.now() - received;
781
+ opts.reqlog?.log(rec);
782
+ }
783
+ };
784
+ if (opts.claudeCodeOnly && requestSessionId(req) === undefined) {
785
+ rec.turnType = "foreign";
786
+ rec.forwardedBytes = rawBody.length;
787
+ const agent = firstNonEmptyHeader(req, "user-agent");
788
+ if (agent)
789
+ rec.userAgent = agent.slice(0, 80);
790
+ return logged(forwardRaw(req, res, rawBody, opts, upstream, state.shutdownSignal, rec));
791
+ }
792
+ let body;
793
+ try {
794
+ body = JSON.parse(rawBody.toString("utf-8"));
795
+ if (!Array.isArray(body.messages))
796
+ throw new Error("no messages array");
797
+ }
798
+ catch {
799
+ // Not a shape we understand — forward verbatim rather than break the session.
800
+ return logged(forwardRaw(req, res, rawBody, opts, upstream, state.shutdownSignal, rec));
801
+ }
802
+ // Defensive legacy strip pass first: old marker-wrapped notices (including
803
+ // one copied into an away-summary or top-level system prompt) must never
804
+ // reach Anthropic or MemTree. Hook-delivered notices never enter this body.
805
+ const stripped = stripNoticeBlocks(body.messages);
806
+ const strippedSystem = stripNoticeSystem(body.system);
807
+ let forwardBody = rawBody;
808
+ if (stripped.stripped || strippedSystem.stripped) {
809
+ body.messages = stripped.messages;
810
+ if (strippedSystem.system === undefined)
811
+ delete body.system;
812
+ else
813
+ body.system = strippedSystem.system;
814
+ forwardBody = Buffer.from(JSON.stringify(body), "utf-8");
815
+ if (opts.debug)
816
+ console.error("[ccc proxy] stripped legacy notice span(s) from request");
817
+ }
818
+ const messages = body.messages;
819
+ // CC 2.1.207 appends ambient role=system blocks after the typed prompt. Use
820
+ // the last non-system conversation message for classification while keeping
821
+ // every system block in the body sent to MemTree/Anthropic.
822
+ const lastMsg = lastNonSystemMessage(messages);
823
+ const isUserTurn = isNonToolUserMessage(lastMsg);
824
+ const isToolResultTurn = isToolResultUserMessage(lastMsg);
825
+ const isAwaySummary = isAwaySummaryUserMessage(lastMsg);
826
+ // Neither a prompt nor a tool result: a background task notification
827
+ // arrives as a trailing role=system block after the assistant's last reply,
828
+ // so the last conversation message is that reply. It carries the whole
829
+ // history like any turn and must live by the budget like a tool turn; it
830
+ // once fell through every branch and forwarded the uncompacted 4.4MB
831
+ // history (Prompt is too long, 2026-09-30).
832
+ const isContinuationTurn = lastMsg !== undefined && !isUserTurn && !isToolResultTurn;
833
+ // Claude Code's security monitor re-sends the whole session as one
834
+ // `<transcript>` message after most actions. Before this check it looked
835
+ // like a main-thread followup: it bumped the route epoch (wiping the main
836
+ // tool loop's compressed route mid-turn, forcing a blocking recompress),
837
+ // cost a MemTree passthrough, and started a from-scratch index of a
838
+ // conversation that already has one. It must touch none of that state, so
839
+ // it is handled here, before anything below mutates it. Anthropic already
840
+ // caches its append-only transcript well, so it goes out verbatim.
841
+ let clientInfo;
842
+ try {
843
+ clientInfo = describeClaudeCodeRequest(body);
844
+ if (clientInfo.suspectedSideRequest) {
845
+ clientInfo.sessionTag = sessionTag(requestSessionId(req));
846
+ }
847
+ }
848
+ catch {
849
+ // logging only; never affects the request
850
+ }
851
+ // The header rule alone is not enough: Claude Code releases before
852
+ // cc_turn_origin existed send main-thread requests with no turn origin, so
853
+ // the monitor's <transcript> block must also be recognised. A candidate
854
+ // whose transcript does not match keeps the ordinary handling (the
855
+ // pre-existing behaviour) and records why, so a format change in Claude
856
+ // Code shows up in requests.jsonl instead of silently misrouting.
857
+ let sideRequest = false;
858
+ if (clientInfo && isClaudeCodeSideRequest(clientInfo)) {
859
+ try {
860
+ rec.transcript = inspectMonitorTranscript(body);
861
+ sideRequest = rec.transcript.ok;
862
+ if (!rec.transcript.ok) {
863
+ console.error(`[ccc proxy] possible side request, transcript format not recognised ` +
864
+ `(${rec.transcript.reason}${rec.transcript.badLine ? ` at line ${rec.transcript.badLine}` : ""}); ` +
865
+ `handled as an ordinary request. Claude Code may have changed the monitor format.`);
866
+ }
867
+ }
868
+ catch {
869
+ // never let the format check affect the request
870
+ }
871
+ }
872
+ if (sideRequest && clientInfo) {
873
+ if (typeof body.model === "string")
874
+ rec.model = body.model;
875
+ rec.stream = body.stream === true;
876
+ rec.client = clientInfo;
877
+ recordTurn(rec, "side-request", forwardBody);
878
+ capture(opts, "anthropic-request-side", forwardBody);
879
+ return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec));
880
+ }
881
+ if (isAwaySummary && state.memtreeLinkPlacement !== "off") {
882
+ const sessionId = requestSessionId(req);
883
+ // Warm the newest-tree lookup now (never waits) so it can answer by the
884
+ // time the recap finishes streaming.
885
+ state.newestTrees?.peek(sessionId);
886
+ recapLinkAppenders.set(req, (streamedTextChars) => {
887
+ const link = recapLink(state, sessionId);
888
+ return link ? recapLinkText(link.link, streamedTextChars, link.note) : undefined;
889
+ });
890
+ }
891
+ const isLocalBashCommand = isLocalBashCommandTurn(messages);
892
+ // CC 2.1.207 identifies agent API calls explicitly. Use that wire-level
893
+ // attribution before lifecycle-hook state so an agent request cannot claim
894
+ // or consume a main prompt arm even if SubagentStart ordering is delayed.
895
+ const { lane: requestRouteLane, key: requestRouteKey } = routeIdentity(req, isAwaySummary);
896
+ const isSubagentRequest = requestRouteLane === "agent";
897
+ const isMainRequest = requestRouteLane === "main";
898
+ const requestSizeKey = laneSizeKey(req, requestRouteKey, body.model);
899
+ // Extended 1M context arrives as the `context-1m` beta header (Claude Code
900
+ // strips the `[1m]` model suffix on the wire). Current native-1M models send
901
+ // neither, so contextLimitForModel also needs the launcher's native setting.
902
+ const modelContextLimit = contextLimitForModel(body.model, headerText(req.headers, "anthropic-beta"), opts.nativeOneMillionContext !== false);
903
+ // Before anything reads the size sample: replace an unknown or stale
904
+ // estimate with an exact count where it would decide the outcome.
905
+ const requestSessionKey = requestSessionId(req);
906
+ const countSession = createCountTokensSession({
907
+ upstream, headers: forwardableRequestHeaders(req), requestUrl: req.url,
908
+ signal: state.shutdownSignal,
909
+ onAttempt: (result) => {
910
+ (rec.countTokens ??= []).push({ statusClass: result.outcome, ms: result.ms });
911
+ },
912
+ }, state.countTokensCooldowns);
913
+ const requestSizing = new RequestSizing(countSession, opts.countTokens === true);
914
+ const sizeSlot = wholeRequestSizeSlot(state, isMainRequest, requestSessionKey, requestSizeKey);
915
+ const originalCandidate = requestSizing.register(rawBody, "original", sizeSlot.get());
916
+ const forwardCandidate = requestSizing.register(forwardBody, "original", sizeSlot.get());
917
+ candidateSizeOwners.set(originalCandidate, sizeSlot);
918
+ candidateSizeOwners.set(forwardCandidate, sizeSlot);
919
+ const sizingLimits = () => ({
920
+ budget: resolveBudget(opts, state, body.model, modelContextLimit).tokens,
921
+ context: modelContextLimit,
922
+ output: typeof body.max_tokens === "number" ? Math.max(0, body.max_tokens) : 0,
923
+ });
924
+ const rides = state.memoryRoutes.has(requestRouteKey) ||
925
+ (isMainRequest && requestSessionKey !== undefined && state.stablePrefixes.has(requestSessionKey));
926
+ if (!rides) {
927
+ const measured = await requestSizing.measure(forwardCandidate, sizingLimits(), "plan");
928
+ if (measured.source === "exact") {
929
+ sizeSlot.set({ tokens: measured.tokens, forwardedBytes: forwardCandidate.body.length });
930
+ }
931
+ }
932
+ rec.routeLane = requestRouteLane;
933
+ // Stored by the server on the usage row (client_meta): which Claude Code,
934
+ // lane, agent and model produced this request.
935
+ const clientMeta = memtreeClientMeta({
936
+ info: clientInfo,
937
+ lane: requestRouteLane,
938
+ agentId: firstNonEmptyHeader(req, "x-claude-code-agent-id"),
939
+ parentAgentId: firstNonEmptyHeader(req, "x-claude-code-parent-agent-id"),
940
+ model: body.model,
941
+ project: opts.projectMeta,
942
+ });
943
+ // API identity and validated history alone govern route eligibility.
944
+ const rideableCandidate = getMemoryRoute(state, requestRouteKey);
945
+ const existingRide = rideableCandidate && memoryRoutedToolBody(body, messages, rideableCandidate, state.mainRouteEpoch, requestSessionId(req));
946
+ const isFollowupUserTurn = isUserTurn && hasEarlierNonToolUserMessage(messages);
947
+ const routeEpoch = state.mainRouteEpoch;
948
+ let routeDecisionGeneration = state.mainRouteDecisionGeneration;
949
+ let routeDecisionSettled = false;
950
+ const toolForwarding = capturePromptDelivery(state, isMainRequest, lastMsg, requestSessionId(req));
951
+ if (isFollowupUserTurn) {
952
+ routeDecisionGeneration = ++state.mainRouteDecisionGeneration;
953
+ reserveRouteDecision(state, requestRouteKey, routeDecisionGeneration);
954
+ }
955
+ const hookOwnedMainFollowup = isFollowupUserTurn &&
956
+ !isAwaySummary &&
957
+ !isSubagentRequest &&
958
+ state.mainNoticePending &&
959
+ state.mainPromptText !== undefined &&
960
+ messageCarriesPromptText(lastMsg, state.mainPromptText);
961
+ // Local `!command` turns do not consistently emit UserPromptSubmit, and the
962
+ // API history contains bash wrappers rather than the literal typed command.
963
+ // Their strict main-thread replay shape can safely own its own notice.
964
+ const localCommandMainFollowup = isFollowupUserTurn &&
965
+ !isAwaySummary &&
966
+ !isSubagentRequest &&
967
+ isLocalBashCommand;
968
+ const displayForThisTurn = (hookOwnedMainFollowup || localCommandMainFollowup) &&
969
+ state.activeSubagents.size === 0;
970
+ const noticePromptId = state.mainPromptId;
971
+ const noticePromptGeneration = state.mainPromptGeneration;
972
+ requestSendPolicies.set(req, {
973
+ original: rawBody,
974
+ failedRebuild: false,
975
+ allowed: new WeakSet(),
976
+ sizing: requestSizing,
977
+ select: (buffer) => selectForwardCandidate({
978
+ opts, state, res, rec, body, modelContextLimit, req, routeKey: requestRouteKey,
979
+ preferredBody: buffer, limits: sizingLimits(),
980
+ }),
981
+ fitsNative: async (buffer) => {
982
+ const candidate = requestSizing.register(buffer, rec.compress ? "replacement" : "ride");
983
+ const measured = await requestSizing.measure(candidate, sizingLimits());
984
+ // A validated ride is still eligible for bounded late recovery. Final
985
+ // selection considers it together with the original and any replacement.
986
+ return candidate.kind === "ride" || fitsNative(measured.tokens, sizingLimits());
987
+ },
988
+ recoverLate: async (buffer, tokens) => {
989
+ if (rec.compress || compactionMode(opts, state, requestSessionKey).mode === "off" ||
990
+ (rec.turnType.startsWith("tool") && opts.toolRouteRecovery === false))
991
+ return undefined;
992
+ return recoverLateCount({ opts, state, req, rec, bodyBuffer: buffer,
993
+ modelContextLimit, clientMeta, sizing: requestSizing });
994
+ },
995
+ delivered: () => { state.fallbackFailures.succeeded(requestRouteKey); toolForwarding.settle(true); },
996
+ });
997
+ const rawMsgsForMemtree = messagesWithSystem(messages, body.system);
998
+ const msgsForMemtree = normalizeMessagesForMemtree(rawMsgsForMemtree);
999
+ const hash = MemtreeClient.hashMessages(msgsForMemtree);
1000
+ // The main lane's latest conversation: what its final index sends, plus the
1001
+ // reply the session ends with (scheduleFinalIndex, on Stop).
1002
+ const mainSessionId = isMainRequest ? requestSessionId(req) : undefined;
1003
+ if (mainSessionId !== undefined) {
1004
+ opts.memtree.noteMainConversation(mainSessionId, {
1005
+ messages: msgsForMemtree,
1006
+ modelContextLimit,
1007
+ clientMeta,
1008
+ messageTimesFor: transcriptTimesFor(opts, mainSessionId),
1009
+ readReply: (signal) => opts.transcriptUsage?.finalReplyAsync
1010
+ ? opts.transcriptUsage.finalReplyAsync(mainSessionId, signal)
1011
+ : opts.transcriptUsage?.finalReply?.(mainSessionId),
1012
+ });
1013
+ }
1014
+ // Hook state controls notices only. Hidden requests cannot consume main notices.
1015
+ if (typeof body.model === "string")
1016
+ rec.model = body.model;
1017
+ rec.stream = body.stream === true;
1018
+ // Observation only: which requests carry Claude Code's turn origin.
1019
+ if (clientInfo)
1020
+ rec.client = clientInfo;
1021
+ if (!isFollowupUserTurn) {
1022
+ // Tool turn or FIRST user turn: keep the index fed off the response path
1023
+ // and forward as-is (on the lane's memory route or the session's stable
1024
+ // prefix when one applies) — except a tool turn whose estimated size
1025
+ // reached the budget, which compresses once to the target below
1026
+ // (planToolCompaction), exactly like a main human turn.
1027
+ // On the first user turn nothing is indexed yet, so a blocking compress
1028
+ // would be a guaranteed no-op costing first-token latency
1029
+ // (plans/2026-07-05_PLAN_first_user_turn_nonblocking.md).
1030
+ if (opts.debug && isUserTurn) {
1031
+ console.error("[ccc proxy] first user turn: index in background, forward verbatim");
1032
+ }
1033
+ let routedBody = forwardBody;
1034
+ let routedTool = false;
1035
+ let routedCandidate;
1036
+ let routeMiss;
1037
+ if (isToolResultTurn || isContinuationTurn || isUserTurn) {
1038
+ // Reuse only this API session/lane's independently validated prefix.
1039
+ const activeRoute = rideableCandidate;
1040
+ if (activeRoute) {
1041
+ const rewritten = memoryRoutedToolBody(body, messages, activeRoute, state.mainRouteEpoch, requestSessionId(req));
1042
+ if (rewritten) {
1043
+ const owner = activeRoute.stablePrefix ?? activeRoute;
1044
+ routedCandidate = requestSizing.register(rewritten, "ride", owner.lastSize);
1045
+ candidateSizeOwners.set(routedCandidate, owner);
1046
+ // Advisory window pressure can request recovery, but never removes a
1047
+ // structurally valid candidate before request-local counting/selection.
1048
+ if (!fitsNative(requestSizing.plan(routedCandidate).tokens, sizingLimits())) {
1049
+ routeMiss = "rejected";
1050
+ regrantSmallerWindowRecovery(state, requestRouteKey, modelContextLimit);
1051
+ }
1052
+ routedBody = rewritten;
1053
+ routedTool = true;
1054
+ if (opts.debug) {
1055
+ console.error("[ccc proxy] tool turn matched active memory route");
1056
+ }
1057
+ }
1058
+ else {
1059
+ // A mismatch means a different/resumed conversation shape, or two
1060
+ // requesters colliding on one lane (children sharing only a
1061
+ // parent-agent-id land on the same key; their prefix hashes
1062
+ // disagree and the loser lands here). A route stored under this key
1063
+ // always carries this requester's session id — installMemoryRoute
1064
+ // derives both from the same request — so this is by construction a
1065
+ // same-session divergence: rebuild while retaining the previous route.
1066
+ routeMiss = "rejected";
1067
+ if (opts.debug) {
1068
+ console.error("[ccc proxy] tool turn rejected active memory route");
1069
+ }
1070
+ }
1071
+ }
1072
+ else {
1073
+ routeMiss = "missing";
1074
+ if (opts.debug) {
1075
+ console.error("[ccc proxy] tool turn has no active memory route");
1076
+ }
1077
+ }
1078
+ }
1079
+ const toolSessionId = requestSessionId(req);
1080
+ // A concurrently replaced stable prefix takes precedence over an older
1081
+ // route. The current prefix still undergoes its own history validation.
1082
+ if (routedTool &&
1083
+ isMainRequest &&
1084
+ toolSessionId !== undefined &&
1085
+ rideableCandidate?.stablePrefix !== state.stablePrefixes.get(toolSessionId)) {
1086
+ if (routedCandidate)
1087
+ requestSizing.excludeFallback(routedCandidate);
1088
+ routedTool = false;
1089
+ routedBody = forwardBody;
1090
+ routeMiss = "superseded";
1091
+ }
1092
+ if (routeMiss !== undefined)
1093
+ rec.routeMiss = routeMiss;
1094
+ if (isContinuationTurn)
1095
+ rec.continuation = true;
1096
+ // Cheap shape check: without an earlier real user message there is
1097
+ // nothing MemTree could compress, so no attempt is worth making.
1098
+ const canCompress = hasEarlierNonToolUserMessage(messages);
1099
+ let toolRide = routedTool
1100
+ ? {
1101
+ raw: routedBody,
1102
+ turnType: "tool-memory",
1103
+ sizeHolder: rideableCandidate.stablePrefix ?? rideableCandidate,
1104
+ }
1105
+ : undefined;
1106
+ let toolNeedsOriginal = false;
1107
+ if (opts.toolRouteRecovery === false) {
1108
+ // Kill switch: no size check and no compress call on any tool turn.
1109
+ // A route still rides; a miss forwards whole and records "disabled".
1110
+ if ((routeMiss === "missing" || routeMiss === "rejected") && canCompress) {
1111
+ rec.routeRecovery = { outcome: "disabled" };
1112
+ }
1113
+ }
1114
+ else if (isToolResultTurn || isContinuationTurn || (isUserTurn && !!existingRide)) {
1115
+ // Every lane's tool turn lives by the budget, like a main human turn:
1116
+ // under it, forward (on the lane's route or the session's stable
1117
+ // prefix, else whole) with no compress call; at it, compress once to
1118
+ // the target (planToolCompaction). A "replay" is a client retry of the
1119
+ // request that installed the route, forwarded verbatim.
1120
+ const plan = planToolCompaction({
1121
+ opts,
1122
+ state,
1123
+ body,
1124
+ messages,
1125
+ sessionId: toolSessionId,
1126
+ routeKey: requestRouteKey,
1127
+ sizeKey: requestSizeKey,
1128
+ sizing: requestSizing,
1129
+ isMainRequest,
1130
+ modelContextLimit,
1131
+ forwardBody,
1132
+ rec,
1133
+ ride: toolRide,
1134
+ canCompress,
1135
+ });
1136
+ if (plan.kind === "ride")
1137
+ toolRide = plan.ride;
1138
+ if (plan.kind === "pass" && plan.original) {
1139
+ toolRide = undefined;
1140
+ toolNeedsOriginal = true;
1141
+ }
1142
+ if (plan.kind === "compress") {
1143
+ requestSendPolicies.get(req).failedRebuild = true;
1144
+ // Clear the caller's old ride before any in-flight/backoff/cooldown
1145
+ // exit. An over-window prefix cannot remain the implicit fallback.
1146
+ toolRide = plan.fallback;
1147
+ toolNeedsOriginal = plan.overWindow;
1148
+ const prior = state.toolRecoveryAttemptedLanes.get(requestRouteKey);
1149
+ if (prior?.awaitingIndex && Date.now() >= prior.awaitingIndex.deadline) {
1150
+ prior.awaitingIndex = undefined;
1151
+ }
1152
+ if (prior?.inFlight || (!prior && state.toolRecoveryAttemptedLanes.size >= 128 &&
1153
+ [...state.toolRecoveryAttemptedLanes.values()].every((attempt) => attempt.inFlight))) {
1154
+ // A concurrent request of this lane is already compressing.
1155
+ rec.routeRecovery = { outcome: "in-flight" };
1156
+ }
1157
+ else if (prior?.awaitingIndex && !plan.overWindow) {
1158
+ // The last attempt found no tree for this conversation, so a
1159
+ // compress call could only pass everything through again. Forward
1160
+ // now; the page check lets the next tool turn try once it exists.
1161
+ rec.routeRecovery = { outcome: "awaiting-index" };
1162
+ checkAwaitedIndex(opts, prior, state.shutdownSignal);
1163
+ }
1164
+ else if (prior?.retryAtTokens !== undefined &&
1165
+ plan.estimateTokens < prior.retryAtTokens &&
1166
+ Date.now() < (prior.retryDeadline ?? 0)) {
1167
+ // This lane's last attempt this human turn produced nothing; wait
1168
+ // for growth instead of paying a blocking call on every tool turn.
1169
+ rec.routeRecovery = { outcome: "backoff" };
1170
+ }
1171
+ else if (Date.now() < state.toolRecoveryCooldownUntil) {
1172
+ // A recent attempt burned the full compress budget and still
1173
+ // failed. Every tool turn appends a tool_result and rehashes, so
1174
+ // compress() dedup can never absorb the repeat: without this
1175
+ // cooldown a MemTree outage would add the whole blocking budget to
1176
+ // every tool turn over the budget. A cooldown skip does not start
1177
+ // the lane's backoff.
1178
+ rec.routeRecovery = { outcome: "cooldown" };
1179
+ }
1180
+ else {
1181
+ const recoveryAttempt = { modelContextLimit, inFlight: true };
1182
+ if (!prior && state.toolRecoveryAttemptedLanes.size >= 128) {
1183
+ const idle = [...state.toolRecoveryAttemptedLanes].find(([, attempt]) => !attempt.inFlight);
1184
+ if (idle)
1185
+ state.toolRecoveryAttemptedLanes.delete(idle[0]);
1186
+ }
1187
+ state.toolRecoveryAttemptedLanes.set(requestRouteKey, recoveryAttempt);
1188
+ // Serialized non-system conversation bytes, for the record only —
1189
+ // computed only when an attempt is made.
1190
+ const conversationBytes = Buffer.byteLength(JSON.stringify(messages.filter((m) => m.role !== "system")), "utf-8");
1191
+ return logged(recoverToolRouteMiss({
1192
+ opts,
1193
+ state,
1194
+ req,
1195
+ res,
1196
+ upstream,
1197
+ body,
1198
+ messages,
1199
+ forwardBody: plan.overWindow ? rawBody : forwardBody,
1200
+ originalBody: rawBody,
1201
+ msgsForMemtree,
1202
+ hash,
1203
+ modelContextLimit,
1204
+ routeEpoch,
1205
+ rec,
1206
+ conversationBytes,
1207
+ routeKey: requestRouteKey,
1208
+ sizeKey: requestSizeKey,
1209
+ isMainRequest,
1210
+ recoveryAttempt,
1211
+ sizing: requestSizing,
1212
+ inputCandidate: forwardCandidate,
1213
+ toolForwarding,
1214
+ clientMeta,
1215
+ compaction: { target: plan.target, threshold: plan.threshold },
1216
+ ...(plan.replaces !== undefined
1217
+ ? {
1218
+ stable: {
1219
+ targetTokens: plan.targetTokens,
1220
+ explicitTarget: plan.explicitTarget,
1221
+ modelContextLimit,
1222
+ replaces: plan.replaces,
1223
+ },
1224
+ }
1225
+ : {}),
1226
+ ...(plan.fallback ? { fallback: plan.fallback } : {}),
1227
+ retryAtTokens: plan.estimateTokens +
1228
+ Math.floor(plan.budgetTokens * TOOL_COMPACTION_RETRY_BUDGET_RATIO),
1229
+ }).finally(() => {
1230
+ recoveryAttempt.inFlight = false;
1231
+ }));
1232
+ }
1233
+ // No attempt: send the old ride when there is one, else the whole
1234
+ // history.
1235
+ if (plan.fallback) {
1236
+ toolRide = plan.fallback;
1237
+ rec.compaction.keptPrefix = true;
1238
+ }
1239
+ }
1240
+ }
1241
+ if (toolRide)
1242
+ requestSendPolicies.get(req)?.allowed.add(toolRide.raw);
1243
+ const sendBody = toolRide?.raw ?? (toolNeedsOriginal ? rawBody : forwardBody);
1244
+ recordTurn(rec, toolRide ? toolRide.turnType : isUserTurn ? "first-user" : "tool", sendBody);
1245
+ opts.memtree.indexInBackground(hash, msgsForMemtree, modelContextLimit, toolSessionId, clientMeta, transcriptTimesFor(opts, toolSessionId, agentAttributionId(req)));
1246
+ capture(opts, toolRide ? "anthropic-request-memory-tool" : "anthropic-request", sendBody);
1247
+ // The size Anthropic reports is the next budget check's anchor: a ride
1248
+ // reports to its prefix (or route), a whole request to the session's (or
1249
+ // lane's) passthrough size.
1250
+ return logged(forwardRaw(req, res, sendBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
1251
+ toolForwarding.settle(delivered);
1252
+ if (toolRide)
1253
+ noteRideSize(toolRide.sizeHolder, rec, sendBody.length);
1254
+ else {
1255
+ noteWholeRequestSize(state, isMainRequest, toolSessionId, requestSizeKey, rec, sendBody.length);
1256
+ }
1257
+ }));
1258
+ }
1259
+ // The away recap is a fork of the main conversation: its history is the
1260
+ // main thread's plus one question. Ride the main thread's last compressed
1261
+ // prefix instead of compressing separately, so the request stays under the
1262
+ // window, hits the prompt cache the main thread warmed, and costs no
1263
+ // MemTree call. Any mismatch falls through to the recap's own compression.
1264
+ if (isAwaySummary) {
1265
+ const fork = forkRoutedBody(body, messages, state.lastMainRoute, requestSessionId(req));
1266
+ if ("body" in fork) {
1267
+ requestSizing.register(fork.body, "ride");
1268
+ requestSendPolicies.get(req)?.allowed.add(fork.body);
1269
+ releaseRouteDecision(state, requestRouteKey, routeDecisionGeneration);
1270
+ recordTurn(rec, "fork-memory", fork.body);
1271
+ capture(opts, "anthropic-request-memory-fork", fork.body);
1272
+ return logged(forwardRaw(req, res, fork.body, opts, upstream, state.shutdownSignal, rec));
1273
+ }
1274
+ rec.forkMiss = fork.miss;
1275
+ }
1276
+ const markMainPromptDelivered = () => toolForwarding.settle(true);
1277
+ // A newer same-lane replacement prevents older asynchronous installation.
1278
+ const routeDecisionCurrent = () => state.mainRouteEpoch === routeEpoch &&
1279
+ routeDecisionHolds(state, requestRouteKey, routeDecisionGeneration);
1280
+ const commitRouteDecision = () => {
1281
+ routeDecisionSettled = true;
1282
+ };
1283
+ const releaseUncommittedRouteDecision = () => {
1284
+ if (routeDecisionSettled)
1285
+ return;
1286
+ releaseRouteDecision(state, requestRouteKey, routeDecisionGeneration);
1287
+ routeDecisionSettled = true;
1288
+ };
1289
+ // An uncompressed fallback releases this replacement reservation. The old
1290
+ // validated route remains available to requests whose histories still match.
1291
+ const releaseForOriginalFallback = () => {
1292
+ requestSendPolicies.get(req).failedRebuild = true;
1293
+ releaseUncommittedRouteDecision();
1294
+ };
1295
+ const sessionId = requestSessionId(req);
1296
+ // Stable-prefix (edge) compaction: the main thread's human turns only. The
1297
+ // recap rides the main route above; subagents keep per-turn compression.
1298
+ const edge = isMainRequest && sessionId !== undefined
1299
+ ? planEdgeCompaction({
1300
+ sizing: requestSizing,
1301
+ opts,
1302
+ state,
1303
+ body,
1304
+ messages,
1305
+ sessionId,
1306
+ modelContextLimit,
1307
+ forwardBody,
1308
+ rec,
1309
+ })
1310
+ : undefined;
1311
+ // An active subagent can repeat/embed the human prompt in its own request;
1312
+ // producer suppression must also preserve the arm for the later main call.
1313
+ if (displayForThisTurn)
1314
+ state.mainNoticePending = false;
1315
+ /**
1316
+ * Forward a compressed body — a fresh compaction, or a ride on the stable
1317
+ * prefix — and make it the route for the rest of this human turn's tool
1318
+ * loop (and, for a compaction, the session's new stable prefix).
1319
+ */
1320
+ const forwardCompressed = async (compressedBody, compressedRaw, turnType, stable, result) => {
1321
+ if (!(await requestSendPolicies.get(req).fitsNative(compressedRaw))) {
1322
+ releaseUncommittedRouteDecision();
1323
+ recordTurn(rec, "followup-degraded", rawBody);
1324
+ return logged(forwardRaw(req, res, rawBody, opts, upstream, state.shutdownSignal, rec));
1325
+ }
1326
+ requestSendPolicies.get(req)?.allowed.add(compressedRaw);
1327
+ if (opts.debug) {
1328
+ console.error(`[ccc proxy] user turn ${turnType}: ${forwardBody.length} → ` +
1329
+ `${compressedRaw.length} body bytes`);
1330
+ }
1331
+ // Claude can consume message_stop, execute a fast local tool, and close the
1332
+ // SSE response before Node observes the downstream HTTP `finish` event.
1333
+ // Activate the route once the complete Anthropic response has been accepted
1334
+ // by the downstream response, so an immediate tool-result request cannot
1335
+ // race the later forwardRaw() delivery promise.
1336
+ let routeActivationAttempted = false;
1337
+ const activateMemoryRoute = () => {
1338
+ if (routeActivationAttempted)
1339
+ return;
1340
+ if (!requestSendPolicies.get(req)?.allowed.has(compressedRaw)) {
1341
+ releaseUncommittedRouteDecision();
1342
+ routeActivationAttempted = true;
1343
+ return;
1344
+ }
1345
+ if (!routeDecisionCurrent()) {
1346
+ releaseUncommittedRouteDecision();
1347
+ routeActivationAttempted = true;
1348
+ return;
1349
+ }
1350
+ // Away lane: commit the decision but store nothing. Nothing can ever
1351
+ // read an away route — tool turns and count_tokens can never classify as
1352
+ // away — so storing it would only mislead and burn heap. All the
1353
+ // reservation bookkeeping above and around this stays exactly as for any
1354
+ // lane: it is what stops a stale slow away duplicate from stepping on a
1355
+ // newer one.
1356
+ if (requestRouteLane === "away") {
1357
+ commitRouteDecision();
1358
+ routeActivationAttempted = true;
1359
+ return;
1360
+ }
1361
+ const installed = installMemoryRoute(state, requestRouteKey, req, body, messages, compressedBody, routeEpoch, routeDecisionGeneration, stable);
1362
+ commitRouteDecision();
1363
+ // Leave this false if installMemoryRoute unexpectedly throws: the
1364
+ // delivery-complete fallback then gets one safe retry.
1365
+ routeActivationAttempted = true;
1366
+ if (opts.debug) {
1367
+ console.error(`[ccc proxy] memory route activation: ${installed ? "installed" : "unavailable"}`);
1368
+ }
1369
+ };
1370
+ // If a newer same-lane decision is live, keep this request's reservation
1371
+ // until its protocol-complete activation (or terminal forward failure).
1372
+ // The newer request may still end without mutating anything and hand
1373
+ // ownership back while this response is in flight. Releasing here would let
1374
+ // this request install later with no committed generation protecting that
1375
+ // route from an even older completion.
1376
+ recordTurn(rec, turnType, compressedRaw);
1377
+ capture(opts, turnType === "followup-prefix" ? "anthropic-request-memory-prefix" : "anthropic-request", compressedRaw);
1378
+ // Queue the success notice before the stream starts: a long live stream may
1379
+ // claim its display notice before message_stop. A prefix ride made no
1380
+ // compress call, so there is nothing new to announce.
1381
+ if (result) {
1382
+ queueCompressionNotice({
1383
+ state,
1384
+ req,
1385
+ displayForThisTurn,
1386
+ noticePromptGeneration,
1387
+ noticePromptId,
1388
+ result,
1389
+ rec,
1390
+ });
1391
+ }
1392
+ return logged(forwardRaw(req, res, compressedRaw, opts, upstream, state.shutdownSignal, rec,
1393
+ // Wrapped like the recovery twin's protocol-complete callback — not
1394
+ // for behavior (forwardRaw's notify catch swallows a throw either
1395
+ // way, and routeActivationAttempted stays false so the
1396
+ // delivered-settle retry below still gets its one safe attempt) but
1397
+ // for observability: a bare throw here otherwise leaves zero trace,
1398
+ // unlike the recovery path's "activation-error" install fate.
1399
+ () => {
1400
+ try {
1401
+ activateMemoryRoute();
1402
+ }
1403
+ catch (err) {
1404
+ if (opts.debug) {
1405
+ console.error(`[ccc proxy] followup route activation threw at protocol-complete: ${err}`);
1406
+ }
1407
+ }
1408
+ }).then((delivered) => {
1409
+ // The size Anthropic reported for this request is where the next
1410
+ // human turn's budget check starts.
1411
+ const sizePrefix = stable
1412
+ ? "ride" in stable
1413
+ ? stable.ride
1414
+ : stable.installed
1415
+ : undefined;
1416
+ if (sizePrefix)
1417
+ notePrefixSize(sizePrefix, rec, compressedRaw.length);
1418
+ // A route already installed at protocol-complete deliberately survives
1419
+ // delivered=false: that settle may be the fast-tool abort (client
1420
+ // consumed message_stop, closed the SSE response, and its immediate
1421
+ // tool request must ride the prefix). A socket death before flush
1422
+ // settles identically; an identical-body retry reuses the same
1423
+ // validated compressed prefix with an empty suffix.
1424
+ if (!delivered) {
1425
+ // Protocol-complete activation may already have installed the route
1426
+ // before a fast downstream close. Otherwise this forward made no
1427
+ // route decision, so return an uncommitted agent reservation.
1428
+ if (!routeActivationAttempted)
1429
+ releaseUncommittedRouteDecision();
1430
+ return;
1431
+ }
1432
+ markMainPromptDelivered();
1433
+ // Route the rest of this human turn's tool loop — and the count_tokens
1434
+ // calls Claude Code sizes its context with — through the same compressed
1435
+ // prefix. Without this the tool loop re-sends the full history, so
1436
+ // count_tokens reports the uncompressed conversation and Claude Code
1437
+ // auto-compacts a context that memory had already shrunk.
1438
+ // Retain delivery completion as a defensive retry if protocol-time
1439
+ // route bookkeeping failed unexpectedly. Guarded like the recovery
1440
+ // twin: a repeat throw here would reject inside this settle callback
1441
+ // and strand the uncommitted reservation, so release it instead.
1442
+ try {
1443
+ activateMemoryRoute();
1444
+ }
1445
+ catch (err) {
1446
+ if (!routeActivationAttempted)
1447
+ releaseUncommittedRouteDecision();
1448
+ if (opts.debug) {
1449
+ console.error(`[ccc proxy] followup route activation retry threw: ${err}`);
1450
+ }
1451
+ }
1452
+ }));
1453
+ };
1454
+ if (existingRide && (!edge || edge.kind === "off")) {
1455
+ const owner = rideableCandidate.stablePrefix ?? rideableCandidate;
1456
+ const candidate = requestSizing.register(existingRide, "ride", owner.lastSize);
1457
+ candidateSizeOwners.set(candidate, owner);
1458
+ return forwardCompressed(JSON.parse(existingRide.toString("utf-8")), existingRide, "followup-prefix", undefined, undefined);
1459
+ }
1460
+ if (edge?.kind === "ride") {
1461
+ // The stable prefix still covers this conversation and prefix + newer
1462
+ // turns fit the budget: the same prefix bytes as the last request, so
1463
+ // Anthropic reads them from cache, and no compress call. MemTree still
1464
+ // gets the history to index.
1465
+ opts.memtree.indexInBackground(hash, msgsForMemtree, modelContextLimit, sessionId, clientMeta, transcriptTimesFor(opts, sessionId, agentAttributionId(req)));
1466
+ return forwardCompressed(edge.routed.body, edge.routed.raw, "followup-prefix", { ride: edge.prefix }, undefined);
1467
+ }
1468
+ /**
1469
+ * A compaction that produced no new prefix: send this turn on the old one
1470
+ * instead of the whole history when it is still valid. Undefined when there
1471
+ * is none, or when the server passed a `/memtree-compact` request through
1472
+ * (the conversation is under the asked-for target: send it whole).
1473
+ */
1474
+ const rideOldPrefix = (passedThrough) => {
1475
+ if (edge?.kind !== "compress" || !edge.fallback)
1476
+ return undefined;
1477
+ if (passedThrough && edge.reason === "manual")
1478
+ return undefined;
1479
+ rec.compaction.keptPrefix = true;
1480
+ return forwardCompressed(edge.fallback.routed.body, edge.fallback.routed.raw, "followup-prefix", { ride: edge.fallback.prefix }, undefined);
1481
+ };
1482
+ /** A main request about to go out whole: size it and drop any stale prefix. */
1483
+ const sendingWhole = () => { requestSendPolicies.get(req).failedRebuild = true; };
1484
+ const noteWholeSize = () => {
1485
+ if (isMainRequest)
1486
+ notePassthroughSize(state, sessionId, rec, forwardBody.length);
1487
+ };
1488
+ // The complete request upload can outlive its downstream subscriber while
1489
+ // MemTree compression is in flight. Track that subscriber locally without
1490
+ // feeding its lifetime into MemtreeClient.compress(): compression promises
1491
+ // are hash-deduped and may still be serving another live retry.
1492
+ let downstreamClosedDuringCompression = res.destroyed && !res.writableFinished;
1493
+ const markDownstreamClosedDuringCompression = () => {
1494
+ if (!res.writableFinished)
1495
+ downstreamClosedDuringCompression = true;
1496
+ };
1497
+ res.once("close", markDownstreamClosedDuringCompression);
1498
+ // The hidden away-summary request compresses too, but its page is not the
1499
+ // conversation the user is looking at.
1500
+ const linkSeq = isMainRequest && !isAwaySummary ? nextMemtreeCallSeq(state) : undefined;
1501
+ let compression;
1502
+ try {
1503
+ compression = await runBlockingCompression({
1504
+ opts,
1505
+ state,
1506
+ body,
1507
+ msgsForMemtree,
1508
+ hash,
1509
+ modelContextLimit,
1510
+ rec,
1511
+ sessionId,
1512
+ agentId: agentAttributionId(req),
1513
+ clientMeta,
1514
+ sizing: requestSizing,
1515
+ inputCandidate: forwardCandidate,
1516
+ ...(edge?.kind === "compress"
1517
+ ? { compaction: { target: edge.target, threshold: edge.threshold } }
1518
+ : edge?.kind === "off"
1519
+ ? { compaction: {} }
1520
+ : {}),
1521
+ });
1522
+ }
1523
+ catch {
1524
+ const kept = rideOldPrefix(false);
1525
+ if (kept)
1526
+ return kept;
1527
+ releaseUncommittedRouteDecision();
1528
+ recordTurn(rec, "followup-degraded", rawBody);
1529
+ capture(opts, "anthropic-request", rawBody);
1530
+ return logged(forwardRaw(req, res, rawBody, opts, upstream, state.shutdownSignal, rec)
1531
+ .then((delivered) => {
1532
+ if (isMainRequest)
1533
+ notePassthroughSize(state, sessionId, rec, rawBody.length);
1534
+ if (delivered)
1535
+ markMainPromptDelivered();
1536
+ }));
1537
+ }
1538
+ finally {
1539
+ res.off("close", markDownstreamClosedDuringCompression);
1540
+ }
1541
+ const { result } = compression;
1542
+ noteMemtreeHealth(state, compression);
1543
+ if (linkSeq !== undefined) {
1544
+ noteMemtreePage(state, linkSeq, sessionId, result);
1545
+ }
1546
+ // Any answer settles a `/memtree-compact`: a compressed one became the
1547
+ // prefix, a passthrough means there was nothing to compact. A failure keeps
1548
+ // it pending for the next turn.
1549
+ if (result && sessionId !== undefined)
1550
+ state.compactNow.delete(sessionId);
1551
+ if (downstreamClosedDuringCompression ||
1552
+ (res.destroyed && !res.writableFinished)) {
1553
+ releaseUncommittedRouteDecision();
1554
+ recordTurn(rec, "followup-client-closed", Buffer.alloc(0));
1555
+ return logged(Promise.resolve());
1556
+ }
1557
+ // No await occurs between this check and the selected forwarder's call.
1558
+ // Each forwarder installs its own close listener synchronously, so this is
1559
+ // an event-loop-atomic handoff of downstream-close ownership.
1560
+ if (!result) {
1561
+ const kept = rideOldPrefix(false);
1562
+ if (kept)
1563
+ return kept;
1564
+ // MemTree down/slow/402: the user's own Anthropic call is never gated on
1565
+ // it. Degrade to passthrough and queue a display-only hook notice for a
1566
+ // visible turn. The hidden away-summary request deliberately stays quiet.
1567
+ // Unpaid key (402, from this compress OR an earlier background index) gets
1568
+ // a payment-specific notice instead of the generic degraded one, at most
1569
+ // once per proxy process after it has actually been delivered.
1570
+ recordTurn(rec, "followup-degraded", forwardBody);
1571
+ releaseForOriginalFallback();
1572
+ sendingWhole();
1573
+ capture(opts, "anthropic-request", forwardBody);
1574
+ const paymentDetail = opts.memtree.paymentRequiredDetail;
1575
+ const mayQueueNotice = displayForThisTurn && state.mainPromptGeneration === noticePromptGeneration;
1576
+ if (mayQueueNotice && paymentDetail !== null && !state.paymentNoticeShown) {
1577
+ const detailFirstLine = sanitizeNoticeDetail(paymentDetail.split(/[\r\n]/, 1)[0]);
1578
+ state.notices.queueSuffix(detailFirstLine
1579
+ ? `${PAYMENT_REQUIRED_NOTICE}\n${detailFirstLine}`
1580
+ : PAYMENT_REQUIRED_NOTICE, () => {
1581
+ state.paymentNoticeShown = true;
1582
+ }, noticePromptId);
1583
+ }
1584
+ else if (mayQueueNotice && paymentDetail === null) {
1585
+ state.notices.queueSuffix(DEGRADED_NOTICE, undefined, noticePromptId);
1586
+ }
1587
+ return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
1588
+ noteWholeSize();
1589
+ if (delivered)
1590
+ markMainPromptDelivered();
1591
+ }));
1592
+ }
1593
+ const actuallyCompressed = didMemtreeCompress(result);
1594
+ const historyCheck = checkCompressedHistory(result, msgsForMemtree);
1595
+ rec.history = {
1596
+ retainedChars: historyCheck.retainedChars,
1597
+ priorHistoryChars: historyCheck.priorHistoryChars,
1598
+ usable: historyCheck.usable,
1599
+ };
1600
+ if (!actuallyCompressed || !historyCheck.usable) {
1601
+ const kept = rideOldPrefix(!actuallyCompressed);
1602
+ if (kept)
1603
+ return kept;
1604
+ // Two distinct ways to get an unusable answer, one recovery.
1605
+ //
1606
+ // No cached/indexed tokens means the server is still warming an index and
1607
+ // returned the messages as-is. Preserve true passthrough semantics:
1608
+ // flattening that no-op response changes Anthropic's structured
1609
+ // conversation and made the first request disagree with the full-history
1610
+ // tool loop that followed it.
1611
+ //
1612
+ // A fully indexed response that carries no prior conversation is the more
1613
+ // dangerous case: it looks like a perfect compression by every usage-based
1614
+ // measure, so nothing downstream would notice that the model is about to be
1615
+ // asked to continue a conversation it can no longer see. Forwarding the
1616
+ // real history costs context but never silently amnesias the session.
1617
+ releaseForOriginalFallback();
1618
+ sendingWhole();
1619
+ if (actuallyCompressed && opts.debug) {
1620
+ console.error(`[ccc proxy] memory response dropped the conversation ` +
1621
+ `(retained ${historyCheck.retainedChars} of ` +
1622
+ `${historyCheck.priorHistoryChars} prior chars); forwarding history`);
1623
+ }
1624
+ recordTurn(rec, actuallyCompressed ? "followup-empty-memory" : "followup-noop", forwardBody);
1625
+ capture(opts, "anthropic-request", forwardBody);
1626
+ return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
1627
+ noteWholeSize();
1628
+ if (delivered)
1629
+ markMainPromptDelivered();
1630
+ }));
1631
+ }
1632
+ // Invariant from the early return above: past this point the MemTree result
1633
+ // actually compressed (actuallyCompressed is true) and the retained history
1634
+ // is usable — every remaining path forwards the compressed body.
1635
+ let built;
1636
+ try {
1637
+ built = buildCompressedBody(body, result);
1638
+ }
1639
+ catch (err) {
1640
+ releaseUncommittedRouteDecision();
1641
+ throw err;
1642
+ }
1643
+ if (built === null) {
1644
+ const kept = rideOldPrefix(false);
1645
+ if (kept)
1646
+ return kept;
1647
+ // The server compressed but returned no usable `flattened_messages`
1648
+ // (pre-flatten server, or a malformed field). The flatten format lives
1649
+ // server-side only — the client deliberately has no local fallback, that
1650
+ // drift is what caused the append/adherence regressions — so forward the
1651
+ // real history instead, exactly like the unusable branch above.
1652
+ releaseForOriginalFallback();
1653
+ sendingWhole();
1654
+ if (opts.debug) {
1655
+ console.error("[ccc proxy] compressed result carried no server flatten; " +
1656
+ "forwarding history");
1657
+ }
1658
+ recordTurn(rec, "followup-no-flatten", forwardBody);
1659
+ capture(opts, "anthropic-request", forwardBody);
1660
+ return logged(forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
1661
+ noteWholeSize();
1662
+ if (delivered)
1663
+ markMainPromptDelivered();
1664
+ }));
1665
+ }
1666
+ // A compaction: this result becomes the session's stable prefix once the
1667
+ // response completes. The server may also have compressed on its own
1668
+ // (over its budget with no threshold from us): that is a budget compaction.
1669
+ let stable;
1670
+ if (edge?.kind === "compress") {
1671
+ const compaction = rec.compaction;
1672
+ compaction.reason ??= compaction.prefixMiss ? "prefix-mismatch" : "budget";
1673
+ stable = {
1674
+ targetTokens: edge.targetTokens,
1675
+ explicitTarget: edge.explicitTarget,
1676
+ modelContextLimit,
1677
+ };
1678
+ }
1679
+ return forwardCompressed(built.compressedBody, built.compressedRaw, "followup-compressed", stable, result);
1680
+ }
1681
+ function queueCompressionNotice(args) {
1682
+ const { state, req, displayForThisTurn, noticePromptGeneration, noticePromptId, result, rec, } = args;
1683
+ if (!displayForThisTurn ||
1684
+ state.mainPromptGeneration !== noticePromptGeneration) {
1685
+ return;
1686
+ }
1687
+ // Announce only actual (re)indexing, measured as growth in how much of the
1688
+ // original prompt the index covers (`cached_tokens`). Unchanged coverage
1689
+ // means this turn rode the existing index with the new messages appended
1690
+ // after it — MemTree did no new indexing work worth reporting.
1691
+ //
1692
+ // Deliberately NOT keyed on the memory text: the server unfolds the index
1693
+ // against the current question, so its rendering (and length) changes on
1694
+ // nearly every turn regardless of indexing. Coverage is missing only on
1695
+ // servers old enough that `didMemtreeCompress` would have degraded this
1696
+ // turn already; announce rather than suppress on an unknown quantity.
1697
+ //
1698
+ // A newly ready MemTree page also earns the line: the link rides only the
1699
+ // success line (never its own message), so a finished index is announced
1700
+ // on the next compressed turn even when coverage stayed flat.
1701
+ const indexedTokens = cachedPromptTokenCount(result);
1702
+ const sessionId = requestSessionId(req);
1703
+ if (indexedTokens !== undefined) {
1704
+ const last = state.lastNoticedIndexCoverage;
1705
+ // Record every observed coverage, announced or not, so a server-side
1706
+ // index rebuild that shrinks coverage re-announces once it grows past
1707
+ // its own new baseline rather than staying silent until it beats the old.
1708
+ state.lastNoticedIndexCoverage = { sessionId, indexedTokens };
1709
+ if (last &&
1710
+ last.sessionId === sessionId &&
1711
+ indexedTokens <= last.indexedTokens &&
1712
+ !state.notices.linkPending(sessionId)) {
1713
+ return;
1714
+ }
1715
+ }
1716
+ state.notices.queuePrefix(() => compressedTotalsText(...compressionTotals(rec, result)), undefined, noticePromptId);
1717
+ }
1718
+ /**
1719
+ * Before and after sizes for the success line, read when the line is shown.
1720
+ * Before: the estimated request that would otherwise have been sent (the
1721
+ * old prefix plus suffix on recompaction), else the server's raw_prompt_tokens
1722
+ * for the original history. After: Anthropic's reported compressed input once
1723
+ * usage arrives, else "before" scaled using the bytes of that same estimate.
1724
+ */
1725
+ function compressionTotals(rec, result) {
1726
+ const original = rec?.compaction?.estimatedTokens ?? rawPromptTokenCount(result);
1727
+ if (!rec)
1728
+ return [original, undefined];
1729
+ const reported = reportedInputTokens(rec);
1730
+ if (reported !== undefined)
1731
+ return [original, reported];
1732
+ const fwd = rec.forwardedBytes;
1733
+ const basisBytes = rec.compaction?.estimatedTokens !== undefined
1734
+ ? rec.compaction.estimatedBytes
1735
+ : rec.requestBytes;
1736
+ if (original === undefined || !fwd || !basisBytes)
1737
+ return [original, undefined];
1738
+ return [original, Math.round((original * fwd) / basisBytes)];
1739
+ }
1740
+ /**
1741
+ * The link on the success line: the newest page for the hook's session,
1742
+ * exactly as the server stamped it, keyed by the index it was compressed
1743
+ * against so it is announced once per newly finished index. The server hands
1744
+ * out its short spelling (`/m/<leading hex of the request id>`), which is
1745
+ * permanent — it outlives this proxy — and fits a terminal line.
1746
+ */
1747
+ function installMemtreeLink(state, placement) {
1748
+ const resolve = (sessionId) => sessionLink(state, sessionId, currentMemtreePage(state, sessionId));
1749
+ switch (placement) {
1750
+ case "success":
1751
+ state.notices.setLink(resolve);
1752
+ break;
1753
+ case "turn":
1754
+ // The success line carries the current page on the line below it; the
1755
+ // end-of-turn trailer then skips a link that line already showed.
1756
+ state.notices.setLink(resolve);
1757
+ state.notices.setTrailer(resolve, placement);
1758
+ break;
1759
+ case "message":
1760
+ case "stop":
1761
+ // These trailers show on every message or every Stop; no second copy.
1762
+ state.notices.setTrailer(resolve, placement);
1763
+ break;
1764
+ case "off":
1765
+ break;
1766
+ }
1767
+ }
1768
+ /**
1769
+ * The link line for a resumed main session: its newest page, from memory when
1770
+ * this proxy served it (an in-app `/resume` back to an earlier conversation),
1771
+ * else from the on-disk store (a new `ccc --resume` process). Adopts it as the
1772
+ * session's current page so trailers continue from it, at a sequence number
1773
+ * any later call beats.
1774
+ */
1775
+ function resumeLinkLine(state, input) {
1776
+ if (input.agent_id !== undefined || input.source === "compact")
1777
+ return undefined;
1778
+ if (state.memtreeLinkPlacement === "off")
1779
+ return undefined;
1780
+ const sessionId = input.session_id;
1781
+ const inMemory = state.memtreePages.get(sessionId);
1782
+ const page = inMemory && inMemory.sessionId === sessionId
1783
+ ? inMemory
1784
+ : state.memtreeLinkStore?.get(sessionId);
1785
+ const link = sessionLink(state, sessionId, page);
1786
+ if (!link)
1787
+ return undefined;
1788
+ if (page && page !== inMemory) {
1789
+ rememberMemtreePage(state, sessionId, {
1790
+ sessionId,
1791
+ url: page.url,
1792
+ index: page.index,
1793
+ compressed: page.compressed,
1794
+ seq: state.memtreeCallSeq,
1795
+ });
1796
+ }
1797
+ return state.notices.resumeLine(link, sessionId);
1798
+ }
1799
+ export const MEMTREE_COMPACT_MIN_TOKENS = 20_000;
1800
+ /** The reply to a ccc slash command, or undefined for an ordinary prompt. */
1801
+ function sessionCommandReply(state, sessionId, prompt) {
1802
+ if (sessionCommandArgs(prompt, MEMTREE_HELP_COMMAND) !== undefined) {
1803
+ return [
1804
+ "• /memtree-view · show this session's MemTree page link",
1805
+ `• /memtree-compact [tokens | off] · compact this session on your next message (default half the budget, at least ${MEMTREE_COMPACT_MIN_TOKENS / 1000}k)`,
1806
+ ].join("\n");
1807
+ }
1808
+ if (isMemtreeViewCommand(prompt))
1809
+ return memtreeViewLine(state, sessionId);
1810
+ const args = sessionCommandArgs(prompt, MEMTREE_COMPACT_COMMAND);
1811
+ if (args === undefined)
1812
+ return undefined;
1813
+ const keep = "then that compressed history is reused unchanged until the conversation reaches the budget again. /memtree-compact off to stop.";
1814
+ if (/^off$/i.test(args)) {
1815
+ state.compactTargets.set(sessionId, null);
1816
+ state.memoryRoutes.delete(JSON.stringify([sessionId, "main"]));
1817
+ state.compactNow.delete(sessionId);
1818
+ state.stablePrefixes.delete(sessionId);
1819
+ return `${TRAILER_LABEL} compaction off: the conversation is sent whole, and MemTree compresses only when it outgrows the model's budget.`;
1820
+ }
1821
+ if (args === "") {
1822
+ // Back to the automatic target (half the budget, or CCC_COMPACT_TARGET).
1823
+ // Under CCC_COMPACT_TARGET=off the default is off, so pin the automatic
1824
+ // target for this session instead of falling back to that default.
1825
+ if (state.defaultCompactOff)
1826
+ state.compactTargets.set(sessionId, undefined);
1827
+ else
1828
+ state.compactTargets.delete(sessionId);
1829
+ state.compactNow.add(sessionId);
1830
+ return `${TRAILER_LABEL} compacting: your next message is sent compressed to about half the budget, ${keep}`;
1831
+ }
1832
+ const target = parseTokenCount(args);
1833
+ if (target === undefined || target < MEMTREE_COMPACT_MIN_TOKENS) {
1834
+ return `${TRAILER_LABEL} usage: /memtree-compact [tokens, e.g. 400k, at least ${MEMTREE_COMPACT_MIN_TOKENS / 1000}k | off]`;
1835
+ }
1836
+ state.compactTargets.set(sessionId, target);
1837
+ state.compactNow.add(sessionId);
1838
+ return `${TRAILER_LABEL} compacting: your next message is sent compressed to about ${Math.round(target / 1000)}k tokens, ${keep}`;
1839
+ }
1840
+ /** "50k", "50000", "1.5m" → tokens; undefined when not a positive count. */
1841
+ export function parseTokenCount(text) {
1842
+ const match = /^(\d+(?:\.\d+)?)\s*([km])?$/i.exec(text.trim());
1843
+ if (!match)
1844
+ return undefined;
1845
+ const scale = { k: 1_000, m: 1_000_000 }[match[2]?.toLowerCase()] ?? 1;
1846
+ const value = Math.round(Number(match[1]) * scale);
1847
+ return Number.isFinite(value) && value > 0 ? value : undefined;
1848
+ }
1849
+ /** The `/memtree-view` answer: this session's newest page, or why there is none. */
1850
+ function memtreeViewLine(state, sessionId) {
1851
+ const inMemory = state.memtreePages.get(sessionId);
1852
+ const page = inMemory && (inMemory.sessionId === undefined || inMemory.sessionId === sessionId)
1853
+ ? inMemory
1854
+ : state.memtreeLinkStore?.get(sessionId);
1855
+ const link = sessionLink(state, sessionId, page);
1856
+ if (!link) {
1857
+ return `${TRAILER_LABEL} no page yet: this session has not been indexed. The link appears once it has.`;
1858
+ }
1859
+ // Blocked-prompt reasons need an explicit hyperlink in styled terminals.
1860
+ const url = terminalSupportsHyperlinks() ? hyperlink(link.link, link.link) : link.link;
1861
+ const lines = linkLines(LINK_LABEL, url, link.note);
1862
+ // The memory the agent works from can be an older tree than the newest one.
1863
+ return page?.compressed && page.url !== link.link
1864
+ ? `${lines}\n memory from ${page.url}`
1865
+ : lines;
1866
+ }
1867
+ /**
1868
+ * The link shown for a session: its newest completed tree when the server
1869
+ * named one (newestTrees), else `source`, the page its memory came from. The
1870
+ * key is the tree, so each newly finished tree is announced once.
1871
+ */
1872
+ function sessionLink(state, sessionId, source) {
1873
+ const newest = state.newestTrees?.peek(sessionId);
1874
+ const note = source && !source.compressed ? { note: NOT_COMPRESSED_NOTE } : {};
1875
+ if (newest)
1876
+ return { key: newest.key, link: newest.url, ...note };
1877
+ return source ? { key: source.index, link: source.url, ...note } : undefined;
1878
+ }
1879
+ /**
1880
+ * The recap's link: the session's newest tree like the Stop trailer, since the
1881
+ * recap rides the stable compressed prefix and its compress page goes stale.
1882
+ * Falls back to that page; never another session's.
1883
+ */
1884
+ function recapLink(state, sessionId) {
1885
+ const page = currentMemtreePage(state, sessionId);
1886
+ const foreign = page !== undefined && sessionId !== undefined &&
1887
+ page.sessionId !== undefined && page.sessionId !== sessionId;
1888
+ return sessionLink(state, sessionId, foreign ? undefined : page);
1889
+ }
1890
+ function nextMemtreeCallSeq(state) {
1891
+ return ++state.memtreeCallSeq;
1892
+ }
1893
+ /**
1894
+ * Adopt a compress response's MemTree page as the conversation's newest,
1895
+ * unless a call submitted later already reported one. Only a response that
1896
+ * names the index it was compressed against counts: that index is complete,
1897
+ * so the page shows a tree right away, and its identity is what makes the
1898
+ * link worth announcing. A response without it (pre-view server, failed or
1899
+ * uncompressed call) changes nothing.
1900
+ */
1901
+ function noteMemtreePage(state, seq, sessionId, result) {
1902
+ const url = result?.memtreeUrl;
1903
+ const index = result?.memtreeIndex;
1904
+ if (!sessionId || !url || !index)
1905
+ return;
1906
+ const latest = state.memtreePages.get(sessionId);
1907
+ if (latest && latest.seq >= seq)
1908
+ return;
1909
+ const compressed = didMemtreeCompress(result);
1910
+ rememberMemtreePage(state, sessionId, { sessionId, url, index, compressed, seq });
1911
+ state.memtreeLinkStore?.put(sessionId, { url, index, compressed });
1912
+ // A cached newest tree may predate this page; ask again.
1913
+ state.newestTrees?.invalidate(sessionId);
1914
+ }
1915
+ /** Bound optional page state independently of how many sessions use the proxy. */
1916
+ function rememberMemtreePage(state, sessionId, page) {
1917
+ state.memtreePages.delete(sessionId);
1918
+ state.memtreePages.set(sessionId, page);
1919
+ while (state.memtreePages.size > MEMTREE_LINKS_MAX_SESSIONS) {
1920
+ state.memtreePages.delete(state.memtreePages.keys().next().value);
1921
+ }
1922
+ }
1923
+ /**
1924
+ * Record what a blocking operation just proved about MemTree's health: an
1925
+ * unopposed live failure arms the tool-recovery cooldown, any live success
1926
+ * clears it, and a cached-only result leaves the prior state untouched.
1927
+ *
1928
+ * Called from BOTH blocking paths and, critically, BEFORE either one checks
1929
+ * whether its client is still listening. A null says something about the
1930
+ * server, not about the downstream socket — and the full-budget stall that
1931
+ * produces a null is itself the likeliest reason a client gives up, so
1932
+ * learning only from attempts that outlived their client would blind the
1933
+ * cooldown to exactly the outage it exists to bound. Sharing it with the
1934
+ * followup path matters in both directions: an outage first seen on a human
1935
+ * turn must not cost another full stall on the next tool turn, and a
1936
+ * recovered MemTree must not stay locked out for the rest of the window.
1937
+ *
1938
+ * A live no-op, unusable, or non-shrinking answer still proves the server is
1939
+ * responsive and merely unhelpful for this history, and the next turn's
1940
+ * larger tail may well succeed — so those clear the outage fuse and keep
1941
+ * paying the fast, already-indexed round trip rather than locking recovery
1942
+ * out. The asymmetry trades a bounded repeat cost against never suppressing a
1943
+ * recovery a warming index is about to make possible.
1944
+ *
1945
+ * Both directions require evidence about MemTree's health RIGHT NOW; anything
1946
+ * weaker leaves the fuse untouched rather than guessing:
1947
+ *
1948
+ * - Every lane contributes the same shared evidence. A live success clears
1949
+ * the cooldown; a live failure arms it.
1950
+ * - Cache-served calls contribute no evidence. compress() memoizes successes
1951
+ * by hash and returns them with zero server contact, so replaying an
1952
+ * identical body cannot "prove" MemTree is up.
1953
+ * - A shutdown abort arms nothing. compress() maps abort to the same null as
1954
+ * a server failure, but a draining proxy says nothing about MemTree; it
1955
+ * would only misattribute the drain in the last records written.
1956
+ */
1957
+ function noteMemtreeHealth(state, compression) {
1958
+ if (compression.liveHealth === "failure") {
1959
+ if (state.shutdownSignal.aborted)
1960
+ return;
1961
+ state.toolRecoveryCooldownUntil =
1962
+ Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
1963
+ return;
1964
+ }
1965
+ if (compression.liveHealth === "success") {
1966
+ state.toolRecoveryCooldownUntil = 0;
1967
+ }
1968
+ }
1969
+ /**
1970
+ * Whether the holder of `generation` still owns the route decision: true while
1971
+ * no STRICTLY NEWER reservation is live or committed. The holder's own
1972
+ * reservation never blocks itself, and a newer reservation that ended up
1973
+ * mutating nothing has already removed itself, handing ownership back rather
1974
+ * than stranding everyone older.
1975
+ */
1976
+ function routeDecisionHolds(state, key, generation) {
1977
+ if (generation <= state.retiredRouteDecisionFloor)
1978
+ return false;
1979
+ const live = state.routeDecisionsLive.get(key);
1980
+ if (!live)
1981
+ return true;
1982
+ for (const reserved of live) {
1983
+ if (reserved > generation)
1984
+ return false;
1985
+ }
1986
+ return true;
1987
+ }
1988
+ function reserveRouteDecision(state, key, generation) {
1989
+ let live = state.routeDecisionsLive.get(key);
1990
+ if (!live) {
1991
+ if (state.routeDecisionsLive.size >= 128) {
1992
+ const oldest = state.routeDecisionsLive.keys().next().value;
1993
+ const retired = state.routeDecisionsLive.get(oldest);
1994
+ state.retiredRouteDecisionFloor = Math.max(state.retiredRouteDecisionFloor, ...retired);
1995
+ state.routeDecisionsLive.delete(oldest);
1996
+ }
1997
+ live = new Set();
1998
+ state.routeDecisionsLive.set(key, live);
1999
+ }
2000
+ live.add(generation);
2001
+ }
2002
+ function releaseRouteDecision(state, key, generation) {
2003
+ const live = state.routeDecisionsLive.get(key);
2004
+ if (!live)
2005
+ return;
2006
+ live.delete(generation);
2007
+ if (live.size === 0)
2008
+ state.routeDecisionsLive.delete(key);
2009
+ }
2010
+ function installMemoryRoute(state, key, req, originalBody, originalMessages, compressedBody, routeEpoch, decisionGeneration,
2011
+ /**
2012
+ * Main-thread human turns only: the stable prefix these compressed messages
2013
+ * start with. `register` stores it as the session's prefix (a compaction);
2014
+ * a prefix ride passes the prefix it rode, already stored.
2015
+ */
2016
+ stable) {
2017
+ // Same-lane generations order asynchronous installs. Hooks never mutate
2018
+ // these guards; a stale completion cannot overwrite a validated replacement.
2019
+ if (state.mainRouteEpoch !== routeEpoch ||
2020
+ !routeDecisionHolds(state, key, decisionGeneration)) {
2021
+ return undefined;
2022
+ }
2023
+ const sessionId = requestSessionId(req);
2024
+ if (!sessionId || !Array.isArray(compressedBody.messages)) {
2025
+ state.memoryRoutes.delete(key);
2026
+ return undefined;
2027
+ }
2028
+ // Test-only fault-injection seam (undefined in production): placed after
2029
+ // every guard and before the store, exactly where the route object's
2030
+ // cloneJson/hash construction could throw, so tests can reach the
2031
+ // "activation-error" settle label. See ProxyOptions.routeInstallFault.
2032
+ state.routeInstallFault?.();
2033
+ const route = {
2034
+ sessionId,
2035
+ originalSystemHash: routeValueHash(normalizeRouteSystem(originalBody.system)),
2036
+ // Include trailing ambient role=system blocks: MemTree consolidated them
2037
+ // into compressedBody.system, so treating them as suffix would duplicate
2038
+ // those instructions on every tool request.
2039
+ originalPrefixHashes: originalMessages.map(routeMessageHash),
2040
+ compressedMessages: cloneJson(compressedBody.messages),
2041
+ compressedSystem: cloneJson(compressedBody.system),
2042
+ hasCompressedSystem: Object.prototype.hasOwnProperty.call(compressedBody, "system"),
2043
+ routeEpoch,
2044
+ };
2045
+ if (stable && "ride" in stable) {
2046
+ route.stablePrefix = stable.ride;
2047
+ }
2048
+ else if (stable) {
2049
+ // The route's own fields are the prefix: the hashes of every message this
2050
+ // request carried, and the compressed bytes sent for them. Later requests
2051
+ // reuse these bytes unchanged, so the prefix stays a cache read.
2052
+ route.stablePrefix = storeStablePrefix(state, sessionId, originalMessages, route, stable);
2053
+ }
2054
+ setMemoryRoute(state, key, route);
2055
+ state.routeDecisionsLive.set(key, new Set([decisionGeneration]));
2056
+ if (!state.toolRecoveryAttemptedLanes.get(key)?.inFlight)
2057
+ state.toolRecoveryAttemptedLanes.delete(key);
2058
+ if (key === JSON.stringify([sessionId, "main"]))
2059
+ state.lastMainRoute = route;
2060
+ return route;
2061
+ }
2062
+ /**
2063
+ * Store a compaction's compressed bytes as the session's stable prefix. For a
2064
+ * tool-turn compaction (`replaces` set) only while the session's prefix is
2065
+ * still the one the compaction replaced; undefined when skipped.
2066
+ */
2067
+ function storeStablePrefix(state, sessionId, originalMessages, compressed, stable) {
2068
+ if (stable.replaces !== undefined &&
2069
+ (state.stablePrefixes.get(sessionId) ?? null) !== stable.replaces) {
2070
+ return undefined;
2071
+ }
2072
+ const prefix = {
2073
+ sessionId,
2074
+ originalSystemHash: compressed.originalSystemHash,
2075
+ originalPrefixHashes: originalMessages.map(stablePrefixMessageHash),
2076
+ compressedMessages: compressed.compressedMessages,
2077
+ compressedSystem: compressed.compressedSystem,
2078
+ hasCompressedSystem: compressed.hasCompressedSystem,
2079
+ targetTokens: stable.targetTokens,
2080
+ explicitTarget: stable.explicitTarget,
2081
+ modelContextLimit: stable.modelContextLimit,
2082
+ };
2083
+ stable.installed = prefix;
2084
+ boundedSet(state.stablePrefixes, sessionId, prefix);
2085
+ state.compactNow.delete(sessionId);
2086
+ return prefix;
2087
+ }
2088
+ /**
2089
+ * A fork of the main conversation (the away recap) sent on the main thread's
2090
+ * last compressed prefix: that prefix, then the fork's own messages after it.
2091
+ * Only session, system and every prefix message are checked; unlike a tool
2092
+ * turn, the suffix may end in a plain user message (the fork's question).
2093
+ */
2094
+ function forkRoutedBody(body, messages, route, sessionId) {
2095
+ if (!route)
2096
+ return { miss: "no-route" };
2097
+ const miss = prefixMismatch(body, messages, route, sessionId);
2098
+ if (miss)
2099
+ return { miss };
2100
+ const routed = prefixRoutedBody(body, messages, route);
2101
+ if (!routed)
2102
+ return { miss: "prefix" };
2103
+ return { body: routed.raw };
2104
+ }
2105
+ /**
2106
+ * Why `messages` cannot continue from `prefix`, or null when they can: same
2107
+ * session, same system prompt, every message the prefix covers unchanged, and
2108
+ * at least one message after them. The suffix may be anything, human turns
2109
+ * included.
2110
+ */
2111
+ function prefixMismatch(body, messages, prefix, sessionId, hashMessage = routeMessageHash) {
2112
+ if (!sessionId || prefix.sessionId !== sessionId)
2113
+ return "session";
2114
+ if (routeValueHash(normalizeRouteSystem(body.system)) !== prefix.originalSystemHash) {
2115
+ return "system";
2116
+ }
2117
+ const prefixLength = prefix.originalPrefixHashes.length;
2118
+ if (messages.length < prefixLength)
2119
+ return "prefix";
2120
+ for (let i = 0; i < prefixLength; i++) {
2121
+ if (hashMessage(messages[i]) !== prefix.originalPrefixHashes[i]) {
2122
+ return "prefix";
2123
+ }
2124
+ }
2125
+ return null;
2126
+ }
2127
+ /**
2128
+ * Message identity for a stable prefix: routeMessageHash without assistant
2129
+ * thinking blocks. A stable prefix spans human turns, and Claude Code does not
2130
+ * reliably replay earlier turns' thinking (model switch, resume); those blocks
2131
+ * are never sent on the prefix anyway (the compressed bytes stand in for them),
2132
+ * so they must not decide whether it still matches.
2133
+ */
2134
+ /**
2135
+ * The request sent on a compressed prefix: the prefix's compressed messages
2136
+ * (a JSON round trip of the stored bytes, so byte-identical every time), then
2137
+ * the messages after the part it covers, verbatim, with Claude Code's current
2138
+ * billing header grafted into the compressed system and the cache breakpoints
2139
+ * capped at Anthropic's limit (the prefix's own marker always kept).
2140
+ */
2141
+ function prefixRoutedBody(body, messages, prefix) {
2142
+ const routed = {
2143
+ ...body,
2144
+ messages: [
2145
+ ...cloneJson(prefix.compressedMessages),
2146
+ ...messages.slice(prefix.originalPrefixHashes.length),
2147
+ ],
2148
+ };
2149
+ if (prefix.hasCompressedSystem) {
2150
+ routed.system = currentRouteSystem(prefix.compressedSystem, body.system);
2151
+ }
2152
+ else {
2153
+ delete routed.system;
2154
+ }
2155
+ if (!capCacheBreakpoints(routed, prefix.compressedMessages.length, { preserveSystem: true }))
2156
+ return null;
2157
+ if (!validCacheTtlOrder(routed))
2158
+ return null;
2159
+ return { body: routed, raw: Buffer.from(JSON.stringify(routed), "utf-8") };
2160
+ }
2161
+ function serverBudgetKey(model, modelContextLimit) {
2162
+ return JSON.stringify([typeof model === "string" ? model : "", modelContextLimit]);
2163
+ }
2164
+ /**
2165
+ * The session's whole-request budget: CCC_BUDGET_TOKENS when set (tests),
2166
+ * else the model budget the server last reported for this model and window,
2167
+ * else the window times FALLBACK_BUDGET_WINDOW_RATIO (servers that predate
2168
+ * `model_budget_tokens`, and the first request before any response).
2169
+ */
2170
+ function resolveBudget(opts, state, model, modelContextLimit) {
2171
+ if (opts.budgetTokensOverride !== undefined) {
2172
+ return { tokens: opts.budgetTokensOverride, source: "override" };
2173
+ }
2174
+ const reported = state.serverBudgets.get(serverBudgetKey(model, modelContextLimit));
2175
+ if (reported !== undefined)
2176
+ return { tokens: reported, source: "server" };
2177
+ return {
2178
+ tokens: Math.floor(modelContextLimit * FALLBACK_BUDGET_WINDOW_RATIO),
2179
+ source: "window-ratio",
2180
+ };
2181
+ }
2182
+ /** Remember the model budget a compress response reported. */
2183
+ function noteServerBudget(state, model, modelContextLimit, result) {
2184
+ const tokens = result ? modelBudgetTokens(result) : undefined;
2185
+ if (tokens === undefined)
2186
+ return;
2187
+ state.serverReportsBudget = true;
2188
+ boundedSet(state.serverBudgets, serverBudgetKey(model, modelContextLimit), tokens);
2189
+ }
2190
+ /** `/memtree-compact` for this session, else CCC_COMPACT_TARGET, else automatic. */
2191
+ function compactionMode(opts, state, sessionId) {
2192
+ const configured = sessionId !== undefined && state.compactTargets.has(sessionId)
2193
+ ? state.compactTargets.get(sessionId)
2194
+ : opts.defaultCompactTarget;
2195
+ if (configured === null)
2196
+ return { mode: "off" };
2197
+ if (configured === undefined)
2198
+ return { mode: "auto" };
2199
+ return { mode: "explicit", tokens: configured };
2200
+ }
2201
+ /** What a compaction aims at: the explicit N (capped under the budget), else budget/2. */
2202
+ function compactionTarget(mode, budgetTokens) {
2203
+ const target = mode.mode === "explicit"
2204
+ ? Math.min(mode.tokens, Math.floor(budgetTokens * MAX_EXPLICIT_TARGET_BUDGET_RATIO))
2205
+ : Math.floor(budgetTokens / 2);
2206
+ return Math.max(SERVER_MIN_TARGET_TOKENS, target);
2207
+ }
2208
+ /**
2209
+ * Target and threshold for compress calls other than a main-thread human turn
2210
+ * or a tool turn (subagent and recap followups). They never build a
2211
+ * stable prefix, so they only need to stay under the budget: a server that
2212
+ * understands the threshold gets both, an older one gets neither and uses its
2213
+ * own model budget. Never a bare target, which would force a compression on
2214
+ * every call.
2215
+ */
2216
+ function laneCompaction(opts, state, sessionId, model, modelContextLimit) {
2217
+ const mode = compactionMode(opts, state, sessionId);
2218
+ if (mode.mode === "off" || !state.serverReportsBudget)
2219
+ return {};
2220
+ const budget = resolveBudget(opts, state, model, modelContextLimit);
2221
+ return {
2222
+ target: compactionTarget(mode, budget.tokens),
2223
+ threshold: Math.max(SERVER_MIN_TARGET_TOKENS, budget.tokens),
2224
+ };
2225
+ }
2226
+ /** Calibrated input must leave room for the requested output as well. */
2227
+ function calibratedSizeExceedsWindow(record, body, modelContextLimit) {
2228
+ const outputTokens = typeof body.max_tokens === "number" && Number.isFinite(body.max_tokens)
2229
+ ? Math.max(0, body.max_tokens) : 0;
2230
+ return record.sizeSource === "reported" &&
2231
+ (record.estimatedTokens ?? 0) + outputTokens > modelContextLimit;
2232
+ }
2233
+ function calibratedSizeExceedsLimits(record, body, modelContextLimit) {
2234
+ return (record.sizeSource === "reported" &&
2235
+ (record.estimatedTokens ?? 0) >= record.budgetTokens) ||
2236
+ calibratedSizeExceedsWindow(record, body, modelContextLimit);
2237
+ }
2238
+ /** Only calibrated usage may force an estimate-driven compaction. */
2239
+ function budgetCompaction(state, record, target, body, modelContextLimit) {
2240
+ if (calibratedSizeExceedsLimits(record, body, modelContextLimit))
2241
+ return { target };
2242
+ // Older servers may ignore a threshold, so do not send a forcing target
2243
+ // until support is known. They can still compact against their own budget.
2244
+ if (!state.serverReportsBudget)
2245
+ return {};
2246
+ const threshold = Math.max(SERVER_MIN_TARGET_TOKENS, record.budgetTokens);
2247
+ record.thresholdTokens = threshold;
2248
+ return { target, threshold };
2249
+ }
2250
+ /** Anthropic's whole input size for a request: uncached + cache read + cache write. */
2251
+ function reportedInputTokens(rec) {
2252
+ const usage = rec.usage;
2253
+ if (!usage || typeof usage.input_tokens !== "number")
2254
+ return undefined;
2255
+ return (usage.input_tokens +
2256
+ (usage.cache_read_input_tokens ?? 0) +
2257
+ (usage.cache_creation_input_tokens ?? 0));
2258
+ }
2259
+ /** Keep the newest (largest-body) reported size of a request on this prefix. */
2260
+ function notePrefixSize(prefix, rec, bytes) {
2261
+ const sample = reportedSample(rec, bytes);
2262
+ if (sample === undefined)
2263
+ return;
2264
+ if (prefix.lastSize && bytes < prefix.lastSize.forwardedBytes)
2265
+ return;
2266
+ prefix.lastSize = sample;
2267
+ }
2268
+ /** Record the reported size of a main-thread request forwarded whole. */
2269
+ function notePassthroughSize(state, sessionId, rec, bytes) {
2270
+ const sample = reportedSample(rec, bytes);
2271
+ if (sessionId === undefined || sample === undefined)
2272
+ return;
2273
+ boundedSet(state.passthroughSizes, sessionId, sample);
2274
+ }
2275
+ /**
2276
+ * The size Anthropic reported for a body of `bytes`, if it can describe one:
2277
+ * an implausible ratio would mis-size every later request it anchors, so it
2278
+ * is dropped and the previous sample kept.
2279
+ */
2280
+ function reportedSample(rec, bytes) {
2281
+ if (changedDeliveryRecords.has(rec))
2282
+ return undefined;
2283
+ const tokens = reportedInputTokens(rec);
2284
+ if (tokens === undefined)
2285
+ return undefined;
2286
+ const sample = { tokens, forwardedBytes: bytes };
2287
+ return plausibleSample(sample) ? sample : undefined;
2288
+ }
2289
+ /**
2290
+ * Key of a non-main request's size sample (ProxyState.laneSizes): its lane,
2291
+ * its own agent id (helpers that carry only a parent id stay apart from the
2292
+ * agent), and its model, whose tokenizer the ratio belongs to.
2293
+ */
2294
+ function laneSizeKey(req, routeKey, model) {
2295
+ return JSON.stringify([
2296
+ routeKey,
2297
+ firstNonEmptyHeader(req, "x-claude-code-agent-id") ?? null,
2298
+ typeof model === "string" ? model : null,
2299
+ ]);
2300
+ }
2301
+ /** The session's passthrough size on the main thread, else the lane's. */
2302
+ function wholeRequestSizeSlot(state, isMainRequest, sessionId, sizeKey) {
2303
+ const main = isMainRequest && sessionId !== undefined;
2304
+ return {
2305
+ get: () => (main ? state.passthroughSizes.get(sessionId) : state.laneSizes.get(sizeKey)),
2306
+ set: (sample) => {
2307
+ if (!plausibleSample(sample))
2308
+ return;
2309
+ if (main)
2310
+ boundedSet(state.passthroughSizes, sessionId, sample);
2311
+ else
2312
+ boundedSet(state.laneSizes, sizeKey, sample, MEMORY_ROUTE_MAX_LANES);
2313
+ },
2314
+ };
2315
+ }
2316
+ /** Size of a tool turn sent on a ride, recorded on its prefix or route. */
2317
+ const noteRideSize = notePrefixSize;
2318
+ /**
2319
+ * Record the reported size of a request forwarded whole: the session's
2320
+ * passthrough size on the main thread, else its lane's.
2321
+ */
2322
+ function noteWholeRequestSize(state, isMainRequest, sessionId, sizeKey, rec, bytes) {
2323
+ if (isMainRequest && sessionId !== undefined) {
2324
+ notePassthroughSize(state, sessionId, rec, bytes);
2325
+ return;
2326
+ }
2327
+ const sample = reportedSample(rec, bytes);
2328
+ if (sample === undefined)
2329
+ return;
2330
+ boundedSet(state.laneSizes, sizeKey, sample, MEMORY_ROUTE_MAX_LANES);
2331
+ }
2332
+ function boundedSet(map, key, value, limit = STABLE_PREFIX_MAX_SESSIONS) {
2333
+ map.delete(key);
2334
+ map.set(key, value);
2335
+ while (map.size > limit) {
2336
+ const oldest = map.keys().next().value;
2337
+ if (oldest === undefined)
2338
+ break;
2339
+ map.delete(oldest);
2340
+ }
2341
+ }
2342
+ /**
2343
+ * Stable-prefix (edge) compaction for a main-thread human turn:
2344
+ *
2345
+ * - With a stored prefix that still matches the conversation and a size
2346
+ * (prefix + newer turns) under the budget: ride it, no compress call.
2347
+ * - Otherwise compress: forced to the target when the prefix outgrew the
2348
+ * budget, the window/target changed, or `/memtree-compact` asked; while the
2349
+ * session is still passing through, let the server decide against the
2350
+ * budget (threshold), or — on a server that predates the threshold — force
2351
+ * only once the proxy's own estimate reaches the budget.
2352
+ *
2353
+ * Fills rec.compaction with the budget, estimate and reason.
2354
+ */
2355
+ function planEdgeCompaction(args) {
2356
+ const { opts, state, body, messages, sessionId, modelContextLimit, forwardBody, rec } = args;
2357
+ const mode = compactionMode(opts, state, sessionId);
2358
+ const budget = resolveBudget(opts, state, body.model, modelContextLimit);
2359
+ const compaction = {
2360
+ mode: mode.mode,
2361
+ budgetTokens: budget.tokens,
2362
+ budgetSource: budget.source,
2363
+ };
2364
+ rec.compaction = compaction;
2365
+ if (mode.mode === "off") {
2366
+ state.stablePrefixes.delete(sessionId);
2367
+ state.compactNow.delete(sessionId);
2368
+ return { kind: "off" };
2369
+ }
2370
+ const targetTokens = compactionTarget(mode, budget.tokens);
2371
+ const explicitTarget = mode.mode === "explicit";
2372
+ compaction.targetTokens = targetTokens;
2373
+ const forced = (reason, fallback) => {
2374
+ compaction.reason = reason;
2375
+ return {
2376
+ kind: "compress",
2377
+ ...(reason === "budget"
2378
+ ? budgetCompaction(state, compaction, targetTokens, body, modelContextLimit)
2379
+ : { target: targetTokens }),
2380
+ targetTokens,
2381
+ explicitTarget,
2382
+ reason,
2383
+ ...(fallback ? { fallback } : {}),
2384
+ };
2385
+ };
2386
+ let prefix = state.stablePrefixes.get(sessionId);
2387
+ if (prefix) {
2388
+ const miss = prefixMismatch(body, messages, prefix, sessionId, stablePrefixMessageHash);
2389
+ if (miss) {
2390
+ // Rewind, edit, fork, /clear, or a changed system prompt: the prefix no
2391
+ // longer stands for this conversation. Start over from passthrough.
2392
+ compaction.prefixMiss = miss;
2393
+ prefix = undefined;
2394
+ }
2395
+ }
2396
+ if (prefix) {
2397
+ boundedSet(state.stablePrefixes, sessionId, prefix);
2398
+ const routed = prefixRoutedBody(body, messages, prefix);
2399
+ if (!routed) {
2400
+ // Cache TTLs are not conversation identity, but a stored 5m prefix
2401
+ // cannot precede a new 1h suffix. Rebuild without an invalid fallback;
2402
+ // never rewrite the prefix's bytes or the user's marker TTLs.
2403
+ return forced("prefix-mismatch");
2404
+ }
2405
+ const prefixCandidate = args.sizing.register(routed.raw, "ride", prefix.lastSize);
2406
+ candidateSizeOwners.set(prefixCandidate, prefix);
2407
+ const size = args.sizing.plan(prefixCandidate);
2408
+ compaction.estimatedTokens = size.tokens;
2409
+ compaction.estimatedBytes = routed.raw.length;
2410
+ compaction.sizeSource = size.source;
2411
+ const overWindow = routedBodyExceedsContext(body, routed.raw, modelContextLimit) ||
2412
+ calibratedSizeExceedsWindow(compaction, body, modelContextLimit);
2413
+ const fallback = overWindow ? undefined : { prefix, routed };
2414
+ if (state.compactNow.has(sessionId))
2415
+ return forced("manual", fallback);
2416
+ if (prefix.modelContextLimit !== modelContextLimit ||
2417
+ (explicitTarget && prefix.targetTokens !== targetTokens)) {
2418
+ return forced("target-change", fallback);
2419
+ }
2420
+ if (size.tokens >= budget.tokens || overWindow)
2421
+ return forced("budget", fallback);
2422
+ return { kind: "ride", prefix, routed };
2423
+ }
2424
+ if (state.compactNow.has(sessionId))
2425
+ return forced("manual");
2426
+ const size = args.sizing.plan(args.sizing.register(forwardBody, "original"));
2427
+ compaction.estimatedTokens = size.tokens;
2428
+ compaction.estimatedBytes = forwardBody.length;
2429
+ compaction.sizeSource = size.source;
2430
+ if (calibratedSizeExceedsLimits(compaction, body, modelContextLimit)) {
2431
+ return forced(compaction.prefixMiss ? "prefix-mismatch" : "budget");
2432
+ }
2433
+ if (state.serverReportsBudget) {
2434
+ // The server measures the request and compresses only past the budget.
2435
+ const threshold = Math.max(SERVER_MIN_TARGET_TOKENS, budget.tokens);
2436
+ compaction.thresholdTokens = threshold;
2437
+ return { kind: "compress", target: targetTokens, threshold, targetTokens, explicitTarget };
2438
+ }
2439
+ // Without calibrated usage or threshold support, let the server decide
2440
+ // against its own model budget rather than forcing from transport bytes.
2441
+ return { kind: "compress", targetTokens, explicitTarget };
2442
+ }
2443
+ /**
2444
+ * Stable-prefix compaction for a tool turn, on every lane: the rule human
2445
+ * turns follow (planEdgeCompaction), minus the server-side threshold call —
2446
+ * a tool turn under the budget makes no compress call at all.
2447
+ *
2448
+ * - The ride: the lane's route (`ride`, already matched), else on the main
2449
+ * thread the session's stable prefix when it still covers this history.
2450
+ * - Its size: estimateRequestTokens anchored on the size Anthropic reported
2451
+ * for the previous request of the same shape (the ride's prefix or route,
2452
+ * else the session's / lane's whole-request size), bytes/4 without one.
2453
+ * - Under the budget (and within the window): ride, or forward whole.
2454
+ * - At the budget: compress, forcing the target only with calibrated usage; on the
2455
+ * main thread the result becomes the stable prefix later tool AND human
2456
+ * turns ride.
2457
+ *
2458
+ * Fills rec.compaction.
2459
+ */
2460
+ function planToolCompaction(args) {
2461
+ const { opts, state, body, messages, sessionId, modelContextLimit, forwardBody, rec } = args;
2462
+ const mode = compactionMode(opts, state, sessionId);
2463
+ const budget = resolveBudget(opts, state, body.model, modelContextLimit);
2464
+ const compaction = {
2465
+ mode: mode.mode,
2466
+ budgetTokens: budget.tokens,
2467
+ budgetSource: budget.source,
2468
+ };
2469
+ rec.compaction = compaction;
2470
+ if (mode.mode === "off")
2471
+ return { kind: "off" };
2472
+ const targetTokens = compactionTarget(mode, budget.tokens);
2473
+ compaction.targetTokens = targetTokens;
2474
+ const stableLane = args.isMainRequest && sessionId !== undefined;
2475
+ const current = stableLane ? state.stablePrefixes.get(sessionId) : undefined;
2476
+ let ride = args.ride;
2477
+ let overWindow = false;
2478
+ if (!ride && current && !prefixMismatch(body, messages, current, sessionId, stablePrefixMessageHash)) {
2479
+ const routed = prefixRoutedBody(body, messages, current);
2480
+ if (routed) {
2481
+ const prefixCandidate = args.sizing.register(routed.raw, "ride", current.lastSize);
2482
+ candidateSizeOwners.set(prefixCandidate, current);
2483
+ if (routedBodyExceedsContext(body, routed.raw, modelContextLimit))
2484
+ overWindow = true;
2485
+ else
2486
+ ride = { raw: routed.raw, turnType: "tool-prefix", sizeHolder: current };
2487
+ }
2488
+ }
2489
+ if (!ride) {
2490
+ const originalSize = args.sizing.plan(args.sizing.register(forwardBody, "original"));
2491
+ overWindow ||= originalSize.tokens + Math.max(0, body.max_tokens ?? 0) > modelContextLimit;
2492
+ }
2493
+ const sample = ride
2494
+ ? ride.sizeHolder.lastSize
2495
+ : stableLane
2496
+ ? state.passthroughSizes.get(sessionId)
2497
+ : state.laneSizes.get(args.sizeKey);
2498
+ const candidate = args.sizing.register(ride?.raw ?? forwardBody, ride ? "ride" : "original", sample);
2499
+ if (ride)
2500
+ candidateSizeOwners.set(candidate, ride.sizeHolder);
2501
+ const size = args.sizing.plan(candidate);
2502
+ compaction.estimatedTokens = size.tokens;
2503
+ compaction.estimatedBytes = (ride?.raw ?? forwardBody).length;
2504
+ compaction.sizeSource = size.source;
2505
+ overWindow ||= !fitsNative(size.tokens, { budget: budget.tokens, context: modelContextLimit,
2506
+ output: typeof body.max_tokens === "number" ? Math.max(0, body.max_tokens) : 0 });
2507
+ if (!args.canCompress) {
2508
+ if (overWindow)
2509
+ return { kind: "pass", original: true };
2510
+ return ride ? { kind: "ride", ride } : { kind: "pass" };
2511
+ }
2512
+ if (size.tokens < budget.tokens && !overWindow) {
2513
+ return ride ? { kind: "ride", ride } : { kind: "pass" };
2514
+ }
2515
+ compaction.reason = "budget";
2516
+ return {
2517
+ kind: "compress",
2518
+ ...budgetCompaction(state, compaction, targetTokens, body, modelContextLimit),
2519
+ targetTokens,
2520
+ explicitTarget: mode.mode === "explicit",
2521
+ estimateTokens: size.tokens,
2522
+ budgetTokens: budget.tokens,
2523
+ overWindow,
2524
+ ...(ride && !overWindow ? { fallback: ride } : {}),
2525
+ ...(stableLane ? { replaces: current ?? null } : {}),
2526
+ };
2527
+ }
2528
+ function memoryRoutedToolBody(body, messages, route, routeEpoch, sessionId) {
2529
+ // Model is deliberately not route identity: Claude Code switches models
2530
+ // mid-loop (overload fallback, /model, /fast), and the compressed prefix is
2531
+ // plain message content valid for any model. Session + prefix hashes pin the
2532
+ // conversation; requiring model equality dropped the whole tool loop to
2533
+ // full-history passthrough on every mid-turn switch.
2534
+ if (!sessionId ||
2535
+ route.sessionId !== sessionId ||
2536
+ route.routeEpoch !== routeEpoch ||
2537
+ routeValueHash(normalizeRouteSystem(body.system)) !==
2538
+ route.originalSystemHash) {
2539
+ return null;
2540
+ }
2541
+ const prefixLength = route.originalPrefixHashes.length;
2542
+ if (messages.length < prefixLength)
2543
+ return null;
2544
+ for (let i = 0; i < prefixLength; i++) {
2545
+ if (routeMessageHash(messages[i]) !== route.originalPrefixHashes[i]) {
2546
+ return null;
2547
+ }
2548
+ }
2549
+ const suffix = messages.slice(prefixLength);
2550
+ if (suffix.length && !validToolRouteSuffix(suffix) && !isNonToolUserMessage(suffix[suffix.length - 1]))
2551
+ return null;
2552
+ const routed = {
2553
+ ...body,
2554
+ messages: [...cloneJson(route.compressedMessages), ...suffix],
2555
+ };
2556
+ if (route.hasCompressedSystem) {
2557
+ routed.system = currentRouteSystem(route.compressedSystem, body.system);
2558
+ }
2559
+ else {
2560
+ delete routed.system;
2561
+ }
2562
+ if (!capCacheBreakpoints(routed, route.compressedMessages.length, { preserveSystem: true }))
2563
+ return null;
2564
+ if (!validCacheTtlOrder(routed))
2565
+ return null;
2566
+ return Buffer.from(JSON.stringify(routed), "utf-8");
2567
+ }
2568
+ /** Size the assembled input (including system/tools) and reserve output room. */
2569
+ function routedBodyExceedsContext(body, routedBody, modelContextLimit) {
2570
+ const outputTokens = typeof body.max_tokens === "number" &&
2571
+ Number.isFinite(body.max_tokens) ? Math.max(0, body.max_tokens) : 0;
2572
+ // This is the existing local byte estimate, not a tokenizer or a hard cap.
2573
+ // Final forwarding checks can replace this estimate with Count Tokens.
2574
+ return approxTokensFromBytes(routedBody.length) + outputTokens > modelContextLimit;
2575
+ }
2576
+ function regrantSmallerWindowRecovery(state, key, modelContextLimit) {
2577
+ const previous = state.toolRecoveryAttemptedLanes.get(key);
2578
+ if (previous && !previous.inFlight && modelContextLimit < previous.modelContextLimit) {
2579
+ // The route has already been evicted. Dropping the attempt mark lifts its
2580
+ // backoff/awaiting-index, and an outage cooldown can defer the new
2581
+ // attempt without losing it; the next actual attempt records the smaller
2582
+ // capacity before it awaits anything.
2583
+ state.toolRecoveryAttemptedLanes.delete(key);
2584
+ }
2585
+ }
2586
+ /** Deterministic system identity. Message identities use cached per-message hashes. */
2587
+ function routeValueHash(value) {
2588
+ return createHash("sha256")
2589
+ .update(JSON.stringify({
2590
+ present: value !== undefined,
2591
+ value: stableRouteValue(value),
2592
+ }))
2593
+ .digest("hex");
2594
+ }
2595
+ /** Canonicalize semantically identical Anthropic content representations. */
2596
+ function normalizeRouteContent(content) {
2597
+ const blocks = typeof content === "string"
2598
+ ? [{ type: "text", text: content }]
2599
+ : content;
2600
+ return withoutContentBlockCacheControl(normalizeReminderContent(blocks));
2601
+ }
2602
+ /**
2603
+ * Claude Code changes request-attribution fields such as `cch` and
2604
+ * `cc_prev_req` during a tool loop. That synthetic top-level system block is
2605
+ * not conversation identity. Ignore exactly one standalone billing block
2606
+ * there, while keeping header-like text in messages fully identity-bearing.
2607
+ */
2608
+ function normalizeRouteSystem(system) {
2609
+ const normalized = normalizeRouteContent(system);
2610
+ const headers = routeBillingHeaders(normalized);
2611
+ if (headers.length !== 1)
2612
+ return normalized;
2613
+ return replaceSingleRouteBillingHeader(normalized, ROUTE_BILLING_HEADER_PLACEHOLDER);
2614
+ }
2615
+ function normalizeReminderContent(content) {
2616
+ if (typeof content === "string")
2617
+ return normalizeRouteText(content);
2618
+ if (!Array.isArray(content))
2619
+ return content;
2620
+ return content.map((part) => {
2621
+ if (typeof part === "string")
2622
+ return normalizeRouteText(part);
2623
+ if (!part || typeof part !== "object")
2624
+ return part;
2625
+ const copy = { ...part };
2626
+ if (copy.type === "text" && typeof copy.text === "string") {
2627
+ copy.text = normalizeRouteText(copy.text);
2628
+ }
2629
+ else if (copy.type === "tool_result") {
2630
+ copy.content = normalizeReminderContent(copy.content);
2631
+ }
2632
+ return copy;
2633
+ });
2634
+ }
2635
+ const ROUTE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
2636
+ const ROUTE_BILLING_HEADER_PLACEHOLDER = "x-anthropic-billing-header: <dynamic>";
2637
+ function normalizeRouteText(text) {
2638
+ return stripSystemReminderText(text);
2639
+ }
2640
+ /** Preserve Claude's current per-request billing metadata after prefix grafting. */
2641
+ function currentRouteSystem(compressedSystem, currentSystem) {
2642
+ const currentHeaders = routeBillingHeaders(currentSystem);
2643
+ const compressedHeaders = routeBillingHeaders(compressedSystem);
2644
+ if (currentHeaders.length === 1 && compressedHeaders.length === 1) {
2645
+ return replaceSingleRouteBillingHeader(cloneJson(compressedSystem), currentHeaders[0]);
2646
+ }
2647
+ // The one-synthetic-header invariant broke (Claude reordered the header
2648
+ // fields, or the compressed system carries duplicate header-like blocks).
2649
+ // Never replay the FIRST request's stale cch/cc_prev_req attribution for
2650
+ // every tool call in the turn: drop the recognizable billing headers from
2651
+ // the routed system instead. Missing attribution is safer than wrong
2652
+ // attribution.
2653
+ const stripped = withoutRouteBillingHeaders(cloneJson(compressedSystem));
2654
+ // JSON.stringify omits undefined-valued keys, so an all-header system is
2655
+ // sent with no `system` field rather than an empty block list.
2656
+ return Array.isArray(stripped) && stripped.length === 0
2657
+ ? undefined
2658
+ : stripped;
2659
+ }
2660
+ function routeBillingHeaders(value) {
2661
+ if (typeof value === "string") {
2662
+ return isRouteBillingHeader(value) ? [value] : [];
2663
+ }
2664
+ if (!Array.isArray(value))
2665
+ return [];
2666
+ const headers = [];
2667
+ for (const item of value) {
2668
+ if (typeof item === "string") {
2669
+ if (isRouteBillingHeader(item))
2670
+ headers.push(item);
2671
+ continue;
2672
+ }
2673
+ if (item &&
2674
+ typeof item === "object" &&
2675
+ item.type === "text") {
2676
+ const text = item.text;
2677
+ if (typeof text === "string" && isRouteBillingHeader(text)) {
2678
+ headers.push(text);
2679
+ }
2680
+ }
2681
+ }
2682
+ return headers;
2683
+ }
2684
+ function isRouteBillingHeader(text) {
2685
+ if (/[\r\n]/.test(text) ||
2686
+ !text.startsWith(ROUTE_BILLING_HEADER_PREFIX)) {
2687
+ return false;
2688
+ }
2689
+ const fields = text.slice(ROUTE_BILLING_HEADER_PREFIX.length).trim();
2690
+ return (/^cc_version=[^;]+;/.test(fields) &&
2691
+ /(?:^|;\s*)cc_entrypoint=[^;]+;/.test(fields));
2692
+ }
2693
+ function replaceSingleRouteBillingHeader(value, replacement) {
2694
+ if (typeof value === "string") {
2695
+ return isRouteBillingHeader(value) ? replacement : value;
2696
+ }
2697
+ if (Array.isArray(value)) {
2698
+ return value.map((item) => {
2699
+ if (typeof item === "string") {
2700
+ return isRouteBillingHeader(item) ? replacement : item;
2701
+ }
2702
+ if (!item ||
2703
+ typeof item !== "object" ||
2704
+ item.type !== "text") {
2705
+ return item;
2706
+ }
2707
+ const block = item;
2708
+ return typeof block.text === "string" &&
2709
+ isRouteBillingHeader(block.text)
2710
+ ? { ...block, text: replacement }
2711
+ : item;
2712
+ });
2713
+ }
2714
+ return value;
2715
+ }
2716
+ /** Remove every recognizable synthetic billing-header block from a system value. */
2717
+ function withoutRouteBillingHeaders(value) {
2718
+ if (typeof value === "string") {
2719
+ return isRouteBillingHeader(value) ? undefined : value;
2720
+ }
2721
+ if (!Array.isArray(value))
2722
+ return value;
2723
+ return value.filter((item) => {
2724
+ if (typeof item === "string")
2725
+ return !isRouteBillingHeader(item);
2726
+ if (!item ||
2727
+ typeof item !== "object" ||
2728
+ item.type !== "text") {
2729
+ return true;
2730
+ }
2731
+ const text = item.text;
2732
+ return !(typeof text === "string" && isRouteBillingHeader(text));
2733
+ });
2734
+ }
2735
+ /** Anthropic's limit on cache_control breakpoints per request. */
2736
+ /**
2737
+ * Claude Code marks its own blocks with cache_control, but the server's
2738
+ * flatten turns the compressed conversation into one plain-string user
2739
+ * message, so a compressed request carried no breakpoint at all: Anthropic
2740
+ * billed every compressed first request in full and cached nothing
2741
+ * (requests.jsonl, 2026-09-26: followup-compressed 51.8M uncached input,
2742
+ * 0.06M cache write; tool-recompressed 133.5M uncached, 0 either way).
2743
+ * Marking the flattened message caches system + tools + compressed history,
2744
+ * and every tool turn that rides the route reads it back.
2745
+ */
2746
+ function withFlattenCacheBreakpoint(body, ttl) {
2747
+ const messages = body.messages;
2748
+ if (messages.length !== 1)
2749
+ return messages;
2750
+ const only = messages[0];
2751
+ const text = typeof only.content === "string" ? only.content : undefined;
2752
+ if (text === undefined)
2753
+ return messages;
2754
+ const cacheControl = { type: "ephemeral" };
2755
+ if (ttl !== undefined)
2756
+ cacheControl.ttl = ttl;
2757
+ return [{ ...only, content: [{ type: "text", text, cache_control: cacheControl }] }];
2758
+ }
2759
+ /**
2760
+ * Match the last existing marker in Anthropic's tools/system/messages order.
2761
+ * For a valid request it is the shortest TTL, so a new final marker cannot
2762
+ * put 1h after a retained 5m marker. Preserve omitted TTLs (default 5m).
2763
+ */
2764
+ function cacheTtlOf(body) {
2765
+ let last;
2766
+ forEachCacheControl(body, (cc) => { last = cc.ttl; });
2767
+ return last;
2768
+ }
2769
+ /** A transformed request must not place a 1h breakpoint after a 5m one. */
2770
+ function validCacheTtlOrder(body) {
2771
+ let sawShort = false;
2772
+ let valid = true;
2773
+ forEachCacheControl(body, (cc) => {
2774
+ if (cc.ttl === "1h") {
2775
+ if (sawShort)
2776
+ valid = false;
2777
+ }
2778
+ else {
2779
+ sawShort = true;
2780
+ }
2781
+ });
2782
+ return valid;
2783
+ }
2784
+ function forEachCacheControl(body, visit) {
2785
+ const blocks = (value) => {
2786
+ if (!Array.isArray(value))
2787
+ return;
2788
+ for (const item of value) {
2789
+ if (item && typeof item === "object" && item.cache_control) {
2790
+ visit(item.cache_control);
2791
+ }
2792
+ }
2793
+ };
2794
+ // Anthropic prompt order matters for mixed cache TTLs.
2795
+ blocks(body.tools);
2796
+ blocks(body.system);
2797
+ if (Array.isArray(body.messages)) {
2798
+ for (const m of body.messages)
2799
+ blocks(m?.content);
2800
+ }
2801
+ }
2802
+ /** Ignore only Anthropic content-block cache metadata, never user/tool data. */
2803
+ function withoutContentBlockCacheControl(content) {
2804
+ if (Array.isArray(content)) {
2805
+ return content.map((part) => withoutContentBlockCacheControl(part));
2806
+ }
2807
+ if (!content || typeof content !== "object")
2808
+ return content;
2809
+ const { cache_control: _cacheControl, ...block } = content;
2810
+ return block;
2811
+ }
2812
+ function stableRouteValue(value) {
2813
+ if (Array.isArray(value))
2814
+ return value.map(stableRouteValue);
2815
+ if (!value || typeof value !== "object")
2816
+ return value;
2817
+ const out = {};
2818
+ for (const key of Object.keys(value).sort()) {
2819
+ out[key] = stableRouteValue(value[key]);
2820
+ }
2821
+ return out;
2822
+ }
2823
+ function validToolRouteSuffix(messages) {
2824
+ if (!messages.length || messages.some(isNonToolUserMessage))
2825
+ return false;
2826
+ const toolUses = new Set();
2827
+ const toolResults = [];
2828
+ for (const message of messages) {
2829
+ if (!Array.isArray(message?.content))
2830
+ continue;
2831
+ for (const part of message.content) {
2832
+ if (!part || typeof part !== "object")
2833
+ continue;
2834
+ if (part.type === "tool_use" && typeof part.id === "string") {
2835
+ toolUses.add(part.id);
2836
+ }
2837
+ else if (part.type === "tool_result" &&
2838
+ typeof part.tool_use_id === "string") {
2839
+ toolResults.push(part.tool_use_id);
2840
+ }
2841
+ }
2842
+ }
2843
+ return (toolUses.size > 0 &&
2844
+ toolResults.length > 0 &&
2845
+ toolResults.every((id) => toolUses.has(id)));
2846
+ }
2847
+ function requestSessionId(req) {
2848
+ const value = req.headers["x-claude-code-session-id"];
2849
+ const text = Array.isArray(value) ? value[0] : value;
2850
+ return typeof text === "string" && text.trim() ? text.trim() : undefined;
2851
+ }
2852
+ function hasAgentAttribution(req) {
2853
+ return agentAttributionId(req) !== undefined;
2854
+ }
2855
+ function cloneJson(value) {
2856
+ if (value === undefined)
2857
+ return value;
2858
+ return JSON.parse(JSON.stringify(value));
2859
+ }
2860
+ /**
2861
+ * The blocking-compression step, shared by the main followup path and
2862
+ * tool-route miss recovery: the compress call plus the `compress` telemetry
2863
+ * record. Callers own downstream-close tracking (compression promises are
2864
+ * hash-deduped and may serve another live retry, so a per-request disconnect
2865
+ * signal must never feed compress()) and all decisions about the returned
2866
+ * result: both `didMemtreeCompress(result)` and
2867
+ * `checkCompressedHistory(result, msgsForMemtree).usable` must hold before
2868
+ * the result may be forwarded or become a route.
2869
+ */
2870
+ async function runBlockingCompression(args) {
2871
+ const { opts, state, body, msgsForMemtree, hash, modelContextLimit, rec } = args;
2872
+ const compaction = args.compaction ??
2873
+ laneCompaction(opts, state, args.sessionId, body.model, modelContextLimit);
2874
+ const messageUsage = args.sessionId !== undefined && opts.transcriptUsage
2875
+ ? opts.transcriptUsage.usageFor(args.sessionId, msgsForMemtree, args.agentId)
2876
+ : undefined;
2877
+ const messageTimes = transcriptTimesFor(opts, args.sessionId, args.agentId)?.(msgsForMemtree);
2878
+ const compressStarted = Date.now();
2879
+ const compressMeta = {
2880
+ // Model + tools drive the server's model-based memory budget
2881
+ // (e.g. 800k whole-request target for Fable / Opus 5). Omitting
2882
+ // them silently downgrades to the server's static 50k fallback.
2883
+ // `[1m]` is re-attached when the session is 1M-context so the
2884
+ // server's budget telemetry names the variant it actually served.
2885
+ model: modelForMemtree(typeof body.model === "string" ? body.model : undefined, modelContextLimit),
2886
+ tools: Array.isArray(body.tools) ? body.tools : undefined,
2887
+ ...(compaction.target !== undefined
2888
+ ? { compressionTargetTokens: compaction.target }
2889
+ : {}),
2890
+ ...(compaction.threshold !== undefined
2891
+ ? { compressionThresholdTokens: compaction.threshold }
2892
+ : {}),
2893
+ // The size this request was judged by, when a plausible sample (reported
2894
+ // usage or an exact count) calibrated it. Sent so the server compresses
2895
+ // whatever the fallback gate would refuse to send uncompressed; a bytes/4
2896
+ // guess is not sent, as it would force needless compression.
2897
+ ...(args.inputCandidate && args.sizing.clientInputTokens(args.inputCandidate) !== undefined
2898
+ ? { clientInputTokens: args.sizing.clientInputTokens(args.inputCandidate) }
2899
+ : {}),
2900
+ ...(messageUsage && Object.keys(messageUsage).length ? { messageUsage } : {}),
2901
+ ...(messageTimes && Object.keys(messageTimes).length ? { messageTimes } : {}),
2902
+ ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
2903
+ ...(args.clientMeta ? { clientMeta: args.clientMeta } : {}),
2904
+ };
2905
+ // Sampled BEFORE the call, while it still describes this call: after the
2906
+ // await the hash is in the cache regardless of who put it there.
2907
+ const cached = opts.memtree.hasCachedCompress(hash, modelContextLimit, compressMeta);
2908
+ // compress() maps every failure to a resolved null (it never rejects).
2909
+ // Keep foreground work finite even if a transport ignores its abort signal.
2910
+ let deadline;
2911
+ const maximumWait = Math.min(30_000, Math.max(1, Number.isFinite(opts.memtree.compressBudgetMs) ? opts.memtree.compressBudgetMs : 15_000));
2912
+ const result = await Promise.race([
2913
+ opts.memtree.compress(hash, msgsForMemtree, modelContextLimit, state.shutdownSignal, compressMeta),
2914
+ new Promise((resolve) => { deadline = setTimeout(() => resolve(null), maximumWait); }),
2915
+ ]).finally(() => { if (deadline)
2916
+ clearTimeout(deadline); });
2917
+ const compressMs = Date.now() - compressStarted;
2918
+ noteServerBudget(state, body.model, modelContextLimit, result);
2919
+ // Sampled at this call's settle, before a concurrent same-request failure of
2920
+ // another class can overwrite the complete-key entry.
2921
+ const failureArming = result === null &&
2922
+ opts.memtree.lastCompressFailureArming(hash, modelContextLimit, compressMeta);
2923
+ const hadLiveSuccess = !cached && result !== null;
2924
+ // Only arming-class live failures (network error, timeout, 5xx, 402) are
2925
+ // fuse evidence. A responsive server's other 4xx failed THIS call but says
2926
+ // nothing about MemTree's health, so it contributes neither failure nor
2927
+ // success — the fuse is untouched.
2928
+ const hadLiveFailure = !cached && result === null && failureArming;
2929
+ rec.compress = {
2930
+ ms: compressMs,
2931
+ ok: result !== null,
2932
+ // Budget-consumed heuristic: the client maps every failure to null, so a
2933
+ // null that took (roughly) the whole abort budget was almost certainly
2934
+ // the AbortSignal timeout, not a fast server error.
2935
+ timedOut: result === null && compressMs >= opts.memtree.compressBudgetMs,
2936
+ };
2937
+ return {
2938
+ result,
2939
+ // Cached-only operations leave the existing fuse state untouched.
2940
+ liveHealth: hadLiveSuccess
2941
+ ? "success"
2942
+ : hadLiveFailure
2943
+ ? "failure"
2944
+ : "none",
2945
+ };
2946
+ }
2947
+ /**
2948
+ * Shared final request shape for a validated compression result: lift any
2949
+ * returned system message to `body.system`, and forward the SERVER's flatten
2950
+ * of the compressed conversation as the single user message Anthropic
2951
+ * receives. The flatten format (closed transcript container, per-human-turn
2952
+ * headers, live-tail framing, header escaping) lives server-side only; the
2953
+ * client forwards `flattened_messages` verbatim and never re-derives it.
2954
+ * Returns null when the server provided no usable flatten (a pre-flatten
2955
+ * server, or a malformed field) — callers degrade to forwarding the original
2956
+ * history. Route candidates must be created from this `compressedBody` —
2957
+ * never from raw `result.messages` — so tool-turn rewrites extend exactly
2958
+ * the bytes that were sent.
2959
+ */
2960
+ function buildCompressedBody(body, result) {
2961
+ const flattened = serverFlattenedMessages(result);
2962
+ if (flattened === null)
2963
+ return null;
2964
+ const systemMsg = result.messages.find((m) => m.role === "system");
2965
+ const compressedBody = {
2966
+ ...body,
2967
+ messages: flattened,
2968
+ };
2969
+ if (systemMsg?.content != null) {
2970
+ compressedBody.system = systemMsg.content;
2971
+ }
2972
+ compressedBody.messages = withFlattenCacheBreakpoint(compressedBody, cacheTtlOf(body));
2973
+ // Reserve a slot for the prefix even when all four original markers were
2974
+ // on retained tools/system blocks. Only this transformed body is capped.
2975
+ if (!capCacheBreakpoints(compressedBody, compressedBody.messages.length))
2976
+ return null;
2977
+ if (!validCacheTtlOrder(compressedBody))
2978
+ return null;
2979
+ return {
2980
+ compressedBody,
2981
+ compressedRaw: Buffer.from(JSON.stringify(compressedBody), "utf-8"),
2982
+ };
2983
+ }
2984
+ /**
2985
+ * A tool turn's compaction (planToolCompaction said its estimated size
2986
+ * reached the budget): one blocking compression forced to the target,
2987
+ * sharing the followup path's complete selection pipeline. This is a soft
2988
+ * fuse — any failure, no-op, unusable, or non-shrinking result degrades to
2989
+ * the body the turn would otherwise have sent (`fallback`: the old route or
2990
+ * prefix ride, recorded as keptPrefix; else the whole history). A validated
2991
+ * smaller result forwards exactly one compressed Anthropic leg, claims no
2992
+ * human-turn state (no notice, no prompt arm/delivery mutation), and at
2993
+ * protocol-complete installs the lane's route and, on the main thread, the
2994
+ * session's new stable prefix (`stable`), so later tool and human turns ride
2995
+ * it with no compress call. An attempt that produced no prefix sets the
2996
+ * lane's backoff (`retryAtTokens`).
2997
+ */
2998
+ async function recoverToolRouteMiss(args) {
2999
+ const { opts, state, req, res, upstream, body, messages, forwardBody, msgsForMemtree, hash, modelContextLimit, routeEpoch, rec, conversationBytes, routeKey, isMainRequest, recoveryAttempt, toolForwarding, stable, fallback, } = args;
3000
+ const backOff = () => {
3001
+ if (args.retryAtTokens !== undefined) {
3002
+ recoveryAttempt.retryAtTokens = args.retryAtTokens;
3003
+ recoveryAttempt.retryDeadline = Date.now() + TOOL_RECOVERY_FAILURE_COOLDOWN_MS;
3004
+ }
3005
+ };
3006
+ const sessionId = requestSessionId(req);
3007
+ const routeOwning = sessionId !== undefined;
3008
+ // Only a route-owning recovery reserves the decision generation. A
3009
+ // transform-only attempt must not advance it — and, reciprocally, its late
3010
+ // completion can never install over (or clear) a route someone else
3011
+ // installed after this miss began, because it never activates at all.
3012
+ const decisionGeneration = routeOwning
3013
+ ? ++state.mainRouteDecisionGeneration
3014
+ : state.mainRouteDecisionGeneration;
3015
+ if (routeOwning)
3016
+ reserveRouteDecision(state, routeKey, decisionGeneration);
3017
+ // A reservation that ends up installing nothing must be RETURNED. It was
3018
+ // taken before the outcome was known, and while held it marks stale every
3019
+ // concurrent install and post-await clear that captured the previous
3020
+ // generation. Failing to release it means a failed/no-op/no-gain/
3021
+ // client-closed recovery silently suppresses a concurrent followup's
3022
+ // install, leaving the conversation with no route at all. Dropping our own
3023
+ // entry is unconditional and order-independent: it never moves anyone
3024
+ // else's reservation, so releasing before or after a newer sibling makes no
3025
+ // difference to who holds the decision.
3026
+ const releaseOwnReservation = () => {
3027
+ if (routeOwning)
3028
+ releaseRouteDecision(state, routeKey, decisionGeneration);
3029
+ };
3030
+ /**
3031
+ * The attempt produced nothing: send what the turn would have sent without
3032
+ * it — the old ride (kept prefix) or the whole history — and back off.
3033
+ */
3034
+ const forwardOriginal = () => {
3035
+ requestSendPolicies.get(req).failedRebuild = true;
3036
+ backOff();
3037
+ if (fallback) {
3038
+ requestSendPolicies.get(req)?.allowed.add(fallback.raw);
3039
+ if (rec.compaction)
3040
+ rec.compaction.keptPrefix = true;
3041
+ recordTurn(rec, fallback.turnType, fallback.raw);
3042
+ capture(opts, "anthropic-request-memory-tool", fallback.raw);
3043
+ return forwardRaw(req, res, fallback.raw, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
3044
+ toolForwarding.settle(delivered);
3045
+ noteRideSize(fallback.sizeHolder, rec, fallback.raw.length);
3046
+ });
3047
+ }
3048
+ recordTurn(rec, "tool", args.originalBody);
3049
+ capture(opts, "anthropic-request", args.originalBody);
3050
+ return forwardRaw(req, res, args.originalBody, opts, upstream, state.shutdownSignal, rec).then((delivered) => {
3051
+ toolForwarding.settle(delivered);
3052
+ noteWholeRequestSize(state, isMainRequest, sessionId, args.sizeKey, rec, args.originalBody.length);
3053
+ });
3054
+ };
3055
+ // Same downstream-close tracking as the followup path: compression
3056
+ // promises are hash-deduped and may serve another live retry, so the
3057
+ // subscriber's lifetime is tracked locally, never fed into compress().
3058
+ let downstreamClosedDuringCompression = res.destroyed && !res.writableFinished;
3059
+ const markDownstreamClosed = () => {
3060
+ if (!res.writableFinished)
3061
+ downstreamClosedDuringCompression = true;
3062
+ };
3063
+ res.once("close", markDownstreamClosed);
3064
+ const linkSeq = isMainRequest ? nextMemtreeCallSeq(state) : undefined;
3065
+ let compression;
3066
+ try {
3067
+ compression = await runBlockingCompression({
3068
+ opts,
3069
+ state,
3070
+ body,
3071
+ msgsForMemtree,
3072
+ hash,
3073
+ modelContextLimit,
3074
+ rec,
3075
+ sessionId: requestSessionId(req),
3076
+ agentId: agentAttributionId(req),
3077
+ clientMeta: args.clientMeta,
3078
+ sizing: args.sizing,
3079
+ inputCandidate: args.inputCandidate,
3080
+ ...(args.compaction ? { compaction: args.compaction } : {}),
3081
+ });
3082
+ }
3083
+ catch {
3084
+ releaseOwnReservation();
3085
+ rec.routeRecovery = { conversationBytes, outcome: "failed" };
3086
+ return forwardOriginal();
3087
+ }
3088
+ finally {
3089
+ res.off("close", markDownstreamClosed);
3090
+ }
3091
+ const { result } = compression;
3092
+ // Every lane writes the shared cooldown now: with subagents on the same
3093
+ // recovery path, a subagent's compress failure is the same evidence about
3094
+ // MemTree's health as main's, and its successes clear the cooldown too.
3095
+ noteMemtreeHealth(state, compression);
3096
+ if (linkSeq !== undefined) {
3097
+ noteMemtreePage(state, linkSeq, requestSessionId(req), result);
3098
+ }
3099
+ if (downstreamClosedDuringCompression ||
3100
+ (res.destroyed && !res.writableFinished)) {
3101
+ // The MemTree work keeps its cache/index value, but a dead client gets
3102
+ // no Anthropic request and no route.
3103
+ rec.routeRecovery = { conversationBytes, outcome: "client-closed" };
3104
+ releaseOwnReservation();
3105
+ // Same hazard class as the post-forward fates "upstream-failed" /
3106
+ // "client-aborted": no route was installed and the client's identical
3107
+ // retry is imminent, so no backoff is set and the attempt mark is
3108
+ // dropped; the retry compresses again, a compress-cache hit (client
3109
+ // closes are deliberately never fed into compress()). Retire only this
3110
+ // attempt so a late settle cannot remove a newer attempt.
3111
+ if (state.toolRecoveryAttemptedLanes.get(routeKey) === recoveryAttempt) {
3112
+ state.toolRecoveryAttemptedLanes.delete(routeKey);
3113
+ }
3114
+ recordTurn(rec, "tool", Buffer.alloc(0));
3115
+ return;
3116
+ }
3117
+ if (!result) {
3118
+ rec.routeRecovery = { conversationBytes, outcome: "failed" };
3119
+ releaseOwnReservation();
3120
+ // An ordinary server/network failure or timeout retains the background
3121
+ // submission: its longer independent budget can still warm the index for
3122
+ // a later turn. An unpaid key (402, possibly set by this very compress)
3123
+ // or a shutting-down proxy gets no retry.
3124
+ if (!state.shutdownSignal.aborted &&
3125
+ opts.memtree.paymentRequiredDetail === null) {
3126
+ opts.memtree.indexInBackground(hash, msgsForMemtree, modelContextLimit, requestSessionId(req), args.clientMeta, transcriptTimesFor(opts, requestSessionId(req), agentAttributionId(req)));
3127
+ }
3128
+ return forwardOriginal();
3129
+ }
3130
+ // Any non-null response already submitted this history to the server; an
3131
+ // extra indexInBackground for the same request would be a duplicate.
3132
+ const actuallyCompressed = didMemtreeCompress(result);
3133
+ const historyCheck = checkCompressedHistory(result, msgsForMemtree);
3134
+ rec.history = {
3135
+ retainedChars: historyCheck.retainedChars,
3136
+ priorHistoryChars: historyCheck.priorHistoryChars,
3137
+ usable: historyCheck.usable,
3138
+ };
3139
+ if (!actuallyCompressed || !historyCheck.usable) {
3140
+ // Index-warming no-op (flattening it would change structured history for
3141
+ // nothing) or an indexed answer that dropped the conversation (amnesia —
3142
+ // fatal for a route). Both preserve the original body.
3143
+ rec.routeRecovery = {
3144
+ conversationBytes,
3145
+ outcome: actuallyCompressed ? "unusable" : "noop",
3146
+ };
3147
+ // No tree yet (nothing indexed): wait for it to be built rather than
3148
+ // retrying on growth, which on a fast-growing loop is every tool turn.
3149
+ const pageId = result.memtreeUrl ? memtreePageId(result.memtreeUrl) : undefined;
3150
+ if (!actuallyCompressed && !(cachedPromptTokenCount(result) ?? 0) && pageId) {
3151
+ recoveryAttempt.awaitingIndex = {
3152
+ pageId,
3153
+ checking: false,
3154
+ deadline: Date.now() + (opts.awaitedIndexWaitTimeoutMs ?? 60_000),
3155
+ };
3156
+ rec.routeRecovery.awaitingIndex = true;
3157
+ }
3158
+ releaseOwnReservation();
3159
+ return forwardOriginal();
3160
+ }
3161
+ // Serializing the compressed body is the one synchronous step here that can
3162
+ // realistically throw: these are the multi-megabyte payloads that court
3163
+ // V8's string-length ceiling, which is exactly why this fuse exists. A
3164
+ // throw would otherwise escape as a proxy 500 AND strand the reservation,
3165
+ // turning a recoverable tool turn into a failed one. Degrade instead — the
3166
+ // original body is still forwardable.
3167
+ let built;
3168
+ try {
3169
+ built = buildCompressedBody(body, result);
3170
+ }
3171
+ catch (err) {
3172
+ rec.routeRecovery = { conversationBytes, outcome: "build-failed" };
3173
+ releaseOwnReservation();
3174
+ if (opts.debug) {
3175
+ console.error(`[ccc proxy] recovered body build failed: ${err?.message ?? err}`);
3176
+ }
3177
+ return forwardOriginal();
3178
+ }
3179
+ if (built === null) {
3180
+ // The server compressed but returned no usable `flattened_messages`
3181
+ // (pre-flatten server, or a malformed field). The client never re-derives
3182
+ // the flatten locally — that drifted from the server's canonical format
3183
+ // once already — so degrade to the original body, same envelope as every
3184
+ // other non-forwardable recovery outcome.
3185
+ rec.routeRecovery = { conversationBytes, outcome: "no-flatten" };
3186
+ releaseOwnReservation();
3187
+ if (opts.debug) {
3188
+ console.error("[ccc proxy] recovered result carried no server flatten; " +
3189
+ "forwarding original body");
3190
+ }
3191
+ return forwardOriginal();
3192
+ }
3193
+ const { compressedBody, compressedRaw } = built;
3194
+ if (!(await requestSendPolicies.get(req).fitsNative(compressedRaw))) {
3195
+ rec.routeRecovery = { conversationBytes, outcome: "unusable" };
3196
+ releaseOwnReservation();
3197
+ return forwardOriginal();
3198
+ }
3199
+ if (compressedRaw.length >= (fallback?.raw ?? forwardBody).length) {
3200
+ // The final transformed body is the proof of payload recovery; a result
3201
+ // with no byte gain over what the turn would send anyway is not worth a
3202
+ // route built on it.
3203
+ rec.routeRecovery = { conversationBytes, outcome: "no-gain" };
3204
+ releaseOwnReservation();
3205
+ return forwardOriginal();
3206
+ }
3207
+ rec.routeRecovery = { conversationBytes, outcome: "compressed" };
3208
+ let activationAttempted = false;
3209
+ let installFate;
3210
+ let installedRoute;
3211
+ const activateRecoveredRoute = () => {
3212
+ if (activationAttempted)
3213
+ return;
3214
+ if (!requestSendPolicies.get(req)?.allowed.has(compressedRaw)) {
3215
+ releaseOwnReservation();
3216
+ activationAttempted = true;
3217
+ return;
3218
+ }
3219
+ if (!routeOwning) {
3220
+ // A sessionless request can use this response but cannot store a reusable route.
3221
+ activationAttempted = true;
3222
+ return;
3223
+ }
3224
+ // Installation checks the same lane-generation guard before any mutation.
3225
+ const installed = installMemoryRoute(state, routeKey, req, body, messages, compressedBody, routeEpoch, decisionGeneration, stable);
3226
+ // Leave this false if installMemoryRoute unexpectedly throws: the
3227
+ // delivery-complete fallback then gets one safe retry.
3228
+ activationAttempted = true;
3229
+ installedRoute = installed;
3230
+ installFate = installed ? "installed" : "stale";
3231
+ // A tool continuation can arrive after message_stop while this HTTP
3232
+ // stream is still draining. Its route is already complete, so a smaller
3233
+ // window may buy recovery now. Mutate only this attempt's object: a late
3234
+ // transport settle must not release a newer attempt in the same lane.
3235
+ if (installed)
3236
+ recoveryAttempt.inFlight = false;
3237
+ if (opts.debug) {
3238
+ console.error(`[ccc proxy] recovered memory route activation: ${installed ? "installed" : "unavailable"}`);
3239
+ }
3240
+ };
3241
+ if (opts.debug) {
3242
+ console.error(`[ccc proxy] tool-route miss recovered: ${forwardBody.length} → ` +
3243
+ `${compressedRaw.length} body bytes`);
3244
+ }
3245
+ recordTurn(rec, "tool-recompressed", compressedRaw);
3246
+ // Tagged as a TOOL memory leg, not a followup one: capture-based tooling
3247
+ // must be able to tell a recovered tool request from a normal compressed
3248
+ // human turn without cross-referencing reqlog.
3249
+ capture(opts, "anthropic-request-memory-tool", compressedRaw);
3250
+ // Set when either activation attempt throws. Protocol-complete firing
3251
+ // means the upstream served the complete turn and the proxy accepted the
3252
+ // message_stop bytes into the response — not that the client received
3253
+ // them. A throw there must NOT fall through to "client-aborted" or
3254
+ // "upstream-failed" at settle: in the common sub-case (fast-tool abort)
3255
+ // the client consumed its answer and no identical-body retry is coming.
3256
+ // The close is not always client-owned — an upstream socket error after
3257
+ // the data chunk carrying message_stop lands here too — and a socket
3258
+ // that dies before the queued bytes flush DOES retry the identical body.
3259
+ // Those rarer closes keep the lane's backoff — an accepted, bounded
3260
+ // degradation (the retry forwards uncompressed until the history grows
3261
+ // past the retry size or the next human turn clears it), because the
3262
+ // closes are indistinguishable at settle time and lifting the backoff
3263
+ // would fund one blocking recompress per tool turn under a deterministic
3264
+ // activation throw.
3265
+ let activationThrew = false;
3266
+ requestSendPolicies.get(req)?.allowed.add(compressedRaw);
3267
+ const delivered = await forwardRaw(req, res, compressedRaw, opts, upstream, state.shutdownSignal, rec,
3268
+ // Protocol-complete (accepted SSE message_stop or a complete 2xx JSON
3269
+ // response) is the real activation point, exactly as on the followup
3270
+ // path, so a fast tool request right after message_stop can ride.
3271
+ () => {
3272
+ try {
3273
+ activateRecoveredRoute();
3274
+ }
3275
+ catch {
3276
+ // forwardRaw's own guard would swallow this anyway; catching here
3277
+ // records that a route was earned but its bookkeeping threw, which
3278
+ // the settle label below must be able to tell apart from a
3279
+ // mid-stream abort or an upstream failure.
3280
+ activationThrew = true;
3281
+ }
3282
+ });
3283
+ toolForwarding.settle(delivered);
3284
+ // Defensive delivery-complete fallback, mirroring the followup path. A
3285
+ // candidate already activated at message_stop deliberately survives a
3286
+ // delivered=false settle (fast-tool abort); an upstream 500/529 or an
3287
+ // incomplete response never reached protocol-complete and never installs.
3288
+ // Swallow a repeat throw: if it escaped here, the settle logic below
3289
+ // would be skipped and the lane's reservation stranded for the rest of
3290
+ // the epoch. With no install fate the fallback below still labels
3291
+ // ("activation-error" for this corner) and releases; the backoff stays,
3292
+ // since the delivered client will not retry.
3293
+ if (delivered) {
3294
+ try {
3295
+ activateRecoveredRoute();
3296
+ }
3297
+ catch {
3298
+ // Fate/release handled by the settle logic below. Remember the throw:
3299
+ // this is a fully delivered response with no route, which must not be
3300
+ // labeled (or have its backoff lifted) as an upstream failure.
3301
+ activationThrew = true;
3302
+ }
3303
+ }
3304
+ rec.routeRecovery.install = !routeOwning
3305
+ ? "no-session"
3306
+ : // No install fate normally means protocol-complete never fired —
3307
+ // split by who owned the close: a client mid-stream abort is not
3308
+ // evidence about upstream health, and the attempt-rate tripwire needs
3309
+ // to count the two separately. The one exception is a turn the
3310
+ // upstream served to protocol-complete whose route bookkeeping threw
3311
+ // at an activation attempt (the protocol-complete attempt, the
3312
+ // delivered retry, or both): label it distinctly so it neither
3313
+ // pollutes the upstream-failure metric nor lifts the backoff. In
3314
+ // the common sub-case the client got its answer (a fast-tool abort
3315
+ // consumed message_stop first) and no identical-body retry is coming;
3316
+ // the rarer closes — a pre-flush socket death (which does retry, into
3317
+ // the lane's backoff) or an upstream-owned error after the accepted
3318
+ // message_stop chunk — land here too. See the activationThrew comment
3319
+ // above for why that trade-off is deliberate.
3320
+ installFate ??
3321
+ (activationThrew
3322
+ ? "activation-error"
3323
+ : rec.clientAborted
3324
+ ? "client-aborted"
3325
+ : "upstream-failed");
3326
+ if (stable)
3327
+ rec.routeRecovery.prefix = stable.installed ? "installed" : "not-installed";
3328
+ // The compacted request's reported size anchors the next budget check.
3329
+ const sizeHolder = stable?.installed ?? installedRoute;
3330
+ if (sizeHolder)
3331
+ notePrefixSize(sizeHolder, rec, compressedRaw.length);
3332
+ // Nothing to ride next turn, or a result that still does not fit the
3333
+ // window (the next turn would reject it and compress again): wait for
3334
+ // growth before the lane tries again.
3335
+ if (!sizeHolder || routedBodyExceedsContext(body, compressedRaw, modelContextLimit))
3336
+ backOff();
3337
+ // A reservation that lost its race (stale) or never reached
3338
+ // protocol-complete (upstream 5xx, truncated stream) installed nothing, so
3339
+ // it must stop suppressing whoever is still trying to install.
3340
+ if (rec.routeRecovery.install !== "installed")
3341
+ releaseOwnReservation();
3342
+ // A failed forward AFTER a healthy compress — upstream 5xx/529 or a client
3343
+ // mid-stream abort — left no route and the client retries the identical
3344
+ // body, so drop the lane's attempt mark, lifting the backoff just set —
3345
+ // identity-guarded so a late settle cannot remove a newer lane attempt.
3346
+ // The retry's recompress is a compress-cache hit, so the re-attempt is
3347
+ // cheap. A later route mismatch deliberately does NOT lift a backoff:
3348
+ // siblings sharing a parent-agent fallback lane genuinely mismatch each
3349
+ // other every turn, and lifting it there would be one real blocking
3350
+ // compress per tool step. The two fates are split in reqlog so the
3351
+ // attempt-rate tripwire can tell client behavior from upstream health;
3352
+ // the backoff lift treats them alike.
3353
+ if ((rec.routeRecovery.install === "upstream-failed" ||
3354
+ rec.routeRecovery.install === "client-aborted") &&
3355
+ state.toolRecoveryAttemptedLanes.get(routeKey) === recoveryAttempt) {
3356
+ state.toolRecoveryAttemptedLanes.delete(routeKey);
3357
+ }
3358
+ }
3359
+ /** Stamp the classified turn type and forwarded-size fields on the record. */
3360
+ function recordTurn(rec, turnType, forwardBody) {
3361
+ rec.turnType = turnType;
3362
+ rec.forwardedBytes = forwardBody.length;
3363
+ rec.approxInputTokens = approxTokensFromBytes(forwardBody.length);
3364
+ }
3365
+ function headerText(headers, name) {
3366
+ const value = headers[name];
3367
+ return Array.isArray(value) ? value.join(",") : value ?? "";
3368
+ }
3369
+ /**
3370
+ * count_tokens: strip notices and mirror an active memory route during its
3371
+ * tool loop. The response itself remains byte-transparent.
3372
+ */
3373
+ async function handleCountTokens(req, res, opts, upstream, state) {
3374
+ const rawBody = await readBody(req);
3375
+ let forwardBody = rawBody;
3376
+ try {
3377
+ const body = JSON.parse(rawBody.toString("utf-8"));
3378
+ if (Array.isArray(body.messages)) {
3379
+ const stripped = stripNoticeBlocks(body.messages);
3380
+ const strippedSystem = stripNoticeSystem(body.system);
3381
+ if (stripped.stripped || strippedSystem.stripped) {
3382
+ body.messages = stripped.messages;
3383
+ if (strippedSystem.system === undefined)
3384
+ delete body.system;
3385
+ else
3386
+ body.system = strippedSystem.system;
3387
+ forwardBody = Buffer.from(JSON.stringify(body), "utf-8");
3388
+ }
3389
+ const lastMsg = lastNonSystemMessage(body.messages);
3390
+ // A tool_result tail is never an away-summary probe, so the lane key
3391
+ // only distinguishes main vs agent identity here.
3392
+ const countRoute = isToolResultUserMessage(lastMsg)
3393
+ ? getMemoryRoute(state, routeIdentity(req, false).key)
3394
+ : undefined;
3395
+ if (countRoute) {
3396
+ const routed = memoryRoutedToolBody(body, body.messages, countRoute, state.mainRouteEpoch, requestSessionId(req));
3397
+ if (routed)
3398
+ forwardBody = routed;
3399
+ }
3400
+ }
3401
+ }
3402
+ catch {
3403
+ // Unknown shape: forward verbatim.
3404
+ }
3405
+ return forwardRaw(req, res, forwardBody, opts, upstream, state.shutdownSignal, undefined).then(() => undefined);
3406
+ }
3407
+ /**
3408
+ * Forward a buffered request and pipe the response back byte-for-byte. A
3409
+ * passive observer parses copies of SSE/JSON chunks for request logging and
3410
+ * completion validation; its output is discarded and can never change the
3411
+ * client response.
3412
+ */
3413
+ /**
3414
+ * Requests whose streamed response gets a text block appended: the hidden
3415
+ * away-summary (recap) request, so the recap Claude Code shows ends with the
3416
+ * MemTree link. Keyed by the incoming request so every forwarding path below
3417
+ * picks it up without threading a parameter through each one.
3418
+ */
3419
+ const recapLinkAppenders = new WeakMap();
3420
+ /** One delivery-time retry, against the assembled body whose exact count exceeded the budget. */
3421
+ async function recoverLateCount(args) {
3422
+ try {
3423
+ const body = JSON.parse(args.bodyBuffer.toString("utf-8"));
3424
+ const messages = normalizeMessagesForMemtree(messagesWithSystem(body.messages, body.system));
3425
+ const budget = resolveBudget(args.opts, args.state, body.model, args.modelContextLimit).tokens;
3426
+ const outcome = await runBlockingCompression({
3427
+ opts: args.opts, state: args.state, body, msgsForMemtree: messages,
3428
+ hash: MemtreeClient.hashMessages(messages), modelContextLimit: args.modelContextLimit,
3429
+ rec: args.rec, sessionId: requestSessionId(args.req), agentId: agentAttributionId(args.req),
3430
+ clientMeta: args.clientMeta, sizing: args.sizing,
3431
+ inputCandidate: args.sizing.register(args.bodyBuffer, "ride"),
3432
+ compaction: { target: compactionTarget(compactionMode(args.opts, args.state, requestSessionId(args.req)), budget) },
3433
+ });
3434
+ noteMemtreeHealth(args.state, outcome);
3435
+ if (!outcome.result || !didMemtreeCompress(outcome.result) ||
3436
+ !checkCompressedHistory(outcome.result, messages).usable)
3437
+ return undefined;
3438
+ const built = buildCompressedBody(body, outcome.result);
3439
+ if (!built)
3440
+ return undefined;
3441
+ return built.compressedRaw;
3442
+ }
3443
+ catch {
3444
+ return undefined;
3445
+ }
3446
+ }
3447
+ /** Select a measured eligible body before delivery; refusing one candidate never
3448
+ * prevents considering the original or a validated prefix retained by planning. */
3449
+ async function selectForwardCandidate(args) {
3450
+ const policy = requestSendPolicies.get(args.req);
3451
+ const intended = policy.failedRebuild && !policy.allowed.has(args.preferredBody)
3452
+ ? policy.original : args.preferredBody;
3453
+ const preferred = policy.sizing.register(intended, policy.allowed.has(intended) ? args.rec.compress ? "replacement" : "ride" : "original");
3454
+ const measured = await policy.sizing.measure(preferred, args.limits);
3455
+ let target = preferred;
3456
+ if (preferred.kind === "ride" && measured.source === "exact" &&
3457
+ measured.tokens > args.limits.budget && !args.rec.compress && !args.rec.lateCountRecovery) {
3458
+ args.rec.lateCountRecovery = { outcome: "forwarded" };
3459
+ const replacement = await policy.recoverLate(preferred.body, measured.tokens);
3460
+ if (replacement)
3461
+ target = policy.sizing.register(replacement, "replacement");
3462
+ }
3463
+ const selection = await policy.sizing.select(target, args.limits);
3464
+ if (!selection) {
3465
+ if (args.rec.lateCountRecovery)
3466
+ args.rec.lateCountRecovery.outcome = "refused";
3467
+ refuseWholeRequest(args, measured.tokens, args.limits.budget);
3468
+ return undefined;
3469
+ }
3470
+ const { candidate, measurement } = selection;
3471
+ const changed = candidate !== preferred || intended !== args.preferredBody;
3472
+ if (changed) {
3473
+ policy.allowed.delete(args.preferredBody);
3474
+ changedDeliveryRecords.add(args.rec);
3475
+ selectedCandidates.set(args.rec, candidate);
3476
+ recordTurn(args.rec, args.rec.turnType, candidate.body);
3477
+ }
3478
+ if (args.rec.lateCountRecovery)
3479
+ args.rec.lateCountRecovery.outcome =
3480
+ changed && candidate.kind === "replacement" ? "compressed" : "forwarded";
3481
+ delete args.rec.overBudgetForward;
3482
+ if (measurement.tokens > args.limits.budget ||
3483
+ (measurement.source !== "exact" && candidate.kind === "original" &&
3484
+ measurement.estimatedTokens > args.limits.budget)) {
3485
+ args.rec.overBudgetForward = { reason: measurement.source === "exact"
3486
+ ? "exact-count-fits-window" : measurement.source === "bytes"
3487
+ ? "bytes-fallback-fits-window" : "estimate-fits-window" };
3488
+ }
3489
+ return changed ? candidate.body : intended;
3490
+ }
3491
+ /** Write the bounded fallback refusal (route-fallback.ts); always false. */
3492
+ function refuseWholeRequest(args, inputTokens, budgetTokens) {
3493
+ const failure = args.state.fallbackFailures.fail(args.routeKey);
3494
+ args.rec.forwardedBytes = 0;
3495
+ args.rec.approxInputTokens = 0;
3496
+ console.error("[ccc proxy] request exceeds native context window; recovery failed", {
3497
+ lane: args.routeKey, attempt: failure.attempt, status: failure.status,
3498
+ inputTokens, outputTokens: args.body.max_tokens ?? 0,
3499
+ budgetTokens, modelContextLimit: args.modelContextLimit,
3500
+ });
3501
+ if (args.res.headersSent || args.res.destroyed)
3502
+ return false;
3503
+ args.res.writeHead(failure.status, { "content-type": "application/json", ...failure.headers });
3504
+ args.res.end(JSON.stringify(failure.body));
3505
+ return false;
3506
+ }
3507
+ const changedDeliveryRecords = new WeakSet();
3508
+ const selectedCandidates = new WeakMap();
3509
+ const candidateSizeOwners = new WeakMap();
3510
+ const requestSendPolicies = new WeakMap();
3511
+ async function forwardRaw(req, res, bodyBuffer, opts, upstream, shutdownSignal, rec, onProtocolComplete) {
3512
+ const policy = requestSendPolicies.get(req);
3513
+ const selected = policy ? await policy.select(bodyBuffer) : bodyBuffer;
3514
+ if (!selected)
3515
+ return false;
3516
+ const delivered = await forwardAccepted(req, res, selected, opts, upstream, shutdownSignal, rec, selected === bodyBuffer ? onProtocolComplete : undefined);
3517
+ if (delivered && rec)
3518
+ noteSelectedCandidateSize(rec);
3519
+ return delivered;
3520
+ }
3521
+ /** Attribute a fallback response only to the body and size owner actually sent. */
3522
+ function noteSelectedCandidateSize(rec) {
3523
+ const candidate = selectedCandidates.get(rec);
3524
+ const owner = candidate && candidateSizeOwners.get(candidate);
3525
+ const tokens = reportedInputTokens(rec);
3526
+ if (!candidate || !owner || tokens === undefined)
3527
+ return;
3528
+ const sample = { tokens, forwardedBytes: candidate.body.length };
3529
+ if (!plausibleSample(sample))
3530
+ return;
3531
+ if ("set" in owner)
3532
+ owner.set(sample);
3533
+ else if (!owner.lastSize || sample.forwardedBytes >= owner.lastSize.forwardedBytes) {
3534
+ owner.lastSize = sample;
3535
+ }
3536
+ }
3537
+ /** forwardRaw once the request's send policy (if any) accepted the body. */
3538
+ function forwardAccepted(req, res, bodyBuffer, opts, upstream, shutdownSignal, rec, onProtocolComplete) {
3539
+ const policy = requestSendPolicies.get(req);
3540
+ return new Promise((resolve) => {
3541
+ const headers = forwardableRequestHeaders(req);
3542
+ headers["content-length"] = String(bodyBuffer.length);
3543
+ const appendRecapText = recapLinkAppenders.get(req);
3544
+ // The passive observer must be able to decode its copy to verify complete
3545
+ // delivery (message_stop for SSE, complete JSON otherwise). Constrain the
3546
+ // negotiated coding to what the observation decoders support, so an
3547
+ // upstream choice like zstd cannot mark a byte-perfectly delivered
3548
+ // response as failed.
3549
+ // The bytes written to the client stay exact.
3550
+ headers["accept-encoding"] = observableAcceptEncoding(headers["accept-encoding"]);
3551
+ // Appending needs plain SSE text; the recap is tiny, so skip compression.
3552
+ if (appendRecapText)
3553
+ headers["accept-encoding"] = "identity";
3554
+ const forwardStarted = Date.now();
3555
+ let settled = false;
3556
+ let upstreamCompleted = false;
3557
+ let responseFinished = false;
3558
+ let protocolComplete = false;
3559
+ let successfulStatus = false;
3560
+ let clientAborted = false;
3561
+ let shutdownCancelled = false;
3562
+ let protocolCompleteNotified = false;
3563
+ let upstreamReq;
3564
+ let activeUpstreamRes;
3565
+ const notifyProtocolComplete = () => {
3566
+ if (protocolCompleteNotified ||
3567
+ !successfulStatus ||
3568
+ onProtocolComplete === undefined) {
3569
+ return;
3570
+ }
3571
+ protocolCompleteNotified = true;
3572
+ try {
3573
+ onProtocolComplete();
3574
+ }
3575
+ catch {
3576
+ // Route/observer bookkeeping can never affect byte delivery.
3577
+ }
3578
+ };
3579
+ const settle = (ok) => {
3580
+ if (settled)
3581
+ return;
3582
+ settled = true;
3583
+ if (ok)
3584
+ policy?.delivered();
3585
+ res.off("finish", onResponseFinish);
3586
+ res.off("close", onResponseClose);
3587
+ shutdownSignal.removeEventListener("abort", cancelForShutdown);
3588
+ resolve(ok);
3589
+ };
3590
+ const maybeSettle = () => {
3591
+ if (upstreamCompleted && responseFinished) {
3592
+ settle(successfulStatus && protocolComplete && !clientAborted);
3593
+ }
3594
+ };
3595
+ const onResponseFinish = () => {
3596
+ responseFinished = true;
3597
+ maybeSettle();
3598
+ };
3599
+ const onResponseClose = () => {
3600
+ if (res.writableFinished || shutdownCancelled)
3601
+ return;
3602
+ clientAborted = true;
3603
+ // Observability only: lets callers (and reqlog consumers) tell a
3604
+ // client-owned close from an upstream failure after the settle.
3605
+ if (rec)
3606
+ rec.clientAborted = true;
3607
+ activeUpstreamRes?.destroy();
3608
+ upstreamReq?.destroy();
3609
+ settle(false);
3610
+ };
3611
+ const completeUpstream = (complete) => {
3612
+ upstreamCompleted = true;
3613
+ protocolComplete = complete;
3614
+ if (complete && !res.destroyed)
3615
+ notifyProtocolComplete();
3616
+ if (res.destroyed && !res.writableFinished && !shutdownCancelled) {
3617
+ // The destroy is client-owned here: upstream ended cleanly (its
3618
+ // error/aborted handlers settle synchronously before this can run)
3619
+ // and the shutdownCancelled guard excludes the proxy-owned destroy —
3620
+ // on the decoder path, decoder.end() defers finish() a tick, and a
3621
+ // shutdown landing in that gap would otherwise be stamped as a
3622
+ // client abort (onResponseClose has the same guard). Stamp directly —
3623
+ // this settle detaches onResponseClose before the 'close' event that
3624
+ // normally stamps can fire, and losing that race would misclassify a
3625
+ // client abort as "upstream-failed" in the recovery settle.
3626
+ clientAborted = true;
3627
+ if (rec)
3628
+ rec.clientAborted = true;
3629
+ settle(false);
3630
+ return;
3631
+ }
3632
+ maybeSettle();
3633
+ };
3634
+ const cancelForShutdown = () => {
3635
+ if (settled || shutdownCancelled)
3636
+ return;
3637
+ shutdownCancelled = true;
3638
+ // Teardown is proxy-owned. Detach the ordinary client-close classifier
3639
+ // before destroying either side of the pipe.
3640
+ res.off("close", onResponseClose);
3641
+ activeUpstreamRes?.destroy();
3642
+ upstreamReq?.destroy();
3643
+ if (!res.destroyed && !res.writableEnded)
3644
+ res.destroy();
3645
+ settle(false);
3646
+ };
3647
+ res.once("finish", onResponseFinish);
3648
+ res.once("close", onResponseClose);
3649
+ shutdownSignal.addEventListener("abort", cancelForShutdown, { once: true });
3650
+ if (shutdownSignal.aborted) {
3651
+ cancelForShutdown();
3652
+ return;
3653
+ }
3654
+ upstreamReq = upstream.module.request({
3655
+ host: upstream.host,
3656
+ port: upstream.port,
3657
+ method: req.method,
3658
+ path: req.url, // path + query string verbatim (?beta=true etc.)
3659
+ headers,
3660
+ }, (upstreamRes) => {
3661
+ activeUpstreamRes = upstreamRes;
3662
+ const contentType = String(upstreamRes.headers["content-type"] ?? "");
3663
+ const isSse = contentType.includes("text/event-stream");
3664
+ const contentEncoding = String(upstreamRes.headers["content-encoding"] ?? "identity").trim().toLowerCase();
3665
+ const compressed = contentEncoding !== "" && contentEncoding !== "identity";
3666
+ let sawFirstByte = false;
3667
+ let sawMessageStop = false;
3668
+ let observerFailed = false;
3669
+ const observeSseEvent = (data) => {
3670
+ if (rec) {
3671
+ mergeUsageFromSseEvent(data, rec);
3672
+ if (rec.firstContentMs === undefined &&
3673
+ data?.type === "content_block_delta") {
3674
+ rec.firstContentMs = Date.now() - forwardStarted;
3675
+ }
3676
+ }
3677
+ if (data?.type === "message_stop") {
3678
+ sawMessageStop = true;
3679
+ }
3680
+ };
3681
+ // Rewrite only a plain, successful SSE stream; anything else passes
3682
+ // through untouched and simply carries no link.
3683
+ const appendText = appendRecapText &&
3684
+ isSse &&
3685
+ !compressed &&
3686
+ (upstreamRes.statusCode ?? 0) >= 200 &&
3687
+ (upstreamRes.statusCode ?? 0) < 300
3688
+ ? appendRecapText
3689
+ : undefined;
3690
+ const sseObserver = isSse
3691
+ ? new SseNoticeRewriter({
3692
+ onEvent: observeSseEvent,
3693
+ ...(appendText ? { endOfTurnText: appendText } : {}),
3694
+ })
3695
+ : null;
3696
+ const incrementalDecoder = sseObserver && compressed
3697
+ ? createObservationDecoder(contentEncoding)
3698
+ : null;
3699
+ const observedChunks = !isSse || (compressed && !incrementalDecoder) ? [] : null;
3700
+ const observeRawChunk = (chunk) => {
3701
+ if (rec && !sawFirstByte) {
3702
+ sawFirstByte = true;
3703
+ rec.ttfbMs = Date.now() - forwardStarted;
3704
+ }
3705
+ if (sseObserver && !compressed)
3706
+ sseObserver.push(chunk);
3707
+ if (observedChunks)
3708
+ observedChunks.push(Buffer.from(chunk));
3709
+ };
3710
+ if (rec) {
3711
+ rec.upstreamStatus = upstreamRes.statusCode ?? 502;
3712
+ }
3713
+ successfulStatus =
3714
+ typeof upstreamRes.statusCode === "number" &&
3715
+ upstreamRes.statusCode >= 200 &&
3716
+ upstreamRes.statusCode < 300;
3717
+ res.writeHead(upstreamRes.statusCode ?? 502, forwardableResponseHeaders(upstreamRes));
3718
+ if (incrementalDecoder && sseObserver) {
3719
+ // Gate each encoded SSE chunk on locally decoding its copy. This
3720
+ // guarantees message_start usage is recorded before the identical
3721
+ // gzip/Brotli/deflate bytes can trigger Claude's MessageDisplay hook.
3722
+ // Only the original bytes are written to the client.
3723
+ let decoderFailed = false;
3724
+ let pendingForward = null;
3725
+ let pendingFinish = null;
3726
+ incrementalDecoder.on("data", (chunk) => {
3727
+ if (!decoderFailed)
3728
+ sseObserver.push(chunk);
3729
+ });
3730
+ incrementalDecoder.on("error", () => {
3731
+ decoderFailed = true;
3732
+ observerFailed = true;
3733
+ const forward = pendingForward;
3734
+ pendingForward = null;
3735
+ forward?.();
3736
+ const finish = pendingFinish;
3737
+ pendingFinish = null;
3738
+ finish?.();
3739
+ });
3740
+ res.once("close", () => incrementalDecoder.destroy());
3741
+ const forwardEncoded = (chunk) => {
3742
+ if (res.destroyed || res.writableEnded) {
3743
+ upstreamRes.destroy();
3744
+ return false;
3745
+ }
3746
+ if (res.write(chunk))
3747
+ upstreamRes.resume();
3748
+ else
3749
+ res.once("drain", () => upstreamRes.resume());
3750
+ return true;
3751
+ };
3752
+ upstreamRes.on("data", (chunk) => {
3753
+ upstreamRes.pause();
3754
+ observeRawChunk(chunk);
3755
+ if (decoderFailed) {
3756
+ forwardEncoded(chunk);
3757
+ return;
3758
+ }
3759
+ let forwarded = false;
3760
+ let accepted = false;
3761
+ const forwardOnce = () => {
3762
+ if (forwarded)
3763
+ return accepted;
3764
+ forwarded = true;
3765
+ accepted = forwardEncoded(chunk);
3766
+ return accepted;
3767
+ };
3768
+ pendingForward = forwardOnce;
3769
+ try {
3770
+ incrementalDecoder.write(chunk, (err) => {
3771
+ if (err) {
3772
+ decoderFailed = true;
3773
+ observerFailed = true;
3774
+ }
3775
+ if (pendingForward === forwardOnce)
3776
+ pendingForward = null;
3777
+ const chunkAccepted = forwardOnce();
3778
+ if (chunkAccepted &&
3779
+ !decoderFailed &&
3780
+ sawMessageStop) {
3781
+ notifyProtocolComplete();
3782
+ }
3783
+ });
3784
+ }
3785
+ catch {
3786
+ decoderFailed = true;
3787
+ observerFailed = true;
3788
+ if (pendingForward === forwardOnce)
3789
+ pendingForward = null;
3790
+ forwardOnce();
3791
+ }
3792
+ });
3793
+ upstreamRes.on("end", () => {
3794
+ let finished = false;
3795
+ const finish = () => {
3796
+ if (finished)
3797
+ return;
3798
+ finished = true;
3799
+ pendingFinish = null;
3800
+ sseObserver.flush();
3801
+ if (!res.destroyed && !res.writableEnded)
3802
+ res.end();
3803
+ completeUpstream(!observerFailed && sawMessageStop);
3804
+ };
3805
+ if (decoderFailed) {
3806
+ finish();
3807
+ return;
3808
+ }
3809
+ try {
3810
+ pendingFinish = finish;
3811
+ incrementalDecoder.end(finish);
3812
+ }
3813
+ catch {
3814
+ finish();
3815
+ }
3816
+ });
3817
+ upstreamRes.on("error", () => {
3818
+ incrementalDecoder.destroy();
3819
+ res.destroy();
3820
+ settle(false);
3821
+ });
3822
+ upstreamRes.on("aborted", () => {
3823
+ incrementalDecoder.destroy();
3824
+ res.destroy();
3825
+ settle(false);
3826
+ });
3827
+ return;
3828
+ }
3829
+ if (appendText && sseObserver) {
3830
+ // Same observation as below, but the client gets the rewriter's
3831
+ // output (original frames plus the appended block) instead of the
3832
+ // raw bytes. Backpressure mirrors pipe().
3833
+ upstreamRes.on("data", (chunk) => {
3834
+ if (rec && !sawFirstByte) {
3835
+ sawFirstByte = true;
3836
+ rec.ttfbMs = Date.now() - forwardStarted;
3837
+ }
3838
+ const out = sseObserver.push(chunk);
3839
+ if (!out || res.destroyed || res.writableEnded)
3840
+ return;
3841
+ if (!res.write(out)) {
3842
+ upstreamRes.pause();
3843
+ res.once("drain", () => upstreamRes.resume());
3844
+ }
3845
+ if (sawMessageStop && !observerFailed && !res.destroyed) {
3846
+ notifyProtocolComplete();
3847
+ }
3848
+ });
3849
+ upstreamRes.on("end", () => {
3850
+ const rest = sseObserver.flush();
3851
+ if (rest && !res.destroyed && !res.writableEnded)
3852
+ res.write(rest);
3853
+ if (!res.destroyed && !res.writableEnded)
3854
+ res.end();
3855
+ completeUpstream(!observerFailed && sawMessageStop);
3856
+ });
3857
+ upstreamRes.on("error", () => {
3858
+ res.destroy();
3859
+ settle(false);
3860
+ });
3861
+ upstreamRes.on("aborted", () => {
3862
+ res.destroy();
3863
+ settle(false);
3864
+ });
3865
+ return;
3866
+ }
3867
+ upstreamRes.on("data", observeRawChunk);
3868
+ upstreamRes.pipe(res);
3869
+ if (isSse && !compressed) {
3870
+ // Registered after pipe(), so this runs only after the raw chunk
3871
+ // containing the complete message_stop frame has been accepted by
3872
+ // ServerResponse. The callback still runs synchronously before a
3873
+ // client can issue the resulting tool request.
3874
+ upstreamRes.on("data", () => {
3875
+ if (sawMessageStop && !observerFailed && !res.destroyed) {
3876
+ notifyProtocolComplete();
3877
+ }
3878
+ });
3879
+ }
3880
+ upstreamRes.on("end", () => {
3881
+ let observed = null;
3882
+ if (observedChunks) {
3883
+ observed = decodeForObservation(Buffer.concat(observedChunks), contentEncoding);
3884
+ if (rec && observed && !isSse) {
3885
+ mergeUsageFromJsonBody(observed, rec);
3886
+ }
3887
+ }
3888
+ if (isSse) {
3889
+ if (compressed && !incrementalDecoder) {
3890
+ if (observed)
3891
+ sseObserver?.push(observed);
3892
+ else
3893
+ observerFailed = true;
3894
+ }
3895
+ sseObserver?.flush();
3896
+ completeUpstream(!observerFailed && sawMessageStop);
3897
+ }
3898
+ else {
3899
+ completeUpstream(observed !== null && isCompleteJsonResponse(req.url, observed));
3900
+ }
3901
+ });
3902
+ upstreamRes.on("error", () => {
3903
+ res.destroy();
3904
+ settle(false);
3905
+ });
3906
+ upstreamRes.on("aborted", () => {
3907
+ res.destroy();
3908
+ settle(false);
3909
+ });
3910
+ });
3911
+ upstreamReq.on("error", (err) => {
3912
+ if (!shutdownCancelled) {
3913
+ sendAnthropicError(res, `upstream connection failed: ${err.message}`);
3914
+ }
3915
+ settle(false);
3916
+ });
3917
+ upstreamReq.end(bodyBuffer);
3918
+ });
3919
+ }
3920
+ function isCompleteJsonResponse(requestUrl, body) {
3921
+ try {
3922
+ const parsed = JSON.parse(body.toString("utf-8"));
3923
+ const pathname = new URL(requestUrl ?? "/", "http://127.0.0.1").pathname;
3924
+ if (pathname.endsWith("/count_tokens")) {
3925
+ return (typeof parsed?.input_tokens === "number" &&
3926
+ Number.isFinite(parsed.input_tokens) &&
3927
+ parsed.input_tokens >= 0);
3928
+ }
3929
+ return parsed?.type === "message" && Array.isArray(parsed.content);
3930
+ }
3931
+ catch {
3932
+ return false;
3933
+ }
3934
+ }
3935
+ /** Transparent streaming passthrough for everything else. */
3936
+ function passThroughStreaming(req, res, upstream, shutdownSignal) {
3937
+ return new Promise((resolve) => {
3938
+ const headers = forwardableRequestHeaders(req);
3939
+ // The body is piped unmodified here, so keep the client's original
3940
+ // content-length (SKIP_REQUEST_HEADERS strips it for the buffered paths,
3941
+ // which recompute it); dropping it would silently convert the request to
3942
+ // chunked transfer-encoding.
3943
+ if (req.headers["content-length"] !== undefined) {
3944
+ headers["content-length"] = req.headers["content-length"];
3945
+ }
3946
+ let settled = false;
3947
+ let shutdownCancelled = false;
3948
+ let incomingUploadEnded = req.readableEnded;
3949
+ let upstreamUploadFinished = false;
3950
+ let upstreamResponseEnded = false;
3951
+ let downstreamFinished = res.writableFinished;
3952
+ let pendingErrorResponse = false;
3953
+ let upstreamReq;
3954
+ let activeUpstreamRes;
3955
+ const settle = () => {
3956
+ if (settled)
3957
+ return;
3958
+ settled = true;
3959
+ shutdownSignal.removeEventListener("abort", cancelForShutdown);
3960
+ resolve();
3961
+ };
3962
+ /** Clean completion owns all four independently asynchronous seams. */
3963
+ const settleIfComplete = () => {
3964
+ if (incomingUploadEnded &&
3965
+ upstreamUploadFinished &&
3966
+ upstreamResponseEnded &&
3967
+ downstreamFinished) {
3968
+ settle();
3969
+ }
3970
+ };
3971
+ /** Every abnormal exit tears down the whole duplex exchange exactly once. */
3972
+ const tearDown = () => {
3973
+ if (settled)
3974
+ return;
3975
+ // An early upstream response can mark ClientRequest/IncomingMessage as
3976
+ // destroyed while their keep-alive socket still awaits the rest of the
3977
+ // upload. Capture both transports before stream teardown and close them
3978
+ // explicitly so server.close() cannot inherit a half-owned connection.
3979
+ const downstreamSocket = req.socket;
3980
+ const upstreamSocket = activeUpstreamRes?.socket ?? upstreamReq?.socket;
3981
+ activeUpstreamRes?.unpipe(res);
3982
+ if (upstreamReq)
3983
+ req.unpipe(upstreamReq);
3984
+ // Mark settled before destroy(): close/error events can fire reentrantly.
3985
+ settle();
3986
+ if (!req.destroyed)
3987
+ req.destroy();
3988
+ if (upstreamReq && !upstreamReq.destroyed)
3989
+ upstreamReq.destroy();
3990
+ if (activeUpstreamRes && !activeUpstreamRes.destroyed) {
3991
+ activeUpstreamRes.destroy();
3992
+ }
3993
+ if (!res.destroyed)
3994
+ res.destroy();
3995
+ if (upstreamSocket && !upstreamSocket.destroyed)
3996
+ upstreamSocket.destroy();
3997
+ if (!downstreamSocket.destroyed)
3998
+ downstreamSocket.destroy();
3999
+ };
4000
+ const onIncomingEnd = () => {
4001
+ incomingUploadEnded = true;
4002
+ settleIfComplete();
4003
+ };
4004
+ const onIncomingClose = () => {
4005
+ if (settled)
4006
+ return;
4007
+ if (req.complete) {
4008
+ incomingUploadEnded = true;
4009
+ settleIfComplete();
4010
+ }
4011
+ else {
4012
+ tearDown();
4013
+ }
4014
+ };
4015
+ const onDownstreamFinish = () => {
4016
+ downstreamFinished = true;
4017
+ if (pendingErrorResponse)
4018
+ tearDown();
4019
+ else
4020
+ settleIfComplete();
4021
+ };
4022
+ const onResponseClose = () => {
4023
+ if (settled)
4024
+ return;
4025
+ if (res.writableFinished) {
4026
+ downstreamFinished = true;
4027
+ if (pendingErrorResponse)
4028
+ tearDown();
4029
+ else
4030
+ settleIfComplete();
4031
+ }
4032
+ else {
4033
+ tearDown();
4034
+ }
4035
+ };
4036
+ const cancelForShutdown = () => {
4037
+ if (settled || shutdownCancelled)
4038
+ return;
4039
+ shutdownCancelled = true;
4040
+ tearDown();
4041
+ };
4042
+ req.once("end", onIncomingEnd);
4043
+ req.once("aborted", tearDown);
4044
+ req.once("error", tearDown);
4045
+ req.once("close", onIncomingClose);
4046
+ res.once("finish", onDownstreamFinish);
4047
+ res.once("error", tearDown);
4048
+ res.once("close", onResponseClose);
4049
+ shutdownSignal.addEventListener("abort", cancelForShutdown, { once: true });
4050
+ if (shutdownSignal.aborted) {
4051
+ cancelForShutdown();
4052
+ return;
4053
+ }
4054
+ upstreamReq = upstream.module.request({
4055
+ host: upstream.host,
4056
+ port: upstream.port,
4057
+ method: req.method,
4058
+ path: req.url,
4059
+ headers,
4060
+ }, (upstreamRes) => {
4061
+ if (settled) {
4062
+ upstreamRes.destroy();
4063
+ return;
4064
+ }
4065
+ activeUpstreamRes = upstreamRes;
4066
+ const onUpstreamResponseEnd = () => {
4067
+ upstreamResponseEnded = true;
4068
+ settleIfComplete();
4069
+ };
4070
+ const onUpstreamResponseClose = () => {
4071
+ if (!settled && !upstreamResponseEnded)
4072
+ tearDown();
4073
+ };
4074
+ upstreamRes.once("end", onUpstreamResponseEnd);
4075
+ upstreamRes.once("error", tearDown);
4076
+ upstreamRes.once("aborted", tearDown);
4077
+ upstreamRes.once("close", onUpstreamResponseClose);
4078
+ try {
4079
+ res.writeHead(upstreamRes.statusCode ?? 502, forwardableResponseHeaders(upstreamRes));
4080
+ }
4081
+ catch {
4082
+ tearDown();
4083
+ return;
4084
+ }
4085
+ upstreamRes.pipe(res);
4086
+ });
4087
+ upstreamReq.once("finish", () => {
4088
+ upstreamUploadFinished = true;
4089
+ settleIfComplete();
4090
+ });
4091
+ upstreamReq.once("close", () => {
4092
+ if (!settled && !upstreamUploadFinished && !pendingErrorResponse) {
4093
+ tearDown();
4094
+ }
4095
+ });
4096
+ upstreamReq.on("error", (err) => {
4097
+ if (settled)
4098
+ return;
4099
+ if (shutdownCancelled || res.headersSent || res.destroyed) {
4100
+ tearDown();
4101
+ return;
4102
+ }
4103
+ // Preserve the existing Anthropic-shaped 502 when no upstream bytes
4104
+ // were committed. Ownership remains until that response flushes, then
4105
+ // the still-open incoming upload/socket is torn down as one exchange.
4106
+ pendingErrorResponse = true;
4107
+ if (upstreamReq)
4108
+ req.unpipe(upstreamReq);
4109
+ activeUpstreamRes?.unpipe(res);
4110
+ sendAnthropicError(res, `upstream connection failed: ${err.message}`);
4111
+ if (res.writableFinished)
4112
+ onDownstreamFinish();
4113
+ });
4114
+ try {
4115
+ req.pipe(upstreamReq);
4116
+ }
4117
+ catch {
4118
+ tearDown();
4119
+ }
4120
+ });
4121
+ }
4122
+ function forwardableRequestHeaders(req) {
4123
+ const out = {};
4124
+ for (const [key, value] of Object.entries(req.headers)) {
4125
+ if (SKIP_REQUEST_HEADERS.has(key.toLowerCase()))
4126
+ continue;
4127
+ if (value === undefined)
4128
+ continue;
4129
+ out[key] = Array.isArray(value) ? value.join(", ") : value;
4130
+ }
4131
+ return out;
4132
+ }
4133
+ function forwardableResponseHeaders(upstreamRes) {
4134
+ const out = {};
4135
+ for (const [key, value] of Object.entries(upstreamRes.headers)) {
4136
+ if (SKIP_RESPONSE_HEADERS.has(key.toLowerCase()))
4137
+ continue;
4138
+ if (value === undefined)
4139
+ continue;
4140
+ out[key] = value;
4141
+ }
4142
+ return out;
4143
+ }
4144
+ /** Well-formed Anthropic-shaped error body so Claude Code fails fast, not weird. */
4145
+ function sendAnthropicError(res, message) {
4146
+ if (res.headersSent) {
4147
+ res.destroy();
4148
+ return;
4149
+ }
4150
+ const payload = JSON.stringify({
4151
+ type: "error",
4152
+ error: { type: "api_error", message },
4153
+ });
4154
+ res.writeHead(502, {
4155
+ "content-type": "application/json",
4156
+ "content-length": String(Buffer.byteLength(payload)),
4157
+ });
4158
+ res.end(payload);
4159
+ }
4160
+ let captureCounter = 0;
4161
+ /** Test-only diagnostics: dump forwarded bodies for smoke-test inspection. */
4162
+ function capture(opts, kind, body) {
4163
+ const dir = opts.captureDir;
4164
+ if (!dir)
4165
+ return;
4166
+ try {
4167
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
4168
+ // mkdir does not tighten a directory reused from an earlier run.
4169
+ chmodSync(dir, 0o700);
4170
+ const name = `${String(++captureCounter).padStart(4, "0")}-${kind}-${randomBytes(8).toString("hex")}.json`;
4171
+ // Never reuse an existing file (which may have permissive old modes).
4172
+ writeFileSync(join(dir, name), body, { mode: 0o600, flag: "wx" });
4173
+ }
4174
+ catch {
4175
+ // diagnostics only — never break the proxy path
4176
+ }
4177
+ }
4178
+ function readBody(req) {
4179
+ return readAll(req);
4180
+ }
4181
+ const OBSERVABLE_ENCODINGS = new Set(["gzip", "br", "deflate", "identity"]);
4182
+ /**
4183
+ * Restrict a client Accept-Encoding value to codings the passive observer can
4184
+ * decode (see createObservationDecoder/decodeForObservation). Client tokens
4185
+ * are kept verbatim (q-values included) so negotiation semantics survive —
4186
+ * except q=0 tokens, which the client explicitly refuses and so cannot count
4187
+ * as an acceptable coding; an absent header means "anything", so advertise
4188
+ * the full supported set, and if nothing supported and acceptable remains
4189
+ * fall back to identity, which every client accepts.
4190
+ */
4191
+ const REFUSED_Q_ZERO = /;\s*q\s*=\s*0(?:\.0{0,3})?\s*(?:;|$)/i;
4192
+ function observableAcceptEncoding(clientValue) {
4193
+ if (clientValue === undefined)
4194
+ return "gzip, br, deflate";
4195
+ const kept = clientValue
4196
+ .split(",")
4197
+ .map((token) => token.trim())
4198
+ .filter((token) => OBSERVABLE_ENCODINGS.has(token.split(";", 1)[0].trim().toLowerCase()) &&
4199
+ !REFUSED_Q_ZERO.test(token));
4200
+ return kept.length > 0 ? kept.join(", ") : "identity";
4201
+ }
4202
+ /** Incremental decoder used only to observe a copy of encoded SSE bytes. */
4203
+ function createObservationDecoder(encoding) {
4204
+ if (encoding === "gzip")
4205
+ return createGunzip();
4206
+ if (encoding === "br")
4207
+ return createBrotliDecompress();
4208
+ if (encoding === "deflate")
4209
+ return createInflate();
4210
+ return null;
4211
+ }
4212
+ /** Decode a response copy for diagnostics without ever touching forwarded bytes. */
4213
+ function decodeForObservation(body, encoding) {
4214
+ try {
4215
+ if (!encoding || encoding === "identity")
4216
+ return body;
4217
+ if (encoding === "gzip")
4218
+ return gunzipSync(body);
4219
+ if (encoding === "br")
4220
+ return brotliDecompressSync(body);
4221
+ if (encoding === "deflate")
4222
+ return inflateSync(body);
4223
+ }
4224
+ catch {
4225
+ // Diagnostics only. Unknown/corrupt encodings do not affect proxying.
4226
+ }
4227
+ return null;
4228
+ }
4229
+ function readAll(stream) {
4230
+ return new Promise((resolve, reject) => {
4231
+ const chunks = [];
4232
+ stream.on("data", (c) => chunks.push(c));
4233
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
4234
+ stream.on("error", reject);
4235
+ });
4236
+ }
4237
+ /** Test seam. */
4238
+ export const __testCapCacheBreakpoints = capCacheBreakpoints;
4239
+ export const __testEstimateRequestTokens = estimateRequestTokens;
4240
+ /** Reads message times from the session's transcript, when there is one. */
4241
+ function transcriptTimesFor(opts, sessionId, agentId) {
4242
+ const source = opts.transcriptUsage;
4243
+ if (sessionId === undefined || !source?.timesFor)
4244
+ return undefined;
4245
+ return (messages) => source.timesFor(sessionId, messages, agentId);
4246
+ }
4247
+ //# sourceMappingURL=proxy.js.map