codex-copilot-dx 0.8.3 → 0.8.5

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
@@ -18,15 +18,19 @@ The adapter handles response history, encrypted reasoning state, function/custom
18
18
 
19
19
  ### Fast and Standard modes
20
20
 
21
- Codex can select Standard or Fast without changing CCDX configuration. When the client requests `gpt-5.6-sol` with `service_tier: "priority"`, CCDX uses `gpt-5.6-sol-fast` only when the live Copilot catalog advertises that exact model as enabled, selectable, OpenAI-owned, and Responses-only. Standard mode continues to use `gpt-5.6-sol`; CCDX does not invent Fast availability when the upstream catalog does not provide it.
21
+ Codex can select Standard or Fast without changing CCDX configuration. When the client requests a model with `service_tier: "priority"`, CCDX uses its exact `-fast` model only when the live Copilot catalog advertises that variant as enabled, selectable, OpenAI-owned, and Responses-only. Standard mode continues to use the base model; CCDX does not invent Fast availability when the upstream catalog does not provide it. This retains the established `gpt-5.6-sol` / `gpt-5.6-sol-fast` mapping and applies the same exact-match rule to future models.
22
22
 
23
23
  Codex Auto-review uses the hidden `codex-auto-review` model ID. CCDX maps it to Copilot's `gpt-5.5` Responses model by default, independently of the interactive Fast/Standard selection, and logs both model IDs when the mapping is used. The review target can be changed interactively without reinstalling or restarting CCDX.
24
24
 
25
25
  ### GPT-6 model catalog
26
26
 
27
- Codex App requests a versioned, capability-rich model catalog that is different from Copilot's raw model list. CCDX reads the complete catalog bundled with the installed Codex App and preserves every other model entry and capability. It exposes the bundled `gpt-6-astra` entry only when Copilot's available catalog advertises that exact model as enabled, selectable, OpenAI-owned, and Responses-capable.
27
+ Codex App requests a versioned, capability-rich model catalog that is different from Copilot's raw model list. CCDX reads the complete catalog bundled with the installed Codex App and preserves every other model entry and capability. It exposes each managed GPT-6 entry only when Copilot advertises that exact model as enabled, selectable, OpenAI-owned, and Responses-capable.
28
28
 
29
- CCDX exposes the GPT-6 Standard path and removes its unverified speed-tier metadata. It leaves the bundled reasoning-effort options unchanged, including Ultra when supplied by the installed catalog. GPT-5.6 Sol Fast follows the separate rule above. Versioned model discovery matches the bundled catalog to the client's `client_version`, caches it by application binary identity, and reloads it after an App update. If a matching usable catalog cannot be loaded, the versioned models request returns `503`; an unversioned request returns the Copilot-style list.
29
+ When an installed 0.155.x catalog does not yet contain `gpt-6-sol` or `gpt-6-luna`, CCDX derives their client-compatible entries from that catalog's GPT-6 Astra schema and the published OpenAI Codex model definitions at commit `0a2eb4696c26ac33204bcd255721ab30220a4774`. Only those two missing entries are inserted. Their fallback reasoning controls are intersected with Copilot's advertised efforts; every other bundled entry remains byte-for-byte equivalent. A newer client-provided Sol/Luna entry always wins and is preserved.
30
+
31
+ CCDX exposes GPT-6 Standard paths and removes unverified speed-tier metadata. It leaves bundled reasoning-effort options unchanged, including Ultra when supplied by the installed catalog. Fast follows the exact upstream-variant rule above. Versioned model discovery matches the bundled catalog to the client's `client_version`, caches it by application binary identity, and reloads it after an App update. If a matching usable catalog cannot be loaded, the versioned models request returns `503`; an unversioned request returns the Copilot-style list.
32
+
33
+ `/v1/models` still attempts live discovery first. With a usable in-process catalog, it limits that upstream wait to 1.5 seconds (or a shorter configured upstream timeout) before falling back to last-known-good data. Without a catalog it retains the regular upstream timeout. Received HTTP `401`/`403` responses remain errors, not cached successes; fallback does not grant inference access. This does not change the explicitly live `ccdx models` command.
30
34
 
31
35
  ## Prerequisites
32
36
 
@@ -78,6 +82,8 @@ Normal startup and reuse of an existing adapter check `~/.codex/config.toml` onc
78
82
 
79
83
  The two token limits are fixed defaults, not detected model capabilities. Existing custom values are retained. Writes target the listed keys with format-preserving edits and atomic replacement; this is not a general TOML formatter. Quoted keys, quoted table headers, and explicit dotted shell-environment declarations are recognized; inline shell-environment tables are left intact. Startup and image configuration commands validate UTF-8/TOML and verify that unrelated settings retain their values before writing. An unsafe edit leaves the original file unchanged. Use `ccdx doctor config` to inspect parsed values and preview what this writer would do with the current file. No actual change means no file rewrite.
80
84
 
85
+ Interactive image setup rereads the shared config after prompts and network validation, so unrelated changes made during setup are preserved. It checks the snapshot again before committing and rejects conflicting image declarations. Identical configuration bytes are not replaced; private file permissions may still be corrected. These checks reduce lost-update risks but are not a filesystem-wide transaction with other applications that do not share a lock.
86
+
81
87
  ## Version 0.7.0 migration
82
88
 
83
89
  CCDX 0.7.0 retires the former Claude App, Claude Code, Anthropic Messages API, isolated Claude account, and PM Studio patch integrations. Their setup, status, restore, profile, and routing commands are no longer supported.
@@ -111,6 +117,20 @@ The selector first queries the running adapter, including its last-known-good mo
111
117
 
112
118
  The selection is saved in `~/.config/codex-copilot-dx/config.json`, or under `XDG_CONFIG_HOME` when set. Choosing `gpt-5.5` clears the override and restores the package default. A running adapter reads the setting on the next Auto-review request.
113
119
 
120
+ ### Runtime caches
121
+
122
+ ```bash
123
+ ccdx cache
124
+ ccdx cache --limit 128
125
+ ccdx cache --limit default
126
+ ccdx cache --clean
127
+ ccdx cache --clean --history
128
+ ```
129
+
130
+ `ccdx cache` separates the shared in-memory Responses history from the rebuildable input-image transform cache. The history limit accepts 16–1024 MiB, is saved in the normal CCDX settings file, and applies immediately to a compatible running adapter. `CCDX_RESPONSE_HISTORY_MAX_BYTES` remains the higher-precedence environment override. Lowering the limit below current usage is rejected rather than evicting tasks silently. `ccdx cache -128` is a short alias for `--limit 128`; `-clean` aliases `--clean`.
131
+
132
+ Plain `--clean` clears only completed image transforms; the next matching request may recompute them. Adding `--history` also invalidates all process-local `previous_response_id` chains and can make a task's next continuation fail, so it requires interactive confirmation or `--yes`. Cleanup waits for active cache users to finish and never deletes Codex transcripts, generated image files, provider credentials, image edit handles, model catalogs, or usage/debug logs. A stopped or older adapter must be restarted before runtime cleanup; a saved limit applies on the next start.
133
+
114
134
  ### Terminal animation
115
135
 
116
136
  Choose the terminal activity animation interactively:
@@ -147,6 +167,8 @@ ccdx enable-image
147
167
 
148
168
  The command accepts an HTTPS base URL or a full `/images/generations` endpoint, then asks for an API key without echoing the key. A bare origin such as `https://api.example` expands to `/v1/images/generations`; `https://api.example/v1` expands to the same endpoint. Custom base paths are retained, and existing full endpoints remain unchanged. CCDX uses GET for the corresponding `/models` catalog and POST for image requests; there is no endpoint-search or automatic fallback. It asks for a numeric model selection only when multiple image models are available, then detects either the standard OpenAI Images request format or the supported `input.messages` format for the selected model only. Its single validation request omits the prompt and does not generate an image.
149
169
 
170
+ An empty key answer retains the saved key only when the normalized endpoint has the same origin (scheme, host, and port). Changing origins requires explicitly entering a key before any provider request; the old key is never automatically sent to a new origin.
171
+
150
172
  The provider credential is stored in `~/.config/codex-copilot-dx/image-provider.json`, or under `XDG_CONFIG_HOME`, with mode `0600`. The API key is never written to `~/.codex/config.toml`; that file receives only a marked local MCP entry pointing to the running CCDX adapter with a 210-second tool timeout. Only after enabling, CCDX installs `ccdx-image` guidance under `~/.codex/skills`, with a self-contained Node helper. The concise `SKILL.md` keeps operation selection, safety, and final-image delivery rules; editing details and helper instructions live in references read only for those operations. Codex uses the MCP tool directly, including through deferred tool search; an already-open task without that tool can use the helper to call the same local service and display the saved image. Neither route requires Python, an OpenAI SDK, or copying credentials. Built-in skills and unrelated tools are not modified.
151
173
 
152
174
  The enable command verifies the local MCP handshake and generation-tool directory without generating an image. It reports provider configuration separately from local service readiness; it cannot assert that every running Codex task has refreshed its catalog. Codex normally detects changed skills; restart Codex App once if an open task has not refreshed them. Normal CCDX startup installs or updates this guidance only for an already-enabled provider, without repeating API setup. Without `enable-image`, it installs neither this skill nor the MCP registration. Unchanged guidance is not rewritten. The existing adapter can remain running because provider settings are loaded when the tool is invoked.
@@ -237,7 +259,9 @@ For the complete machine-readable status payload:
237
259
  curl -s http://127.0.0.1:2026/_ccdx/status | jq
238
260
  ```
239
261
 
240
- The status endpoint is restricted to the socket's real loopback address even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms, admission pressure, memory use, response-history size, image queue/cache and adaptive-history state, model-cache counts, token expiry state, and the ten most recent upstream `response.failed` diagnostics retained by the running process. Failure diagnostics include the event type, model, error code/message, response/request IDs when supplied upstream, and retry outcome; long opaque encrypted values are redacted. They are cleared when the adapter restarts. The endpoint never includes prompts, completions, tool arguments, image content, account names, or token values.
262
+ The status endpoint is restricted to the socket's real loopback address even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms, admission pressure, memory use, response-history size, image queue/cache and adaptive-history state, model-cache counts, token expiry state, and the ten most recent upstream `response.failed` diagnostics retained by the running process. Failure diagnostics include the event type, model, error code/message, response/request IDs when supplied upstream, and retry outcome. Common credential forms, long opaque encrypted values, and explicitly labelled input/prompt/tool-content echoes are redacted. They are cleared when the adapter restarts. No request/response bodies, credential fields, or account profiles are copied into status. Arbitrary upstream prose cannot be guaranteed free of sensitive content; review diagnostics before sharing them.
263
+
264
+ `image_mcp` has its own HTTP route counter. After the image handler has been used, `image_generation` separately reports active, succeeded, failed, busy, cancelled, and chat-file delivery-failure counts, including tool errors returned with HTTP 200. A delivery failure can accompany a successful generation; it never causes another generation. Before handler initialization this field is `null`, meaning not observed, not necessarily disabled. Status never initializes that handler, reads its provider credential, installs guidance, or probes the provider. Input-image optimization metrics remain separate. `logging` reports pending records/bytes, queue caps, overflow drops, and write failures for usage and enabled debug-file logging.
241
265
 
242
266
  `stream_performance.by_route` also reports `request_ttft_ms`, measured from adapter entry to the first observed output delta, including local preparation. The existing `ttft_ms` continues to start at upstream dispatch. Neither timestamp means the client has drained or displayed the output. `preparation_ms` contains bounded histograms for admission (including history reservations), body reading/decompression/JSON parsing, history materialization/compact preparation, image optimization, and request serialization. Each sample measures one operation, including failed operations; retries can add samples. These are diagnostic intervals rather than a complete breakdown of request time: body reading includes internal decoded-body admission waits; authentication, routing, and other preparation are not individually timed. Non-streaming requests contribute preparation samples without inventing a first-token measurement.
243
267
 
@@ -269,7 +293,7 @@ Recovery first tries compatible local credentials and starts Device Flow only wh
269
293
  - The GitHub OAuth credential is stored locally at `~/.local/share/copilot-api/github_token`. Short-lived Copilot service tokens remain in process memory and are refreshed as needed.
270
294
  - The optional image API credential is stored separately with mode `0600` only after `ccdx enable-image`; it is never placed in Codex configuration or diagnostic output.
271
295
  - Runtime status and usage records retain metadata rather than request/response content. Diagnostic logging records status and error messages; it is not a general redaction layer for arbitrary error text. See Debug logging before sharing logs.
272
- - Request and response bodies are bounded. Queues, history, image work, SSE parsing, retries, and shutdown all have explicit resource or time limits.
296
+ - Request/response bodies, request-admission queues, retained history, image work, SSE buffers, log queues, retries, and shutdown have explicit limits. These component limits are not a single process-wide memory ceiling; completed image files are persistent user artifacts, not an automatically evicted cache.
273
297
 
274
298
  ## Configuration
275
299
 
@@ -316,7 +340,7 @@ Recovery first tries compatible local credentials and starts Device Flow only wh
316
340
  | `CCDX_EXISTING_ADAPTER_TIMEOUT_MS` | `500` | Timeout for detecting an already-running local adapter during startup |
317
341
  | `CCDX_MODEL_REFRESH_TIMEOUT_MS` | `5000` | Timeout for Copilot model refresh or live Auto-review model lookup |
318
342
  | `CCDX_MODEL_REFRESH_INTERVAL_MS` | `7200000` | Periodic model refresh interval; `0` disables periodic refresh, while startup cache/refresh behavior still applies |
319
- | `CCDX_RESPONSE_HISTORY_MAX_BYTES` | `67108864` | Total in-memory byte budget for locally expanded Responses history |
343
+ | `CCDX_RESPONSE_HISTORY_MAX_BYTES` | `67108864` | Higher-precedence total in-memory byte budget for locally expanded Responses history; `ccdx cache --limit` provides a persistent 16–1024 MiB setting when this is unset |
320
344
  | `CCDX_RESPONSE_HISTORY_MAX_ENTRIES` | `4096` | Maximum stored incremental Responses history nodes |
321
345
  | `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token-usage log |
322
346
  | `CCDX_USAGE_MAX_BYTES` | `33554432` | Rotate the usage log at this size and retain one `.1` backup; set to `0` to disable rotation |
@@ -333,9 +357,11 @@ ccdx usage
333
357
 
334
358
  The summary covers the current log and its single rotated backup, not an unbounded lifetime history. It reports recorded responses and per-model input, cache-read, output, and total token metadata.
335
359
 
360
+ Usage writes are asynchronous and batched under the existing cross-process rotation lock. Pending and in-flight records share a 4096-record / 4 MiB ceiling. If either ceiling is reached, new excess records are dropped with a warning and a status counter; inference does not wait or retry. Normal records and per-record rotation boundaries are unchanged. The bounded shutdown flush remains 1.5 seconds; disk failures and shutdown deadlines can prevent persistence, so usage is diagnostic rather than a billing ledger.
361
+
336
362
  ## Debug logging
337
363
 
338
- Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or provide a custom path. Add `CCDX_LOG_LEVEL=debug` for upstream attempts, retry causes, status codes, and timings. The adapter does not deliberately dump request/response bodies or authorization headers. The log mirror writes console text verbatim, including diagnostic/error messages that can originate in external components, so review logs before sharing them. `ccdx doctor config` bypasses file logging entirely.
364
+ Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or provide a custom path. Add `CCDX_LOG_LEVEL=debug` for upstream attempts, retry causes, status codes, and timings. The adapter does not deliberately dump request/response bodies or authorization headers. File diagnostics redact common credential forms and labelled content echoes, but this cannot identify every secret in arbitrary external prose; review logs before sharing them. File writes are asynchronous and batched with a 4096-record / 2 MiB pending-and-in-flight ceiling. Overflow drops excess file records with one warning and a status counter; terminal output continues. CLI exit paths flush for at most 1.5 seconds. `ccdx doctor config` bypasses file logging entirely.
339
365
 
340
366
  ## Large histories and images
341
367
 
@@ -359,6 +385,8 @@ Changing the model or transport of a continuation can require removing incompati
359
385
 
360
386
  After the stream handshake succeeds, stream liveness uses the idle timeout rather than the expired handshake deadline. Eligible HTTP-200 failure recovery acquires a bounded preparation budget and history admission before rebuilding its payload, then releases those reservations when the retry opens upstream or fails. Normal streams still release preparation admission after their response headers arrive.
361
387
 
388
+ Downstream backpressure waits also observe cancellation and the stream deadline. A client that stops reading cannot retain the pending stream indefinitely after timeout; CCDX closes that connection without retrying its request. Final error-frame delivery has a separate one-second maximum drain wait.
389
+
362
390
  Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Allowlisted rate-limit, retry, model, trace, and upstream request-ID metadata are forwarded; cookies, authorization, body-length, and encoding headers are not.
363
391
 
364
392
  CCDX keeps each native Responses message on its first upstream ID when Copilot changes that ID during streaming. Message deltas, completion events, and cached history stay consistent, addressing this cause of duplicate live messages in Codex App. Tool identities, reasoning/encrypted content, message phases, and speed-tier routing are unchanged by this ID normalization. Stable events retain their original SSE bytes; changed events are rewritten with bounded, backpressured streaming.
package/bin/cli.mjs CHANGED
@@ -16,13 +16,14 @@ import { cliCommandName, cliHelp, parseAdapterAddressOptions, parseAdapterProbeO
16
16
  import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
17
17
  import { closeHttpServer } from "../src/shutdown.mjs";
18
18
  import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
19
- import { autoReviewModelPreference, terminalAnimationPreference } from "../src/user-settings.mjs";
19
+ import { autoReviewModelPreference, responseHistoryLimitPreference, terminalAnimationPreference } from "../src/user-settings.mjs";
20
20
  import { fetchLiveCopilotModels, formatLiveCopilotModels } from "../src/cli-models.mjs";
21
21
  import { runAuthCommand } from "../src/cli-auth.mjs";
22
22
  import { createProfileRuntime } from "../src/profile-runtime.mjs";
23
23
  import { createProfileModelRuntime } from "../src/profile-model-runtime.mjs";
24
24
  import { createCodexModelCatalog } from "../src/codex-model-catalog.mjs";
25
25
  import { readImageProviderConfig } from "../src/image-provider-config.mjs";
26
+ import { setResponseHistoryMaxBytes } from "../src/response-history.mjs";
26
27
 
27
28
  const LOCAL_VERSION = localPackageVersion();
28
29
  const CLI_NAME = cliCommandName();
@@ -80,6 +81,26 @@ if (CLI.command === "usage") {
80
81
  });
81
82
  process.exit(0);
82
83
  }
84
+ if (CLI.command === "cache") {
85
+ try {
86
+ const probe = parseAdapterProbeOptions(process.env);
87
+ const { runCacheCommand } = await import("../src/cli-cache.mjs");
88
+ await runCacheCommand({
89
+ action: CLI.action,
90
+ limitMib: CLI.limitMib,
91
+ resetLimit: CLI.resetLimit,
92
+ history: CLI.history,
93
+ yes: CLI.yes,
94
+ host: probe.adapterHost,
95
+ port: probe.adapterPort,
96
+ timeoutMs: probe.existingAdapterTimeoutMs,
97
+ });
98
+ process.exit(0);
99
+ } catch (e) {
100
+ console.error(status("err", e.message));
101
+ process.exit(1);
102
+ }
103
+ }
83
104
  if (CLI.command === "retired") {
84
105
  console.error(status("err", `${CLI.integration} integration was retired in ccdx 0.7.0.`));
85
106
  if (CLI.integration === "PM Studio") {
@@ -182,6 +203,7 @@ if (CLI.command === "doctor" && CLI.configOnly) {
182
203
  let RUNTIME;
183
204
  try {
184
205
  RUNTIME = parseRuntimeOptions(process.env);
206
+ setResponseHistoryMaxBytes(responseHistoryLimitPreference().bytes);
185
207
  } catch (e) {
186
208
  console.error(e.message);
187
209
  process.exit(2);
@@ -248,6 +270,7 @@ if (CLI.command === "doctor") {
248
270
  online: CLI.online,
249
271
  compat: CLI.compat,
250
272
  });
273
+ await LOGGING.cleanup();
251
274
  process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
252
275
  }
253
276
 
@@ -270,7 +293,10 @@ async function printUpdateNotice() {
270
293
  try {
271
294
  assertSafeAdapterHost(ADAPTER_HOST, process.env);
272
295
  void runInBackground(printUpdateNotice);
273
- if (await reuseRunningAdapterIfAvailable()) process.exit(0);
296
+ if (await reuseRunningAdapterIfAvailable()) {
297
+ await LOGGING.cleanup();
298
+ process.exit(0);
299
+ }
274
300
 
275
301
  // Ensure GitHub login, using device flow if no token exists.
276
302
  await ensureAuth();
@@ -347,7 +373,7 @@ try {
347
373
  exitCode = 1;
348
374
  console.error(status("err", `Shutdown failed: ${e.message}`));
349
375
  } finally {
350
- LOGGING.cleanup();
376
+ await LOGGING.cleanup();
351
377
  process.exit(exitCode);
352
378
  }
353
379
  };
@@ -357,6 +383,6 @@ try {
357
383
  console.error(status("err", e.message));
358
384
  await closeHttpServer(activeServer, { timeoutMs: RUNTIME.shutdownTimeoutMs }).catch(() => {});
359
385
  await flushUsageWrites();
360
- LOGGING.cleanup();
386
+ await LOGGING.cleanup();
361
387
  process.exit(1);
362
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -17,12 +17,19 @@ import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs
17
17
  import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
18
18
  import { createResponsesImagePressureController } from "./responses-image-pressure.mjs";
19
19
  import { createResponseFailureDiagnostics } from "./response-failures.mjs";
20
+ import {
21
+ ADAPTER_CACHE_PATH,
22
+ cacheRuntimeSnapshot,
23
+ cleanRuntimeCaches,
24
+ setHistoryCacheLimit,
25
+ } from "./cache-control.mjs";
20
26
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
21
27
  import {
22
28
  createRequestAdmission,
23
29
  createRequestAbort,
24
30
  httpError,
25
31
  logRequestFailure,
32
+ readJsonBody,
26
33
  sendJsonError,
27
34
  } from "./http-transport.mjs";
28
35
 
@@ -192,7 +199,7 @@ export function createAdapterHandler(options = {}) {
192
199
  streamIdleTimeoutMs,
193
200
  upstreamTimeoutMs,
194
201
  });
195
- const dispatch = (req, res, pathname) => {
202
+ const dispatch = async (req, res, pathname) => {
196
203
  if (pathname === "/mcp/image") {
197
204
  if (imageMcpHandler) return imageMcpHandler(req, res);
198
205
  return import("./image-mcp.mjs").then(({ createImageMcpHandler }) => {
@@ -213,6 +220,7 @@ export function createAdapterHandler(options = {}) {
213
220
  admission: acquireRequest,
214
221
  imagePressure,
215
222
  responseFailures,
223
+ imageGeneration: imageMcpHandler?.stats?.(),
216
224
  modelRegistry: codexModelRegistry,
217
225
  codexClient,
218
226
  codexModelRegistry,
@@ -226,6 +234,56 @@ export function createAdapterHandler(options = {}) {
226
234
  return;
227
235
  }
228
236
 
237
+ if (pathname === ADAPTER_CACHE_PATH) {
238
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
239
+ res.writeHead(403, { "Content-Type": "application/json" });
240
+ res.end(JSON.stringify({ error: "Cache control is available only from loopback" }));
241
+ return;
242
+ }
243
+ if (req.method === "GET") {
244
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
245
+ res.end(JSON.stringify(cacheRuntimeSnapshot()));
246
+ return;
247
+ }
248
+ if (req.method !== "POST") {
249
+ res.writeHead(405, { "Content-Type": "application/json", Allow: "GET, POST" });
250
+ res.end(JSON.stringify({ error: "Only GET and POST are supported" }));
251
+ return;
252
+ }
253
+ if (Object.hasOwn(req.headers || {}, "origin")) {
254
+ res.writeHead(403, { "Content-Type": "application/json" });
255
+ res.end(JSON.stringify({ error: "Browser origins are not allowed for cache control" }));
256
+ return;
257
+ }
258
+ const mediaType = String(req.headers?.["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
259
+ if (mediaType !== "application/json") {
260
+ res.writeHead(415, { "Content-Type": "application/json" });
261
+ res.end(JSON.stringify({ error: "Cache control requires application/json" }));
262
+ return;
263
+ }
264
+ const abort = createRequestAbort(req, res);
265
+ abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
266
+ try {
267
+ const body = await readJsonBody(req, {
268
+ maxBodyBytes: 8 * 1024,
269
+ maxDecodedBodyBytes: 8 * 1024,
270
+ signal: abort.signal,
271
+ });
272
+ let result;
273
+ if (body?.action === "set_limit") result = setHistoryCacheLimit(body.max_bytes);
274
+ else if (body?.action === "clean") result = cleanRuntimeCaches({ history: body.history === true });
275
+ else throw httpError("Unknown cache action", 400);
276
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
277
+ res.end(JSON.stringify(result));
278
+ } catch (error) {
279
+ if (["ccdx_cache_busy", "ccdx_cache_limit_below_usage"].includes(error?.code)) error.statusCode = 409;
280
+ sendJsonError(res, error, 400);
281
+ } finally {
282
+ abort.cleanup();
283
+ }
284
+ return;
285
+ }
286
+
229
287
  if (req.method === "POST" && pathname === "/v1/responses") {
230
288
  return responsesHandler(req, res);
231
289
  }
@@ -237,7 +295,12 @@ export function createAdapterHandler(options = {}) {
237
295
  if (req.method === "GET" && pathname === "/v1/models") {
238
296
  const clientVersion = modelClientVersion(req.url);
239
297
  const abort = createRequestAbort(req, res);
240
- abort.setTimeout(upstreamTimeoutMs);
298
+ // Keep live discovery and explicit auth failures authoritative, but do
299
+ // not make a usable cached picker wait the full inference timeout.
300
+ const modelTimeoutMs = isValidModelList(codexModelRegistry?.models)
301
+ ? Math.min(upstreamTimeoutMs, parsePositiveInteger(options.cachedModelsTimeoutMs, 1500))
302
+ : upstreamTimeoutMs;
303
+ abort.setTimeout(modelTimeoutMs);
241
304
  listModelsFn({ signal: abort.signal })
242
305
  .then(async ({ status, body }) => {
243
306
  abort.clearTimeout();
@@ -308,7 +371,9 @@ export function createAdapterHandler(options = {}) {
308
371
  return;
309
372
  }
310
373
 
311
- const trackRequest = pathname !== ADAPTER_HEALTH_PATH && pathname !== ADAPTER_STATUS_PATH;
374
+ const trackRequest = pathname !== ADAPTER_HEALTH_PATH
375
+ && pathname !== ADAPTER_STATUS_PATH
376
+ && pathname !== ADAPTER_CACHE_PATH;
312
377
  const routeName = classifyAdapterRoute(req.method, pathname);
313
378
  const complete = trackRequest ? requestMetrics.begin(routeName) : () => {};
314
379
  const streamPerformance = trackRequest ? streamPerformanceMetrics.begin(routeName) : null;
@@ -0,0 +1,59 @@
1
+ import {
2
+ MIB,
3
+ RESPONSE_HISTORY_MAX_MIB,
4
+ RESPONSE_HISTORY_MIN_MIB,
5
+ } from "./cache-limits.mjs";
6
+ import {
7
+ clearResponseHistory,
8
+ responseHistoryStats,
9
+ setResponseHistoryMaxBytes,
10
+ } from "./response-history.mjs";
11
+ import {
12
+ clearImageOptimizationCache,
13
+ imageOptimizationStats,
14
+ } from "./image-optimization.mjs";
15
+
16
+ export const ADAPTER_CACHE_PATH = "/_ccdx/cache";
17
+
18
+ export function cacheRuntimeSnapshot() {
19
+ return {
20
+ response_history: responseHistoryStats(),
21
+ image_optimization: imageOptimizationStats(),
22
+ };
23
+ }
24
+
25
+ export function setHistoryCacheLimit(maxBytes) {
26
+ const mib = Number(maxBytes) / MIB;
27
+ if (!Number.isSafeInteger(mib)
28
+ || mib < RESPONSE_HISTORY_MIN_MIB
29
+ || mib > RESPONSE_HISTORY_MAX_MIB) {
30
+ const error = new Error(`History cache limit must be an integer from ${RESPONSE_HISTORY_MIN_MIB} to ${RESPONSE_HISTORY_MAX_MIB} MiB`);
31
+ error.code = "ccdx_cache_limit_invalid";
32
+ throw error;
33
+ }
34
+ return { changed: setResponseHistoryMaxBytes(maxBytes).previousMaxBytes !== maxBytes, ...cacheRuntimeSnapshot() };
35
+ }
36
+
37
+ export function cleanRuntimeCaches({ history = false } = {}) {
38
+ const imageStats = imageOptimizationStats();
39
+ if (imageStats.active > 0 || imageStats.queued > 0 || imageStats.cache_inflight > 0) {
40
+ const error = new Error("Image optimization is active; retry cache cleanup after current requests finish");
41
+ error.code = "ccdx_cache_busy";
42
+ throw error;
43
+ }
44
+ const historyStats = responseHistoryStats();
45
+ if (history && historyStats.pinnedTrees > 0) {
46
+ const error = new Error("Response history is active; retry cleanup after current requests finish");
47
+ error.code = "ccdx_cache_busy";
48
+ throw error;
49
+ }
50
+ const imageOptimization = clearImageOptimizationCache();
51
+ const responseHistory = history ? clearResponseHistory() : null;
52
+ return {
53
+ cleaned: {
54
+ image_optimization: imageOptimization,
55
+ ...(responseHistory ? { response_history: responseHistory } : {}),
56
+ },
57
+ ...cacheRuntimeSnapshot(),
58
+ };
59
+ }
@@ -0,0 +1,4 @@
1
+ export const RESPONSE_HISTORY_DEFAULT_MIB = 64;
2
+ export const RESPONSE_HISTORY_MIN_MIB = 16;
3
+ export const RESPONSE_HISTORY_MAX_MIB = 1024;
4
+ export const MIB = 1024 * 1024;
@@ -0,0 +1,147 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import os from "node:os";
3
+ import { ADAPTER_CACHE_PATH } from "./cache-control.mjs";
4
+ import { MIB, RESPONSE_HISTORY_DEFAULT_MIB } from "./cache-limits.mjs";
5
+ import { adapterBaseUrl } from "./running-adapter.mjs";
6
+ import {
7
+ readUserSettings,
8
+ responseHistoryLimitPreference,
9
+ writeResponseHistoryLimitMib,
10
+ } from "./user-settings.mjs";
11
+
12
+ const RESPONSE_HISTORY_MAX_MIB_KEY = "response_history_max_mib";
13
+
14
+ function mib(bytes) {
15
+ const value = Number(bytes);
16
+ return Number.isFinite(value) ? `${(value / MIB).toFixed(value % MIB === 0 ? 0 : 1)} MiB` : "unknown";
17
+ }
18
+
19
+ async function cacheRequest(baseUrl, { body, fetchImpl, timeoutMs }) {
20
+ const response = await fetchImpl(`${baseUrl}${ADAPTER_CACHE_PATH}`, {
21
+ method: body ? "POST" : "GET",
22
+ headers: { Accept: "application/json", ...(body ? { "Content-Type": "application/json" } : {}) },
23
+ ...(body ? { body: JSON.stringify(body) } : {}),
24
+ signal: AbortSignal.timeout(timeoutMs),
25
+ });
26
+ let payload;
27
+ try { payload = await response.json(); } catch { payload = null; }
28
+ if (!response.ok) {
29
+ const error = new Error(payload?.error || `Cache control returned HTTP ${response.status}`);
30
+ error.statusCode = response.status;
31
+ throw error;
32
+ }
33
+ return payload;
34
+ }
35
+
36
+ async function runningSnapshot(baseUrl, options) {
37
+ try { return { supported: true, data: await cacheRequest(baseUrl, options) }; }
38
+ catch (error) {
39
+ if ([404, 405].includes(error?.statusCode)) return { supported: false, error };
40
+ if (error?.name === "TimeoutError" || error?.cause?.code === "ECONNREFUSED") return { supported: false, offline: true, error };
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ async function confirmHistoryCleanup({ input, output, prompt, yes }) {
46
+ if (yes) return;
47
+ if (prompt) {
48
+ if (!await prompt()) throw new Error("History cache cleanup cancelled");
49
+ return;
50
+ }
51
+ if (!input.isTTY || !output.isTTY) {
52
+ throw new Error("History cleanup can break task continuation; rerun with --yes to confirm");
53
+ }
54
+ const readline = createInterface({ input, output });
55
+ try {
56
+ const answer = String(await readline.question(
57
+ "Clear response history for all tasks? Their next continuation may fail [y/N]: ",
58
+ )).trim().toLowerCase();
59
+ if (!["y", "yes"].includes(answer)) throw new Error("History cache cleanup cancelled");
60
+ } finally { readline.close(); }
61
+ }
62
+
63
+ function formatStatus(preference, runtime) {
64
+ const lines = ["ccdx cache", `Configured history limit: ${mib(preference.bytes)} (${preference.source})`];
65
+ if (!runtime?.supported) {
66
+ lines.push(runtime?.offline
67
+ ? "Runtime cache: adapter is not running"
68
+ : "Runtime cache: restart ccdx to enable cache control");
69
+ return lines.join("\n");
70
+ }
71
+ const history = runtime.data.response_history || {};
72
+ const images = runtime.data.image_optimization || {};
73
+ lines.push(
74
+ `History: ${mib(history.bytes)} / ${mib(history.maxBytes)}, ${history.entries || 0} entries, ${history.evicted || 0} evicted`,
75
+ `Image transforms: ${mib(images.cache_bytes)} / ${mib(images.cache_max_bytes)}, ${images.cache_entries || 0} entries`,
76
+ );
77
+ return lines.join("\n");
78
+ }
79
+
80
+ export async function runCacheCommand({
81
+ action = "status",
82
+ limitMib,
83
+ resetLimit = false,
84
+ history = false,
85
+ yes = false,
86
+ env = process.env,
87
+ home = os.homedir(),
88
+ host = "127.0.0.1",
89
+ port = 2026,
90
+ input = process.stdin,
91
+ output = process.stdout,
92
+ prompt,
93
+ fetchImpl = fetch,
94
+ timeoutMs = 1000,
95
+ } = {}) {
96
+ const baseUrl = adapterBaseUrl(host, port);
97
+ const requestOptions = { fetchImpl, timeoutMs };
98
+ const preference = responseHistoryLimitPreference({ env, home });
99
+ const runtime = await runningSnapshot(baseUrl, requestOptions);
100
+ if (action === "status") {
101
+ const text = formatStatus(preference, runtime);
102
+ output.write(`${text}\n`);
103
+ return { preference, runtime };
104
+ }
105
+ if (action === "limit") {
106
+ if (String(env.CCDX_RESPONSE_HISTORY_MAX_BYTES || "").trim()) {
107
+ throw new Error("CCDX_RESPONSE_HISTORY_MAX_BYTES overrides saved cache settings; unset it before using ccdx cache --limit");
108
+ }
109
+ const targetMib = resetLimit ? RESPONSE_HISTORY_DEFAULT_MIB : Number(limitMib);
110
+ const targetBytes = targetMib * MIB;
111
+ if (runtime.supported && targetBytes < runtime.data.response_history.bytes) {
112
+ throw new Error(`History currently uses ${mib(runtime.data.response_history.bytes)}; clean history or choose a larger limit`);
113
+ }
114
+ const previous = readUserSettings({ env, home, strict: true });
115
+ const previousMib = Object.hasOwn(previous, RESPONSE_HISTORY_MAX_MIB_KEY)
116
+ ? previous[RESPONSE_HISTORY_MAX_MIB_KEY]
117
+ : null;
118
+ const saved = writeResponseHistoryLimitMib(resetLimit ? null : targetMib, { env, home });
119
+ try {
120
+ if (runtime.supported) {
121
+ await cacheRequest(baseUrl, { ...requestOptions, body: { action: "set_limit", max_bytes: targetBytes } });
122
+ }
123
+ } catch (error) {
124
+ writeResponseHistoryLimitMib(previousMib, { env, home });
125
+ throw error;
126
+ }
127
+ output.write(`History cache limit: ${targetMib} MiB${runtime.supported ? " (active now)" : " (saved; restart ccdx to apply)"}\n`);
128
+ return { ...saved, active: runtime.supported };
129
+ }
130
+ if (action === "clean") {
131
+ if (!runtime.supported) throw new Error("Start or restart ccdx before cleaning runtime caches");
132
+ if (history) await confirmHistoryCleanup({ input, output, prompt, yes });
133
+ const result = await cacheRequest(baseUrl, {
134
+ ...requestOptions,
135
+ body: { action: "clean", history },
136
+ });
137
+ const images = result.cleaned.image_optimization;
138
+ output.write(`Cleared image transform cache: ${images.entries} entries, ${mib(images.bytes)}\n`);
139
+ if (history) {
140
+ const cleared = result.cleaned.response_history;
141
+ output.write(`Cleared response history: ${cleared.entries} entries, ${mib(cleared.bytes)}\n`);
142
+ output.write("Saved Codex transcripts and generated image files were not deleted.\n");
143
+ }
144
+ return result;
145
+ }
146
+ throw new Error(`Unknown cache action: ${action}`);
147
+ }
package/src/cli-image.mjs CHANGED
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { createInterface } from "node:readline/promises";
6
- import { atomicWriteFilePairSync } from "./atomic-file.mjs";
6
+ import { atomicWriteFileIfChangedSync, atomicWriteFilePairSync } from "./atomic-file.mjs";
7
7
  import { terminalCell } from "./cli-table.mjs";
8
8
  import { computeImageMcpCodexConfig } from "./config.mjs";
9
9
  import { probeImageTool } from "./image-readiness.mjs";
@@ -11,6 +11,7 @@ import { updateImageSkill } from "./image-skill.mjs";
11
11
  import {
12
12
  imageProviderConfigPath,
13
13
  inspectImageProvider,
14
+ normalizeImageEndpoint,
14
15
  readImageProviderConfig,
15
16
  validateImageProviderConfig,
16
17
  } from "./image-provider-config.mjs";
@@ -110,12 +111,15 @@ function enableImageProvider({ providerConfig, providerPath, codexPath, codexCon
110
111
  const normalized = validateImageProviderConfig(providerConfig, { filePath: providerPath });
111
112
  const skill = updateImageSkill({ enabled: true, codexPath, adapterPort, adapterHost });
112
113
  try {
114
+ if (fileContent(codexPath) !== codexContent) {
115
+ throw new Error("Codex configuration changed during image setup; retry to preserve the newer settings");
116
+ }
113
117
  atomicWriteFilePairSync(
114
118
  codexPath,
115
119
  codex.content,
116
120
  providerPath,
117
121
  `${JSON.stringify(normalized, null, 2)}\n`,
118
- { mode: 0o600 },
122
+ { mode: 0o600, writeFile: atomicWriteFileIfChangedSync },
119
123
  );
120
124
  } catch (error) {
121
125
  skill.rollback();
@@ -134,7 +138,12 @@ function disableImageProvider({ providerPath, codexPath, codexContent, adapterPo
134
138
  const skill = updateImageSkill({ enabled: false, codexPath, adapterPort, adapterHost });
135
139
  try {
136
140
  if (fs.existsSync(codexPath)) {
137
- atomicWriteFilePairSync(codexPath, codex.content, providerPath, null, { mode: 0o600 });
141
+ if (fileContent(codexPath) !== codexContent) {
142
+ throw new Error("Codex configuration changed during image cleanup; retry to preserve the newer settings");
143
+ }
144
+ atomicWriteFilePairSync(codexPath, codex.content, providerPath, null, {
145
+ mode: 0o600, writeFile: atomicWriteFileIfChangedSync,
146
+ });
138
147
  } else if (providerExists) {
139
148
  fs.unlinkSync(providerPath);
140
149
  }
@@ -203,10 +212,11 @@ export async function runImageCommand({
203
212
  output.write(`${commandName} enable-image\n`);
204
213
  output.write("Enter an HTTPS base URL (for example, https://api.example/v1) or a full /images/generations endpoint.\n");
205
214
  const endpointAnswer = String(await ask(`API base URL or endpoint${current ? ` [${current.endpoint}]` : ""}: `) || "").trim();
206
- const endpoint = endpointAnswer || current?.endpoint;
207
- if (!endpoint) throw new Error("Image API base URL or endpoint is required");
208
- const keyAnswer = String(await askSecret(`API key${current ? " [Enter to keep current]" : ""}: `) || "").trim();
209
- const apiKey = keyAnswer || current?.api_key;
215
+ if (!endpointAnswer && !current?.endpoint) throw new Error("Image API base URL or endpoint is required");
216
+ const endpoint = normalizeImageEndpoint(endpointAnswer || current.endpoint);
217
+ const sameOrigin = current && new URL(endpoint).origin === new URL(current.endpoint).origin;
218
+ const keyAnswer = String(await askSecret(`API key${sameOrigin ? " [Enter to keep current]" : current ? " [new origin; enter a key]" : ""}: `) || "").trim();
219
+ const apiKey = keyAnswer || (sameOrigin ? current.api_key : undefined);
210
220
  if (!apiKey) throw new Error("Image API key is required");
211
221
 
212
222
  output.write("Checking image API...\n");
@@ -227,7 +237,9 @@ export async function runImageCommand({
227
237
  },
228
238
  providerPath,
229
239
  codexPath,
230
- codexContent,
240
+ // Prompts and provider validation may take minutes. Merge into the latest
241
+ // shared file rather than replacing edits made while we were waiting.
242
+ codexContent: fileContent(codexPath),
231
243
  adapterPort,
232
244
  adapterHost,
233
245
  });