codex-copilot-dx 0.8.11 → 0.9.1

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
@@ -131,6 +131,17 @@ ccdx cache --clean --history
131
131
 
132
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
133
 
134
+ ### Request-body limits
135
+
136
+ ```bash
137
+ ccdx limits
138
+ ccdx limits --decoded 256
139
+ ccdx limits -256
140
+ ccdx limits --decoded default
141
+ ```
142
+
143
+ `ccdx limits` distinguishes the saved decoded request-body limit from the running adapter's active limit. The default is 128 MiB; the opt-in saved limit accepts 128–512 MiB. Changes take effect only after restarting the adapter, never restart it automatically, and do not clear conversations or caches. `CCDX_MAX_DECODED_BODY_BYTES` overrides the saved setting; unset it before changing the saved limit. The separate 64 MiB raw/compressed-body limit is not changed. Larger decoded requests can use substantially more process memory during JSON parsing and image preparation; raising this limit may enable a large thread to compact, but does not guarantee successful inference or compaction. Return to `default` after temporary use.
144
+
134
145
  ### Terminal animation
135
146
 
136
147
  Choose the terminal activity animation interactively:
@@ -173,6 +184,8 @@ The provider credential is stored in `~/.config/codex-copilot-dx/image-provider.
173
184
 
174
185
  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.
175
186
 
187
+ For Qwen-style image responses, CCDX accepts canonical PNG, JPEG, or WebP `data:image/...;base64,` output within the existing 32 MiB image limit. Public HTTPS image URLs still use the same pinned-DNS download checks; HTTP, private-network targets, and URLs with embedded credentials remain rejected.
188
+
176
189
  Existing compatible `ccdx_image` registrations are reused without requiring CCDX's comment markers or resetting user timeouts/tool policies. Missing or damaged markers alone do not block startup. Core GPT configuration is committed independently before optional image maintenance: image configuration conflicts, image-only write failures, or Skill maintenance failures warn without aborting the core service. No new image guidance is installed after failed image configuration. With images disabled or their provider configuration invalid/unreadable, normal startup performs no image configuration maintenance, Skill installation, or image-network probe; explicit `disable-image` owns cleanup. Automatic updates/removal are limited to recognizable CCDX defaults and isolated TOML declarations, never everything between two comments. Ambiguous shared-inline or customized registrations are left intact; `disable-image` still disables the CCDX provider and removes its owned guidance. Invalid TOML/UTF-8 in the shared Codex config or a failed essential core configuration write remains an explicit error rather than a silent success.
177
190
 
178
191
  Direct MCP generations and edits retain their original inline image content and editing ID, and also save the exact image bytes privately under `~/.local/share/codex-copilot-dx/images`. The result includes ready-to-use image Markdown for Codex's final chat reply, so the image appears as a clickable thumbnail instead of only inside a tool result. Files use unique names and are not overwritten or removed on adapter restart. Asking to show the same image again reuses its existing Markdown, not a new generation or edit. If saving fails, CCDX still returns the generated image content, reports the delivery failure, and does not retry the provider or invent a file path. The bundled helper continues saving once at its existing output location, without a second adapter-side copy. Existing enabled users receive updated, unmodified CCDX-owned guidance on the next CCDX startup; no new setup command is needed.
package/bin/cli.mjs CHANGED
@@ -101,6 +101,24 @@ if (CLI.command === "cache") {
101
101
  process.exit(1);
102
102
  }
103
103
  }
104
+ if (CLI.command === "limits") {
105
+ try {
106
+ const probe = parseAdapterProbeOptions(process.env);
107
+ const { runLimitsCommand } = await import("../src/cli-limits.mjs");
108
+ await runLimitsCommand({
109
+ action: CLI.action,
110
+ limitMib: CLI.limitMib,
111
+ resetLimit: CLI.resetLimit,
112
+ host: probe.adapterHost,
113
+ port: probe.adapterPort,
114
+ timeoutMs: probe.existingAdapterTimeoutMs,
115
+ });
116
+ process.exit(0);
117
+ } catch (e) {
118
+ console.error(status("err", e.message));
119
+ process.exit(1);
120
+ }
121
+ }
104
122
  if (CLI.command === "retired") {
105
123
  console.error(status("err", `${CLI.integration} integration was retired in ccdx 0.7.0.`));
106
124
  if (CLI.integration === "PM Studio") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.8.11",
3
+ "version": "0.9.1",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -0,0 +1,55 @@
1
+ import os from "node:os";
2
+ import { MIB } from "./cache-limits.mjs";
3
+ import { readAdapterStatus } from "./cli-status.mjs";
4
+ import { loadRuntimeConfig } from "./runtime-config.mjs";
5
+ import { decodedBodyLimitPreference, writeDecodedBodyLimitMib } from "./user-settings.mjs";
6
+
7
+ function mib(bytes) {
8
+ const value = Number(bytes);
9
+ return `${(value / MIB).toFixed(value % MIB === 0 ? 0 : 1)} MiB`;
10
+ }
11
+
12
+ export async function runLimitsCommand({
13
+ action = "status",
14
+ limitMib,
15
+ resetLimit = false,
16
+ env = process.env,
17
+ home = os.homedir(),
18
+ host = "127.0.0.1",
19
+ port = 2026,
20
+ output = process.stdout,
21
+ fetchImpl = fetch,
22
+ timeoutMs = 1000,
23
+ } = {}) {
24
+ if (action === "set") {
25
+ if (String(env.CCDX_MAX_DECODED_BODY_BYTES || "").trim()) {
26
+ throw new Error("CCDX_MAX_DECODED_BODY_BYTES overrides saved limits; unset it before using ccdx limits --decoded");
27
+ }
28
+ writeDecodedBodyLimitMib(resetLimit ? null : limitMib, { env, home });
29
+ } else if (action !== "status") {
30
+ throw new Error(`Unknown limits action: ${action}`);
31
+ }
32
+
33
+ const preference = decodedBodyLimitPreference({ env, home });
34
+ let runtime;
35
+ try {
36
+ runtime = await readAdapterStatus({ host, port, timeoutMs, fetchImpl });
37
+ } catch {
38
+ runtime = null;
39
+ }
40
+ const activeDecoded = Number(runtime?.data?.limits?.max_decoded_body_bytes);
41
+ const activeRaw = Number(runtime?.data?.limits?.max_body_bytes);
42
+ const lines = [
43
+ "ccdx limits",
44
+ `Configured decoded request body: ${mib(preference.bytes)} (${preference.source})`,
45
+ runtime && Number.isFinite(activeDecoded) && activeDecoded > 0
46
+ ? `Running decoded request body: ${mib(activeDecoded)}`
47
+ : "Running decoded request body: unavailable",
48
+ `Raw request body: ${mib(runtime && Number.isFinite(activeRaw) && activeRaw > 0 ? activeRaw : loadRuntimeConfig(env).maxBodyBytes)}${runtime ? " (running)" : " (configured)"}`,
49
+ ];
50
+ if (!runtime) lines.push("Running adapter status unavailable; restart ccdx to apply and verify this limit.");
51
+ else if (activeDecoded !== preference.bytes) lines.push("Restart ccdx to apply the configured decoded-body limit.");
52
+ if (action === "set") lines.push("Existing conversations and caches were not modified.");
53
+ output.write(`${lines.join("\n")}\n`);
54
+ return { preference, runtime };
55
+ }
@@ -21,6 +21,7 @@ const HELP_TOPICS = new Set([
21
21
  "models",
22
22
  "usage",
23
23
  "cache",
24
+ "limits",
24
25
  "animation",
25
26
  "enable-image",
26
27
  "disable-image",
@@ -187,6 +188,21 @@ export function parseCliArgs(args = []) {
187
188
  yes: Boolean(options["--yes"]),
188
189
  });
189
190
  }
191
+ if (command === "limits") {
192
+ const limitArgs = rest.length === 1 && /^-\d+$/.test(rest[0])
193
+ ? ["--decoded", rest[0].slice(1)]
194
+ : rest;
195
+ const options = parseOptions(limitArgs, new Map([["--decoded", "value"]]));
196
+ const rawLimit = options["--decoded"];
197
+ if (rawLimit && rawLimit !== "default" && !/^\d+$/.test(rawLimit)) {
198
+ throw new Error(`Decoded request-body limit must be an integer MiB value or default: ${rawLimit}`);
199
+ }
200
+ return baseCommand("limits", {
201
+ action: rawLimit ? "set" : "status",
202
+ limitMib: rawLimit && rawLimit !== "default" ? Number(rawLimit) : undefined,
203
+ resetLimit: rawLimit === "default",
204
+ });
205
+ }
190
206
  if (PM_COMMANDS.has(command)) {
191
207
  return baseCommand("retired", { integration: "PM Studio" });
192
208
  }
@@ -334,6 +350,7 @@ function topicHelp(name, topic) {
334
350
  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.`,
335
351
  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.`,
336
352
  cache: `Usage:\n ${name} cache\n ${name} cache --limit <MiB|default>\n ${name} cache --clean [--history] [--yes]\n\nShows runtime cache use, saves and applies the shared response-history limit, or clears the rebuildable image-transform cache. --history also clears in-memory response history for all tasks and can make their next continuation fail, so interactive confirmation or --yes is required. Generated image files and saved Codex transcripts are never deleted. Short aliases: -128 sets 128 MiB and -clean equals --clean.`,
353
+ limits: `Usage:\n ${name} limits\n ${name} limits --decoded <MiB|default>\n\nShows configured and running request-body limits, or saves a decoded request-body limit from 128 to 512 MiB. The raw-body limit is unchanged. Restart ccdx to apply a saved change; no conversation or cache is cleared. Short alias: -256 sets 256 MiB.`,
337
354
  animation: `Usage:\n ${name} animation\n\nPreviews all terminal activity animations together and selects the theme used the next time the adapter starts.`,
338
355
  "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.`,
339
356
  "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.`,
@@ -358,6 +375,7 @@ export function cliHelp(commandName = "ccdx", topic = "") {
358
375
  ${name} models [--format table|plain]
359
376
  ${name} usage [--format table|plain]
360
377
  ${name} cache [--limit <MiB|default> | --clean [--history] [--yes]]
378
+ ${name} limits [--decoded <MiB|default>]
361
379
  ${name} animation
362
380
  ${name} enable-image
363
381
  ${name} disable-image
@@ -375,6 +393,7 @@ Commands:
375
393
  models Query a saved account's live Copilot model catalog
376
394
  usage Summarize locally recorded token usage
377
395
  cache Inspect, size, or clear bounded runtime caches
396
+ limits Inspect or configure the decoded request-body limit
378
397
  animation Select the terminal activity animation
379
398
  enable-image Configure and enable the optional image provider
380
399
  disable-image Disable the image provider and remove its credential
@@ -1,6 +1,7 @@
1
1
  import { promisify } from "node:util";
2
2
  import * as zlib from "node:zlib";
3
3
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
4
+ import { decodedBodyLimitPreference } from "./user-settings.mjs";
4
5
  import { status } from "./status.mjs";
5
6
  import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
6
7
  import { redactDiagnosticText } from "./diagnostic-text.mjs";
@@ -13,7 +14,7 @@ const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress)
13
14
 
14
15
  const HTTP_RUNTIME_CONFIG = loadRuntimeConfig();
15
16
  const MAX_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxBodyBytes;
16
- const MAX_DECODED_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxDecodedBodyBytes;
17
+ const MAX_DECODED_BODY_BYTES = decodedBodyLimitPreference().bytes;
17
18
  const MAX_INFLIGHT_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxInflightBodyBytes;
18
19
  const MAX_QUEUED_REQUESTS = HTTP_RUNTIME_CONFIG.maxQueuedRequests;
19
20
  const REQUEST_QUEUE_TIMEOUT_MS = HTTP_RUNTIME_CONFIG.requestQueueTimeoutMs;
@@ -73,6 +73,29 @@ function checkedImage(config, value) {
73
73
  return value;
74
74
  }
75
75
 
76
+ function decodeGeneratedImageDataUri(value, signal) {
77
+ if (signal?.aborted) throw providerError("Image generation timed out or was cancelled", "ccdx_image_timeout");
78
+ const maxEncodedLength = 4 * Math.ceil(IMAGE_MAX_BYTES / 3);
79
+ if (value.length > maxEncodedLength + 32) {
80
+ throw providerError("Generated image exceeds the size limit", "ccdx_image_too_large");
81
+ }
82
+ const match = /^data:(image\/(?:png|jpeg|webp));base64,/.exec(value);
83
+ if (!match) throw providerError("Image API returned an unsupported image data URI", "ccdx_image_format_unsupported");
84
+ const encoded = value.slice(match[0].length);
85
+ if (!encoded || encoded.length > maxEncodedLength || encoded.length % 4 !== 0
86
+ || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) {
87
+ throw providerError("Image API returned invalid image data", "ccdx_image_data_invalid");
88
+ }
89
+ const bytes = Buffer.from(encoded, "base64");
90
+ if (bytes.length > IMAGE_MAX_BYTES) throw providerError("Generated image exceeds the size limit", "ccdx_image_too_large");
91
+ if (bytes.toString("base64") !== encoded) throw providerError("Image API returned invalid image data", "ccdx_image_data_invalid");
92
+ if (imageMetadata(bytes).mimeType !== match[1]) {
93
+ throw providerError("Image API returned image data with a mismatched format", "ccdx_image_format_unsupported");
94
+ }
95
+ if (signal?.aborted) throw providerError("Image generation timed out or was cancelled", "ccdx_image_timeout");
96
+ return bytes;
97
+ }
98
+
76
99
  function generationBody(config, prompt, size, image) {
77
100
  if (config.protocol === "qwen-messages") {
78
101
  return {
@@ -306,9 +329,9 @@ export async function generateImage(config, {
306
329
  if (!source) throw providerError("Image API response did not contain an image");
307
330
  let bytes;
308
331
  try {
309
- bytes = source.type === "base64"
310
- ? Buffer.from(source.value, "base64")
311
- : await downloadImage(source.value, { signal: requestSignal, maxBytes: IMAGE_MAX_BYTES });
332
+ if (source.type === "base64") bytes = Buffer.from(source.value, "base64");
333
+ else if (source.value.startsWith("data:")) bytes = decodeGeneratedImageDataUri(source.value, requestSignal);
334
+ else bytes = await downloadImage(source.value, { signal: requestSignal, maxBytes: IMAGE_MAX_BYTES });
312
335
  } catch (error) {
313
336
  if (error?.code?.startsWith?.("ccdx_")) throw error;
314
337
  const code = String(error?.cause?.code || error?.code || "").replace(/[^A-Z0-9_-]/gi, "").slice(0, 64);
@@ -4,12 +4,14 @@ import { copilotRuntimeStatus } from "./copilot.mjs";
4
4
  import { imageOptimizationStats } from "./image-optimization.mjs";
5
5
  import { responseHistoryStats } from "./response-history.mjs";
6
6
  import { loadRuntimeConfig } from "./runtime-config.mjs";
7
+ import { decodedBodyLimitPreference } from "./user-settings.mjs";
7
8
  import { profileRouting } from "./profile-routing.mjs";
8
9
  import { usageLoggingStats } from "./usage.mjs";
9
10
  import { debugLoggingStats } from "./log.mjs";
10
11
 
11
12
  export { ADAPTER_STATUS_PATH };
12
13
  const OBSERVABILITY_RUNTIME_CONFIG = loadRuntimeConfig();
14
+ const OBSERVABILITY_DECODED_BODY_BYTES = decodedBodyLimitPreference().bytes;
13
15
 
14
16
  const ROUTE_NAMES = Object.freeze([
15
17
  "responses",
@@ -208,7 +210,7 @@ export function runtimeStatusPayload({
208
210
  routing: profileRouting(),
209
211
  limits: {
210
212
  max_body_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxBodyBytes,
211
- max_decoded_body_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxDecodedBodyBytes,
213
+ max_decoded_body_bytes: OBSERVABILITY_DECODED_BODY_BYTES,
212
214
  max_sse_buffer_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxSseBufferBytes,
213
215
  response_history_max_bytes: history.maxBytes,
214
216
  response_history_max_entries: history.maxEntries,
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
5
5
  import { DEFAULT_CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
6
- import { loadRuntimeConfig } from "./runtime-config.mjs";
6
+ import { loadRuntimeConfig, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
7
7
  import {
8
8
  MIB,
9
9
  RESPONSE_HISTORY_DEFAULT_MIB,
@@ -19,6 +19,9 @@ import {
19
19
  const AUTO_REVIEW_MODEL_KEY = "auto_review_model";
20
20
  const TERMINAL_ANIMATION_KEY = "terminal_animation";
21
21
  const RESPONSE_HISTORY_MAX_MIB_KEY = "response_history_max_mib";
22
+ const DECODED_BODY_LIMIT_MIB_KEY = "decoded_body_limit_mib";
23
+ export const DECODED_BODY_LIMIT_MIN_MIB = RUNTIME_DEFAULTS.maxDecodedBodyBytes / MIB;
24
+ export const DECODED_BODY_LIMIT_MAX_MIB = 512;
22
25
  export { RESPONSE_HISTORY_MAX_MIB, RESPONSE_HISTORY_MIN_MIB };
23
26
 
24
27
  export function userSettingsPath({ env = process.env, home = os.homedir() } = {}) {
@@ -66,6 +69,17 @@ export function readUserSettings({ env = process.env, home = os.homedir(), stric
66
69
  delete parsed[RESPONSE_HISTORY_MAX_MIB_KEY];
67
70
  }
68
71
  }
72
+ if (Object.hasOwn(parsed, DECODED_BODY_LIMIT_MIB_KEY)) {
73
+ const value = parsed[DECODED_BODY_LIMIT_MIB_KEY];
74
+ if (!Number.isSafeInteger(value)
75
+ || value < DECODED_BODY_LIMIT_MIN_MIB
76
+ || value > DECODED_BODY_LIMIT_MAX_MIB) {
77
+ if (strict) {
78
+ throw invalidSettings(filePath, `${DECODED_BODY_LIMIT_MIB_KEY} must be an integer from ${DECODED_BODY_LIMIT_MIN_MIB} to ${DECODED_BODY_LIMIT_MAX_MIB}`);
79
+ }
80
+ delete parsed[DECODED_BODY_LIMIT_MIB_KEY];
81
+ }
82
+ }
69
83
  return parsed;
70
84
  } catch (error) {
71
85
  if (error?.code === "ENOENT") return {};
@@ -114,6 +128,39 @@ export function responseHistoryLimitPreference({ env = process.env, home = os.ho
114
128
  return { bytes: RESPONSE_HISTORY_DEFAULT_MIB * MIB, mib: RESPONSE_HISTORY_DEFAULT_MIB, source: "default" };
115
129
  }
116
130
 
131
+ export function decodedBodyLimitPreference({ env = process.env, home = os.homedir() } = {}) {
132
+ const environment = String(env.CCDX_MAX_DECODED_BODY_BYTES || "").trim();
133
+ if (environment) {
134
+ return { bytes: loadRuntimeConfig(env).maxDecodedBodyBytes, source: "environment" };
135
+ }
136
+ const settings = readUserSettings({ env, home });
137
+ const mib = settings[DECODED_BODY_LIMIT_MIB_KEY];
138
+ if (Number.isSafeInteger(mib)) return { bytes: mib * MIB, mib, source: "settings" };
139
+ return { bytes: RUNTIME_DEFAULTS.maxDecodedBodyBytes, mib: DECODED_BODY_LIMIT_MIN_MIB, source: "default" };
140
+ }
141
+
142
+ export function writeDecodedBodyLimitMib(mib, { env = process.env, home = os.homedir() } = {}) {
143
+ const filePath = userSettingsPath({ env, home });
144
+ const settings = readUserSettings({ env, home, strict: true });
145
+ const next = { ...settings };
146
+ if (mib === null || mib === undefined || mib === "") {
147
+ if (!Object.hasOwn(next, DECODED_BODY_LIMIT_MIB_KEY)) {
148
+ return { changed: false, filePath, ...decodedBodyLimitPreference({ env, home }) };
149
+ }
150
+ delete next[DECODED_BODY_LIMIT_MIB_KEY];
151
+ } else {
152
+ const parsed = Number(mib);
153
+ if (!Number.isSafeInteger(parsed)
154
+ || parsed < DECODED_BODY_LIMIT_MIN_MIB
155
+ || parsed > DECODED_BODY_LIMIT_MAX_MIB) {
156
+ throw new Error(`Decoded request-body limit must be an integer from ${DECODED_BODY_LIMIT_MIN_MIB} to ${DECODED_BODY_LIMIT_MAX_MIB} MiB`);
157
+ }
158
+ next[DECODED_BODY_LIMIT_MIB_KEY] = parsed;
159
+ }
160
+ const changed = atomicWriteFileIfChangedSync(filePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
161
+ return { changed, filePath, ...decodedBodyLimitPreference({ env, home }) };
162
+ }
163
+
117
164
  export function writeResponseHistoryLimitMib(mib, { env = process.env, home = os.homedir() } = {}) {
118
165
  const filePath = userSettingsPath({ env, home });
119
166
  const settings = readUserSettings({ env, home, strict: true });