codex-copilot-dx 0.4.30 → 0.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,7 +87,7 @@ Or set:
87
87
  CCDX_CONFIGURE_CLAUDE_DESKTOP=1 npx codex-copilot-dx@latest
88
88
  ```
89
89
 
90
- This writes a local Claude App 3P gateway profile that points to the adapter root URL, such as `http://127.0.0.1:2026`. The profile uses a generated local bearer key unless `CCDX_CLAUDE_DESKTOP_API_KEY` is set. Restart Claude App after running the command.
90
+ This writes a local Claude App 3P gateway profile that points to the adapter root URL, such as `http://127.0.0.1:2026`. The profile uses a generated local bearer key unless `CCDX_CLAUDE_DESKTOP_API_KEY` is set. Later starts restore that key only from the active managed profile when its gateway URL matches the adapter. Restart Claude App after running the command.
91
91
 
92
92
  When reusing an already-running adapter, Claude App profile updates require `CCDX_CLAUDE_DESKTOP_API_KEY` or `CCDX_PROXY_API_KEY` so the profile key matches the running process. Otherwise the existing adapter is left untouched.
93
93
 
@@ -120,7 +120,7 @@ Environment variables:
120
120
  | `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
121
121
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
122
122
  | `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
123
- | `CCDX_CLAUDE_DESKTOP_API_KEY` | generated for opt-in setup | Bearer key written into the Claude App profile and recognized by the adapter for model discovery |
123
+ | `CCDX_CLAUDE_DESKTOP_API_KEY` | managed profile or generated for opt-in setup | Explicit bearer key written into the Claude App profile and recognized by the adapter for model discovery |
124
124
  | `CCDX_CLAUDE_MODEL_ALIASES` | built-in Claude aliases | Comma-separated Desktop-to-upstream aliases, for example `claude-sonnet-4-6=claude-sonnet-4.6` |
125
125
  | `CCDX_GITHUB_TOKEN` | unset | Explicit GitHub Copilot OAuth token to validate and import before device login |
126
126
  | `CCDX_GITHUB_TOKEN_PATH` | unset | Explicit file containing a GitHub Copilot OAuth token to validate and import before device login |
@@ -130,7 +130,7 @@ Environment variables:
130
130
  | `CCDX_TOKEN_LOCK_TIMEOUT_MS` | `600000` | Maximum time to wait for another local process to finish GitHub token login/import |
131
131
  | `CCDX_TOKEN_LOCK_STALE_MS` | `900000` | Age after which a stale GitHub token lock file can be removed |
132
132
  | `CCDX_EXISTING_ADAPTER_TIMEOUT_MS` | `500` | Timeout for detecting an already-running local adapter during startup |
133
- | `CCDX_MODEL_REFRESH_INTERVAL_MS` | `1800000` | Interval for refreshing Copilot model metadata; successful lists are cached locally as last-known-good data |
133
+ | `CCDX_MODEL_REFRESH_INTERVAL_MS` | `7200000` | Interval for refreshing Copilot model metadata; successful lists are cached locally as last-known-good data |
134
134
  | `CCDX_RESPONSE_HISTORY_MAX_BYTES` | `67108864` | Total in-memory byte budget for locally expanded Responses history |
135
135
  | `CCDX_RESPONSE_HISTORY_MAX_ENTRIES` | `4096` | Maximum stored incremental Responses history nodes |
136
136
  | `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
package/bin/cli.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { ensureAuth, openCodex } from "../src/launcher.mjs";
4
4
  import { ensureCodexConfig } from "../src/config.mjs";
5
5
  import { ensureClaudeConfig } from "../src/claude-config.mjs";
6
- import { applyClaudeDesktopConfig, formatClaudeDesktopApplyResult, generatedClaudeDesktopApiKey } from "../src/claude-desktop-config.mjs";
6
+ import { applyClaudeDesktopConfig, formatClaudeDesktopApplyResult, generatedClaudeDesktopApiKey, loadManagedClaudeDesktopApiKey } from "../src/claude-desktop-config.mjs";
7
7
  import { startAdapter } from "../src/adapter.mjs";
8
8
  import { cacheModelEndpoints, listModels, refreshVSCodeVersion } from "../src/copilot.mjs";
9
9
  import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
@@ -14,7 +14,7 @@ import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
14
14
  import { runDoctor } from "../src/doctor.mjs";
15
15
  import { checkRunningAdapter } from "../src/running-adapter.mjs";
16
16
  import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
17
- import { loadModelCache, saveModelCache } from "../src/model-cache.mjs";
17
+ import { isValidModelList, loadModelCache, saveModelCache } from "../src/model-cache.mjs";
18
18
  import { initializeModelRegistry, runInBackground } from "../src/startup.mjs";
19
19
  import { cliHelp, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
20
20
  import { closeHttpServer } from "../src/shutdown.mjs";
@@ -57,7 +57,7 @@ const MODEL_REFRESH_TIMEOUT_MS = RUNTIME.modelRefreshTimeoutMs;
57
57
  const EXISTING_ADAPTER_TIMEOUT_MS = RUNTIME.existingAdapterTimeoutMs;
58
58
  const CONFIGURE_CLAUDE_DESKTOP = CLI.configureClaudeDesktop || process.env.CCDX_CONFIGURE_CLAUDE_DESKTOP === "1";
59
59
  const LOGGING = configureLogging();
60
- const MODEL_REGISTRY = { modelDefs: undefined, source: "built-in" };
60
+ const MODEL_REGISTRY = { modelDefs: undefined, models: undefined, source: "built-in" };
61
61
  let activeServer = null;
62
62
  let modelRefreshTimer = null;
63
63
  let shuttingDown = false;
@@ -68,13 +68,16 @@ if (LOGGING.filePath) {
68
68
  }
69
69
 
70
70
  function applyModelsToRegistry(models, source, { updateClaudeModels = true } = {}) {
71
+ if (!isValidModelList(models)) throw new Error("Copilot models response contained no valid models");
71
72
  const modelDefs = updateClaudeModels
72
73
  ? claudeDesktopModelDefsFromCopilotModels(models)
73
74
  : MODEL_REGISTRY.modelDefs;
74
- if (updateClaudeModels && !modelDefs.length) throw new Error("Copilot models response contained no Claude models");
75
75
  cacheModelEndpoints(models);
76
- if (updateClaudeModels) MODEL_REGISTRY.modelDefs = modelDefs;
77
- MODEL_REGISTRY.source = source;
76
+ MODEL_REGISTRY.models = models;
77
+ if (updateClaudeModels && modelDefs.length) {
78
+ MODEL_REGISTRY.modelDefs = modelDefs;
79
+ MODEL_REGISTRY.source = source;
80
+ }
78
81
  return modelDefs;
79
82
  }
80
83
 
@@ -116,8 +119,10 @@ async function refreshClaudeDesktopModelDefs() {
116
119
  }
117
120
  if (customAliases) {
118
121
  console.log(status("info", "Using CCDX_CLAUDE_MODEL_ALIASES for Claude models"));
119
- } else {
122
+ } else if (modelDefs?.length) {
120
123
  console.log(status("ok", `Refreshed Claude models from GitHub Copilot: ${modelDefs.map((model) => model.id).join(", ")}`));
124
+ } else {
125
+ console.log(status("warn", "Copilot models response contained no Claude models; using built-in Claude models"));
121
126
  }
122
127
  return modelDefs;
123
128
  } catch (e) {
@@ -130,7 +135,7 @@ async function refreshClaudeDesktopModelDefs() {
130
135
  }
131
136
 
132
137
  function currentClaudeDesktopApiKey() {
133
- return process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "";
138
+ return String(process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "").trim();
134
139
  }
135
140
 
136
141
  async function reuseRunningAdapterIfAvailable() {
@@ -222,9 +227,17 @@ try {
222
227
  console.log(status("warn", `ADAPTER_HOST=${ADAPTER_HOST} exposes the adapter beyond loopback because CCDX_ALLOW_LAN=1 is set. Use only on trusted networks.`));
223
228
  }
224
229
 
225
- const claudeDesktopApiKey = CONFIGURE_CLAUDE_DESKTOP
226
- ? (currentClaudeDesktopApiKey() || generatedClaudeDesktopApiKey())
227
- : currentClaudeDesktopApiKey();
230
+ const configuredClaudeDesktopApiKey = currentClaudeDesktopApiKey();
231
+ const restoredClaudeDesktopApiKey = configuredClaudeDesktopApiKey ? "" : loadManagedClaudeDesktopApiKey({
232
+ port: ADAPTER_PORT,
233
+ host: ADAPTER_HOST,
234
+ });
235
+ if (restoredClaudeDesktopApiKey) {
236
+ console.log(status("ok", "Restored Claude App gateway key from the managed profile"));
237
+ }
238
+ const claudeDesktopApiKey = configuredClaudeDesktopApiKey
239
+ || restoredClaudeDesktopApiKey
240
+ || (CONFIGURE_CLAUDE_DESKTOP ? generatedClaudeDesktopApiKey() : "");
228
241
 
229
242
  // Start the in-process adapter.
230
243
  activeServer = await startAdapter(ADAPTER_PORT, ADAPTER_HOST, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
package/src/adapter.mjs CHANGED
@@ -4,6 +4,7 @@ import { chatCompletions, listModels, responses as copilotResponses, responsesCo
4
4
  import { webStreamLines } from "./stream.mjs";
5
5
  import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
6
6
  import { CODEX_AUTO_REVIEW_MODEL, claudeDesktopModelsResponse, resolveAnthropicModel, resolveOpenAIModel, modelIsResponsesOnly, modelSupportsChatCompletions } from "./models.mjs";
7
+ import { isValidModelList } from "./model-cache.mjs";
7
8
  import { status } from "./status.mjs";
8
9
  import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
9
10
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
@@ -102,6 +103,20 @@ export function shouldServeClaudeDesktopModels(req, claudeDesktopApiKey) {
102
103
  return token === claudeDesktopApiKey || xApiKey === claudeDesktopApiKey;
103
104
  }
104
105
 
106
+ function modelListCanUseLastKnownGood(statusCode) {
107
+ return [408, 425, 429].includes(statusCode) || statusCode >= 500;
108
+ }
109
+
110
+ function sendLastKnownGoodModels(res, modelRegistry) {
111
+ if (!isValidModelList(modelRegistry?.models) || res.destroyed || res.writableEnded) return false;
112
+ res.writeHead(200, {
113
+ "Content-Type": "application/json",
114
+ "X-CCDX-Model-Source": "last-known-good",
115
+ });
116
+ res.end(JSON.stringify(modelRegistry.models));
117
+ return true;
118
+ }
119
+
105
120
  function responsesInputItems(input, { clone = true } = {}) {
106
121
  if (input === undefined || input === null) return [];
107
122
  if (typeof input === "string") {
@@ -784,16 +799,13 @@ export function createAdapterHandler(options = {}) {
784
799
  res.end(JSON.stringify(resp));
785
800
  } catch (e) {
786
801
  logRequestFailure("Responses", e, abort);
787
- if (!res.destroyed && !res.writableEnded) {
788
- if (!res.headersSent) res.writeHead(e?.statusCode || 502);
789
- res.end("Bad Gateway");
790
- }
802
+ sendJsonError(res, e, 502);
791
803
  }
792
804
  }
793
805
  }
794
806
  } catch (e) {
795
807
  logRequestFailure("Responses", e, abort);
796
- sendJsonError(res, e);
808
+ sendJsonError(res, e, 502);
797
809
  } finally {
798
810
  abort.cleanup();
799
811
  }
@@ -823,7 +835,7 @@ export function createAdapterHandler(options = {}) {
823
835
  });
824
836
  } catch (e) {
825
837
  logRequestFailure("Responses compact", e, abort);
826
- sendJsonError(res, e);
838
+ sendJsonError(res, e, 502);
827
839
  } finally {
828
840
  abort.cleanup();
829
841
  }
@@ -840,9 +852,29 @@ export function createAdapterHandler(options = {}) {
840
852
  const abort = createRequestAbort(req, res);
841
853
  abort.setTimeout(upstreamTimeoutMs);
842
854
  listModelsFn({ signal: abort.signal })
843
- .then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
855
+ .then(({ status, body }) => {
856
+ if (status >= 200 && status < 300) {
857
+ let models;
858
+ try { models = JSON.parse(body); } catch {}
859
+ if (isValidModelList(models)) {
860
+ if (options.modelRegistry) {
861
+ options.modelRegistry.models = models;
862
+ }
863
+ res.writeHead(status, { "Content-Type": "application/json" });
864
+ res.end(body);
865
+ return;
866
+ }
867
+ if (sendLastKnownGoodModels(res, options.modelRegistry)) return;
868
+ sendJsonError(res, httpError("Copilot models response contained no valid models", 502), 502);
869
+ return;
870
+ }
871
+ if (modelListCanUseLastKnownGood(status) && sendLastKnownGoodModels(res, options.modelRegistry)) return;
872
+ res.writeHead(status, { "Content-Type": "application/json" });
873
+ res.end(body);
874
+ })
844
875
  .catch((e) => {
845
876
  logRequestFailure("Models", e, abort);
877
+ if (sendLastKnownGoodModels(res, options.modelRegistry)) return;
846
878
  if (!res.destroyed && !res.writableEnded) {
847
879
  if (!res.headersSent) res.writeHead(e?.statusCode || 502);
848
880
  res.end(JSON.stringify({ error: e.message }));
@@ -927,7 +959,7 @@ export function createAdapterHandler(options = {}) {
927
959
  }
928
960
  } catch (e) {
929
961
  logRequestFailure("Messages", e, abort);
930
- sendJsonError(res, e);
962
+ sendJsonError(res, e, 502);
931
963
  } finally {
932
964
  abort.cleanup();
933
965
  }
package/src/anthropic.mjs CHANGED
@@ -161,6 +161,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
161
161
  let finishReason = null;
162
162
  const toolBlocks = {}; // openaiIndex -> { anthropicIndex }
163
163
  let sawToolUse = false;
164
+ const trailingTextChunks = [];
164
165
  let outputTokens = 0;
165
166
 
166
167
  const ensureStart = async () => {
@@ -199,10 +200,14 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
199
200
  const delta = choice.delta || {};
200
201
  await ensureStart();
201
202
 
202
- if (delta.content && !sawToolUse) {
203
- await openText();
204
- await emit("content_block_delta", { type: "content_block_delta", index: blockIndex,
205
- delta: { type: "text_delta", text: delta.content } });
203
+ if (delta.content) {
204
+ if (sawToolUse) {
205
+ trailingTextChunks.push(delta.content);
206
+ } else {
207
+ await openText();
208
+ await emit("content_block_delta", { type: "content_block_delta", index: blockIndex,
209
+ delta: { type: "text_delta", text: delta.content } });
210
+ }
206
211
  }
207
212
 
208
213
  if (Array.isArray(delta.tool_calls)) {
@@ -231,6 +236,14 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
231
236
  for (const oi of Object.keys(toolBlocks)) {
232
237
  await emit("content_block_stop", { type: "content_block_stop", index: toolBlocks[oi].anthropicIndex });
233
238
  }
239
+ if (trailingTextChunks.length) {
240
+ await openText();
241
+ for (const text of trailingTextChunks) {
242
+ await emit("content_block_delta", { type: "content_block_delta", index: blockIndex,
243
+ delta: { type: "text_delta", text } });
244
+ }
245
+ await closeText();
246
+ }
234
247
 
235
248
  const stop_reason = finishReason ? mapStopReason(finishReason) : (sawToolUse ? "tool_use" : "end_turn");
236
249
  await emit("message_delta", { type: "message_delta", delta: { stop_reason, stop_sequence: null },
@@ -159,6 +159,34 @@ function readActiveProfile(paths) {
159
159
  return readJsonFile(path.join(paths.configLibraryPath, `${appliedId}.json`), {});
160
160
  }
161
161
 
162
+ export function loadManagedClaudeDesktopApiKey({
163
+ port = 2026,
164
+ host = "127.0.0.1",
165
+ home = os.homedir(),
166
+ platform = process.platform,
167
+ env = process.env,
168
+ } = {}) {
169
+ try {
170
+ const paths = claudeDesktopPaths(home, platform, env);
171
+ const meta = readJsonFile(paths.metaPath, {});
172
+ if (String(meta.appliedId || "").trim() !== DEFAULT_CLAUDE_DESKTOP_PROFILE_ID) return "";
173
+
174
+ const profilePath = path.join(paths.configLibraryPath, `${DEFAULT_CLAUDE_DESKTOP_PROFILE_ID}.json`);
175
+ const profile = readJsonFile(profilePath, {});
176
+ if (profile.inferenceProvider !== "gateway") return "";
177
+ if (String(profile.inferenceGatewayAuthScheme || "").trim().toLowerCase() !== "bearer") return "";
178
+
179
+ const profileBaseUrl = String(profile.inferenceGatewayBaseUrl || "").trim();
180
+ validateClaudeDesktopBaseUrl(profileBaseUrl);
181
+ if (new URL(profileBaseUrl).href !== new URL(localGatewayBaseUrl(host, port)).href) return "";
182
+
183
+ const apiKey = String(profile.inferenceGatewayApiKey || "").trim();
184
+ return apiKey && apiKey !== "dummy" ? apiKey : "";
185
+ } catch {
186
+ return "";
187
+ }
188
+ }
189
+
162
190
  export function applyClaudeDesktopConfig({
163
191
  port = 2026,
164
192
  host = "127.0.0.1",
@@ -51,7 +51,7 @@ export function parseRuntimeOptions(env = process.env) {
51
51
  adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
52
52
  modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1 }),
53
53
  existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
54
- modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 30 * 60 * 1000, { min: 0 }),
54
+ modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 2 * 60 * 60 * 1000, { min: 0 }),
55
55
  upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1 }),
56
56
  streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1 }),
57
57
  streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1 }),
@@ -101,10 +101,10 @@ async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
101
101
  } else if (encoding === "br") {
102
102
  decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
103
103
  } else if (encoding === "zstd") {
104
- if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
104
+ if (!zstdDecompressAsync) throw httpError("Unsupported Content-Encoding: zstd", 415);
105
105
  decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
106
106
  } else {
107
- throw new Error(`Unsupported Content-Encoding: ${encoding}`);
107
+ throw httpError(`Unsupported Content-Encoding: ${encoding}`, 415);
108
108
  }
109
109
  if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
110
110
  }
@@ -112,17 +112,31 @@ async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
112
112
  return decoded;
113
113
  }
114
114
 
115
+ function parseRequestJson(text) {
116
+ try {
117
+ return JSON.parse(text);
118
+ } catch (e) {
119
+ throw httpError(`Invalid JSON request body: ${e.message}`, 400);
120
+ }
121
+ }
122
+
115
123
  export async function readJsonBody(req, {
116
124
  maxBodyBytes = MAX_BODY_BYTES,
117
125
  maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
118
126
  } = {}) {
119
127
  const encodings = contentEncodings(req.headers?.["content-encoding"]);
120
128
  if (encodings.every((encoding) => encoding === "identity")) {
121
- return JSON.parse(await readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes));
129
+ return parseRequestJson(await readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes));
122
130
  }
123
131
  const buffer = await readRequestBuffer(req, maxBodyBytes);
124
- const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
125
- return JSON.parse(decoded.toString("utf8"));
132
+ let decoded;
133
+ try {
134
+ decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
135
+ } catch (e) {
136
+ if (e?.statusCode) throw e;
137
+ throw httpError(`Invalid compressed request body: ${e.message}`, 400);
138
+ }
139
+ return parseRequestJson(decoded.toString("utf8"));
126
140
  }
127
141
 
128
142
  export function sendJsonError(res, err, fallbackStatus = 400) {
@@ -167,7 +181,7 @@ export function isAbortLikeError(err) {
167
181
  export function abortErrorStatusCode(reason) {
168
182
  if (["upstream_timeout", "stream_handshake_timeout", "stream_idle_timeout"].includes(reason)) return 504;
169
183
  if (reason === "client_aborted" || reason === "client_closed") return 499;
170
- return 400;
184
+ return 502;
171
185
  }
172
186
 
173
187
  export function createRequestAbort(req, res) {
@@ -10,7 +10,12 @@ export function modelCachePath(home = os.homedir()) {
10
10
 
11
11
  function modelData(models) {
12
12
  const data = Array.isArray(models) ? models : models?.data;
13
- return Array.isArray(data) ? data : null;
13
+ if (!Array.isArray(data) || !data.some((model) => String(model?.id || "").trim())) return null;
14
+ return data;
15
+ }
16
+
17
+ export function isValidModelList(models) {
18
+ return modelData(models) !== null;
14
19
  }
15
20
 
16
21
  export function loadModelCache({
@@ -22,7 +27,7 @@ export function loadModelCache({
22
27
  const parsed = JSON.parse(fs.readFileSync(modelCachePath(home), "utf8"));
23
28
  const savedAt = Date.parse(parsed.saved_at);
24
29
  if (!Number.isFinite(savedAt) || now() - savedAt > maxAgeMs) return null;
25
- if (!modelData(parsed.models)) return null;
30
+ if (!isValidModelList(parsed.models)) return null;
26
31
  return parsed.models;
27
32
  } catch {
28
33
  return null;
@@ -30,7 +35,7 @@ export function loadModelCache({
30
35
  }
31
36
 
32
37
  export function saveModelCache(models, { home = os.homedir() } = {}) {
33
- if (!modelData(models)) return false;
38
+ if (!isValidModelList(models)) return false;
34
39
  const filePath = modelCachePath(home);
35
40
  const tempPath = `${filePath}.${process.pid}.tmp`;
36
41
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
@@ -73,6 +73,7 @@ function jsonByteLength(value, seen = new Set()) {
73
73
 
74
74
  const histories = new Map();
75
75
  const childrenById = new Map();
76
+ const treeLru = new Map();
76
77
  const evictedIds = new Set();
77
78
  let totalBytes = 0;
78
79
  let maxBytes = positiveInt(process.env.CCDX_RESPONSE_HISTORY_MAX_BYTES, DEFAULT_MAX_BYTES);
@@ -83,6 +84,12 @@ function rememberEvictedId(id) {
83
84
  while (evictedIds.size > 256) evictedIds.delete(evictedIds.values().next().value);
84
85
  }
85
86
 
87
+ function touchTree(rootId) {
88
+ if (!rootId) return;
89
+ treeLru.delete(rootId);
90
+ treeLru.set(rootId, true);
91
+ }
92
+
86
93
  function linkChild(parentId, id) {
87
94
  if (!parentId) return;
88
95
  let children = childrenById.get(parentId);
@@ -104,6 +111,7 @@ function unlinkChild(parentId, id) {
104
111
  function removeSubtree(rootId) {
105
112
  const pending = [rootId];
106
113
  const removed = new Set();
114
+ const affectedRoots = new Set();
107
115
  while (pending.length) {
108
116
  const id = pending.pop();
109
117
  if (removed.has(id)) continue;
@@ -114,25 +122,46 @@ function removeSubtree(rootId) {
114
122
  for (const id of removed) {
115
123
  const entry = histories.get(id);
116
124
  if (!entry) continue;
125
+ affectedRoots.add(entry.rootId);
117
126
  unlinkChild(entry.parentId, id);
118
127
  childrenById.delete(id);
119
128
  totalBytes -= entry.bytes;
120
129
  histories.delete(id);
121
130
  rememberEvictedId(id);
122
131
  }
132
+ for (const affectedRoot of affectedRoots) {
133
+ const entry = histories.get(affectedRoot);
134
+ if (!entry || entry.rootId !== affectedRoot) treeLru.delete(affectedRoot);
135
+ }
136
+ if (!histories.has(rootId)) treeLru.delete(rootId);
137
+ }
138
+
139
+ function assignSubtreeRoot(id, rootId) {
140
+ const pending = [id];
141
+ const seen = new Set();
142
+ while (pending.length) {
143
+ const currentId = pending.pop();
144
+ if (seen.has(currentId)) continue;
145
+ seen.add(currentId);
146
+ const entry = histories.get(currentId);
147
+ if (entry) entry.rootId = rootId;
148
+ const children = childrenById.get(currentId);
149
+ if (children) pending.push(...children);
150
+ }
123
151
  }
124
152
 
125
153
  function enforceLimits() {
126
154
  while (histories.size > maxEntries || totalBytes > maxBytes) {
127
- const oldestId = histories.keys().next().value;
128
- if (!oldestId) break;
129
- removeSubtree(oldestId);
155
+ const oldestRootId = treeLru.keys().next().value;
156
+ if (!oldestRootId) break;
157
+ removeSubtree(oldestRootId);
130
158
  }
131
159
  }
132
160
 
133
161
  export function clearResponseHistoryForTests() {
134
162
  histories.clear();
135
163
  childrenById.clear();
164
+ treeLru.clear();
136
165
  evictedIds.clear();
137
166
  totalBytes = 0;
138
167
  maxBytes = DEFAULT_MAX_BYTES;
@@ -163,9 +192,12 @@ export function materializeResponseHistory(responseId) {
163
192
  chain.push(entry);
164
193
  currentId = entry.parentId;
165
194
  }
195
+ const rootId = chain[0]?.rootId;
166
196
  const items = [];
167
197
  for (const entry of chain.reverse()) items.push(...entry.inputItems, ...entry.outputItems);
168
- return cloneJson(items);
198
+ const materialized = cloneJson(items);
199
+ touchTree(rootId);
200
+ return materialized;
169
201
  }
170
202
 
171
203
  export function rememberResponseHistoryNode({ id, parentId, inputItems, outputItems, takeOwnership = false }) {
@@ -176,20 +208,30 @@ export function rememberResponseHistoryNode({ id, parentId, inputItems, outputIt
176
208
  rememberEvictedId(id);
177
209
  return;
178
210
  }
211
+ const existing = histories.get(id);
212
+ const parentEntry = parentId ? histories.get(parentId) : null;
213
+ const rootId = parentEntry?.rootId || (parentId ? existing?.rootId : id) || id;
179
214
  const entry = {
180
215
  parentId: parentId || null,
216
+ rootId,
181
217
  inputItems: takeOwnership ? inputItems : cloneJson(inputItems),
182
218
  outputItems: takeOwnership ? outputItems : cloneJson(outputItems),
183
219
  bytes,
184
220
  };
185
- const existing = histories.get(id);
221
+ const oldRootId = existing?.rootId;
186
222
  if (existing) {
187
223
  totalBytes -= existing.bytes;
188
224
  unlinkChild(existing.parentId, id);
189
225
  }
190
226
  histories.set(id, entry);
191
227
  linkChild(entry.parentId, id);
228
+ if (oldRootId && oldRootId !== rootId) {
229
+ assignSubtreeRoot(id, rootId);
230
+ const oldRoot = histories.get(oldRootId);
231
+ if (!oldRoot || oldRoot.rootId !== oldRootId) treeLru.delete(oldRootId);
232
+ }
192
233
  totalBytes += entry.bytes;
193
234
  evictedIds.delete(id);
235
+ touchTree(rootId);
194
236
  enforceLimits();
195
237
  }