codex-copilot-dx 0.7.7 → 0.7.9

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
 
@@ -135,6 +137,31 @@ All nine animations use the same 20-column colon track and cyan/blue palette. Th
135
137
 
136
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.
137
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. Enabling also installs CCDX-owned `ccdx-image` guidance under `~/.codex/skills`, with a self-contained Node helper. 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. The original built-in imagegen skill remains available for capabilities such as editing and reference images.
151
+
152
+ 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 for providers already enabled in 0.7.8, without repeating API setup. Unchanged guidance is not rewritten. The existing adapter can remain running because provider settings are loaded when the tool is invoked.
153
+
154
+ Inspect or disable the optional provider with:
155
+
156
+ ```bash
157
+ ccdx image-status
158
+ ccdx disable-image
159
+ ```
160
+
161
+ `image-status` never prints the API key and performs only a local, non-generating tool-readiness check. Disabling removes the stored provider credential, the CCDX-owned MCP block, and unmodified CCDX image guidance; user-modified skill files are preserved. Cached tool calls then fail closed. The Node helper creates a new file under the task's `output/imagegen` directory by default and reports its absolute path; it never overwrites an existing file or automatically repeats a failed generation. 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.
162
+
163
+ 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.
164
+
138
165
  ### Update
139
166
 
140
167
  Choose a source interactively:
@@ -202,7 +229,7 @@ For the complete machine-readable status payload:
202
229
  curl -s http://127.0.0.1:2026/_ccdx/status | jq
203
230
  ```
204
231
 
205
- 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.
232
+ 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.
206
233
 
207
234
  `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.
208
235
 
@@ -232,6 +259,7 @@ Recovery first tries compatible local credentials and starts Device Flow only wh
232
259
  - CCDX binds to loopback by default. Non-loopback hosts are rejected unless `CCDX_ALLOW_LAN=1` is set explicitly.
233
260
  - 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.
234
261
  - 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.
262
+ - 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.
235
263
  - 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.
236
264
  - Request and response bodies are bounded. Queues, history, image work, SSE parsing, retries, and shutdown all have explicit resource or time limits.
237
265
 
@@ -319,7 +347,7 @@ When expanded history exceeds Copilot's 50-image request limit, CCDX removes old
319
347
 
320
348
  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.
321
349
 
322
- 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.
350
+ 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.
323
351
 
324
352
  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.
325
353
 
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,17 +214,32 @@ 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
+ await configureCodexClient();
199
218
 
200
219
  await openCodex();
201
220
  console.log(`
202
221
  ${status("ok", "Ready, using the existing ccdx adapter")}
203
222
 
204
223
  Adapter: ${running.baseUrl}
224
+
225
+ Tip: Run ${CLI_NAME} help to view more features and commands.
205
226
  `);
206
227
  return true;
207
228
  }
208
229
 
230
+ async function configureCodexClient() {
231
+ const imageProviderEnabled = Boolean(readImageProviderConfig());
232
+ ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST, imageProviderEnabled });
233
+ if (imageProviderEnabled) {
234
+ try {
235
+ const { syncEnabledImageSkill } = await import("../src/cli-image.mjs");
236
+ syncEnabledImageSkill({ adapterPort: ADAPTER_PORT, adapterHost: ADAPTER_HOST });
237
+ } catch (error) {
238
+ console.warn(status("warn", `Image guidance could not be updated: ${error.message}`));
239
+ }
240
+ }
241
+ }
242
+
209
243
  if (CLI.command === "doctor") {
210
244
  const checks = await runDoctor({
211
245
  commandName: CLI_NAME,
@@ -274,7 +308,7 @@ try {
274
308
  });
275
309
 
276
310
  // Point Codex App at the adapter.
277
- ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
311
+ await configureCodexClient();
278
312
 
279
313
  // Launch Codex when available.
280
314
  await openCodex();
@@ -294,6 +328,8 @@ try {
294
328
 
295
329
  Adapter: ${adapterBaseUrl(ADAPTER_HOST, ADAPTER_PORT)}
296
330
 
331
+ Tip: Run ${CLI_NAME} help to view more features and commands.
332
+
297
333
  Press Ctrl+C to stop.
298
334
  `);
299
335
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
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,230 @@
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 { probeImageTool } from "./image-readiness.mjs";
10
+ import { updateImageSkill } from "./image-skill.mjs";
11
+ import {
12
+ imageProviderConfigPath,
13
+ inspectImageProvider,
14
+ readImageProviderConfig,
15
+ validateImageProviderConfig,
16
+ } from "./image-provider-config.mjs";
17
+
18
+ function defaultCodexConfigPath(home) {
19
+ return path.join(home, ".codex", "config.toml");
20
+ }
21
+
22
+ async function visibleQuestion(input, output, question) {
23
+ const prompt = createInterface({ input, output });
24
+ try { return await prompt.question(question); }
25
+ finally { prompt.close(); }
26
+ }
27
+
28
+ export function hiddenQuestion(input, output, question) {
29
+ if (!input?.isTTY || !output?.isTTY || typeof input.setRawMode !== "function") {
30
+ return Promise.reject(new Error("Hidden API key input requires an interactive terminal"));
31
+ }
32
+ output.write(question);
33
+ const wasRaw = Boolean(input.isRaw);
34
+ readline.emitKeypressEvents(input);
35
+ input.setRawMode(true);
36
+ input.resume();
37
+ let value = "";
38
+ return new Promise((resolve, reject) => {
39
+ const cleanup = () => {
40
+ input.off("keypress", onKeypress);
41
+ input.setRawMode(wasRaw);
42
+ if (!wasRaw) input.pause();
43
+ output.write("\n");
44
+ };
45
+ const onKeypress = (text, key = {}) => {
46
+ if (key.ctrl && key.name === "c") {
47
+ cleanup();
48
+ reject(new Error("Image provider setup cancelled"));
49
+ return;
50
+ }
51
+ if (key.name === "return" || key.name === "enter") {
52
+ cleanup();
53
+ resolve(value);
54
+ return;
55
+ }
56
+ if (key.name === "backspace" || key.name === "delete") {
57
+ value = value.slice(0, -1);
58
+ return;
59
+ }
60
+ if (!key.ctrl && !key.meta && typeof text === "string" && text >= " " && text !== "\x7f") value += text;
61
+ };
62
+ input.on("keypress", onKeypress);
63
+ });
64
+ }
65
+
66
+ function fileContent(filePath) {
67
+ try { return fs.readFileSync(filePath, "utf8"); }
68
+ catch (error) {
69
+ if (error?.code === "ENOENT") return "";
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ function chooseModelLines(modelIds, selected) {
75
+ return modelIds.map((model, index) => ` ${index + 1}. ${terminalCell(model)}${model === selected ? " [current]" : ""}`);
76
+ }
77
+
78
+ async function chooseModel(modelIds, current, ask, output) {
79
+ if (modelIds.length === 1) return modelIds[0];
80
+ const currentIndex = Math.max(0, modelIds.indexOf(current));
81
+ output.write("Available image models:\n");
82
+ output.write(`${chooseModelLines(modelIds, current).join("\n")}\n`);
83
+ while (true) {
84
+ const answer = String(await ask(`Select [${currentIndex + 1}]: `) || "").trim();
85
+ if (!answer) return modelIds[currentIndex];
86
+ const selected = Number(answer) - 1;
87
+ if (Number.isInteger(selected) && selected >= 0 && selected < modelIds.length) return modelIds[selected];
88
+ output.write(`Enter a number from 1 to ${modelIds.length}.\n`);
89
+ }
90
+ }
91
+
92
+ function configSummary(config) {
93
+ const endpoint = new URL(config.endpoint);
94
+ return {
95
+ endpoint: `${endpoint.origin}${endpoint.pathname}`,
96
+ model: config.model,
97
+ protocol: config.protocol,
98
+ };
99
+ }
100
+
101
+ function enableImageProvider({ providerConfig, providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
102
+ const codex = computeImageMcpCodexConfig(codexContent, {
103
+ enabled: true,
104
+ adapterPort,
105
+ adapterHost,
106
+ });
107
+ const normalized = validateImageProviderConfig(providerConfig, { filePath: providerPath });
108
+ const skill = updateImageSkill({ enabled: true, codexPath, adapterPort, adapterHost });
109
+ try {
110
+ atomicWriteFilePairSync(
111
+ codexPath,
112
+ codex.content,
113
+ providerPath,
114
+ `${JSON.stringify(normalized, null, 2)}\n`,
115
+ { mode: 0o600 },
116
+ );
117
+ } catch (error) {
118
+ skill.rollback();
119
+ throw error;
120
+ }
121
+ return { codexChanged: codex.changed, config: normalized };
122
+ }
123
+
124
+ function disableImageProvider({ providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
125
+ const codex = computeImageMcpCodexConfig(codexContent, {
126
+ enabled: false,
127
+ adapterPort,
128
+ adapterHost,
129
+ });
130
+ const providerExists = fs.existsSync(providerPath);
131
+ const skill = updateImageSkill({ enabled: false, codexPath, adapterPort, adapterHost });
132
+ try {
133
+ if (fs.existsSync(codexPath)) {
134
+ atomicWriteFilePairSync(codexPath, codex.content, providerPath, null, { mode: 0o600 });
135
+ } else if (providerExists) {
136
+ fs.unlinkSync(providerPath);
137
+ }
138
+ } catch (error) {
139
+ skill.rollback();
140
+ throw error;
141
+ }
142
+ return { changed: providerExists || codex.changed || skill.changed, preservedGuidance: Boolean(skill.preserved) };
143
+ }
144
+
145
+ export function syncEnabledImageSkill({ home = os.homedir(), codexPath = defaultCodexConfigPath(home), ...options } = {}) {
146
+ return updateImageSkill({ ...options, enabled: true, codexPath });
147
+ }
148
+
149
+ function printImageReadiness(readiness, output, commandName) {
150
+ if (readiness.ready) output.write("Local image service: ready (generation tool verified; no image generated).\n");
151
+ else output.write(`Local image service: unavailable (${readiness.reason}). Start or restart ${commandName} to use images.\n`);
152
+ }
153
+
154
+ export async function runImageCommand({
155
+ action,
156
+ commandName = "ccdx",
157
+ env = process.env,
158
+ home = os.homedir(),
159
+ input = process.stdin,
160
+ output = process.stdout,
161
+ prompt,
162
+ promptSecret,
163
+ fetchImpl = fetch,
164
+ probeFetchImpl = fetch,
165
+ adapterPort = 2026,
166
+ adapterHost = "127.0.0.1",
167
+ codexPath = defaultCodexConfigPath(home),
168
+ } = {}) {
169
+ const providerPath = imageProviderConfigPath({ env, home });
170
+ if (action === "status") {
171
+ const config = readImageProviderConfig({ env, home, strict: true });
172
+ if (!config) {
173
+ output.write("Image generation: disabled\n");
174
+ return { enabled: false };
175
+ }
176
+ const summary = configSummary(config);
177
+ output.write(`Image generation: enabled\nEndpoint: ${terminalCell(summary.endpoint)}\nModel: ${terminalCell(summary.model)}\nProtocol: ${terminalCell(summary.protocol)}\n`);
178
+ const readiness = await probeImageTool(fileContent(codexPath), { fetchImpl: probeFetchImpl });
179
+ printImageReadiness(readiness, output, commandName);
180
+ return { enabled: true, ...summary, readiness };
181
+ }
182
+
183
+ const codexContent = fileContent(codexPath);
184
+ if (action === "disable") {
185
+ const result = disableImageProvider({ providerPath, codexPath, codexContent, adapterPort, adapterHost });
186
+ output.write(`${result.changed ? "Disabled" : "Kept disabled"} image generation.\n`);
187
+ output.write(result.preservedGuidance
188
+ ? "Kept user-modified image guidance. Existing image tool calls are now disabled.\n"
189
+ : "Removed CCDX image guidance. Existing image tool calls are now disabled.\n");
190
+ return { enabled: false, ...result };
191
+ }
192
+ if (action !== "enable") throw new Error(`Unknown image command action: ${action}`);
193
+ if (!prompt && (!input.isTTY || !output.isTTY)) {
194
+ throw new Error(`${commandName} enable-image requires an interactive terminal`);
195
+ }
196
+
197
+ const current = readImageProviderConfig({ env, home });
198
+ const ask = prompt || ((question) => visibleQuestion(input, output, question));
199
+ const askSecret = promptSecret || ((question) => hiddenQuestion(input, output, question));
200
+ output.write(`${commandName} enable-image\n`);
201
+ const endpointAnswer = String(await ask(`API endpoint${current ? ` [${current.endpoint}]` : ""}: `) || "").trim();
202
+ const endpoint = endpointAnswer || current?.endpoint;
203
+ if (!endpoint) throw new Error("Image API endpoint is required");
204
+ const keyAnswer = String(await askSecret(`API key${current ? " [Enter to keep current]" : ""}: `) || "").trim();
205
+ const apiKey = keyAnswer || current?.api_key;
206
+ if (!apiKey) throw new Error("Image API key is required");
207
+
208
+ output.write("Checking image API...\n");
209
+ const inspected = await inspectImageProvider({ endpoint, apiKey, fetchImpl });
210
+ const model = await chooseModel(inspected.modelIds, current?.model, ask, output);
211
+ const result = enableImageProvider({
212
+ providerConfig: {
213
+ endpoint: inspected.endpoint,
214
+ api_key: apiKey,
215
+ model,
216
+ protocol: inspected.protocol,
217
+ },
218
+ providerPath,
219
+ codexPath,
220
+ codexContent,
221
+ adapterPort,
222
+ adapterHost,
223
+ });
224
+ output.write(`Enabled image generation with ${terminalCell(model)}.\n`);
225
+ const readiness = await probeImageTool(fileContent(codexPath), { fetchImpl: probeFetchImpl });
226
+ printImageReadiness(readiness, output, commandName);
227
+ output.write("Installed CCDX image guidance for new and existing tasks. Ask Codex to draw an image.\n");
228
+ output.write("If the open task has not refreshed its skills, restart Codex App once.\n");
229
+ return { enabled: true, ...configSummary(result.config), codexChanged: result.codexChanged, readiness };
230
+ }
@@ -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, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image.`,
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
+ "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.`,
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}`));