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 +33 -3
- package/bin/cli.mjs +31 -2
- package/package.json +1 -1
- package/src/adapter.mjs +12 -0
- package/src/cli-image.mjs +199 -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 +169 -0
- package/src/image-provider-config.mjs +190 -0
- package/src/image-provider.mjs +268 -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/src/terminal-animation.mjs +94 -6
- package/src/user-settings.mjs +2 -1
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.
|
|
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,
|
|
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, {
|
|
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, {
|
|
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
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
|
+
}
|
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, 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
|
|
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}`));
|