codex-copilot-dx 0.7.8 → 0.7.10
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 +10 -4
- package/bin/cli.mjs +15 -8
- package/package.json +1 -1
- package/src/adapter.mjs +1 -1
- package/src/cli-animation.mjs +43 -8
- package/src/cli-image.mjs +47 -16
- package/src/cli-options.mjs +4 -4
- package/src/image-mcp.mjs +53 -7
- package/src/image-provider.mjs +51 -4
- package/src/image-readiness.mjs +55 -0
- package/src/image-references.mjs +41 -0
- package/src/image-skill.mjs +147 -0
- package/src/image-tool-client.mjs +150 -0
- package/src/terminal-animation-gallery.mjs +85 -0
package/README.md
CHANGED
|
@@ -131,7 +131,7 @@ The selector is ordered numerically:
|
|
|
131
131
|
8. Relay — fixed nodes receive and pass a short light trail
|
|
132
132
|
9. Split — one incoming light trail divides into two outgoing trails
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
All nine rows preview their animations together while you enter a number to select one. The live previews keep each theme's original timing and leave the input cursor in place; selection ends them immediately. Small terminals, disabled animation, CI and dumb terminals retain static previews. Resizing or clearing the terminal pauses the live gallery so input stays usable. When the gallery cannot run, CCDX retains the selected animation's one-cycle confirmation when supported. The setting is saved in `~/.config/codex-copilot-dx/config.json`, or under `XDG_CONFIG_HOME` when set.
|
|
135
135
|
|
|
136
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
137
|
|
|
@@ -147,7 +147,9 @@ ccdx enable-image
|
|
|
147
147
|
|
|
148
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
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.
|
|
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 uploaded images and other reference-image workflows.
|
|
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.
|
|
151
153
|
|
|
152
154
|
Inspect or disable the optional provider with:
|
|
153
155
|
|
|
@@ -156,9 +158,13 @@ ccdx image-status
|
|
|
156
158
|
ccdx disable-image
|
|
157
159
|
```
|
|
158
160
|
|
|
159
|
-
`image-status` never prints the API key. Disabling removes the stored provider credential
|
|
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 image operations. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`.
|
|
164
|
+
|
|
165
|
+
With `qwen-image-3.0-pro` or `qwen-image-3.0` using either supported protocol, CCDX also exposes `edit_image`. Ask Codex to modify a specific CCDX-generated image; it passes that image's returned `image_id` and the edit instruction to the same configured provider. Each edit returns a new image and ID while preserving the original; omitting `size` retains the source's requested dimensions. The Qwen request contains the source image again on every edit, as required by its [single-turn image editing API](https://help.aliyun.com/zh/model-studio/qwen-image-generation-and-editing-api-reference).
|
|
160
166
|
|
|
161
|
-
|
|
167
|
+
Editing references are opaque IDs bound to the provider configuration, not paths or URLs. Source images up to 10 MiB are kept in a 64 MiB byte-bounded in-memory LRU. IDs expire on eviction, provider/credential changes, or adapter restart; restarting Codex App alone does not clear them. A missing or expired ID fails before upstream dispatch. This feature does not import arbitrary local images or provide masks, transparent output, or an `/images/edits` endpoint. Other providers retain their existing generation behavior. The helper accepts `--image-id` for the same edit operation; its stdout remains the saved absolute file path and stderr reports the new ID. Normal host approvals still apply, and failed edits are never replaced with an automatic new generation.
|
|
162
168
|
|
|
163
169
|
### Update
|
|
164
170
|
|
package/bin/cli.mjs
CHANGED
|
@@ -214,10 +214,7 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
214
214
|
if (!running.ok) return false;
|
|
215
215
|
|
|
216
216
|
console.log(status("ok", `Using existing adapter at ${running.baseUrl}`));
|
|
217
|
-
|
|
218
|
-
host: ADAPTER_HOST,
|
|
219
|
-
imageProviderEnabled: Boolean(readImageProviderConfig()),
|
|
220
|
-
});
|
|
217
|
+
await configureCodexClient();
|
|
221
218
|
|
|
222
219
|
await openCodex();
|
|
223
220
|
console.log(`
|
|
@@ -230,6 +227,19 @@ async function reuseRunningAdapterIfAvailable() {
|
|
|
230
227
|
return true;
|
|
231
228
|
}
|
|
232
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
|
+
|
|
233
243
|
if (CLI.command === "doctor") {
|
|
234
244
|
const checks = await runDoctor({
|
|
235
245
|
commandName: CLI_NAME,
|
|
@@ -298,10 +308,7 @@ try {
|
|
|
298
308
|
});
|
|
299
309
|
|
|
300
310
|
// Point Codex App at the adapter.
|
|
301
|
-
|
|
302
|
-
host: ADAPTER_HOST,
|
|
303
|
-
imageProviderEnabled: Boolean(readImageProviderConfig()),
|
|
304
|
-
});
|
|
311
|
+
await configureCodexClient();
|
|
305
312
|
|
|
306
313
|
// Launch Codex when available.
|
|
307
314
|
await openCodex();
|
package/package.json
CHANGED
package/src/adapter.mjs
CHANGED
|
@@ -196,7 +196,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
196
196
|
if (pathname === "/mcp/image") {
|
|
197
197
|
if (imageMcpHandler) return imageMcpHandler(req, res);
|
|
198
198
|
return import("./image-mcp.mjs").then(({ createImageMcpHandler }) => {
|
|
199
|
-
imageMcpHandler
|
|
199
|
+
imageMcpHandler ||= createImageMcpHandler();
|
|
200
200
|
return imageMcpHandler(req, res);
|
|
201
201
|
});
|
|
202
202
|
}
|
package/src/cli-animation.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
renderTerminalAnimationFrame,
|
|
7
7
|
TERMINAL_ANIMATION_THEMES,
|
|
8
8
|
} from "./terminal-animation.mjs";
|
|
9
|
+
import { startTerminalAnimationGallery } from "./terminal-animation-gallery.mjs";
|
|
9
10
|
import {
|
|
10
11
|
readUserSettings,
|
|
11
12
|
terminalAnimationPreference,
|
|
@@ -34,6 +35,7 @@ export async function runAnimationCommand({
|
|
|
34
35
|
output = process.stdout,
|
|
35
36
|
prompt,
|
|
36
37
|
preview = playTerminalAnimationPreview,
|
|
38
|
+
gallery = startTerminalAnimationGallery,
|
|
37
39
|
} = {}) {
|
|
38
40
|
if (!prompt && (!input.isTTY || !output.isTTY)) {
|
|
39
41
|
throw new Error(`${commandName} animation requires an interactive terminal`);
|
|
@@ -45,24 +47,49 @@ export async function runAnimationCommand({
|
|
|
45
47
|
|
|
46
48
|
output.write(`${commandName} animation\n`);
|
|
47
49
|
output.write(`Current: ${TERMINAL_ANIMATION_THEMES[currentIndex].label} (${current.source})\n\n`);
|
|
50
|
+
const labels = TERMINAL_ANIMATION_THEMES.map((theme, index) => ` ${index + 1}. ${theme.label}${markers(theme, current.theme)}`);
|
|
51
|
+
const prefixWidth = Math.max(...labels.map((label) => label.length)) + 2;
|
|
48
52
|
TERMINAL_ANIMATION_THEMES.forEach((theme, index) => {
|
|
49
|
-
|
|
50
|
-
? ` ${renderTerminalAnimationFrame(theme.id, 10)}`
|
|
51
|
-
: "";
|
|
52
|
-
output.write(` ${index + 1}. ${theme.label}${markers(theme, current.theme)}${staticPreview}\n`);
|
|
53
|
+
output.write(`${labels[index].padEnd(prefixWidth)}${renderTerminalAnimationFrame(theme.id, theme.startFrame)}\n`);
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
let readline;
|
|
57
|
+
const abort = new AbortController();
|
|
56
58
|
const ask = prompt || (async (question) => {
|
|
57
|
-
readline
|
|
58
|
-
|
|
59
|
+
if (!readline) {
|
|
60
|
+
readline = createInterface({ input, output });
|
|
61
|
+
readline.once("SIGINT", () => abort.abort());
|
|
62
|
+
readline.once("close", () => abort.abort());
|
|
63
|
+
}
|
|
64
|
+
return readline.question(question, { signal: abort.signal });
|
|
59
65
|
});
|
|
60
66
|
|
|
61
67
|
let selectedTheme = current.theme;
|
|
62
68
|
let keepCurrent = false;
|
|
69
|
+
let livePreviewed = false;
|
|
70
|
+
let rowsBelow = 0;
|
|
71
|
+
let activeGallery;
|
|
72
|
+
let layoutChanged = false;
|
|
73
|
+
const stopForLayoutChange = () => {
|
|
74
|
+
layoutChanged = true;
|
|
75
|
+
activeGallery?.stop();
|
|
76
|
+
};
|
|
77
|
+
const onKeypress = (_text, key) => {
|
|
78
|
+
if ((key?.ctrl && key.name === "l") || (readline?.getCursorPos().rows ?? 0) > 0) stopForLayoutChange();
|
|
79
|
+
};
|
|
80
|
+
output.on?.("resize", stopForLayoutChange);
|
|
81
|
+
input.on?.("keypress", onKeypress);
|
|
63
82
|
try {
|
|
64
83
|
while (true) {
|
|
65
|
-
|
|
84
|
+
activeGallery = layoutChanged ? { enabled: false, stop() {} }
|
|
85
|
+
: gallery({ output, env, prefixWidth, rowsBelow, getInputRows: () => readline?.getCursorPos().rows ?? 0 });
|
|
86
|
+
livePreviewed ||= activeGallery.enabled;
|
|
87
|
+
let answer;
|
|
88
|
+
try {
|
|
89
|
+
answer = String(await ask(`Select [${currentIndex + 1}], or q to cancel: `) || "").trim();
|
|
90
|
+
} finally {
|
|
91
|
+
activeGallery.stop();
|
|
92
|
+
}
|
|
66
93
|
if (answer.toLowerCase() === "q") {
|
|
67
94
|
output.write("No changes made.\n");
|
|
68
95
|
return { changed: false, cancelled: true, theme: current.theme };
|
|
@@ -79,8 +106,16 @@ export async function runAnimationCommand({
|
|
|
79
106
|
break;
|
|
80
107
|
}
|
|
81
108
|
output.write(`Enter a number from 1 to ${TERMINAL_ANIMATION_THEMES.length}, or q to cancel.\n`);
|
|
109
|
+
rowsBelow += 2;
|
|
82
110
|
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!abort.signal.aborted) throw error;
|
|
113
|
+
output.write("\nNo changes made.\n");
|
|
114
|
+
return { changed: false, cancelled: true, theme: current.theme };
|
|
83
115
|
} finally {
|
|
116
|
+
activeGallery?.stop();
|
|
117
|
+
output.off?.("resize", stopForLayoutChange);
|
|
118
|
+
input.off?.("keypress", onKeypress);
|
|
84
119
|
readline?.close();
|
|
85
120
|
}
|
|
86
121
|
|
|
@@ -88,7 +123,7 @@ export async function runAnimationCommand({
|
|
|
88
123
|
? { changed: false, theme: current.theme }
|
|
89
124
|
: writeTerminalAnimationTheme(selectedTheme, { env, home });
|
|
90
125
|
const disabledValue = disabledEnvironmentValue(env);
|
|
91
|
-
if (!disabledValue) {
|
|
126
|
+
if (!disabledValue && !livePreviewed) {
|
|
92
127
|
try {
|
|
93
128
|
await preview(selectedTheme, { output });
|
|
94
129
|
} catch {
|
package/src/cli-image.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { createInterface } from "node:readline/promises";
|
|
|
6
6
|
import { atomicWriteFilePairSync } from "./atomic-file.mjs";
|
|
7
7
|
import { terminalCell } from "./cli-table.mjs";
|
|
8
8
|
import { computeImageMcpCodexConfig } from "./config.mjs";
|
|
9
|
+
import { probeImageTool } from "./image-readiness.mjs";
|
|
10
|
+
import { updateImageSkill } from "./image-skill.mjs";
|
|
9
11
|
import {
|
|
10
12
|
imageProviderConfigPath,
|
|
11
13
|
inspectImageProvider,
|
|
@@ -103,13 +105,19 @@ function enableImageProvider({ providerConfig, providerPath, codexPath, codexCon
|
|
|
103
105
|
adapterHost,
|
|
104
106
|
});
|
|
105
107
|
const normalized = validateImageProviderConfig(providerConfig, { filePath: providerPath });
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|
|
113
121
|
return { codexChanged: codex.changed, config: normalized };
|
|
114
122
|
}
|
|
115
123
|
|
|
@@ -120,12 +128,27 @@ function disableImageProvider({ providerPath, codexPath, codexContent, adapterPo
|
|
|
120
128
|
adapterHost,
|
|
121
129
|
});
|
|
122
130
|
const providerExists = fs.existsSync(providerPath);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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;
|
|
127
141
|
}
|
|
128
|
-
return { changed: providerExists || codex.changed };
|
|
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`);
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
export async function runImageCommand({
|
|
@@ -138,6 +161,7 @@ export async function runImageCommand({
|
|
|
138
161
|
prompt,
|
|
139
162
|
promptSecret,
|
|
140
163
|
fetchImpl = fetch,
|
|
164
|
+
probeFetchImpl = fetch,
|
|
141
165
|
adapterPort = 2026,
|
|
142
166
|
adapterHost = "127.0.0.1",
|
|
143
167
|
codexPath = defaultCodexConfigPath(home),
|
|
@@ -151,14 +175,18 @@ export async function runImageCommand({
|
|
|
151
175
|
}
|
|
152
176
|
const summary = configSummary(config);
|
|
153
177
|
output.write(`Image generation: enabled\nEndpoint: ${terminalCell(summary.endpoint)}\nModel: ${terminalCell(summary.model)}\nProtocol: ${terminalCell(summary.protocol)}\n`);
|
|
154
|
-
|
|
178
|
+
const readiness = await probeImageTool(fileContent(codexPath), { fetchImpl: probeFetchImpl });
|
|
179
|
+
printImageReadiness(readiness, output, commandName);
|
|
180
|
+
return { enabled: true, ...summary, readiness };
|
|
155
181
|
}
|
|
156
182
|
|
|
157
183
|
const codexContent = fileContent(codexPath);
|
|
158
184
|
if (action === "disable") {
|
|
159
185
|
const result = disableImageProvider({ providerPath, codexPath, codexContent, adapterPort, adapterHost });
|
|
160
186
|
output.write(`${result.changed ? "Disabled" : "Kept disabled"} image generation.\n`);
|
|
161
|
-
output.write(
|
|
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");
|
|
162
190
|
return { enabled: false, ...result };
|
|
163
191
|
}
|
|
164
192
|
if (action !== "enable") throw new Error(`Unknown image command action: ${action}`);
|
|
@@ -194,6 +222,9 @@ export async function runImageCommand({
|
|
|
194
222
|
adapterHost,
|
|
195
223
|
});
|
|
196
224
|
output.write(`Enabled image generation with ${terminalCell(model)}.\n`);
|
|
197
|
-
|
|
198
|
-
|
|
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 };
|
|
199
230
|
}
|
package/src/cli-options.mjs
CHANGED
|
@@ -301,10 +301,10 @@ function topicHelp(name, topic) {
|
|
|
301
301
|
status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, queue, and cache metrics from a running adapter.`,
|
|
302
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.`,
|
|
303
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.`,
|
|
304
|
-
animation: `Usage:\n ${name} animation\n\
|
|
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
|
|
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
|
|
304
|
+
animation: `Usage:\n ${name} animation\n\nPreviews all terminal activity animations together and selects the theme 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.`,
|
|
308
308
|
"auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an advertised Responses model for Codex Auto-review.`,
|
|
309
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.`,
|
|
310
310
|
version: `Usage:\n ${name} --version`,
|
package/src/image-mcp.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { isLoopbackAddress } from "./observability.mjs";
|
|
2
|
-
import { generateImage } from "./image-provider.mjs";
|
|
2
|
+
import { generateImage, supportsImageEditing } from "./image-provider.mjs";
|
|
3
3
|
import { readImageProviderConfig } from "./image-provider-config.mjs";
|
|
4
|
+
import { createImageReferenceStore } from "./image-references.mjs";
|
|
4
5
|
|
|
5
6
|
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
6
7
|
const MCP_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
|
|
7
8
|
const MCP_MAX_BODY_BYTES = 256 * 1024;
|
|
8
9
|
const IMAGE_TOOL_NAME = "generate_image";
|
|
10
|
+
const EDIT_TOOL_NAME = "edit_image";
|
|
9
11
|
|
|
10
12
|
function sameHostSocket(socket) {
|
|
11
13
|
const remote = String(socket?.remoteAddress || "").replace(/^::ffff:/, "");
|
|
@@ -42,7 +44,7 @@ function imageTool() {
|
|
|
42
44
|
return {
|
|
43
45
|
name: IMAGE_TOOL_NAME,
|
|
44
46
|
title: "Generate image",
|
|
45
|
-
description: "Generate a new image
|
|
47
|
+
description: "Generate a new image with the image provider the user enabled in CCDX. Call directly for text-to-image requests; returns the image inline. Credentials and generation are handled by CCDX, with no Python or OpenAI SDK setup. Does not edit or take reference images.",
|
|
46
48
|
inputSchema: {
|
|
47
49
|
type: "object",
|
|
48
50
|
properties: {
|
|
@@ -66,6 +68,25 @@ function imageTool() {
|
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
function editTool() {
|
|
72
|
+
const tool = imageTool();
|
|
73
|
+
return {
|
|
74
|
+
...tool,
|
|
75
|
+
name: EDIT_TOOL_NAME,
|
|
76
|
+
title: "Edit image",
|
|
77
|
+
description: "Edit a previously generated CCDX image using its exact image_id and a change instruction. Preserve the source and return a new image with a new ID for further edits. Source images must still be retained by this running adapter. Does not accept file paths, URLs, masks, or arbitrary uploads.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
...tool.inputSchema,
|
|
80
|
+
properties: {
|
|
81
|
+
image_id: { type: "string", description: "The image_id returned in the result of the specific CCDX image to edit." },
|
|
82
|
+
prompt: { type: "string", description: "Describe the requested changes and what to preserve from the source image." },
|
|
83
|
+
size: { type: "string", enum: [...tool.inputSchema.properties.size.enum], description: "Optional output dimensions; defaults to the source image's requested size." },
|
|
84
|
+
},
|
|
85
|
+
required: ["image_id", "prompt"],
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
function safeToolError(error, apiKey) {
|
|
70
91
|
if (typeof error?.message === "string" && error.message) {
|
|
71
92
|
let message = error.message.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
@@ -79,6 +100,7 @@ export function createImageMcpHandler({
|
|
|
79
100
|
configLoader = () => readImageProviderConfig(),
|
|
80
101
|
generateImageFn = generateImage,
|
|
81
102
|
maxConcurrent = 2,
|
|
103
|
+
imageReferences = createImageReferenceStore(),
|
|
82
104
|
} = {}) {
|
|
83
105
|
let active = 0;
|
|
84
106
|
return async function imageMcpHandler(req, res) {
|
|
@@ -109,11 +131,15 @@ export function createImageMcpHandler({
|
|
|
109
131
|
const reply = (result) => writeJson(res, 200, { jsonrpc: "2.0", id: request.id, result });
|
|
110
132
|
if (request.method === "initialize") {
|
|
111
133
|
const requestedVersion = String(request.params?.protocolVersion || "");
|
|
134
|
+
const config = configLoader();
|
|
135
|
+
if (!config) imageReferences.clear();
|
|
112
136
|
reply({
|
|
113
137
|
protocolVersion: MCP_PROTOCOL_VERSIONS.has(requestedVersion) ? requestedVersion : MCP_PROTOCOL_VERSION,
|
|
114
138
|
capabilities: { tools: { listChanged: false } },
|
|
115
139
|
serverInfo: { name: "ccdx-image", version: "1" },
|
|
116
|
-
instructions:
|
|
140
|
+
instructions: config
|
|
141
|
+
? `The user enabled CCDX as their image provider. Call generate_image for new images.${supportsImageEditing(config) ? " For changes to a CCDX image, call edit_image with that image's returned image_id. Keep each returned ID with its image; there is no global last image." : " Editing is not supported by this configured provider."} Display the returned image. CCDX handles credentials and the API; no Python, SDK installation, extra API key or alternate endpoint is needed. Do not automatically retry failed generations or edits.`
|
|
142
|
+
: "Image generation is disabled. Enable it with ccdx enable-image before requesting images.",
|
|
117
143
|
});
|
|
118
144
|
return;
|
|
119
145
|
}
|
|
@@ -123,22 +149,29 @@ export function createImageMcpHandler({
|
|
|
123
149
|
}
|
|
124
150
|
if (request.method === "tools/list") {
|
|
125
151
|
const config = configLoader();
|
|
126
|
-
|
|
152
|
+
if (!config) imageReferences.clear();
|
|
153
|
+
reply({ tools: config ? [imageTool(), ...(supportsImageEditing(config) ? [editTool()] : [])] : [] });
|
|
127
154
|
return;
|
|
128
155
|
}
|
|
129
156
|
if (request.method !== "tools/call") {
|
|
130
157
|
writeJson(res, 200, jsonRpcError(request.id, -32601, "Method not found"));
|
|
131
158
|
return;
|
|
132
159
|
}
|
|
133
|
-
if (request.params?.name
|
|
160
|
+
if (![IMAGE_TOOL_NAME, EDIT_TOOL_NAME].includes(request.params?.name)) {
|
|
134
161
|
writeJson(res, 200, jsonRpcError(request.id, -32602, "Unknown image tool"));
|
|
135
162
|
return;
|
|
136
163
|
}
|
|
137
164
|
const config = configLoader();
|
|
138
165
|
if (!config) {
|
|
166
|
+
imageReferences.clear();
|
|
139
167
|
reply({ content: [{ type: "text", text: "Image generation is disabled. Run ccdx enable-image first." }], isError: true });
|
|
140
168
|
return;
|
|
141
169
|
}
|
|
170
|
+
const editing = request.params.name === EDIT_TOOL_NAME;
|
|
171
|
+
if (editing && !supportsImageEditing(config)) {
|
|
172
|
+
reply({ content: [{ type: "text", text: "The configured image provider does not support CCDX editing. No edit was submitted." }], isError: true });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
142
175
|
if (active >= maxConcurrent) {
|
|
143
176
|
reply({ content: [{ type: "text", text: "Image generation is busy. Try again after the current request finishes." }], isError: true });
|
|
144
177
|
return;
|
|
@@ -149,11 +182,24 @@ export function createImageMcpHandler({
|
|
|
149
182
|
req.once?.("aborted", cancel);
|
|
150
183
|
res.once?.("close", cancel);
|
|
151
184
|
try {
|
|
152
|
-
const
|
|
185
|
+
const args = request.params?.arguments || {};
|
|
186
|
+
// Only an explicit retained handle can add source pixels to a request.
|
|
187
|
+
const source = editing ? imageReferences.resolve(config, args.image_id) : null;
|
|
188
|
+
const parameters = { prompt: args.prompt, size: args.size ?? source?.size ?? "1024x1024" };
|
|
189
|
+
if (source) parameters.image = source.image;
|
|
190
|
+
const image = await generateImageFn(config, parameters, { signal: abort.signal });
|
|
191
|
+
let imageId = null;
|
|
192
|
+
if (supportsImageEditing(config)) {
|
|
193
|
+
try { imageId = imageReferences.remember(config, { ...image, size: parameters.size }); } catch {}
|
|
194
|
+
}
|
|
195
|
+
const note = imageId ? ` Image ID: ${imageId}. Use this ID for further edits while retained by this adapter.`
|
|
196
|
+
: supportsImageEditing(config) ? " The image is available, but an editing reference could not be retained." : "";
|
|
153
197
|
reply({
|
|
198
|
+
// Codex prioritizes structuredContent over image blocks. Keep IDs in metadata/text.
|
|
199
|
+
...(imageId ? { _meta: { "ccdx/image_id": imageId } } : {}),
|
|
154
200
|
content: [
|
|
155
201
|
{ type: "image", data: image.data, mimeType: image.mimeType },
|
|
156
|
-
{ type: "text", text:
|
|
202
|
+
{ type: "text", text: `${editing ? "Edited" : "Generated"} ${image.width || ""}${image.width && image.height ? "×" : ""}${image.height || image.size} image with ${image.model}.${note}` },
|
|
157
203
|
],
|
|
158
204
|
});
|
|
159
205
|
} catch (error) {
|
package/src/image-provider.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import https from "node:https";
|
|
|
3
3
|
import net from "node:net";
|
|
4
4
|
|
|
5
5
|
export const IMAGE_MAX_BYTES = 32 * 1024 * 1024;
|
|
6
|
+
export const IMAGE_INPUT_MAX_BYTES = 10 * 1024 * 1024;
|
|
6
7
|
export const IMAGE_GENERATION_TIMEOUT_MS = 180_000;
|
|
7
8
|
export const IMAGE_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
8
9
|
export const IMAGE_SIZES = new Set(["1024x1024", "1536x1024", "1024x1536"]);
|
|
@@ -28,15 +29,59 @@ function checkedSize(value) {
|
|
|
28
29
|
return size;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function
|
|
32
|
+
export function supportsImageEditing(config) {
|
|
33
|
+
return ["qwen-image-3.0-pro", "qwen-image-3.0"].includes(config?.model)
|
|
34
|
+
&& ["qwen-messages", "openai-images"].includes(config?.protocol);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkedImage(config, value) {
|
|
38
|
+
if (value === undefined) return undefined;
|
|
39
|
+
if (!supportsImageEditing(config)) {
|
|
40
|
+
throw providerError("Image editing is not supported by the configured provider", "ccdx_image_edit_unsupported");
|
|
41
|
+
}
|
|
42
|
+
if (typeof value !== "string") {
|
|
43
|
+
throw providerError("Source image must be a PNG, JPEG, or WebP base64 data URI", "ccdx_image_input_invalid");
|
|
44
|
+
}
|
|
45
|
+
if (value.length > 4 * Math.ceil(IMAGE_INPUT_MAX_BYTES / 3) + 32) {
|
|
46
|
+
throw providerError("Source image exceeds the 10 MiB size limit", "ccdx_image_input_too_large");
|
|
47
|
+
}
|
|
48
|
+
const match = /^data:(image\/(?:png|jpeg|webp));base64,/.exec(value);
|
|
49
|
+
if (!match) {
|
|
50
|
+
throw providerError("Source image must be a PNG, JPEG, or WebP base64 data URI", "ccdx_image_input_invalid");
|
|
51
|
+
}
|
|
52
|
+
const encoded = value.slice(match[0].length);
|
|
53
|
+
if (encoded.length > 4 * Math.ceil(IMAGE_INPUT_MAX_BYTES / 3)) {
|
|
54
|
+
throw providerError("Source image exceeds the 10 MiB size limit", "ccdx_image_input_too_large");
|
|
55
|
+
}
|
|
56
|
+
if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) {
|
|
57
|
+
throw providerError("Source image contains invalid base64 data", "ccdx_image_input_invalid");
|
|
58
|
+
}
|
|
59
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
60
|
+
if (bytes.length > IMAGE_INPUT_MAX_BYTES) {
|
|
61
|
+
throw providerError("Source image exceeds the 10 MiB size limit", "ccdx_image_input_too_large");
|
|
62
|
+
}
|
|
63
|
+
if (bytes.toString("base64") !== encoded) {
|
|
64
|
+
throw providerError("Source image contains non-canonical base64 data", "ccdx_image_input_invalid");
|
|
65
|
+
}
|
|
66
|
+
let metadata;
|
|
67
|
+
try { metadata = imageMetadata(bytes); } catch {
|
|
68
|
+
throw providerError("Source image has an unsupported image format", "ccdx_image_input_invalid");
|
|
69
|
+
}
|
|
70
|
+
if (metadata.mimeType !== match[1]) {
|
|
71
|
+
throw providerError("Source image format does not match its data URI", "ccdx_image_input_invalid");
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function generationBody(config, prompt, size, image) {
|
|
32
77
|
if (config.protocol === "qwen-messages") {
|
|
33
78
|
return {
|
|
34
79
|
model: config.model,
|
|
35
|
-
input: { messages: [{ role: "user", content: [{ text: prompt }] }] },
|
|
80
|
+
input: { messages: [{ role: "user", content: [...(image ? [{ image }] : []), { text: prompt }] }] },
|
|
36
81
|
parameters: { n: 1, size: size.replace("x", "*"), watermark: false },
|
|
37
82
|
};
|
|
38
83
|
}
|
|
39
|
-
return { model: config.model, prompt, n: 1, size };
|
|
84
|
+
return { model: config.model, prompt, n: 1, size, ...(image ? { image } : {}) };
|
|
40
85
|
}
|
|
41
86
|
|
|
42
87
|
async function boundedJson(response) {
|
|
@@ -210,6 +255,7 @@ export async function downloadPublicImage(urlValue, {
|
|
|
210
255
|
export async function generateImage(config, {
|
|
211
256
|
prompt,
|
|
212
257
|
size = "1024x1024",
|
|
258
|
+
image,
|
|
213
259
|
} = {}, {
|
|
214
260
|
fetchImpl = fetch,
|
|
215
261
|
downloadImage = downloadPublicImage,
|
|
@@ -218,6 +264,7 @@ export async function generateImage(config, {
|
|
|
218
264
|
} = {}) {
|
|
219
265
|
const normalizedPrompt = checkedPrompt(prompt);
|
|
220
266
|
const normalizedSize = checkedSize(size);
|
|
267
|
+
const normalizedImage = checkedImage(config, image);
|
|
221
268
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
222
269
|
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
223
270
|
let response;
|
|
@@ -229,7 +276,7 @@ export async function generateImage(config, {
|
|
|
229
276
|
"Content-Type": "application/json",
|
|
230
277
|
Accept: "application/json",
|
|
231
278
|
},
|
|
232
|
-
body: JSON.stringify(generationBody(config, normalizedPrompt, normalizedSize)),
|
|
279
|
+
body: JSON.stringify(generationBody(config, normalizedPrompt, normalizedSize, normalizedImage)),
|
|
233
280
|
signal: requestSignal,
|
|
234
281
|
});
|
|
235
282
|
} catch (error) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { parse } from "smol-toml";
|
|
3
|
+
import { readBoundedResponseText } from "./http-transport.mjs";
|
|
4
|
+
|
|
5
|
+
export async function probeImageTool(codexContent, { fetchImpl = fetch, timeoutMs = 1500 } = {}) {
|
|
6
|
+
let server;
|
|
7
|
+
let url;
|
|
8
|
+
try {
|
|
9
|
+
server = parse(codexContent).mcp_servers?.ccdx_image;
|
|
10
|
+
if (!server || server.enabled === false) return { ready: false, reason: "not_configured" };
|
|
11
|
+
url = new URL(server.url);
|
|
12
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
13
|
+
const local = ["localhost", "127.0.0.1", "::1", ...Object.values(os.networkInterfaces())
|
|
14
|
+
.flat().filter(Boolean).map((entry) => entry.address)];
|
|
15
|
+
if (url.protocol !== "http:" || !local.includes(host) || url.username || url.password
|
|
16
|
+
|| url.pathname !== "/mcp/image" || url.search || url.hash) {
|
|
17
|
+
return { ready: false, reason: "invalid_local_endpoint" };
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
return { ready: false, reason: "invalid_config" };
|
|
21
|
+
}
|
|
22
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
23
|
+
const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
24
|
+
async function rpc(id, method, params) {
|
|
25
|
+
const response = await fetchImpl(url.href, {
|
|
26
|
+
method: "POST", headers, signal, redirect: "error",
|
|
27
|
+
body: JSON.stringify({ jsonrpc: "2.0", ...(id === undefined ? {} : { id }), method, params }),
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
await response.body?.cancel?.().catch(() => {});
|
|
31
|
+
throw new Error(`HTTP ${response.status}`);
|
|
32
|
+
}
|
|
33
|
+
if (id === undefined) {
|
|
34
|
+
await response.body?.cancel?.().catch(() => {});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const text = await readBoundedResponseText(response, { maxBytes: 64 * 1024, signal, label: "Image tool catalog" });
|
|
38
|
+
const message = JSON.parse(text);
|
|
39
|
+
if (message.jsonrpc !== "2.0" || message.id !== id || message.error) throw new Error("invalid MCP response");
|
|
40
|
+
return message.result;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const initialized = await rpc(1, "initialize", {
|
|
44
|
+
protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "ccdx-image-status", version: "1" },
|
|
45
|
+
});
|
|
46
|
+
if (initialized?.serverInfo?.name !== "ccdx-image") return { ready: false, reason: "unexpected_server" };
|
|
47
|
+
await rpc(undefined, "notifications/initialized", {});
|
|
48
|
+
const result = await rpc(2, "tools/list", {});
|
|
49
|
+
return result?.tools?.some((tool) => tool.name === "generate_image")
|
|
50
|
+
? { ready: true }
|
|
51
|
+
: { ready: false, reason: "tool_unavailable" };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { ready: false, reason: signal.aborted ? "timeout" : /^HTTP \d+$/.test(error.message) ? error.message : "unreachable" };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createByteLruCache } from "./bounded-result-cache.mjs";
|
|
3
|
+
import { IMAGE_INPUT_MAX_BYTES } from "./image-provider.mjs";
|
|
4
|
+
|
|
5
|
+
const IMAGE_ID = /^ccdx_img_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
6
|
+
|
|
7
|
+
function providerScope(config) {
|
|
8
|
+
return createHash("sha256").update(JSON.stringify([
|
|
9
|
+
config.endpoint, config.model, config.protocol, config.api_key,
|
|
10
|
+
])).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createImageReferenceStore({ maxBytes = 64 * 1024 * 1024 } = {}) {
|
|
14
|
+
const cache = createByteLruCache({ maxBytes });
|
|
15
|
+
return {
|
|
16
|
+
remember(config, image) {
|
|
17
|
+
if (!["image/png", "image/jpeg", "image/webp"].includes(image?.mimeType)
|
|
18
|
+
|| typeof image?.data !== "string" || !image.data
|
|
19
|
+
|| image.data.length > Math.ceil(IMAGE_INPUT_MAX_BYTES / 3) * 4
|
|
20
|
+
|| Buffer.byteLength(image.data, "base64") > IMAGE_INPUT_MAX_BYTES) return null;
|
|
21
|
+
const imageId = `ccdx_img_${randomUUID()}`;
|
|
22
|
+
const retained = cache.set(`${providerScope(config)}:${imageId}`, JSON.stringify({
|
|
23
|
+
image: `data:${image.mimeType};base64,${image.data}`,
|
|
24
|
+
size: image.size,
|
|
25
|
+
}));
|
|
26
|
+
return retained ? imageId : null;
|
|
27
|
+
},
|
|
28
|
+
resolve(config, imageId) {
|
|
29
|
+
if (typeof imageId !== "string" || !IMAGE_ID.test(imageId)) {
|
|
30
|
+
throw new Error("Provide the exact image_id returned by CCDX for the image to edit.");
|
|
31
|
+
}
|
|
32
|
+
const value = cache.get(`${providerScope(config)}:${imageId}`);
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
throw new Error("This image reference is unavailable: the adapter restarted, the image was evicted, or the provider changed. No edit was submitted.");
|
|
35
|
+
}
|
|
36
|
+
return JSON.parse(value);
|
|
37
|
+
},
|
|
38
|
+
clear() { cache.clear(); },
|
|
39
|
+
stats() { return cache.stats(); },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { atomicWriteFilePairSync, atomicWriteFileSync } from "./atomic-file.mjs";
|
|
5
|
+
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
6
|
+
|
|
7
|
+
const SKILL_MARKER = /<!-- ccdx:image-skill:([a-f0-9]{64}) -->\n/;
|
|
8
|
+
const HELPER_MARKER = /^\/\/ ccdx:image-helper:([a-f0-9]{64})\n/;
|
|
9
|
+
|
|
10
|
+
function digest(content) {
|
|
11
|
+
return createHash("sha256").update(content).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function quote(value) {
|
|
15
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function conflict(message) {
|
|
19
|
+
return Object.assign(new Error(message), { code: "CCDX_IMAGE_SKILL_CONFLICT" });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function directoryCheck(directory) {
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.lstatSync(directory).isDirectory()) throw conflict(`CCDX image skill path is not a regular directory: ${directory}`);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error?.code !== "ENOENT") throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ownedSnapshot(filePath, marker, preserveUnowned = false) {
|
|
31
|
+
try {
|
|
32
|
+
const stat = fs.lstatSync(filePath);
|
|
33
|
+
if (!stat.isFile()) throw conflict(`Refusing to replace an unowned CCDX image skill path: ${filePath}`);
|
|
34
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
35
|
+
const match = marker.exec(content);
|
|
36
|
+
if (!match || digest(content.replace(marker, "")) !== match[1]) {
|
|
37
|
+
throw conflict(`Preserving an existing or modified image skill file: ${filePath}`);
|
|
38
|
+
}
|
|
39
|
+
return { content, mode: stat.mode & 0o777 };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error?.code === "ENOENT") return null;
|
|
42
|
+
if (preserveUnowned && error?.code === "CCDX_IMAGE_SKILL_CONFLICT") return { unowned: true };
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function removeFile(filePath) {
|
|
48
|
+
try { fs.unlinkSync(filePath); }
|
|
49
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function removeEmptyDirectories(skillDirectory) {
|
|
53
|
+
for (const directory of [path.join(skillDirectory, "scripts"), skillDirectory]) {
|
|
54
|
+
try { fs.rmdirSync(directory); }
|
|
55
|
+
catch (error) { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error; }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function updateImageSkill({
|
|
60
|
+
enabled,
|
|
61
|
+
codexPath,
|
|
62
|
+
adapterPort = 2026,
|
|
63
|
+
adapterHost = "127.0.0.1",
|
|
64
|
+
nodePath = process.execPath,
|
|
65
|
+
} = {}) {
|
|
66
|
+
const skillDirectory = path.join(path.dirname(codexPath), "skills", "ccdx-image");
|
|
67
|
+
const skillPath = path.join(skillDirectory, "SKILL.md");
|
|
68
|
+
const helperPath = path.join(skillDirectory, "scripts", "generate.mjs");
|
|
69
|
+
try {
|
|
70
|
+
directoryCheck(skillDirectory);
|
|
71
|
+
directoryCheck(path.dirname(helperPath));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!enabled && error?.code === "CCDX_IMAGE_SKILL_CONFLICT") {
|
|
74
|
+
return { changed: false, preserved: true, skillPath, rollback: () => {} };
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
const previousSkill = ownedSnapshot(skillPath, SKILL_MARKER, !enabled);
|
|
79
|
+
const previousHelper = ownedSnapshot(helperPath, HELPER_MARKER, !enabled);
|
|
80
|
+
const touched = [];
|
|
81
|
+
let rolledBack = false;
|
|
82
|
+
const rollback = () => {
|
|
83
|
+
if (rolledBack) return;
|
|
84
|
+
for (const [filePath, snapshot] of touched) {
|
|
85
|
+
if (snapshot) atomicWriteFileSync(filePath, snapshot.content, { mode: snapshot.mode, preserveMode: false });
|
|
86
|
+
else removeFile(filePath);
|
|
87
|
+
}
|
|
88
|
+
removeEmptyDirectories(skillDirectory);
|
|
89
|
+
rolledBack = true;
|
|
90
|
+
};
|
|
91
|
+
if (!enabled) {
|
|
92
|
+
const preserved = Boolean(previousSkill?.unowned || previousHelper?.unowned);
|
|
93
|
+
if (!previousSkill?.content && !previousHelper?.content) return { changed: false, preserved, skillPath, rollback: () => {} };
|
|
94
|
+
try {
|
|
95
|
+
for (const [filePath, snapshot] of [[skillPath, previousSkill], [helperPath, previousHelper]]) {
|
|
96
|
+
if (!snapshot?.content) continue;
|
|
97
|
+
touched.push([filePath, snapshot]);
|
|
98
|
+
removeFile(filePath);
|
|
99
|
+
}
|
|
100
|
+
removeEmptyDirectories(skillDirectory);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
rollback();
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
return { changed: true, preserved, skillPath, rollback };
|
|
106
|
+
}
|
|
107
|
+
const host = ["0.0.0.0", "::", "[::]"].includes(adapterHost) ? (adapterHost === "0.0.0.0" ? "127.0.0.1" : "::1") : adapterHost;
|
|
108
|
+
const endpoint = `${adapterBaseUrl(host, adapterPort)}/mcp/image`;
|
|
109
|
+
const skillBody = `---
|
|
110
|
+
name: ccdx-image
|
|
111
|
+
description: Generate images from text and edit previously generated CCDX images using the user's configured provider. Use for drawing a new image or modifying a CCDX result identified in the conversation. Excludes arbitrary image uploads, masks, transparent output, and code-native SVG or diagrams.
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
# CCDX image generation
|
|
115
|
+
|
|
116
|
+
The user enabled this provider through ccdx enable-image. Use the configured CCDX provider directly for text-to-image requests and supported edits of its previous results.
|
|
117
|
+
|
|
118
|
+
For a new image, use the ccdx_image server's generate_image tool. Supply a complete prompt and one size: 1024x1024 (square), 1536x1024 (landscape), or 1024x1536 (portrait). Display its returned image and retain the returned image_id in the conversation for later edits. No Python, SDK installation, extra API key, or provider discovery is needed.
|
|
119
|
+
|
|
120
|
+
For an edit, use edit_image with the image_id of the specific CCDX result the user wants to change and a prompt describing the changes and what to preserve. Omit size to keep the source dimensions unless the user requests another size. Select the ID from this conversation; never assume a global last image or substitute a different result. Each edit returns a new image and image_id and preserves the source. IDs belong to the running CCDX process and can expire after cache eviction or restart. If the source ID is missing or expired, explain that the original image is unavailable for this edit. The provider must support edit_image; an unsupported edit is not permission to generate a replacement.
|
|
121
|
+
|
|
122
|
+
If the desired tool is not directly callable and the host provides tool search, make one targeted search for ccdx_image generate_image or ccdx_image edit_image as appropriate and use that tool if it becomes callable. If it is still unavailable, run the bundled Node helper once. To generate:
|
|
123
|
+
|
|
124
|
+
\`\`\`sh
|
|
125
|
+
${quote(nodePath)} ${quote(helperPath)} --prompt 'A complete image prompt' --size 1024x1024
|
|
126
|
+
\`\`\`
|
|
127
|
+
|
|
128
|
+
To edit, add --image-id with the exact source ID returned earlier and supply the editing prompt. Omit --size to preserve the source dimensions. This sends one edit_image call to the same service.
|
|
129
|
+
|
|
130
|
+
Quote the actual prompt safely for the shell. The helper uses the same local CCDX service and prints only the saved absolute image path to stdout; stderr reports CCDX image_id for subsequent edits. An optional --out argument selects a new output file; existing files are never overwritten. Inspect and display the saved image with the available image viewer or an absolute-path Markdown image, and retain its new ID.
|
|
131
|
+
|
|
132
|
+
The helper needs loopback HTTP access. If the task reports restricted network access, use the execution tool's normal permission-request mechanism for this exact command before running it; localhost is also restricted. Keep the host's approval policy unchanged.
|
|
133
|
+
|
|
134
|
+
Only use the helper when the MCP tool is absent, not after generation or editing fails or times out: the first request may already have consumed a generation or edit. A helper error explicitly saying generation or editing has not started means no image request was submitted; resolve its stated local connection or permission issue before trying again. Otherwise report the failure once without switching providers, installing dependencies, or repeating the image request.
|
|
135
|
+
|
|
136
|
+
For arbitrary uploaded images, reference-image workflows, masks, transparent output, or explicitly requested other providers, keep the relevant native tools and skills; do not substitute a fresh CCDX generation.
|
|
137
|
+
`;
|
|
138
|
+
const skillContent = skillBody.replace("# CCDX image generation", `<!-- ccdx:image-skill:${digest(skillBody)} -->\n# CCDX image generation`);
|
|
139
|
+
const helperBody = `${fs.readFileSync(new URL("./image-tool-client.mjs", import.meta.url), "utf8")}\ntry {\n await runImageToolClient({ endpoint: ${JSON.stringify(endpoint)} });\n} catch (error) {\n console.error(String(error?.message || "Image generation failed").replace(/[\\u0000-\\u001f\\u007f-\\u009f]/g, " ").slice(0, 1000));\n process.exitCode = 1;\n}\n`;
|
|
140
|
+
const helperContent = `// ccdx:image-helper:${digest(helperBody)}\n${helperBody}`;
|
|
141
|
+
if (previousSkill?.content === skillContent && previousHelper?.content === helperContent) {
|
|
142
|
+
return { changed: false, skillPath, rollback: () => {} };
|
|
143
|
+
}
|
|
144
|
+
atomicWriteFilePairSync(helperPath, helperContent, skillPath, skillContent, { mode: 0o600 });
|
|
145
|
+
touched.push([helperPath, previousHelper], [skillPath, previousSkill]);
|
|
146
|
+
return { changed: true, skillPath, rollback };
|
|
147
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
const IMAGE_SIZES = new Set(["1024x1024", "1536x1024", "1024x1536"]);
|
|
7
|
+
const IMAGE_EXTENSIONS = new Map([["image/png", ".png"], ["image/jpeg", ".jpg"], ["image/webp", ".webp"]]);
|
|
8
|
+
const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const MAX_RESPONSE_BYTES = 48 * 1024 * 1024;
|
|
10
|
+
const IMAGE_ID = /^ccdx_img_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
11
|
+
const USAGE = "Usage: generate.mjs --prompt <text> [--image-id <previous-CCDX-image-id>] [--size 1024x1024|1536x1024|1024x1536] [--out <new-file>]";
|
|
12
|
+
|
|
13
|
+
export function parseImageToolArgs(args = []) {
|
|
14
|
+
if (args.length === 1 && ["--help", "-h"].includes(args[0])) return { help: true };
|
|
15
|
+
const values = {};
|
|
16
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
17
|
+
const option = args[index];
|
|
18
|
+
if (!["--prompt", "--image-id", "--size", "--out"].includes(option) || option in values) throw new Error(USAGE);
|
|
19
|
+
if (typeof args[index + 1] !== "string" || !args[index + 1].trim()) throw new Error(`Missing value for ${option}`);
|
|
20
|
+
values[option] = args[index + 1];
|
|
21
|
+
}
|
|
22
|
+
const prompt = String(values["--prompt"] || "").trim();
|
|
23
|
+
if (!prompt || prompt.length > 16_000) throw new Error("Image prompt must contain 1 to 16000 characters");
|
|
24
|
+
const imageId = values["--image-id"]?.trim();
|
|
25
|
+
if (imageId !== undefined && !IMAGE_ID.test(imageId)) throw new Error("Invalid CCDX image ID");
|
|
26
|
+
const size = values["--size"] ? values["--size"].trim().toLowerCase() : (imageId ? undefined : "1024x1024");
|
|
27
|
+
if (size !== undefined && !IMAGE_SIZES.has(size)) throw new Error("Unsupported image size");
|
|
28
|
+
return { prompt, size, out: values["--out"], imageId };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function localImageEndpoint(value) {
|
|
32
|
+
const endpoint = new URL(value);
|
|
33
|
+
const host = endpoint.hostname.replace(/^\[|\]$/g, "");
|
|
34
|
+
const localAddresses = Object.values(os.networkInterfaces()).flat().filter(Boolean).map(({ address }) => address);
|
|
35
|
+
const local = host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host) || localAddresses.includes(host);
|
|
36
|
+
if (!local || endpoint.protocol !== "http:" || endpoint.username || endpoint.password
|
|
37
|
+
|| endpoint.pathname !== "/mcp/image" || endpoint.search || endpoint.hash) {
|
|
38
|
+
throw new Error("The CCDX image helper requires the configured local /mcp/image endpoint");
|
|
39
|
+
}
|
|
40
|
+
return endpoint.href;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cleanError(value) {
|
|
44
|
+
return String(value || "Image generation failed").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 1000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readRpcResponse(response, expectedId) {
|
|
48
|
+
if (!response.ok) throw new Error(`CCDX image service returned HTTP ${response.status}`);
|
|
49
|
+
const declared = Number(response.headers.get("content-length"));
|
|
50
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
51
|
+
await response.body?.cancel();
|
|
52
|
+
throw new Error("CCDX image response exceeds the size limit");
|
|
53
|
+
}
|
|
54
|
+
const chunks = [];
|
|
55
|
+
let bytes = 0;
|
|
56
|
+
for await (const chunk of response.body) {
|
|
57
|
+
bytes += chunk.byteLength;
|
|
58
|
+
if (bytes > MAX_RESPONSE_BYTES) throw new Error("CCDX image response exceeds the size limit");
|
|
59
|
+
chunks.push(Buffer.from(chunk));
|
|
60
|
+
}
|
|
61
|
+
let body;
|
|
62
|
+
try { body = JSON.parse(Buffer.concat(chunks, bytes).toString("utf8")); }
|
|
63
|
+
catch { throw new Error("CCDX image service returned invalid JSON"); }
|
|
64
|
+
if (body?.jsonrpc !== "2.0" || body.id !== expectedId) throw new Error("CCDX image service returned an invalid JSON-RPC response");
|
|
65
|
+
if (body.error) throw new Error(cleanError(body.error.message));
|
|
66
|
+
if (!body.result || typeof body.result !== "object") throw new Error("CCDX image service returned no result");
|
|
67
|
+
return body.result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertNewOutput(filePath) {
|
|
71
|
+
try {
|
|
72
|
+
fs.lstatSync(filePath);
|
|
73
|
+
throw new Error(`Image output already exists: ${filePath}`);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code !== "ENOENT") throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function runImageToolClient({
|
|
80
|
+
endpoint,
|
|
81
|
+
args = process.argv.slice(2),
|
|
82
|
+
cwd = process.cwd(),
|
|
83
|
+
fetchImpl = fetch,
|
|
84
|
+
output = process.stdout,
|
|
85
|
+
metadataOutput = process.stderr,
|
|
86
|
+
signal = AbortSignal.timeout(210_000),
|
|
87
|
+
} = {}) {
|
|
88
|
+
const options = parseImageToolArgs(args);
|
|
89
|
+
if (options.help) {
|
|
90
|
+
output.write(`${USAGE}\n`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const url = localImageEndpoint(endpoint);
|
|
94
|
+
const requestedOutput = options.out ? path.resolve(cwd, options.out) : null;
|
|
95
|
+
if (requestedOutput) assertNewOutput(requestedOutput);
|
|
96
|
+
const directory = requestedOutput ? path.dirname(requestedOutput) : path.resolve(cwd, "output", "imagegen");
|
|
97
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
98
|
+
fs.accessSync(directory, fs.constants.W_OK);
|
|
99
|
+
const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" };
|
|
100
|
+
const post = (body) => fetchImpl(url, {
|
|
101
|
+
method: "POST", headers, body: JSON.stringify(body), redirect: "error", signal,
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
const initialized = await post({
|
|
105
|
+
jsonrpc: "2.0", id: 1, method: "initialize",
|
|
106
|
+
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "ccdx-image-helper", version: "1" } },
|
|
107
|
+
});
|
|
108
|
+
const sessionId = initialized.headers.get("mcp-session-id");
|
|
109
|
+
const server = await readRpcResponse(initialized, 1);
|
|
110
|
+
if (server.serverInfo?.name !== "ccdx-image") throw new Error("The configured local service is not CCDX image generation");
|
|
111
|
+
headers["MCP-Protocol-Version"] = server.protocolVersion || "2025-06-18";
|
|
112
|
+
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
113
|
+
const notified = await post({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
114
|
+
await notified.body?.cancel();
|
|
115
|
+
if (!notified.ok) throw new Error(`CCDX image initialization returned HTTP ${notified.status}`);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const code = error.cause?.code || error.code;
|
|
118
|
+
const detail = ["EPERM", "EACCES"].includes(code)
|
|
119
|
+
? "Local network access was denied. Request normal execution permission for this helper command."
|
|
120
|
+
: cleanError(error.message);
|
|
121
|
+
throw Object.assign(new Error(`Image ${options.imageId ? "editing" : "generation"} has not started: ${detail}`), { code: "CCDX_IMAGE_NOT_STARTED" });
|
|
122
|
+
}
|
|
123
|
+
const argumentsValue = { prompt: options.prompt };
|
|
124
|
+
if (options.size !== undefined) argumentsValue.size = options.size;
|
|
125
|
+
if (options.imageId) argumentsValue.image_id = options.imageId;
|
|
126
|
+
const result = await readRpcResponse(await post({
|
|
127
|
+
jsonrpc: "2.0", id: 2, method: "tools/call",
|
|
128
|
+
params: { name: options.imageId ? "edit_image" : "generate_image", arguments: argumentsValue },
|
|
129
|
+
}), 2);
|
|
130
|
+
if (result.isError) {
|
|
131
|
+
throw new Error(cleanError(result.content?.find((part) => part.type === "text")?.text));
|
|
132
|
+
}
|
|
133
|
+
const image = result.content?.find((part) => part.type === "image");
|
|
134
|
+
const extension = IMAGE_EXTENSIONS.get(image?.mimeType);
|
|
135
|
+
if (!extension || typeof image.data !== "string" || !image.data
|
|
136
|
+
|| image.data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4
|
|
137
|
+
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(image.data)) {
|
|
138
|
+
throw new Error("CCDX image service returned an invalid image");
|
|
139
|
+
}
|
|
140
|
+
const bytes = Buffer.from(image.data, "base64");
|
|
141
|
+
if (!bytes.length || bytes.length > MAX_IMAGE_BYTES || bytes.toString("base64") !== image.data) {
|
|
142
|
+
throw new Error("CCDX image service returned an invalid image");
|
|
143
|
+
}
|
|
144
|
+
const filePath = requestedOutput || path.join(directory, `image-${randomUUID()}${extension}`);
|
|
145
|
+
fs.writeFileSync(filePath, bytes, { flag: "wx", mode: 0o600 });
|
|
146
|
+
output.write(`${filePath}\n`);
|
|
147
|
+
const imageId = result._meta?.["ccdx/image_id"];
|
|
148
|
+
if (typeof imageId === "string" && IMAGE_ID.test(imageId)) metadataOutput.write(`CCDX image_id: ${imageId}\n`);
|
|
149
|
+
return filePath;
|
|
150
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TERMINAL_ANIMATION_THEMES,
|
|
3
|
+
TERMINAL_ANIMATION_TRACK_WIDTH,
|
|
4
|
+
getTerminalAnimationFrameDelay,
|
|
5
|
+
renderTerminalAnimationFrame,
|
|
6
|
+
} from "./terminal-animation.mjs";
|
|
7
|
+
|
|
8
|
+
const ESC = "\u001b";
|
|
9
|
+
const TICK_MS = 32;
|
|
10
|
+
|
|
11
|
+
export function startTerminalAnimationGallery({
|
|
12
|
+
output,
|
|
13
|
+
env = process.env,
|
|
14
|
+
prefixWidth,
|
|
15
|
+
rowsBelow = 0,
|
|
16
|
+
getInputRows = () => 0,
|
|
17
|
+
timers = { setTimeout, clearTimeout },
|
|
18
|
+
now = () => performance.now(),
|
|
19
|
+
} = {}) {
|
|
20
|
+
const columns = Number(output?.columns);
|
|
21
|
+
const rows = Number(output?.rows);
|
|
22
|
+
const disabled = /^(0|false|no|off)$/i.test(String(env.CCDX_TERMINAL_ANIMATION || "").trim());
|
|
23
|
+
const ci = String(env.CI || "").trim();
|
|
24
|
+
if (output?.isTTY !== true || disabled || (ci && !/^(0|false|no|off)$/i.test(ci))
|
|
25
|
+
|| String(env.TERM || "").toLowerCase() === "dumb" || !Number.isFinite(columns) || !Number.isFinite(rows)
|
|
26
|
+
|| columns <= prefixWidth + TERMINAL_ANIMATION_TRACK_WIDTH + 2
|
|
27
|
+
|| rows < TERMINAL_ANIMATION_THEMES.length + rowsBelow + 2) {
|
|
28
|
+
return { enabled: false, stop() {} };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const timelines = TERMINAL_ANIMATION_THEMES.map((theme) => {
|
|
32
|
+
let duration = 0;
|
|
33
|
+
const frames = Array.from({ length: theme.frameCount }, (_, offset) => {
|
|
34
|
+
const frame = theme.startFrame + offset;
|
|
35
|
+
duration += getTerminalAnimationFrameDelay(theme.id, frame);
|
|
36
|
+
return { frame, until: duration };
|
|
37
|
+
});
|
|
38
|
+
return { theme, frames, duration: duration + theme.loopPauseMs, lastFrame: theme.startFrame };
|
|
39
|
+
});
|
|
40
|
+
const startedAt = now();
|
|
41
|
+
let timer;
|
|
42
|
+
let stopped = false;
|
|
43
|
+
const stop = () => {
|
|
44
|
+
if (stopped) return;
|
|
45
|
+
stopped = true;
|
|
46
|
+
if (timer !== undefined) timers.clearTimeout(timer);
|
|
47
|
+
output.off?.("resize", stop);
|
|
48
|
+
output.off?.("error", stop);
|
|
49
|
+
output.off?.("close", stop);
|
|
50
|
+
};
|
|
51
|
+
const draw = () => {
|
|
52
|
+
timer = undefined;
|
|
53
|
+
if (stopped) return;
|
|
54
|
+
try {
|
|
55
|
+
const inputRows = getInputRows();
|
|
56
|
+
// Once input scrolls the menu out of view its old row anchors are no longer safe.
|
|
57
|
+
if (!Number.isSafeInteger(inputRows) || inputRows < 0
|
|
58
|
+
|| Number(output.rows) < timelines.length + rowsBelow + inputRows + 2) {
|
|
59
|
+
stop();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const elapsed = now() - startedAt;
|
|
63
|
+
let update = "";
|
|
64
|
+
timelines.forEach((timeline, index) => {
|
|
65
|
+
const position = elapsed % timeline.duration;
|
|
66
|
+
const frame = timeline.frames.find(({ until }) => position < until)?.frame ?? null;
|
|
67
|
+
if (frame === timeline.lastFrame) return;
|
|
68
|
+
timeline.lastFrame = frame;
|
|
69
|
+
const track = frame === null ? `[${" ".repeat(TERMINAL_ANIMATION_TRACK_WIDTH)}]`
|
|
70
|
+
: renderTerminalAnimationFrame(timeline.theme.id, frame);
|
|
71
|
+
const up = timelines.length - index + rowsBelow + inputRows;
|
|
72
|
+
update += `${ESC}7${ESC}[${up}A${ESC}[${prefixWidth + 1}G${track}${ESC}8`;
|
|
73
|
+
});
|
|
74
|
+
if (update && output.write(update) === false) { stop(); return; }
|
|
75
|
+
timer = timers.setTimeout(draw, TICK_MS);
|
|
76
|
+
timer?.unref?.();
|
|
77
|
+
} catch { stop(); }
|
|
78
|
+
};
|
|
79
|
+
output.on?.("resize", stop);
|
|
80
|
+
output.on?.("error", stop);
|
|
81
|
+
output.on?.("close", stop);
|
|
82
|
+
timer = timers.setTimeout(draw, TICK_MS);
|
|
83
|
+
timer?.unref?.();
|
|
84
|
+
return { enabled: true, stop };
|
|
85
|
+
}
|