codex-copilot-dx 0.4.27 → 0.4.29

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dale Xiao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -12,12 +12,12 @@ A single in-process adapter (port `2026`) exposes both APIs over your Copilot su
12
12
 
13
13
  Supports both HTTP SSE streaming and non-streaming.
14
14
 
15
- Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adapter maps it to Copilot's `gpt-5.4-mini` Responses model by default and logs both model IDs when the mapping is used.
15
+ Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adapter maps it to Copilot's `gpt-5.5` Responses model by default and logs both model IDs when the mapping is used.
16
16
 
17
17
  ## Prerequisites
18
18
 
19
19
  - GitHub Copilot subscription (Individual, Business, or Enterprise)
20
- - Node.js 18.17+
20
+ - Node.js 22.15+ (required for built-in Zstandard request decompression)
21
21
  - [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and/or Claude App installed
22
22
 
23
23
  ## Usage
@@ -100,21 +100,23 @@ Environment variables:
100
100
  | `ADAPTER_HOST` | `127.0.0.1` | Host for the adapter; only loopback hosts are allowed by default |
101
101
  | `ADAPTER_PORT` | `2026` | Port for the adapter |
102
102
  | `CCDX_ALLOW_LAN` | unset | Set to `1` to allow non-loopback `ADAPTER_HOST` values such as `0.0.0.0`; exposes your Copilot-backed adapter beyond this machine |
103
- | `CCDX_MAX_BODY_BYTES` | `134217728` | Maximum compressed/raw request body size |
104
- | `CCDX_MAX_DECODED_BODY_BYTES` | `268435456` | Maximum decoded request body size after decompression |
103
+ | `CCDX_MAX_BODY_BYTES` | `67108864` | Maximum compressed/raw request body size |
104
+ | `CCDX_MAX_DECODED_BODY_BYTES` | `134217728` | Maximum decoded request body size after decompression |
105
+ | `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
105
106
  | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
106
107
  | `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
107
108
  | `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
108
109
  | `CCDX_UPSTREAM_RETRIES` | `2` | Retries for transient Copilot upstream network errors; capped at `5` |
109
110
  | `CCDX_UPSTREAM_RETRY_DELAY_MS` | `300` | Initial upstream retry backoff in milliseconds; capped at `5000` |
110
- | `CCDX_AUTO_REVIEW_MODEL` | `gpt-5.4-mini` | Copilot Responses model used for Codex Auto-review requests |
111
+ | `CCDX_AUTO_REVIEW_MODEL` | `gpt-5.5` | Copilot Responses model used for Codex Auto-review requests |
111
112
  | `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
112
113
  | `CCDX_LOG_LEVEL` | `info` | Set to `debug` to include upstream request attempts, status codes, retry causes, and timings |
113
114
  | `CCDX_LOG_MAX_BYTES` | `16777216` | Rotate the debug log at this size, retaining one `.1` backup; set to `0` to disable rotation |
114
115
  | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
115
116
  | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
116
117
  | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
117
- | `CCDX_IMG_CONCURRENCY` | `4` | Concurrent image optimization tasks; values above `12` are capped at `12` |
118
+ | `CCDX_IMG_CONCURRENCY` | `2` | Global concurrent image optimization tasks; values above `12` are capped at `12` |
119
+ | `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
118
120
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
119
121
  | `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
120
122
  | `CCDX_CLAUDE_DESKTOP_API_KEY` | generated for opt-in setup | Bearer key written into the Claude App profile and recognized by the adapter for model discovery |
@@ -151,7 +153,7 @@ codex-copilot-dx usage
151
153
 
152
154
  Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, and GitHub Copilot's `/responses` endpoint can reject oversized requests with `413 Payload Too Large`.
153
155
 
154
- The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`.
156
+ The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`. It keeps an existing in-bounds WebP unchanged, never replaces an image with a larger encoding, and applies one global concurrency limit across direct and tool-output images.
155
157
 
156
158
  Newer ChatGPT/Codex clients can advertise an `image_gen` namespace that already exists upstream. The adapter removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image inputs and screenshot optimization remain enabled.
157
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,15 +25,24 @@
25
25
  "responses-api"
26
26
  ],
27
27
  "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/dalexiao/codex-copilot-dx.git"
31
+ },
32
+ "homepage": "https://github.com/dalexiao/codex-copilot-dx#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/dalexiao/codex-copilot-dx/issues"
35
+ },
28
36
  "engines": {
29
- "node": ">=18.17"
37
+ "node": ">=22.15.0"
30
38
  },
31
39
  "scripts": {
32
40
  "test": "node --test",
33
41
  "test:smoke": "node scripts/offline-smoke.mjs",
34
42
  "test:e2e": "bash test/e2e.sh",
35
43
  "pack:check": "npm pack --dry-run --json",
36
- "verify": "npm test && npm run test:smoke && npm run pack:check"
44
+ "verify": "npm test && npm run test:smoke && npm run pack:check",
45
+ "prepublishOnly": "npm run verify"
37
46
  },
38
47
  "dependencies": {
39
48
  "gpt-tokenizer": "^3.4.0",
package/src/adapter.mjs CHANGED
@@ -69,6 +69,7 @@ function isResponsesOnlyModel(model) {
69
69
  const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
70
70
  const DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS = 120 * 1000;
71
71
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120 * 1000;
72
+ const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
72
73
 
73
74
  function positiveInt(value, fallback) {
74
75
  const n = Number.parseInt(value, 10);
@@ -78,6 +79,7 @@ function positiveInt(value, fallback) {
78
79
  const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
79
80
  const STREAM_HANDSHAKE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS);
80
81
  const STREAM_IDLE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_IDLE_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS);
82
+ const MAX_SSE_BUFFER_BYTES = positiveInt(process.env.CCDX_MAX_SSE_BUFFER_BYTES, DEFAULT_MAX_SSE_BUFFER_BYTES);
81
83
 
82
84
  export function requestPath(reqUrl) {
83
85
  return new URL(reqUrl || "/", "http://localhost").pathname;
@@ -324,6 +326,9 @@ function readSseEvents(buffer, onData) {
324
326
  const match = buffer.match(/\r?\n\r?\n/);
325
327
  if (!match) return buffer;
326
328
  const chunk = buffer.slice(0, match.index);
329
+ if (Buffer.byteLength(chunk) > MAX_SSE_BUFFER_BYTES) {
330
+ throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
331
+ }
327
332
  buffer = buffer.slice(match.index + match[0].length);
328
333
  const data = chunk
329
334
  .split(/\r?\n/)
@@ -334,6 +339,22 @@ function readSseEvents(buffer, onData) {
334
339
  }
335
340
  }
336
341
 
342
+ function streamErrorData(protocol, error, abort) {
343
+ const code = abort?.reason || "upstream_stream_error";
344
+ const message = `${code}: ${error?.message || "Upstream stream failed"}`;
345
+ if (protocol === "anthropic") {
346
+ return { type: "error", error: { type: "api_error", message } };
347
+ }
348
+ return { type: "error", code, message, param: null };
349
+ }
350
+
351
+ async function endStreamWithError(res, protocol, error, abort) {
352
+ if (res.destroyed || res.writableEnded) return;
353
+ const data = streamErrorData(protocol, error, abort);
354
+ await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`).catch(() => false);
355
+ if (!res.destroyed && !res.writableEnded) res.end();
356
+ }
357
+
337
358
  async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
338
359
  const opened = await openCopilotResponse(reqContext, upstream, options);
339
360
  const { resp, errorText } = opened;
@@ -358,6 +379,9 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
358
379
  const { done, value } = await reader.read();
359
380
  if (done) {
360
381
  buffer += decoder.decode();
382
+ if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
383
+ throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
384
+ }
361
385
  readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
362
386
  res.end();
363
387
  return;
@@ -365,8 +389,14 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
365
389
  options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
366
390
  buffer += decoder.decode(value, { stream: true });
367
391
  buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
392
+ if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
393
+ throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
394
+ }
368
395
  if (!await writeOrDrain(res, value)) return;
369
396
  }
397
+ } catch (e) {
398
+ logRequestFailure("Responses", e, options.abort);
399
+ await endStreamWithError(res, "responses", e, options.abort);
370
400
  } finally {
371
401
  await reader.cancel().catch(() => {});
372
402
  reader.releaseLock();
@@ -508,11 +538,11 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
508
538
  }, { signal: options.signal });
509
539
  } catch (e) {
510
540
  const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
511
- onError(statusCode, e.message);
541
+ await onError(statusCode, e.message);
512
542
  return;
513
543
  }
514
544
  if (!resp.ok) {
515
- onError(resp.status, await resp.text());
545
+ await onError(resp.status, await resp.text());
516
546
  return;
517
547
  }
518
548
  options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
@@ -655,7 +685,8 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
655
685
  }
656
686
  }
657
687
  } catch (e) {
658
- onError(500, e?.message || "upstream stream error");
688
+ const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
689
+ await onError(statusCode, e?.message || "upstream stream error");
659
690
  return;
660
691
  }
661
692
  await emitCompleted();
@@ -723,7 +754,14 @@ export function createAdapterHandler(options = {}) {
723
754
  rememberResponseHistory(prepared, data.response);
724
755
  recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
725
756
  }
726
- }, () => { if (!res.writableEnded) res.end(); }, (status, errMsg) => { if (!res.headersSent) res.writeHead(status || 500); res.end(errMsg); }, {
757
+ }, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg) => {
758
+ if (!res.headersSent) {
759
+ res.writeHead(statusCode || 500, { "Content-Type": "text/plain; charset=utf-8" });
760
+ res.end(errMsg);
761
+ return;
762
+ }
763
+ await endStreamWithError(res, "responses", new Error(errMsg), abort);
764
+ }, {
727
765
  signal: abort.signal,
728
766
  abort,
729
767
  streamIdleTimeoutMs,
@@ -855,18 +893,24 @@ export function createAdapterHandler(options = {}) {
855
893
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
856
894
  let messageId;
857
895
  let usage;
858
- await streamAnthropicFromLines(
859
- webStreamLines(upstream, {
860
- onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
861
- }),
862
- async (event, data) => {
863
- if (event === "message_start") messageId = data.message?.id;
864
- if (event === "message_delta") usage = data.usage;
865
- await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
866
- },
867
- requestedModel,
868
- { forceModel: forceRequestedModel },
869
- );
896
+ try {
897
+ await streamAnthropicFromLines(
898
+ webStreamLines(upstream, {
899
+ onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
900
+ }),
901
+ async (event, data) => {
902
+ if (event === "message_start") messageId = data.message?.id;
903
+ if (event === "message_delta") usage = data.usage;
904
+ await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
905
+ },
906
+ requestedModel,
907
+ { forceModel: forceRequestedModel },
908
+ );
909
+ } catch (e) {
910
+ logRequestFailure("Messages", e, abort);
911
+ await endStreamWithError(res, "anthropic", e, abort);
912
+ return;
913
+ }
870
914
  recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
871
915
  if (!res.writableEnded) res.end();
872
916
  } else {
@@ -2,8 +2,8 @@ import { promisify } from "node:util";
2
2
  import * as zlib from "node:zlib";
3
3
  import { status } from "./status.mjs";
4
4
 
5
- const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
6
- const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
5
+ const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
6
+ const DEFAULT_MAX_DECODED_BODY_BYTES = 128 * 1024 * 1024;
7
7
  const gunzipAsync = promisify(zlib.gunzip);
8
8
  const inflateAsync = promisify(zlib.inflate);
9
9
  const brotliDecompressAsync = promisify(zlib.brotliDecompress);
@@ -27,8 +27,12 @@ function payloadTooLarge(kind, maxBytes) {
27
27
  return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
28
28
  }
29
29
 
30
+ function requestContentLength(req) {
31
+ return Number.parseInt(req.headers?.["content-length"] || "", 10);
32
+ }
33
+
30
34
  async function readRequestBuffer(req, maxBytes) {
31
- const contentLength = Number.parseInt(req.headers?.["content-length"] || "", 10);
35
+ const contentLength = requestContentLength(req);
32
36
  if (Number.isFinite(contentLength) && contentLength > maxBytes) {
33
37
  throw payloadTooLarge("Raw", maxBytes);
34
38
  }
@@ -44,6 +48,35 @@ async function readRequestBuffer(req, maxBytes) {
44
48
  return Buffer.concat(chunks);
45
49
  }
46
50
 
51
+ async function readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes) {
52
+ const contentLength = requestContentLength(req);
53
+ if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
54
+ throw payloadTooLarge("Raw", maxBodyBytes);
55
+ }
56
+ if (Number.isFinite(contentLength) && contentLength > maxDecodedBodyBytes) {
57
+ throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
58
+ }
59
+
60
+ const decoder = new TextDecoder();
61
+ let text = "";
62
+ let total = 0;
63
+ for await (const chunk of req) {
64
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
65
+ total += buffer.length;
66
+ if (total > maxBodyBytes) throw payloadTooLarge("Raw", maxBodyBytes);
67
+ if (total > maxDecodedBodyBytes) throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
68
+ text += decoder.decode(buffer, { stream: true });
69
+ }
70
+ return text + decoder.decode();
71
+ }
72
+
73
+ function contentEncodings(contentEncoding) {
74
+ return String(contentEncoding || "identity")
75
+ .split(",")
76
+ .map((value) => value.trim().toLowerCase())
77
+ .filter(Boolean);
78
+ }
79
+
47
80
  async function decompressBody(decompress, buffer, maxBytes) {
48
81
  try {
49
82
  return await decompress(buffer, { maxOutputLength: maxBytes });
@@ -56,10 +89,7 @@ async function decompressBody(decompress, buffer, maxBytes) {
56
89
  }
57
90
 
58
91
  async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
59
- const encodings = String(contentEncoding || "identity")
60
- .split(",")
61
- .map((value) => value.trim().toLowerCase())
62
- .filter(Boolean);
92
+ const encodings = contentEncodings(contentEncoding);
63
93
 
64
94
  let decoded = buffer;
65
95
  for (const encoding of encodings.reverse()) {
@@ -86,6 +116,10 @@ export async function readJsonBody(req, {
86
116
  maxBodyBytes = MAX_BODY_BYTES,
87
117
  maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
88
118
  } = {}) {
119
+ const encodings = contentEncodings(req.headers?.["content-encoding"]);
120
+ if (encodings.every((encoding) => encoding === "identity")) {
121
+ return JSON.parse(await readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes));
122
+ }
89
123
  const buffer = await readRequestBuffer(req, maxBodyBytes);
90
124
  const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
91
125
  return JSON.parse(decoded.toString("utf8"));
@@ -93,7 +127,11 @@ export async function readJsonBody(req, {
93
127
 
94
128
  export function sendJsonError(res, err, fallbackStatus = 400) {
95
129
  if (res.destroyed || res.writableEnded) return;
96
- if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
130
+ if (res.headersSent) {
131
+ res.end();
132
+ return;
133
+ }
134
+ res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
97
135
  res.end(JSON.stringify({ error: err?.message || "Request failed" }));
98
136
  }
99
137
 
@@ -1,14 +1,26 @@
1
1
  import { status } from "./status.mjs";
2
2
 
3
- const IMG_MAX_DIM = Number.parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
4
- const IMG_QUALITY = Number.parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
5
- const IMG_MIN_BYTES = Number.parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
6
- const DEFAULT_IMG_CONCURRENCY = 4;
3
+ const DEFAULT_IMG_CONCURRENCY = 2;
7
4
  const IMG_MAX_CONCURRENCY = 12;
5
+ const DEFAULT_IMG_MAX_INPUT_PIXELS = 40 * 1000 * 1000;
6
+ const IMG_MAX_DIM = positiveInt(process.env.CCDX_IMG_MAX_DIM, 2048);
7
+ const IMG_QUALITY = positiveInt(process.env.CCDX_IMG_QUALITY, 85, 100);
8
+ const IMG_MIN_BYTES = nonNegativeInt(process.env.CCDX_IMG_MIN_BYTES, 100000);
9
+ const IMG_MAX_INPUT_PIXELS = positiveInt(process.env.CCDX_IMG_MAX_INPUT_PIXELS, DEFAULT_IMG_MAX_INPUT_PIXELS);
8
10
  const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
9
11
  const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
10
12
  let sharpImport = null;
11
13
 
14
+ function positiveInt(value, fallback, max = Number.MAX_SAFE_INTEGER) {
15
+ const n = Number.parseInt(value, 10);
16
+ return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback;
17
+ }
18
+
19
+ function nonNegativeInt(value, fallback) {
20
+ const n = Number.parseInt(value, 10);
21
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
22
+ }
23
+
12
24
  export function parseImageConcurrency(value) {
13
25
  const n = Number.parseInt(value, 10);
14
26
  return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
@@ -31,11 +43,20 @@ export async function optimizeImageDataUrl(dataUrl) {
31
43
 
32
44
  try {
33
45
  const resize = await sharp();
34
- const out = await resize(raw, { failOn: "none" })
46
+ const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
47
+ const metadata = await image.metadata();
48
+ if (mime === "image/webp"
49
+ && metadata.width <= IMG_MAX_DIM
50
+ && metadata.height <= IMG_MAX_DIM
51
+ && (!metadata.orientation || metadata.orientation === 1)) {
52
+ return dataUrl;
53
+ }
54
+ const out = await image
35
55
  .rotate()
36
56
  .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
37
57
  .webp({ quality: IMG_QUALITY, effort: 4 })
38
58
  .toBuffer();
59
+ if (out.length >= raw.length) return dataUrl;
39
60
  const ratio = ((out.length / raw.length) * 100).toFixed(1);
40
61
  console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
41
62
  return `data:image/webp;base64,${out.toString("base64")}`;
@@ -57,16 +78,16 @@ export async function runWithConcurrency(taskFns, concurrency) {
57
78
  }));
58
79
  }
59
80
 
60
- function visitImageParts(parts, tasks) {
81
+ function visitImageParts(parts, tasks, optimizeImage) {
61
82
  if (!Array.isArray(parts)) return;
62
83
  for (const part of parts) {
63
84
  if (!part) continue;
64
85
  if (part.type === "input_image" && typeof part.image_url === "string") {
65
- tasks.push(async () => { part.image_url = await optimizeImageDataUrl(part.image_url); });
86
+ tasks.push(async () => { part.image_url = await optimizeImage(part.image_url); });
66
87
  } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
67
88
  tasks.push(async () => {
68
89
  const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
69
- const optimized = await optimizeImageDataUrl(dataUrl);
90
+ const optimized = await optimizeImage(dataUrl);
70
91
  const match = /^data:([^;]+);base64,(.+)$/.exec(optimized);
71
92
  if (match) {
72
93
  part.source.media_type = match[1];
@@ -77,32 +98,31 @@ function visitImageParts(parts, tasks) {
77
98
  }
78
99
  }
79
100
 
80
- export async function optimizeImagesInBody(reqBody) {
101
+ export async function optimizeImagesInBody(reqBody, {
102
+ concurrency = IMG_CONCURRENCY,
103
+ optimizeImage = optimizeImageDataUrl,
104
+ } = {}) {
81
105
  if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
82
106
  const tasks = [];
107
+ const finalize = [];
83
108
 
84
109
  for (const item of reqBody.input) {
85
110
  if (!item) continue;
86
111
  if (item.type === "message" && Array.isArray(item.content)) {
87
- visitImageParts(item.content, tasks);
112
+ visitImageParts(item.content, tasks, optimizeImage);
88
113
  }
89
114
  if (item.type === "function_call_output") {
90
115
  if (Array.isArray(item.output)) {
91
- visitImageParts(item.output, tasks);
116
+ visitImageParts(item.output, tasks, optimizeImage);
92
117
  } else if (typeof item.output === "string") {
93
118
  const trimmed = item.output.trim();
94
119
  if (trimmed.startsWith("[")) {
95
120
  try {
96
121
  const parsed = JSON.parse(trimmed);
97
122
  if (Array.isArray(parsed)) {
98
- const localTasks = [];
99
- visitImageParts(parsed, localTasks);
100
- if (localTasks.length) {
101
- tasks.push(async () => {
102
- await runWithConcurrency(localTasks, IMG_CONCURRENCY);
103
- item.output = JSON.stringify(parsed);
104
- });
105
- }
123
+ const taskCount = tasks.length;
124
+ visitImageParts(parsed, tasks, optimizeImage);
125
+ if (tasks.length > taskCount) finalize.push(() => { item.output = JSON.stringify(parsed); });
106
126
  }
107
127
  } catch {
108
128
  // Leave non-JSON tool output untouched.
@@ -112,7 +132,8 @@ export async function optimizeImagesInBody(reqBody) {
112
132
  }
113
133
  }
114
134
 
115
- if (tasks.length) await runWithConcurrency(tasks, IMG_CONCURRENCY);
135
+ if (tasks.length) await runWithConcurrency(tasks, concurrency);
136
+ for (const apply of finalize) apply();
116
137
  return reqBody;
117
138
  }
118
139
 
package/src/lock.mjs CHANGED
@@ -1,15 +1,52 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
3
4
 
4
5
  function sleep(ms) {
5
6
  return new Promise((resolve) => setTimeout(resolve, ms));
6
7
  }
7
8
 
9
+ function readLockSnapshot(lockPath) {
10
+ const stat = fs.statSync(lockPath);
11
+ let record = {};
12
+ try {
13
+ record = JSON.parse(fs.readFileSync(lockPath, "utf8"));
14
+ } catch {}
15
+ return {
16
+ dev: stat.dev,
17
+ ino: stat.ino,
18
+ mtimeMs: stat.mtimeMs,
19
+ size: stat.size,
20
+ pid: Number(record.pid),
21
+ owner: typeof record.owner === "string" ? record.owner : "",
22
+ };
23
+ }
24
+
25
+ function processIsAlive(pid) {
26
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch (e) {
31
+ return e?.code === "EPERM";
32
+ }
33
+ }
34
+
35
+ function sameLock(left, right) {
36
+ return left.dev === right.dev
37
+ && left.ino === right.ino
38
+ && left.mtimeMs === right.mtimeMs
39
+ && left.size === right.size
40
+ && left.owner === right.owner;
41
+ }
42
+
8
43
  function maybeRemoveStaleLock(lockPath, staleMs, nowMs) {
9
44
  if (!Number.isFinite(staleMs) || staleMs <= 0) return false;
10
45
  try {
11
- const stat = fs.statSync(lockPath);
12
- if (nowMs - stat.mtimeMs <= staleMs) return false;
46
+ const first = readLockSnapshot(lockPath);
47
+ if (nowMs - first.mtimeMs <= staleMs || processIsAlive(first.pid)) return false;
48
+ const current = readLockSnapshot(lockPath);
49
+ if (!sameLock(first, current)) return false;
13
50
  fs.unlinkSync(lockPath);
14
51
  return true;
15
52
  } catch (e) {
@@ -26,12 +63,20 @@ export async function withFileLock(lockPath, fn, {
26
63
  } = {}) {
27
64
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
28
65
  const started = now();
66
+ const owner = randomUUID();
29
67
  let fd = null;
30
68
 
31
69
  while (fd === null) {
32
70
  try {
33
- fd = fs.openSync(lockPath, "wx", 0o600);
34
- fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() }));
71
+ const candidateFd = fs.openSync(lockPath, "wx", 0o600);
72
+ try {
73
+ fs.writeFileSync(candidateFd, JSON.stringify({ pid: process.pid, owner, created_at: new Date().toISOString() }));
74
+ fd = candidateFd;
75
+ } catch (e) {
76
+ fs.closeSync(candidateFd);
77
+ try { fs.unlinkSync(lockPath); } catch {}
78
+ throw e;
79
+ }
35
80
  break;
36
81
  } catch (e) {
37
82
  if (e?.code !== "EEXIST") throw e;
@@ -50,11 +95,19 @@ export async function withFileLock(lockPath, fn, {
50
95
  try {
51
96
  if (fd !== null) fs.closeSync(fd);
52
97
  } finally {
98
+ let currentOwner = "";
53
99
  try {
54
- fs.unlinkSync(lockPath);
100
+ currentOwner = readLockSnapshot(lockPath).owner;
55
101
  } catch (e) {
56
102
  if (e?.code !== "ENOENT") throw e;
57
103
  }
104
+ if (currentOwner === owner) {
105
+ try {
106
+ fs.unlinkSync(lockPath);
107
+ } catch (e) {
108
+ if (e?.code !== "ENOENT") throw e;
109
+ }
110
+ }
58
111
  }
59
112
  }
60
113
  }
package/src/models.mjs CHANGED
@@ -61,7 +61,7 @@ const DEFAULT_CLAUDE_MODEL_ALIAS_DEFS = [
61
61
  ];
62
62
 
63
63
  export const CODEX_AUTO_REVIEW_MODEL = "codex-auto-review";
64
- export const DEFAULT_CODEX_AUTO_REVIEW_MODEL = "gpt-5.4-mini";
64
+ export const DEFAULT_CODEX_AUTO_REVIEW_MODEL = "gpt-5.5";
65
65
 
66
66
  function cleanList(value) {
67
67
  return String(value || "")
package/src/stream.mjs CHANGED
@@ -1,6 +1,21 @@
1
1
  // Yield fetch Response body lines while preserving SSE semantics for callers.
2
2
  // Strip CRLF tails and release the reader if iteration stops early.
3
- export async function* webStreamLines(response, { onChunk } = {}) {
3
+ const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
4
+
5
+ function positiveInt(value, fallback) {
6
+ const n = Number.parseInt(value, 10);
7
+ return Number.isFinite(n) && n > 0 ? n : fallback;
8
+ }
9
+
10
+ const MAX_SSE_BUFFER_BYTES = positiveInt(process.env.CCDX_MAX_SSE_BUFFER_BYTES, DEFAULT_MAX_SSE_BUFFER_BYTES);
11
+
12
+ function assertSseBufferLimit(buffer, maxBufferBytes) {
13
+ if (Buffer.byteLength(buffer) > maxBufferBytes) {
14
+ throw new Error(`Upstream SSE buffer exceeds ${maxBufferBytes} bytes`);
15
+ }
16
+ }
17
+
18
+ export async function* webStreamLines(response, { onChunk, maxBufferBytes = MAX_SSE_BUFFER_BYTES } = {}) {
4
19
  const reader = response.body.getReader();
5
20
  const decoder = new TextDecoder();
6
21
  let buf = "";
@@ -12,9 +27,14 @@ export async function* webStreamLines(response, { onChunk } = {}) {
12
27
  buf += decoder.decode(value, { stream: true });
13
28
  const lines = buf.split("\n");
14
29
  buf = lines.pop();
15
- for (const line of lines) yield line.replace(/\r$/, "");
30
+ assertSseBufferLimit(buf, maxBufferBytes);
31
+ for (const line of lines) {
32
+ assertSseBufferLimit(line, maxBufferBytes);
33
+ yield line.replace(/\r$/, "");
34
+ }
16
35
  }
17
36
  buf += decoder.decode();
37
+ assertSseBufferLimit(buf, maxBufferBytes);
18
38
  if (buf.length > 0) yield buf.replace(/\r$/, "");
19
39
  } finally {
20
40
  await reader.cancel().catch(() => {});