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 +30 -2
- package/bin/cli.mjs +38 -2
- package/package.json +1 -1
- package/src/adapter.mjs +12 -0
- package/src/cli-image.mjs +230 -0
- package/src/cli-options.mjs +18 -0
- package/src/cli-status.mjs +12 -0
- package/src/config.mjs +88 -6
- package/src/copilot-responses-compat.mjs +28 -18
- package/src/image-mcp.mjs +171 -0
- package/src/image-provider-config.mjs +190 -0
- package/src/image-provider.mjs +268 -0
- package/src/image-readiness.mjs +55 -0
- package/src/image-skill.mjs +143 -0
- package/src/image-tool-client.mjs +141 -0
- package/src/observability.mjs +2 -0
- package/src/response-failures.mjs +100 -0
- package/src/responses-handler.mjs +2 -0
- package/src/responses-proxy.mjs +181 -35
- package/src/responses-request.mjs +23 -0
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
+
}
|
package/src/cli-options.mjs
CHANGED
|
@@ -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
|
|
package/src/cli-status.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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, {
|
|
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}`));
|