codex-copilot-dx 0.7.6 → 0.7.8

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
@@ -74,6 +74,7 @@ Normal startup and reuse of an existing adapter check `~/.codex/config.toml` onc
74
74
  | `model_auto_compact_token_limit` | Add `900000` only when missing | `900000` |
75
75
  | `[shell_environment_policy.set]` OpenAI URL/key | Update `OPENAI_BASE_URL` and set `OPENAI_API_KEY="dummy"` when this section is recognized; leave an absent section absent | Create the section and set both keys; set `shell_environment_policy.inherit="core"` |
76
76
  | `[features]` `context_management` | Add `true` when missing; preserve explicit values and alternative declarations as described above | `true` |
77
+ | `[mcp_servers.ccdx_image]` | Added in a marked CCDX-owned block only after `ccdx enable-image`; removed by `ccdx disable-image` | Absent by default |
77
78
 
78
79
  The two token limits are fixed defaults, not detected model capabilities. Existing custom values are retained. Writes target the listed keys with line-based edits and atomic replacement; this is not a general TOML formatter. The shell environment updater recognizes the literal `[shell_environment_policy.set]` header. 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.
79
80
 
@@ -88,6 +89,7 @@ The upgrade intentionally does not delete or rewrite retired Claude or PM Studio
88
89
  ## Commands
89
90
 
90
91
  Run `ccdx <command> --help` for command-specific behavior and side-effect details.
92
+ After startup, CCDX prints a short reminder that `ccdx help` lists all available commands.
91
93
 
92
94
  ### Live models
93
95
 
@@ -125,12 +127,39 @@ The selector is ordered numerically:
125
127
  4. Chase
126
128
  5. Mirror
127
129
  6. Pulse
128
- 7. Braille
130
+ 7. Stack — short light trails arrive, accumulate, then leave as one bundle
131
+ 8. Relay — fixed nodes receive and pass a short light trail
132
+ 9. Split — one incoming light trail divides into two outgoing trails
129
133
 
130
134
  Enter the corresponding number to select an animation. The Comet row includes a static preview, and after a selection CCDX plays one cycle of the chosen animation as confirmation when terminal animation is enabled. The setting is saved in `~/.config/codex-copilot-dx/config.json`, or under `XDG_CONFIG_HOME` when set.
131
135
 
136
+ All nine animations use the same 20-column colon track and cyan/blue palette. They indicate activity, not completion percentage. Braille was removed in 0.7.7. A saved `terminal_animation: "braille"` falls back to Comet without rewriting the settings file; choosing another animation replaces it, and explicitly choosing Comet clears it. Other settings remain intact.
137
+
132
138
  The selected theme is loaded once when a new adapter starts, so changing it does not restart or alter an adapter that is already running. Stop and start CCDX to apply the new theme. `CCDX_TERMINAL_ANIMATION=0` (also `false`, `no`, or `off`) remains the boolean gate that disables terminal animation regardless of the selected theme.
133
139
 
140
+ ### Optional image generation
141
+
142
+ Image generation is disabled by default. Configure it interactively only when you want Codex App to receive the local CCDX image tool:
143
+
144
+ ```bash
145
+ ccdx enable-image
146
+ ```
147
+
148
+ The command asks for an HTTPS `/images/generations` endpoint and an API key without echoing the key. It reads the endpoint's `/models` catalog, detects either the standard OpenAI Images request format or the supported `input.messages` format, and asks for a numeric model selection only when multiple image models are available. Its validation request omits the prompt and does not generate an image.
149
+
150
+ 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. Restart Codex App after enabling so it loads the tool. The existing CCDX adapter can remain running because provider settings are loaded when the tool is invoked.
151
+
152
+ Inspect or disable the optional provider with:
153
+
154
+ ```bash
155
+ ccdx image-status
156
+ ccdx disable-image
157
+ ```
158
+
159
+ `image-status` never prints the API key. Disabling removes the stored provider credential and the CCDX-owned MCP block; restart Codex App to remove the tool from already running tasks. The image endpoint is contacted only by `enable-image` validation and by explicit `generate_image` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
160
+
161
+ Generated image downloads must use public HTTPS addresses, do not follow redirects, are DNS-checked against private and reserved addresses, and are capped at 32 MiB. The local MCP route accepts calls only from the same device and permits at most two concurrent generations. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`; it does not claim image editing or multi-turn image editing support.
162
+
134
163
  ### Update
135
164
 
136
165
  Choose a source interactively:
@@ -198,7 +227,7 @@ For the complete machine-readable status payload:
198
227
  curl -s http://127.0.0.1:2026/_ccdx/status | jq
199
228
  ```
200
229
 
201
- 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, and token expiry state. It never retains metric samples or includes prompts, completions, tool arguments, image content, account names, or token values.
230
+ 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.
202
231
 
203
232
  `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.
204
233
 
@@ -228,6 +257,7 @@ Recovery first tries compatible local credentials and starts Device Flow only wh
228
257
  - CCDX binds to loopback by default. Non-loopback hosts are rejected unless `CCDX_ALLOW_LAN=1` is set explicitly.
229
258
  - Enabling LAN binding exposes a Copilot-backed API to other reachable machines. Provide your own host firewall and network isolation; do not expose the listener to an untrusted network.
230
259
  - 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.
260
+ - 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.
231
261
  - 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.
232
262
  - Request and response bodies are bounded. Queues, history, image work, SSE parsing, retries, and shutdown all have explicit resource or time limits.
233
263
 
@@ -315,7 +345,7 @@ When expanded history exceeds Copilot's 50-image request limit, CCDX removes old
315
345
 
316
346
  For `/v1/responses/compact`, CCDX sends one terminal `compaction_trigger`, validates completed compaction state, and preserves the complete returned window as the canonical next context. Only a validated window becomes a new local history root. Existing older branches remain available until normal history eviction.
317
347
 
318
- Changing the model or transport of a continuation can require removing incompatible encrypted history from the forwarded request. A specific upstream encrypted-state verification failure can also trigger one sanitized retry. CCDX does not decrypt that state locally.
348
+ Changing the model or transport of a continuation can require removing incompatible encrypted history from the forwarded request. A specific upstream encrypted-state verification failure can also trigger one sanitized retry, including when a nominally successful HTTP response ends with `response.failed`. CCDX withholds only the pre-output prelude of an encrypted continuation while evaluating that terminal event; it never retries after visible model output, so it cannot duplicate an already-started answer. Unrelated failures pass through unchanged. CCDX does not decrypt that state locally.
319
349
 
320
350
  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.
321
351
 
package/bin/cli.mjs CHANGED
@@ -22,6 +22,7 @@ import { runAuthCommand } from "../src/cli-auth.mjs";
22
22
  import { createProfileRuntime } from "../src/profile-runtime.mjs";
23
23
  import { createProfileModelRuntime } from "../src/profile-model-runtime.mjs";
24
24
  import { createCodexModelCatalog } from "../src/codex-model-catalog.mjs";
25
+ import { readImageProviderConfig } from "../src/image-provider-config.mjs";
25
26
 
26
27
  const LOCAL_VERSION = localPackageVersion();
27
28
  const CLI_NAME = cliCommandName();
@@ -54,6 +55,24 @@ if (CLI.command === "animation") {
54
55
  process.exit(1);
55
56
  }
56
57
  }
58
+ if (CLI.command === "image") {
59
+ try {
60
+ const address = CLI.action === "enable"
61
+ ? parseAdapterAddressOptions(process.env)
62
+ : { adapterPort: 2026, adapterHost: "127.0.0.1" };
63
+ const { runImageCommand } = await import("../src/cli-image.mjs");
64
+ await runImageCommand({
65
+ action: CLI.action,
66
+ commandName: CLI_NAME,
67
+ adapterPort: address.adapterPort,
68
+ adapterHost: address.adapterHost,
69
+ });
70
+ process.exit(0);
71
+ } catch (e) {
72
+ console.error(status("err", e.message));
73
+ process.exit(1);
74
+ }
75
+ }
57
76
  if (CLI.command === "usage") {
58
77
  await printUsageSummary({
59
78
  format: CLI.outputFormat || "auto",
@@ -195,13 +214,18 @@ async function reuseRunningAdapterIfAvailable() {
195
214
  if (!running.ok) return false;
196
215
 
197
216
  console.log(status("ok", `Using existing adapter at ${running.baseUrl}`));
198
- ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
217
+ ensureCodexConfig(ADAPTER_PORT, {
218
+ host: ADAPTER_HOST,
219
+ imageProviderEnabled: Boolean(readImageProviderConfig()),
220
+ });
199
221
 
200
222
  await openCodex();
201
223
  console.log(`
202
224
  ${status("ok", "Ready, using the existing ccdx adapter")}
203
225
 
204
226
  Adapter: ${running.baseUrl}
227
+
228
+ Tip: Run ${CLI_NAME} help to view more features and commands.
205
229
  `);
206
230
  return true;
207
231
  }
@@ -274,7 +298,10 @@ try {
274
298
  });
275
299
 
276
300
  // Point Codex App at the adapter.
277
- ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
301
+ ensureCodexConfig(ADAPTER_PORT, {
302
+ host: ADAPTER_HOST,
303
+ imageProviderEnabled: Boolean(readImageProviderConfig()),
304
+ });
278
305
 
279
306
  // Launch Codex when available.
280
307
  await openCodex();
@@ -294,6 +321,8 @@ try {
294
321
 
295
322
  Adapter: ${adapterBaseUrl(ADAPTER_HOST, ADAPTER_PORT)}
296
323
 
324
+ Tip: Run ${CLI_NAME} help to view more features and commands.
325
+
297
326
  Press Ctrl+C to stop.
298
327
  `);
299
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/adapter.mjs CHANGED
@@ -16,6 +16,7 @@ import { createTerminalActivityIndicator } from "./terminal-activity.mjs";
16
16
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
17
17
  import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
18
18
  import { createResponsesImagePressureController } from "./responses-image-pressure.mjs";
19
+ import { createResponseFailureDiagnostics } from "./response-failures.mjs";
19
20
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
20
21
  import {
21
22
  createRequestAdmission,
@@ -159,6 +160,8 @@ export function createAdapterHandler(options = {}) {
159
160
  const requestMetrics = options.requestMetrics || createRequestMetrics();
160
161
  const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
161
162
  const imagePressure = options.imagePressure || createResponsesImagePressureController();
163
+ const responseFailures = options.responseFailures || createResponseFailureDiagnostics();
164
+ let imageMcpHandler = options.imageMcpHandler || null;
162
165
  const responsesHandler = createResponsesHandler({
163
166
  acquireRequest,
164
167
  autoReviewModelResolver: options.autoReviewModelResolver,
@@ -170,6 +173,7 @@ export function createAdapterHandler(options = {}) {
170
173
  openAIModelEnv,
171
174
  responsesPayloadOptions: options.responsesPayloadOptions,
172
175
  responsesFn,
176
+ responseFailures,
173
177
  requestBodyTimeoutMs,
174
178
  streamHandshakeTimeoutMs,
175
179
  streamIdleTimeoutMs,
@@ -189,6 +193,13 @@ export function createAdapterHandler(options = {}) {
189
193
  upstreamTimeoutMs,
190
194
  });
191
195
  const dispatch = (req, res, pathname) => {
196
+ if (pathname === "/mcp/image") {
197
+ if (imageMcpHandler) return imageMcpHandler(req, res);
198
+ return import("./image-mcp.mjs").then(({ createImageMcpHandler }) => {
199
+ imageMcpHandler = createImageMcpHandler();
200
+ return imageMcpHandler(req, res);
201
+ });
202
+ }
192
203
  if (req.method === "GET" && pathname === ADAPTER_STATUS_PATH) {
193
204
  if (!isLoopbackAddress(req.socket?.remoteAddress)) {
194
205
  res.writeHead(403, { "Content-Type": "application/json" });
@@ -201,6 +212,7 @@ export function createAdapterHandler(options = {}) {
201
212
  streamPerformance: streamPerformanceMetrics,
202
213
  admission: acquireRequest,
203
214
  imagePressure,
215
+ responseFailures,
204
216
  modelRegistry: codexModelRegistry,
205
217
  codexClient,
206
218
  codexModelRegistry,
@@ -0,0 +1,199 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import readline from "node:readline";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { atomicWriteFilePairSync } from "./atomic-file.mjs";
7
+ import { terminalCell } from "./cli-table.mjs";
8
+ import { computeImageMcpCodexConfig } from "./config.mjs";
9
+ import {
10
+ imageProviderConfigPath,
11
+ inspectImageProvider,
12
+ readImageProviderConfig,
13
+ validateImageProviderConfig,
14
+ } from "./image-provider-config.mjs";
15
+
16
+ function defaultCodexConfigPath(home) {
17
+ return path.join(home, ".codex", "config.toml");
18
+ }
19
+
20
+ async function visibleQuestion(input, output, question) {
21
+ const prompt = createInterface({ input, output });
22
+ try { return await prompt.question(question); }
23
+ finally { prompt.close(); }
24
+ }
25
+
26
+ export function hiddenQuestion(input, output, question) {
27
+ if (!input?.isTTY || !output?.isTTY || typeof input.setRawMode !== "function") {
28
+ return Promise.reject(new Error("Hidden API key input requires an interactive terminal"));
29
+ }
30
+ output.write(question);
31
+ const wasRaw = Boolean(input.isRaw);
32
+ readline.emitKeypressEvents(input);
33
+ input.setRawMode(true);
34
+ input.resume();
35
+ let value = "";
36
+ return new Promise((resolve, reject) => {
37
+ const cleanup = () => {
38
+ input.off("keypress", onKeypress);
39
+ input.setRawMode(wasRaw);
40
+ if (!wasRaw) input.pause();
41
+ output.write("\n");
42
+ };
43
+ const onKeypress = (text, key = {}) => {
44
+ if (key.ctrl && key.name === "c") {
45
+ cleanup();
46
+ reject(new Error("Image provider setup cancelled"));
47
+ return;
48
+ }
49
+ if (key.name === "return" || key.name === "enter") {
50
+ cleanup();
51
+ resolve(value);
52
+ return;
53
+ }
54
+ if (key.name === "backspace" || key.name === "delete") {
55
+ value = value.slice(0, -1);
56
+ return;
57
+ }
58
+ if (!key.ctrl && !key.meta && typeof text === "string" && text >= " " && text !== "\x7f") value += text;
59
+ };
60
+ input.on("keypress", onKeypress);
61
+ });
62
+ }
63
+
64
+ function fileContent(filePath) {
65
+ try { return fs.readFileSync(filePath, "utf8"); }
66
+ catch (error) {
67
+ if (error?.code === "ENOENT") return "";
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ function chooseModelLines(modelIds, selected) {
73
+ return modelIds.map((model, index) => ` ${index + 1}. ${terminalCell(model)}${model === selected ? " [current]" : ""}`);
74
+ }
75
+
76
+ async function chooseModel(modelIds, current, ask, output) {
77
+ if (modelIds.length === 1) return modelIds[0];
78
+ const currentIndex = Math.max(0, modelIds.indexOf(current));
79
+ output.write("Available image models:\n");
80
+ output.write(`${chooseModelLines(modelIds, current).join("\n")}\n`);
81
+ while (true) {
82
+ const answer = String(await ask(`Select [${currentIndex + 1}]: `) || "").trim();
83
+ if (!answer) return modelIds[currentIndex];
84
+ const selected = Number(answer) - 1;
85
+ if (Number.isInteger(selected) && selected >= 0 && selected < modelIds.length) return modelIds[selected];
86
+ output.write(`Enter a number from 1 to ${modelIds.length}.\n`);
87
+ }
88
+ }
89
+
90
+ function configSummary(config) {
91
+ const endpoint = new URL(config.endpoint);
92
+ return {
93
+ endpoint: `${endpoint.origin}${endpoint.pathname}`,
94
+ model: config.model,
95
+ protocol: config.protocol,
96
+ };
97
+ }
98
+
99
+ function enableImageProvider({ providerConfig, providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
100
+ const codex = computeImageMcpCodexConfig(codexContent, {
101
+ enabled: true,
102
+ adapterPort,
103
+ adapterHost,
104
+ });
105
+ const normalized = validateImageProviderConfig(providerConfig, { filePath: providerPath });
106
+ atomicWriteFilePairSync(
107
+ codexPath,
108
+ codex.content,
109
+ providerPath,
110
+ `${JSON.stringify(normalized, null, 2)}\n`,
111
+ { mode: 0o600 },
112
+ );
113
+ return { codexChanged: codex.changed, config: normalized };
114
+ }
115
+
116
+ function disableImageProvider({ providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
117
+ const codex = computeImageMcpCodexConfig(codexContent, {
118
+ enabled: false,
119
+ adapterPort,
120
+ adapterHost,
121
+ });
122
+ const providerExists = fs.existsSync(providerPath);
123
+ if (fs.existsSync(codexPath)) {
124
+ atomicWriteFilePairSync(codexPath, codex.content, providerPath, null, { mode: 0o600 });
125
+ } else if (providerExists) {
126
+ fs.unlinkSync(providerPath);
127
+ }
128
+ return { changed: providerExists || codex.changed };
129
+ }
130
+
131
+ export async function runImageCommand({
132
+ action,
133
+ commandName = "ccdx",
134
+ env = process.env,
135
+ home = os.homedir(),
136
+ input = process.stdin,
137
+ output = process.stdout,
138
+ prompt,
139
+ promptSecret,
140
+ fetchImpl = fetch,
141
+ adapterPort = 2026,
142
+ adapterHost = "127.0.0.1",
143
+ codexPath = defaultCodexConfigPath(home),
144
+ } = {}) {
145
+ const providerPath = imageProviderConfigPath({ env, home });
146
+ if (action === "status") {
147
+ const config = readImageProviderConfig({ env, home, strict: true });
148
+ if (!config) {
149
+ output.write("Image generation: disabled\n");
150
+ return { enabled: false };
151
+ }
152
+ const summary = configSummary(config);
153
+ output.write(`Image generation: enabled\nEndpoint: ${terminalCell(summary.endpoint)}\nModel: ${terminalCell(summary.model)}\nProtocol: ${terminalCell(summary.protocol)}\n`);
154
+ return { enabled: true, ...summary };
155
+ }
156
+
157
+ const codexContent = fileContent(codexPath);
158
+ if (action === "disable") {
159
+ const result = disableImageProvider({ providerPath, codexPath, codexContent, adapterPort, adapterHost });
160
+ output.write(`${result.changed ? "Disabled" : "Kept disabled"} image generation.\n`);
161
+ output.write("Restart Codex App to remove the image tool from active tasks.\n");
162
+ return { enabled: false, ...result };
163
+ }
164
+ if (action !== "enable") throw new Error(`Unknown image command action: ${action}`);
165
+ if (!prompt && (!input.isTTY || !output.isTTY)) {
166
+ throw new Error(`${commandName} enable-image requires an interactive terminal`);
167
+ }
168
+
169
+ const current = readImageProviderConfig({ env, home });
170
+ const ask = prompt || ((question) => visibleQuestion(input, output, question));
171
+ const askSecret = promptSecret || ((question) => hiddenQuestion(input, output, question));
172
+ output.write(`${commandName} enable-image\n`);
173
+ const endpointAnswer = String(await ask(`API endpoint${current ? ` [${current.endpoint}]` : ""}: `) || "").trim();
174
+ const endpoint = endpointAnswer || current?.endpoint;
175
+ if (!endpoint) throw new Error("Image API endpoint is required");
176
+ const keyAnswer = String(await askSecret(`API key${current ? " [Enter to keep current]" : ""}: `) || "").trim();
177
+ const apiKey = keyAnswer || current?.api_key;
178
+ if (!apiKey) throw new Error("Image API key is required");
179
+
180
+ output.write("Checking image API...\n");
181
+ const inspected = await inspectImageProvider({ endpoint, apiKey, fetchImpl });
182
+ const model = await chooseModel(inspected.modelIds, current?.model, ask, output);
183
+ const result = enableImageProvider({
184
+ providerConfig: {
185
+ endpoint: inspected.endpoint,
186
+ api_key: apiKey,
187
+ model,
188
+ protocol: inspected.protocol,
189
+ },
190
+ providerPath,
191
+ codexPath,
192
+ codexContent,
193
+ adapterPort,
194
+ adapterHost,
195
+ });
196
+ output.write(`Enabled image generation with ${terminalCell(model)}.\n`);
197
+ output.write("Restart Codex App to load the image tool. A running ccdx adapter can remain running.\n");
198
+ return { enabled: true, ...configSummary(result.config), codexChanged: result.codexChanged };
199
+ }
@@ -21,6 +21,9 @@ const HELP_TOPICS = new Set([
21
21
  "models",
22
22
  "usage",
23
23
  "animation",
24
+ "enable-image",
25
+ "disable-image",
26
+ "image-status",
24
27
  "auto-review-model",
25
28
  "update",
26
29
  "version",
@@ -200,6 +203,12 @@ export function parseCliArgs(args = []) {
200
203
  if (rest.length) unexpectedArgs(rest);
201
204
  return baseCommand("animation");
202
205
  }
206
+ if (command === "enable-image" || command === "disable-image" || command === "image-status") {
207
+ if (rest.length) unexpectedArgs(rest);
208
+ return baseCommand("image", {
209
+ action: command === "enable-image" ? "enable" : command === "disable-image" ? "disable" : "status",
210
+ });
211
+ }
203
212
  if (command === "auto-review-model") {
204
213
  if (rest.length) unexpectedArgs(rest);
205
214
  return baseCommand("auto-review-model");
@@ -293,6 +302,9 @@ function topicHelp(name, topic) {
293
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.`,
294
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.`,
295
304
  animation: `Usage:\n ${name} animation\n\nInteractively selects the terminal activity animation used the next time the adapter starts.`,
305
+ "enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint and API key, detects its supported image model and protocol, and registers the local CCDX image tool for Codex App.`,
306
+ "disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential.`,
307
+ "image-status": `Usage:\n ${name} image-status\n\nShows whether the optional image provider is configured without exposing its API key.`,
296
308
  "auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an advertised Responses model for Codex Auto-review.`,
297
309
  update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or GitHub main. With no source, an interactive terminal prompts for one.`,
298
310
  version: `Usage:\n ${name} --version`,
@@ -313,6 +325,9 @@ export function cliHelp(commandName = "ccdx", topic = "") {
313
325
  ${name} models [--format table|plain]
314
326
  ${name} usage [--format table|plain]
315
327
  ${name} animation
328
+ ${name} enable-image
329
+ ${name} disable-image
330
+ ${name} image-status
316
331
  ${name} auto-review-model
317
332
  ${name} update [npm|github]
318
333
  ${name} --version
@@ -326,6 +341,9 @@ Commands:
326
341
  models Query a saved account's live Copilot model catalog
327
342
  usage Summarize locally recorded token usage
328
343
  animation Select the terminal activity animation
344
+ enable-image Configure and enable the optional image provider
345
+ disable-image Disable the image provider and remove its credential
346
+ image-status Show the optional image provider status
329
347
  auto-review-model Select the Codex Auto-review Responses model
330
348
  update Update the global package from npm or GitHub
331
349
 
@@ -1,5 +1,6 @@
1
1
  import { ADAPTER_STATUS_PATH, adapterBaseUrl } from "./running-adapter.mjs";
2
2
  import { status } from "./status.mjs";
3
+ import { terminalCell } from "./cli-table.mjs";
3
4
 
4
5
  function finiteNumber(value) {
5
6
  const number = Number(value);
@@ -135,6 +136,7 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
135
136
  const history = data.response_history || {};
136
137
  const images = data.image_optimization || {};
137
138
  const imageHistory = data.image_history_pressure;
139
+ const responseFailures = data.response_failures;
138
140
  const models = data.models || {};
139
141
  const copilot = data.copilot || {};
140
142
  const limits = data.limits || {};
@@ -156,6 +158,16 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
156
158
  if (imageHistory && typeof imageHistory === "object") {
157
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`));
158
160
  }
161
+ if (responseFailures && typeof responseFailures === "object") {
162
+ lines.push(status("info", `Response failures: ${count(responseFailures.total)} observed, ${count(responseFailures.retried)} compatibility retries`));
163
+ for (const failure of Array.isArray(responseFailures.recent) ? responseFailures.recent.slice(-3).reverse() : []) {
164
+ const model = terminalCell(failure.model, { fallback: "unknown" });
165
+ const code = terminalCell(failure.code, { fallback: "unknown_error" });
166
+ const message = terminalCell(failure.message, { fallback: "unknown failure" }).slice(0, 160);
167
+ const retryPolicy = terminalCell(failure.retry_policy, { fallback: "yes" });
168
+ lines.push(status("warn", ` ${model} ${code}: ${message}${failure.retried ? ` [retried:${retryPolicy}]` : ""}`));
169
+ }
170
+ }
159
171
  lines.push(status("info", `Models: ${count(models.models)} total; Copilot token ${copilot.token_cached ? `cached (${duration(copilot.token_expires_in_ms)} remaining)` : "not cached"}`));
160
172
  if (data.profiles?.codex) {
161
173
  lines.push(status("info", `Profile: ${profileSummary("Codex", data.profiles.codex)}`));
package/src/config.mjs CHANGED
@@ -8,6 +8,9 @@ import { adapterBaseUrl } from "./running-adapter.mjs";
8
8
  const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
9
9
  const MODEL_CONTEXT_WINDOW = 1_000_000;
10
10
  const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
11
+ const IMAGE_MCP_START = "# ccdx:image-mcp:start";
12
+ const IMAGE_MCP_END = "# ccdx:image-mcp:end";
13
+ const IMAGE_MCP_SECTION = "mcp_servers.ccdx_image";
11
14
 
12
15
  function isTomlTableHeader(line) {
13
16
  const source = line.trimStart();
@@ -105,7 +108,65 @@ function ensureContextManagementDefault(lines) {
105
108
  return true;
106
109
  }
107
110
 
108
- export function computeUpdatedCodexConfig(content, adapterPort = 2026, adapterHost = "127.0.0.1") {
111
+ function imageMcpHost(adapterHost) {
112
+ const host = String(adapterHost || "127.0.0.1").trim();
113
+ if (host === "0.0.0.0") return "127.0.0.1";
114
+ if (host === "::" || host === "[::]") return "::1";
115
+ return host;
116
+ }
117
+
118
+ function removeManagedImageMcp(lines) {
119
+ const starts = lines.flatMap((line, index) => line === IMAGE_MCP_START ? [index] : []);
120
+ const ends = lines.flatMap((line, index) => line === IMAGE_MCP_END ? [index] : []);
121
+ let start = starts[0] ?? -1;
122
+ const end = ends[0] ?? -1;
123
+ if (start === -1 && end === -1) return false;
124
+ if (starts.length !== 1 || ends.length !== 1 || end < start) {
125
+ throw new Error("Invalid managed CCDX image MCP block in Codex config");
126
+ }
127
+ lines.splice(start, end - start + 1);
128
+ while (start > 0 && start >= lines.length && lines[start - 1].trim() === "") {
129
+ lines.splice(start - 1, 1);
130
+ start -= 1;
131
+ }
132
+ while (start < lines.length && lines[start].trim() === "" && start > 0 && lines[start - 1].trim() === "") {
133
+ lines.splice(start, 1);
134
+ }
135
+ return true;
136
+ }
137
+
138
+ function hasUnmanagedImageMcp(lines) {
139
+ return lines.some((line) => line.trim() === `[${IMAGE_MCP_SECTION}]`);
140
+ }
141
+
142
+ function ensureImageMcp(lines, enabled, adapterPort, adapterHost) {
143
+ const previous = lines.join("\n");
144
+ removeManagedImageMcp(lines);
145
+ if (enabled) {
146
+ if (hasUnmanagedImageMcp(lines)) {
147
+ throw new Error(`Codex config already defines [${IMAGE_MCP_SECTION}] outside the CCDX-managed block`);
148
+ }
149
+ while (lines.length && lines.at(-1).trim() === "") lines.pop();
150
+ if (lines.length) lines.push("");
151
+ const url = `${adapterBaseUrl(imageMcpHost(adapterHost), adapterPort)}/mcp/image`;
152
+ lines.push(
153
+ IMAGE_MCP_START,
154
+ `[${IMAGE_MCP_SECTION}]`,
155
+ `url = ${JSON.stringify(url)}`,
156
+ "enabled = true",
157
+ "tool_timeout_sec = 210",
158
+ IMAGE_MCP_END,
159
+ );
160
+ }
161
+ return lines.join("\n") !== previous;
162
+ }
163
+
164
+ export function computeUpdatedCodexConfig(
165
+ content,
166
+ adapterPort = 2026,
167
+ adapterHost = "127.0.0.1",
168
+ { imageProviderEnabled } = {},
169
+ ) {
109
170
  const baseUrl = `${adapterBaseUrl(adapterHost, adapterPort)}/v1`;
110
171
  const hadTrailingNewline = content.endsWith("\n");
111
172
  const lines = content.split("\n");
@@ -131,13 +192,16 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026, adapterHo
131
192
  changed = setTomlKey(lines, "shell_environment_policy.set", "OPENAI_BASE_URL", baseUrl) || changed;
132
193
  changed = setTomlKey(lines, "shell_environment_policy.set", "OPENAI_API_KEY", "dummy") || changed;
133
194
  changed = ensureContextManagementDefault(lines) || changed;
195
+ if (typeof imageProviderEnabled === "boolean") {
196
+ changed = ensureImageMcp(lines, imageProviderEnabled, adapterPort, adapterHost) || changed;
197
+ }
134
198
 
135
199
  return { content: lines.join("\n") + (hadTrailingNewline ? "\n" : ""), changed };
136
200
  }
137
201
 
138
- export function initialCodexConfig(adapterPort, adapterHost) {
202
+ export function initialCodexConfig(adapterPort, adapterHost, { imageProviderEnabled = false } = {}) {
139
203
  const baseUrl = `${adapterBaseUrl(adapterHost, adapterPort)}/v1`;
140
- return `openai_base_url = "${baseUrl}"
204
+ const base = `openai_base_url = "${baseUrl}"
141
205
  model_context_window = ${MODEL_CONTEXT_WINDOW}
142
206
  model_auto_compact_token_limit = ${MODEL_AUTO_COMPACT_TOKEN_LIMIT}
143
207
 
@@ -151,20 +215,38 @@ OPENAI_API_KEY = "dummy"
151
215
  [features]
152
216
  context_management = true
153
217
  `;
218
+ return imageProviderEnabled
219
+ ? computeUpdatedCodexConfig(base, adapterPort, adapterHost, { imageProviderEnabled }).content
220
+ : base;
221
+ }
222
+
223
+ export function computeImageMcpCodexConfig(
224
+ content,
225
+ { enabled, adapterPort = 2026, adapterHost = "127.0.0.1" } = {},
226
+ ) {
227
+ const hadTrailingNewline = content.endsWith("\n");
228
+ const lines = content.split("\n");
229
+ if (hadTrailingNewline) lines.pop();
230
+ const changed = ensureImageMcp(lines, Boolean(enabled), adapterPort, adapterHost);
231
+ return { content: lines.join("\n") + (hadTrailingNewline || lines.length ? "\n" : ""), changed };
154
232
  }
155
233
 
156
- export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH, host = "127.0.0.1" } = {}) {
234
+ export function ensureCodexConfig(adapterPort = 2026, {
235
+ filePath = CONFIG_PATH,
236
+ host = "127.0.0.1",
237
+ imageProviderEnabled = false,
238
+ } = {}) {
157
239
  const baseUrl = `${adapterBaseUrl(host, adapterPort)}/v1`;
158
240
 
159
241
  if (!fs.existsSync(filePath)) {
160
242
  // Codex config does not exist yet; create the local proxy defaults.
161
- atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort, host));
243
+ atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort, host, { imageProviderEnabled }));
162
244
  console.log(status("ok", "Created ~/.codex/config.toml"));
163
245
  return;
164
246
  }
165
247
 
166
248
  const content = fs.readFileSync(filePath, "utf-8");
167
- const updated = computeUpdatedCodexConfig(content, adapterPort, host);
249
+ const updated = computeUpdatedCodexConfig(content, adapterPort, host, { imageProviderEnabled });
168
250
 
169
251
  if (!updated.changed) {
170
252
  console.log(status("ok", `Codex already points to ${baseUrl}`));