codex-copilot-dx 0.8.3 → 0.8.4

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
@@ -28,6 +28,8 @@ Codex App requests a versioned, capability-rich model catalog that is different
28
28
 
29
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.
30
30
 
31
+ `/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.
32
+
31
33
  ## Prerequisites
32
34
 
33
35
  - A GitHub account with Copilot access to the desired models
@@ -78,6 +80,8 @@ Normal startup and reuse of an existing adapter check `~/.codex/config.toml` onc
78
80
 
79
81
  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
82
 
83
+ 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.
84
+
81
85
  ## Version 0.7.0 migration
82
86
 
83
87
  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.
@@ -147,6 +151,8 @@ ccdx enable-image
147
151
 
148
152
  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
153
 
154
+ 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.
155
+
150
156
  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
157
 
152
158
  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 +243,9 @@ For the complete machine-readable status payload:
237
243
  curl -s http://127.0.0.1:2026/_ccdx/status | jq
238
244
  ```
239
245
 
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.
246
+ 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.
247
+
248
+ `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
249
 
242
250
  `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
251
 
@@ -269,7 +277,7 @@ Recovery first tries compatible local credentials and starts Device Flow only wh
269
277
  - 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
278
  - 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
279
  - 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.
280
+ - 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
281
 
274
282
  ## Configuration
275
283
 
@@ -333,9 +341,11 @@ ccdx usage
333
341
 
334
342
  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
343
 
344
+ 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.
345
+
336
346
  ## Debug logging
337
347
 
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.
348
+ 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
349
 
340
350
  ## Large histories and images
341
351
 
@@ -359,6 +369,8 @@ Changing the model or transport of a continuation can require removing incompati
359
369
 
360
370
  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
371
 
372
+ 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.
373
+
362
374
  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
375
 
364
376
  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
@@ -248,6 +248,7 @@ if (CLI.command === "doctor") {
248
248
  online: CLI.online,
249
249
  compat: CLI.compat,
250
250
  });
251
+ await LOGGING.cleanup();
251
252
  process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
252
253
  }
253
254
 
@@ -270,7 +271,10 @@ async function printUpdateNotice() {
270
271
  try {
271
272
  assertSafeAdapterHost(ADAPTER_HOST, process.env);
272
273
  void runInBackground(printUpdateNotice);
273
- if (await reuseRunningAdapterIfAvailable()) process.exit(0);
274
+ if (await reuseRunningAdapterIfAvailable()) {
275
+ await LOGGING.cleanup();
276
+ process.exit(0);
277
+ }
274
278
 
275
279
  // Ensure GitHub login, using device flow if no token exists.
276
280
  await ensureAuth();
@@ -347,7 +351,7 @@ try {
347
351
  exitCode = 1;
348
352
  console.error(status("err", `Shutdown failed: ${e.message}`));
349
353
  } finally {
350
- LOGGING.cleanup();
354
+ await LOGGING.cleanup();
351
355
  process.exit(exitCode);
352
356
  }
353
357
  };
@@ -357,6 +361,6 @@ try {
357
361
  console.error(status("err", e.message));
358
362
  await closeHttpServer(activeServer, { timeoutMs: RUNTIME.shutdownTimeoutMs }).catch(() => {});
359
363
  await flushUsageWrites();
360
- LOGGING.cleanup();
364
+ await LOGGING.cleanup();
361
365
  process.exit(1);
362
366
  }
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.4",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -213,6 +213,7 @@ export function createAdapterHandler(options = {}) {
213
213
  admission: acquireRequest,
214
214
  imagePressure,
215
215
  responseFailures,
216
+ imageGeneration: imageMcpHandler?.stats?.(),
216
217
  modelRegistry: codexModelRegistry,
217
218
  codexClient,
218
219
  codexModelRegistry,
@@ -237,7 +238,12 @@ export function createAdapterHandler(options = {}) {
237
238
  if (req.method === "GET" && pathname === "/v1/models") {
238
239
  const clientVersion = modelClientVersion(req.url);
239
240
  const abort = createRequestAbort(req, res);
240
- abort.setTimeout(upstreamTimeoutMs);
241
+ // Keep live discovery and explicit auth failures authoritative, but do
242
+ // not make a usable cached picker wait the full inference timeout.
243
+ const modelTimeoutMs = isValidModelList(codexModelRegistry?.models)
244
+ ? Math.min(upstreamTimeoutMs, parsePositiveInteger(options.cachedModelsTimeoutMs, 1500))
245
+ : upstreamTimeoutMs;
246
+ abort.setTimeout(modelTimeoutMs);
241
247
  listModelsFn({ signal: abort.signal })
242
248
  .then(async ({ status, body }) => {
243
249
  abort.clearTimeout();
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
  });
@@ -302,7 +302,7 @@ function topicHelp(name, topic) {
302
302
  models: `Usage:\n ${name} models [--format table|plain]\n\nPerforms a fresh, read-only Copilot model-directory lookup for the saved account. Interactive terminals use a table by default.`,
303
303
  usage: `Usage:\n ${name} usage [--format table|plain]\n\nSummarizes local token usage metadata without reading prompt or completion content. Interactive terminals use a table by default.`,
304
304
  animation: `Usage:\n ${name} animation\n\nPreviews all terminal activity animations together and selects the theme used the next time the adapter starts.`,
305
- "enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint (base URLs are also accepted) and API key, detects its supported image model and protocol, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image.`,
305
+ "enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint (base URLs are also accepted) and API key, detects its supported image model and protocol, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image. A blank key preserves the saved key only for the same origin; a new origin requires explicit key entry. Concurrent unrelated config edits are preserved.`,
306
306
  "disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential and unmodified CCDX image guidance.`,
307
307
  "image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and probes local image-tool readiness without generating an image or exposing its API key.`,
308
308
  "auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an advertised Responses model for Codex Auto-review.`,
@@ -158,6 +158,17 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
158
158
  if (imageHistory && typeof imageHistory === "object") {
159
159
  lines.push(status("info", `Visual history: ${count(imageHistory.active_recovery_trees)} recovery trees, ${count(imageHistory.adapted_requests)} adapted requests, ${count(imageHistory.historical_images_omitted)} older images omitted, ${count(imageHistory.timeouts_recorded)} timeouts`));
160
160
  }
161
+ if (data.image_generation) {
162
+ const generation = data.image_generation;
163
+ lines.push(status("info", `Image generation: ${count(generation.active)} active, ${count(generation.succeeded)} succeeded, ${count(generation.failed)} failed (${count(generation.busy)} busy), ${count(generation.cancelled)} cancelled, ${count(generation.delivery_failures)} delivery failures`));
164
+ }
165
+ if (data.logging) {
166
+ for (const [name, logging] of Object.entries(data.logging)) {
167
+ if (logging?.enabled === false) continue;
168
+ lines.push(status(logging.dropped_records || logging.write_failures ? "warn" : "info",
169
+ `${name === "usage" ? "Usage" : "Debug"} logging: ${count(logging.pending_records)} pending (${mebibytes(logging.pending_bytes)}), ${count(logging.dropped_records)} dropped, ${count(logging.write_failures)} write failures`));
170
+ }
171
+ }
161
172
  if (responseFailures && typeof responseFailures === "object") {
162
173
  lines.push(status("info", `Response failures: ${count(responseFailures.total)} observed, ${count(responseFailures.retried)} compatibility retries`));
163
174
  for (const failure of Array.isArray(responseFailures.recent) ? responseFailures.recent.slice(-3).reverse() : []) {
@@ -0,0 +1,11 @@
1
+ // Diagnostics are not a copy of the upstream payload. Keep useful error text,
2
+ // but remove common credential forms and explicitly labelled content echoes.
3
+ export function redactDiagnosticText(value) {
4
+ return String(value ?? "")
5
+ .replace(/\bBearer\s+[^\s,;"'<>]+/gi, "Bearer [redacted]")
6
+ .replace(/\b(?:github_pat_|gh[pousr]_|sk-|copilot_)[A-Za-z0-9._-]+/gi, "[redacted]")
7
+ .replace(/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie)["']?\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;]+)/gi, "$1[redacted]")
8
+ .replace(/((?:prompt|input|completion|tool_arguments|arguments|content)["']?\s*[:=]\s*)[^\n]+/gi, "$1[redacted]")
9
+ .replace(/(encrypted(?: function output)? content\s+)[A-Za-z0-9+/_=-]{24,}/gi, "$1[redacted]")
10
+ .replace(/\b[A-Za-z0-9+/_=-]{96,}\b/g, "[redacted]");
11
+ }
@@ -28,3 +28,37 @@ export function rotateFileIfNeededSync(filePath, incomingBytes, maxBytes) {
28
28
  fs.renameSync(filePath, backupPath);
29
29
  return true;
30
30
  }
31
+
32
+ // Callers serialize writers (usage also holds its cross-process file lock).
33
+ // Group adjacent lines without changing the existing per-record rotation rule.
34
+ export async function appendRotatingLines(filePath, lines, maxBytes) {
35
+ let size = 0;
36
+ try {
37
+ const stat = await fs.promises.stat(filePath);
38
+ if (!stat.isFile()) throw new Error("Log destination is not a regular file");
39
+ size = stat.size;
40
+ }
41
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
42
+ let group = [];
43
+ let bytes = 0;
44
+ const flush = async () => {
45
+ if (!group.length) return;
46
+ await fs.promises.appendFile(filePath, group.join(""), { encoding: "utf8", mode: 0o600 });
47
+ group = [];
48
+ bytes = 0;
49
+ };
50
+ for (const line of lines) {
51
+ const length = Buffer.byteLength(line);
52
+ if (maxBytes > 0 && size > 0 && size + length > maxBytes) {
53
+ await flush();
54
+ await fs.promises.rm(rotatedFilePath(filePath), { force: true });
55
+ await fs.promises.rename(filePath, rotatedFilePath(filePath));
56
+ size = 0;
57
+ }
58
+ group.push(line);
59
+ size += length;
60
+ bytes += length;
61
+ if (bytes >= 256 * 1024) await flush();
62
+ }
63
+ await flush();
64
+ }
@@ -3,6 +3,7 @@ import * as zlib from "node:zlib";
3
3
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
4
4
  import { status } from "./status.mjs";
5
5
  import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
6
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
6
7
 
7
8
  const COMPRESSED_BODY_WEIGHT_MULTIPLIER = 4;
8
9
  const gunzipAsync = promisify(zlib.gunzip);
@@ -730,21 +731,29 @@ export function sendUpstreamError(res, response, text) {
730
731
  res.end(text || JSON.stringify({ error: "Upstream request failed" }));
731
732
  }
732
733
 
733
- export function writeOrDrain(res, chunk) {
734
+ export function writeOrDrain(res, chunk, { signal } = {}) {
734
735
  if (res.destroyed || res.writableEnded) return Promise.resolve(false);
736
+ if (signal?.aborted) {
737
+ res.destroy?.();
738
+ return Promise.resolve(false);
739
+ }
735
740
  if (res.write(chunk)) return Promise.resolve(true);
736
741
  return new Promise((resolve, reject) => {
737
742
  const cleanup = () => {
738
743
  res.off("drain", onDrain);
739
744
  res.off("error", onError);
740
745
  res.off("close", onClose);
746
+ signal?.removeEventListener("abort", onAbort);
741
747
  };
742
748
  const onDrain = () => { cleanup(); resolve(true); };
743
749
  const onClose = () => { cleanup(); resolve(false); };
744
750
  const onError = (err) => { cleanup(); reject(err); };
751
+ const onAbort = () => { cleanup(); res.destroy?.(); resolve(false); };
745
752
  res.once("drain", onDrain);
746
753
  res.once("close", onClose);
747
754
  res.once("error", onError);
755
+ signal?.addEventListener("abort", onAbort, { once: true });
756
+ if (signal?.aborted) onAbort();
748
757
  });
749
758
  }
750
759
 
@@ -800,7 +809,7 @@ export function createRequestAbort(req, res) {
800
809
 
801
810
  export function logRequestFailure(label, err, abort) {
802
811
  if (!isAbortLikeError(err)) {
803
- console.error(status("err", `${label} request failed: ${err.message}`));
812
+ console.error(status("err", `${label} request failed: ${redactDiagnosticText(err.message)}`));
804
813
  return;
805
814
  }
806
815
 
package/src/image-mcp.mjs CHANGED
@@ -138,7 +138,8 @@ export function createImageMcpHandler({
138
138
  imageDirectory = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "images"),
139
139
  } = {}) {
140
140
  let active = 0;
141
- return async function imageMcpHandler(req, res) {
141
+ const metrics = { total: 0, succeeded: 0, failed: 0, busy: 0, cancelled: 0, delivery_failures: 0 };
142
+ const handler = async function imageMcpHandler(req, res) {
142
143
  if (!isLoopbackAddress(req.socket?.remoteAddress) && !sameHostSocket(req.socket)) {
143
144
  writeJson(res, 403, jsonRpcError(null, -32001, "Image MCP is available only on this device"));
144
145
  return;
@@ -207,18 +208,23 @@ export function createImageMcpHandler({
207
208
  writeJson(res, 200, jsonRpcError(request.id, -32602, "Unknown image tool"));
208
209
  return;
209
210
  }
211
+ metrics.total += 1;
210
212
  const config = configLoader();
211
213
  if (!config) {
214
+ metrics.failed += 1;
212
215
  imageReferences.clear();
213
216
  reply({ content: [{ type: "text", text: "Image generation is disabled. Run ccdx enable-image first." }], isError: true });
214
217
  return;
215
218
  }
216
219
  const editing = request.params.name === EDIT_TOOL_NAME;
217
220
  if (editing && !supportsImageEditing(config)) {
221
+ metrics.failed += 1;
218
222
  reply({ content: [{ type: "text", text: "The configured image provider does not support CCDX editing. No edit was submitted." }], isError: true });
219
223
  return;
220
224
  }
221
225
  if (active >= maxConcurrent) {
226
+ metrics.busy += 1;
227
+ metrics.failed += 1;
222
228
  reply({ content: [{ type: "text", text: "Image generation is busy. Try again after the current request finishes." }], isError: true });
223
229
  return;
224
230
  }
@@ -234,6 +240,7 @@ export function createImageMcpHandler({
234
240
  const parameters = { prompt: args.prompt, size: args.size ?? source?.size ?? "1024x1024" };
235
241
  if (source) parameters.image = source.image;
236
242
  const image = await generateImageFn(config, parameters, { signal: abort.signal });
243
+ abort.signal.throwIfAborted();
237
244
  let imageId = null;
238
245
  if (supportsImageEditing(config)) {
239
246
  try { imageId = imageReferences.remember(config, { ...image, size: parameters.size }); } catch {}
@@ -250,9 +257,12 @@ export function createImageMcpHandler({
250
257
  saved = await saveChatImage(image, imageDirectory, abort.signal);
251
258
  delivery = `\nInclude this image Markdown in your final chat reply to show its thumbnail:\n${saved.markdown}\nTo display this same image again, reuse this Markdown; do not call generate_image or edit_image.`;
252
259
  } catch {
260
+ if (abort.signal.aborted) throw abort.signal.reason;
261
+ metrics.delivery_failures += 1;
253
262
  delivery = "\nThe image was generated, but its chat-preview file could not be saved. Image content is still returned. Do not claim it was displayed, invent a file path, or generate/edit again to retry delivery.";
254
263
  }
255
264
  }
265
+ abort.signal.throwIfAborted();
256
266
  reply({
257
267
  // Codex prioritizes structuredContent over image blocks. Keep IDs in metadata/text.
258
268
  ...(imageId || saved ? { _meta: { ...(imageId ? { "ccdx/image_id": imageId } : {}), ...(saved ? { "ccdx/image_path": saved.filePath } : {}) } } : {}),
@@ -261,7 +271,10 @@ export function createImageMcpHandler({
261
271
  { type: "text", text: `${editing ? "Edited" : "Generated"} ${image.width || ""}${image.width && image.height ? "×" : ""}${image.height || image.size} image with ${image.model}.${note}${delivery}` },
262
272
  ],
263
273
  });
274
+ metrics.succeeded += 1;
264
275
  } catch (error) {
276
+ if (abort.signal.aborted) metrics.cancelled += 1;
277
+ else metrics.failed += 1;
265
278
  if (!res.destroyed && !res.writableEnded) {
266
279
  reply({ content: [{ type: "text", text: safeToolError(error, config.api_key) }], isError: true });
267
280
  }
@@ -271,4 +284,7 @@ export function createImageMcpHandler({
271
284
  res.off?.("close", cancel);
272
285
  }
273
286
  };
287
+ // Status never reads credentials, loads image guidance, or probes a provider.
288
+ handler.stats = () => ({ ...metrics, active, max_concurrent: maxConcurrent });
289
+ return handler;
274
290
  }
@@ -179,11 +179,16 @@ function publicAddress(address) {
179
179
  const family = net.isIP(address);
180
180
  if (family === 4) return ipv4Public(address);
181
181
  if (family !== 6) return false;
182
- const lower = address.toLowerCase();
182
+ let lower;
183
+ try { lower = new URL(`https://[${address}]/`).hostname.slice(1, -1); }
184
+ catch { return false; }
183
185
  if (lower === "::" || lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd")
184
186
  || /^fe[89ab]/.test(lower) || lower.startsWith("ff") || lower.startsWith("2001:db8:")) return false;
185
- const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
186
- return mapped ? ipv4Public(mapped[1]) : true;
187
+ const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(lower);
188
+ if (!mapped) return true;
189
+ const high = Number.parseInt(mapped[1], 16);
190
+ const low = Number.parseInt(mapped[2], 16);
191
+ return ipv4Public([high >> 8, high & 255, low >> 8, low & 255].join("."));
187
192
  }
188
193
 
189
194
  export async function downloadPublicImage(urlValue, {
@@ -217,7 +222,8 @@ export async function downloadPublicImage(urlValue, {
217
222
  if (signal?.aborted) { onAbort(); return; }
218
223
  signal?.addEventListener("abort", onAbort, { once: true });
219
224
  // DNS and transfer share one deadline; a late lookup cannot start a cancelled download.
220
- Promise.resolve().then(() => settled ? undefined : lookup(url.hostname, { all: true, verbatim: true })).then((addresses) => {
225
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
226
+ Promise.resolve().then(() => settled ? undefined : lookup(hostname, { all: true, verbatim: true })).then((addresses) => {
221
227
  if (settled) return;
222
228
  if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
223
229
  finish(providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe"));
package/src/log.mjs CHANGED
@@ -3,10 +3,17 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { format } from "node:util";
5
5
  import { status } from "./status.mjs";
6
- import { parseByteLimit, rotateFileIfNeededSync } from "./file-rotation.mjs";
6
+ import { appendRotatingLines, parseByteLimit } from "./file-rotation.mjs";
7
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
7
8
 
8
9
  let globalInstall = null;
9
10
  const DEFAULT_LOG_MAX_BYTES = 16 * 1024 * 1024;
11
+ const LOG_QUEUE_MAX_BYTES = 2 * 1024 * 1024;
12
+ const LOG_QUEUE_MAX_RECORDS = 4096;
13
+
14
+ export function debugLoggingStats() {
15
+ return globalInstall?.stats() || { enabled: false };
16
+ }
10
17
 
11
18
  export function normalizeLogLevel(value) {
12
19
  return String(value || "info").trim().toLowerCase() === "debug" ? "debug" : "info";
@@ -35,7 +42,7 @@ export function debugLogMaxBytes(env = process.env) {
35
42
 
36
43
  function renderLogPayload(args) {
37
44
  const stamp = new Date().toISOString();
38
- const text = format(...args);
45
+ const text = redactDiagnosticText(format(...args));
39
46
  return `${text.split(/\r?\n/).map((line) => `${stamp} ${line}`).join("\n")}\n`;
40
47
  }
41
48
 
@@ -43,12 +50,13 @@ export function configureLogging({
43
50
  env = process.env,
44
51
  consoleObj = console,
45
52
  home = os.homedir(),
53
+ appendLines = appendRotatingLines,
46
54
  } = {}) {
47
55
  const level = normalizeLogLevel(env.CCDX_LOG_LEVEL);
48
56
  const filePath = resolveLogPath(env.CCDX_LOG_PATH, { home });
49
57
  if (!filePath) return { level, filePath: null, cleanup: () => {} };
50
58
  if (consoleObj === console && globalInstall) {
51
- return { level, filePath: globalInstall.filePath, cleanup: globalInstall.cleanup };
59
+ return { level, ...globalInstall };
52
60
  }
53
61
 
54
62
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
@@ -59,16 +67,52 @@ export function configureLogging({
59
67
  typeof consoleObj[method] === "function" ? consoleObj[method].bind(consoleObj) : () => {},
60
68
  ]));
61
69
  let writeFailed = false;
70
+ const pending = [];
71
+ let drain = null;
72
+ let pendingBytes = 0;
73
+ let pendingRecords = 0;
74
+ let droppedRecords = 0;
75
+ let writeFailures = 0;
76
+ const stats = () => ({ enabled: true, pending_bytes: pendingBytes, pending_records: pendingRecords,
77
+ dropped_records: droppedRecords, write_failures: writeFailures,
78
+ max_bytes: LOG_QUEUE_MAX_BYTES, max_records: LOG_QUEUE_MAX_RECORDS });
79
+
80
+ function scheduleDrain() {
81
+ if (drain) return;
82
+ drain = Promise.resolve().then(async () => {
83
+ while (pending.length) {
84
+ const batch = pending.splice(0);
85
+ try {
86
+ await appendLines(filePath, batch.map(({ payload }) => payload), debugLogMaxBytes(env));
87
+ } catch (error) {
88
+ writeFailures += 1;
89
+ if (!writeFailed) originals.error(`codex-copilot-dx log write failed: ${redactDiagnosticText(error.message)}`);
90
+ writeFailed = true;
91
+ } finally {
92
+ pendingRecords -= batch.length;
93
+ pendingBytes -= batch.reduce((sum, entry) => sum + entry.bytes, 0);
94
+ }
95
+ }
96
+ }).finally(() => { drain = null; if (pending.length) scheduleDrain(); });
97
+ }
62
98
 
63
99
  function append(args) {
64
100
  try {
65
101
  const payload = renderLogPayload(args);
66
- rotateFileIfNeededSync(filePath, Buffer.byteLength(payload), debugLogMaxBytes(env));
67
- fs.appendFileSync(filePath, payload, { encoding: "utf8", mode: 0o600 });
102
+ const bytes = Buffer.byteLength(payload);
103
+ if (pendingRecords >= LOG_QUEUE_MAX_RECORDS || pendingBytes + bytes > LOG_QUEUE_MAX_BYTES) {
104
+ droppedRecords += 1;
105
+ if (droppedRecords === 1) originals.error("codex-copilot-dx log queue is full; excess file records are dropped, terminal output is unchanged (see ccdx status)");
106
+ return;
107
+ }
108
+ pending.push({ payload, bytes });
109
+ pendingRecords += 1;
110
+ pendingBytes += bytes;
111
+ scheduleDrain();
68
112
  } catch (e) {
69
113
  if (writeFailed) return;
70
114
  writeFailed = true;
71
- originals.error(`codex-copilot-dx log write failed: ${e.message}`);
115
+ originals.error(`codex-copilot-dx log write failed: ${redactDiagnosticText(e.message)}`);
72
116
  }
73
117
  }
74
118
 
@@ -79,13 +123,23 @@ export function configureLogging({
79
123
  };
80
124
  }
81
125
 
126
+ const flush = async ({ timeoutMs = 1500 } = {}) => {
127
+ let timer;
128
+ const completed = await Promise.race([
129
+ (async () => { while (drain) await drain; return true; })(),
130
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
131
+ ]);
132
+ clearTimeout(timer);
133
+ if (!completed) originals.error(`codex-copilot-dx log flush timed out after ${timeoutMs}ms`);
134
+ };
82
135
  const cleanup = () => {
83
136
  for (const method of methods) consoleObj[method] = originals[method];
84
137
  if (consoleObj === console && globalInstall?.cleanup === cleanup) globalInstall = null;
138
+ return flush();
85
139
  };
86
140
 
87
- if (consoleObj === console) globalInstall = { filePath, cleanup };
88
- return { level, filePath, cleanup };
141
+ if (consoleObj === console) globalInstall = { filePath, cleanup, flush, stats };
142
+ return { level, filePath, cleanup, flush, stats };
89
143
  }
90
144
 
91
145
  export function debugLog(message, env = process.env) {
@@ -5,6 +5,8 @@ import { imageOptimizationStats } from "./image-optimization.mjs";
5
5
  import { responseHistoryStats } from "./response-history.mjs";
6
6
  import { loadRuntimeConfig } from "./runtime-config.mjs";
7
7
  import { profileRouting } from "./profile-routing.mjs";
8
+ import { usageLoggingStats } from "./usage.mjs";
9
+ import { debugLoggingStats } from "./log.mjs";
8
10
 
9
11
  export { ADAPTER_STATUS_PATH };
10
12
  const OBSERVABILITY_RUNTIME_CONFIG = loadRuntimeConfig();
@@ -13,6 +15,7 @@ const ROUTE_NAMES = Object.freeze([
13
15
  "responses",
14
16
  "responses_compact",
15
17
  "models",
18
+ "image_mcp",
16
19
  "not_found",
17
20
  ]);
18
21
 
@@ -94,6 +97,7 @@ export function classifyAdapterRoute(method, pathname) {
94
97
  if (method === "POST" && pathname === "/v1/responses") return "responses";
95
98
  if (method === "POST" && pathname === "/v1/responses/compact") return "responses_compact";
96
99
  if (method === "GET" && pathname === "/v1/models") return "models";
100
+ if (pathname === "/mcp/image") return "image_mcp";
97
101
  return "not_found";
98
102
  }
99
103
 
@@ -155,6 +159,7 @@ export function runtimeStatusPayload({
155
159
  admission,
156
160
  imagePressure,
157
161
  responseFailures,
162
+ imageGeneration,
158
163
  modelRegistry,
159
164
  codexClient,
160
165
  codexModelRegistry,
@@ -177,6 +182,8 @@ export function runtimeStatusPayload({
177
182
  admission: admission?.diagnostics?.() || admission?.stats?.() || null,
178
183
  response_history: responseHistoryStats(),
179
184
  image_optimization: imageOptimizationStats(),
185
+ image_generation: imageGeneration || null,
186
+ logging: { usage: usageLoggingStats(), debug: debugLoggingStats() },
180
187
  image_history_pressure: imagePressure?.snapshot?.() || null,
181
188
  response_failures: responseFailures?.snapshot?.() || null,
182
189
  copilot: codexRuntime,
@@ -1,17 +1,16 @@
1
1
  import { terminalCell } from "./cli-table.mjs";
2
+ import { redactDiagnosticText } from "./diagnostic-text.mjs";
2
3
 
3
4
  const DEFAULT_RECENT_FAILURES = 10;
4
5
 
5
6
  function safeIdentifier(value, fallback = "unknown") {
6
- const text = terminalCell(value, { fallback });
7
+ const text = redactDiagnosticText(terminalCell(value, { fallback }));
7
8
  return text.slice(0, 200);
8
9
  }
9
10
 
10
11
  function safeFailureMessage(value) {
11
12
  let text = terminalCell(value, { fallback: "Response failed without an error message" });
12
- text = text
13
- .replace(/(encrypted(?: function output)? content\s+)[A-Za-z0-9+/_=-]{24,}/gi, "$1[redacted]")
14
- .replace(/\b[A-Za-z0-9+/_=-]{96,}\b/g, "[redacted]");
13
+ text = redactDiagnosticText(text);
15
14
  return text.slice(0, 500);
16
15
  }
17
16
 
@@ -319,7 +319,7 @@ export function createResponsesHandler(options) {
319
319
  "Cache-Control": "no-cache",
320
320
  Connection: "keep-alive",
321
321
  });
322
- const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
322
+ const written = await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, { signal: abort.signal });
323
323
  if (!written) return false;
324
324
  if (event === "response.completed") {
325
325
  rememberResponseHistory(prepared, data.response);
@@ -403,13 +403,13 @@ async function proxyStreamingResponses(opened, res, upstream, options) {
403
403
  const body = prelude.length === 1 ? prelude[0] : Buffer.concat(prelude, preludeBytes);
404
404
  prelude.length = 0;
405
405
  preludeBytes = 0;
406
- return writeOrDrain(res, body);
406
+ return writeOrDrain(res, body, { signal: options.signal });
407
407
  };
408
408
  const forwardFrame = async (frame) => {
409
409
  if (!frame) return true;
410
410
  if (!holdPrelude) {
411
411
  writeHeaders();
412
- return writeOrDrain(res, frame);
412
+ return writeOrDrain(res, frame, { signal: options.signal });
413
413
  }
414
414
  prelude.push(frame);
415
415
  preludeBytes += frame.byteLength;
@@ -11,6 +11,10 @@ export async function endStreamWithError(res, error, abort) {
11
11
  markStreamFailure();
12
12
  if (res.destroyed || res.writableEnded) return;
13
13
  const data = streamErrorData(error, abort);
14
- await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`).catch(() => false);
14
+ // Allow a final error frame after an upstream timeout, but never wait forever
15
+ // for a client that has stopped reading (including protocol-error paths).
16
+ await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`, {
17
+ signal: AbortSignal.timeout(1000),
18
+ }).catch(() => false);
15
19
  if (!res.destroyed && !res.writableEnded) res.end();
16
20
  }
package/src/usage.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 { cliOutputFormat, cliOutputWidth, formatResponsiveCliTable, terminalCell } from "./cli-table.mjs";
6
- import { parseByteLimit, rotateFileIfNeededSync, rotatedFilePath } from "./file-rotation.mjs";
6
+ import { appendRotatingLines, parseByteLimit, rotatedFilePath } from "./file-rotation.mjs";
7
7
  import { withFileLock } from "./lock.mjs";
8
8
 
9
9
  const DEFAULT_USAGE_PATH = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "usage.jsonl");
@@ -15,6 +15,17 @@ const USAGE_FLUSH_TIMEOUT_MS = 1_500;
15
15
 
16
16
  const pendingWrites = [];
17
17
  let writeDrain = null;
18
+ const USAGE_QUEUE_MAX_RECORDS = 4096;
19
+ const USAGE_QUEUE_MAX_BYTES = 4 * 1024 * 1024;
20
+ let pendingRecords = 0;
21
+ let pendingBytes = 0;
22
+ let droppedRecords = 0;
23
+ let writeFailures = 0;
24
+
25
+ export function usageLoggingStats() {
26
+ return { pending_records: pendingRecords, pending_bytes: pendingBytes, dropped_records: droppedRecords,
27
+ write_failures: writeFailures, max_records: USAGE_QUEUE_MAX_RECORDS, max_bytes: USAGE_QUEUE_MAX_BYTES };
28
+ }
18
29
 
19
30
  export function usageLogPath() {
20
31
  return process.env.CCDX_USAGE_PATH || DEFAULT_USAGE_PATH;
@@ -114,10 +125,7 @@ async function writeUsageBatch(batch) {
114
125
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
115
126
  await withFileLock(`${path.resolve(filePath)}.lock`, async () => {
116
127
  batch.push(...takePendingWrites(filePath, maxBytes));
117
- for (const { line } of batch) {
118
- rotateFileIfNeededSync(filePath, Buffer.byteLength(line), maxBytes);
119
- await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 });
120
- }
128
+ await appendRotatingLines(filePath, batch.map(({ line }) => line), maxBytes);
121
129
  }, {
122
130
  timeoutMs: USAGE_WRITE_LOCK_TIMEOUT_MS,
123
131
  staleMs: USAGE_WRITE_LOCK_STALE_MS,
@@ -134,10 +142,15 @@ function scheduleUsageWriteDrain() {
134
142
  try {
135
143
  await writeUsageBatch(batch);
136
144
  } catch (error) {
145
+ writeFailures += 1;
137
146
  batch.push(...takePendingWrites(first.filePath, first.maxBytes));
138
147
  console.error(`codex-copilot-dx usage log write failed: ${error.message}`);
139
148
  } finally {
140
- for (const entry of batch) entry.resolve();
149
+ for (const entry of batch) {
150
+ pendingRecords -= 1;
151
+ pendingBytes -= entry.bytes;
152
+ entry.resolve();
153
+ }
141
154
  }
142
155
  }
143
156
  }).finally(() => {
@@ -150,8 +163,16 @@ export function recordUsage(record) {
150
163
  if (!record || process.env.CCDX_DISABLE_USAGE === "1") return Promise.resolve();
151
164
  const filePath = usageLogPath();
152
165
  const line = `${JSON.stringify(record)}\n`;
166
+ const bytes = Buffer.byteLength(line);
167
+ if (pendingRecords >= USAGE_QUEUE_MAX_RECORDS || pendingBytes + bytes > USAGE_QUEUE_MAX_BYTES) {
168
+ droppedRecords += 1;
169
+ if (droppedRecords === 1) console.error("codex-copilot-dx usage queue is full; excess usage records are dropped, inference is unaffected (see ccdx status)");
170
+ return Promise.resolve();
171
+ }
172
+ pendingRecords += 1;
173
+ pendingBytes += bytes;
153
174
  const completed = new Promise((resolve) => {
154
- pendingWrites.push({ filePath, line, maxBytes: usageLogMaxBytes(), resolve });
175
+ pendingWrites.push({ filePath, line, bytes, maxBytes: usageLogMaxBytes(), resolve });
155
176
  });
156
177
  scheduleUsageWriteDrain();
157
178
  return completed;