codex-copilot-dx 0.9.23 → 0.9.25
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 +20 -20
- package/bin/cli.mjs +51 -19
- package/docs/ARCHITECTURE.md +50 -0
- package/docs/README.md +4 -0
- package/docs/REFERENCE.md +124 -71
- package/package.json +3 -2
- package/src/adapter.mjs +5 -2
- package/src/cli-image.mjs +13 -10
- package/src/cli-options.mjs +13 -4
- package/src/cli-status.mjs +5 -0
- package/src/client-integration.mjs +41 -0
- package/src/codex-config-doctor.mjs +3 -3
- package/src/codex-paths.mjs +10 -0
- package/src/codex-thread-compression.mjs +11 -6
- package/src/config.mjs +10 -6
- package/src/copilot-responses-policy.mjs +4 -2
- package/src/dashboard/index.html +7 -6
- package/src/dashboard/language.js +13 -21
- package/src/dashboard/ui.js +10 -20
- package/src/dashboard-api.mjs +0 -12
- package/src/launcher.mjs +7 -2
- package/src/lock.mjs +29 -0
- package/src/native-prediction-overlay.mjs +92 -0
- package/src/native-prediction-profile.mjs +178 -0
- package/src/native-prediction-state.mjs +29 -0
- package/src/native-prediction-transport.mjs +255 -0
- package/src/native-prediction.mjs +170 -0
- package/src/network-route.mjs +102 -0
- package/src/observability.mjs +2 -0
- package/src/operation-error.mjs +37 -0
- package/src/package-update.mjs +2 -3
- package/src/prediction-retirement.mjs +86 -0
- package/src/proxy-tunnel.mjs +102 -0
- package/src/responses-request.mjs +5 -2
- package/src/suggestion-auto.mjs +3 -103
- package/src/suggestion-mcp.mjs +64 -132
- package/src/user-settings.mjs +17 -4
- package/src/version.mjs +23 -46
- package/src/cli-suggestion.mjs +0 -120
- package/src/suggestion-config.mjs +0 -98
- package/src/suggestion-context.mjs +0 -108
- package/src/suggestion-provider.mjs +0 -41
- package/src/suggestion-ui.html +0 -26
package/src/suggestion-mcp.mjs
CHANGED
|
@@ -1,145 +1,77 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
1
|
import { StringDecoder } from "node:string_decoder";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
const MAX_LINE_BYTES = 64 * 1024;
|
|
5
|
+
const PROTOCOL_VERSIONS = new Set(["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]);
|
|
6
|
+
const RETIRED = "CCDX next-message MCP has been retired. Remove the old ccdx_suggestion MCP configuration.";
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
8
|
+
function reply(request) {
|
|
9
|
+
const id = request?.id;
|
|
10
|
+
const validId = id === null || typeof id === "string" || (typeof id === "number" && Number.isFinite(id));
|
|
11
|
+
if (!request || Array.isArray(request) || request.jsonrpc !== "2.0" || typeof request.method !== "string"
|
|
12
|
+
|| (id !== undefined && !validId)) {
|
|
13
|
+
return { jsonrpc: "2.0", id: validId ? id : null, error: { code: -32600, message: "Invalid JSON-RPC request" } };
|
|
14
|
+
}
|
|
15
|
+
if (id === undefined) return null;
|
|
16
|
+
let result;
|
|
17
|
+
switch (request.method) {
|
|
18
|
+
case "initialize":
|
|
19
|
+
result = {
|
|
20
|
+
protocolVersion: PROTOCOL_VERSIONS.has(request.params?.protocolVersion) ? request.params.protocolVersion : "2025-11-25",
|
|
21
|
+
capabilities: { tools: { listChanged: false }, resources: {} },
|
|
22
|
+
serverInfo: { name: "ccdx-suggestion", version: "1" },
|
|
23
|
+
instructions: RETIRED,
|
|
24
|
+
};
|
|
25
|
+
break;
|
|
26
|
+
case "ping": result = {}; break;
|
|
27
|
+
case "tools/list": result = { tools: [] }; break;
|
|
28
|
+
case "resources/list": result = { resources: [] }; break;
|
|
29
|
+
case "resources/templates/list": result = { resourceTemplates: [] }; break;
|
|
30
|
+
case "tools/call": result = { isError: true, content: [{ type: "text", text: RETIRED }] }; break;
|
|
31
|
+
default: return { jsonrpc: "2.0", id, error: { code: -32601, message: RETIRED } };
|
|
32
|
+
}
|
|
33
|
+
return { jsonrpc: "2.0", id, result };
|
|
34
|
+
}
|
|
34
35
|
|
|
35
|
-
export function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const candidates = new Map();
|
|
42
|
-
let active = 0;
|
|
43
|
-
const prune = () => { for (const [id, item] of candidates) if (now() - item.at > 10 * 60_000) candidates.delete(id); };
|
|
44
|
-
const result = data => ({ content: [{ type: "text", text: data.suggestion || "No suggestion available. Open the panel to try again." }], structuredContent: data });
|
|
45
|
-
return async function handle(request, { signal } = {}) {
|
|
46
|
-
const config = loadConfig();
|
|
47
|
-
if (request.method === "initialize") return {
|
|
48
|
-
protocolVersion: protocolVersions.has(request.params?.protocolVersion) ? request.params.protocolVersion : "2025-11-25",
|
|
49
|
-
capabilities: { tools: { listChanged: false }, resources: {} },
|
|
50
|
-
serverInfo: { name: "ccdx-suggestion", version: "1" },
|
|
51
|
-
instructions: "Optional next-message panel. Call only when requested by the user. No automatic messages, no client patch, no approvals implied. Disabled until the user runs ccdx suggestion on.",
|
|
52
|
-
};
|
|
53
|
-
if (request.method === "ping") return {};
|
|
54
|
-
if (request.method === "tools/list") return { tools: config.enabled ? SUGGESTION_TOOLS : [] };
|
|
55
|
-
if (request.method === "resources/list") return { resources: config.enabled ? [{ uri: URI, name: "Next message", mimeType: "text/html;profile=mcp-app" }] : [] };
|
|
56
|
-
if (request.method === "resources/templates/list") return { resourceTemplates: [] };
|
|
57
|
-
if (!config.enabled) { candidates.clear(); throw new Error("Next-message suggestions are disabled. Run ccdx suggestion on to enable them"); }
|
|
58
|
-
if (request.method === "resources/read" && request.params?.uri === URI) return {
|
|
59
|
-
contents: [{ uri: URI, mimeType: "text/html;profile=mcp-app", text: fs.readFileSync(new URL("./suggestion-ui.html", import.meta.url), "utf8") }],
|
|
60
|
-
};
|
|
61
|
-
if (request.method !== "tools/call") throw new Error("Unsupported suggestion method");
|
|
62
|
-
const threadId = suggestionThreadId(request.params?._meta);
|
|
63
|
-
const args = request.params?.arguments || {};
|
|
64
|
-
if (typeof args !== "object" || Array.isArray(args)) throw new Error("Invalid suggestion parameters");
|
|
65
|
-
prune();
|
|
66
|
-
const automatic = () => {
|
|
67
|
-
const item=loadAutomatic(threadId);
|
|
68
|
-
return item?.state==="ready" && typeof item.suggestion==="string" && item.suggestion.length<=300
|
|
69
|
-
&& typeof item.candidate_id==="string" && item.model===config.model && Number.isFinite(item.at) && now()-item.at<=10*60_000 ? item : null;
|
|
70
|
-
};
|
|
71
|
-
if(request.params.name==="latest_suggestion"){
|
|
72
|
-
if(Object.keys(args).length)throw new Error("Invalid latest suggestion parameters");
|
|
73
|
-
const item=automatic();return result(item?{suggestion:item.suggestion,candidate_id:item.candidate_id,model:item.model}:{suggestion:null,candidate_id:null,model:config.model});
|
|
36
|
+
export function runSuggestionStdio({ input = process.stdin, output = process.stdout } = {}) {
|
|
37
|
+
let decoder = new StringDecoder("utf8"), line = "", bytes = 0, closed = false;
|
|
38
|
+
const close = () => { closed = true; input.destroy(); };
|
|
39
|
+
const write = value => {
|
|
40
|
+
if (value && !closed && !output.destroyed && !output.writableEnded) {
|
|
41
|
+
if (!output.write(JSON.stringify(value) + "\n")) input.pause();
|
|
74
42
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
return result({ candidate_id: args.candidate_id, suggestion: item.suggestion, model: item.model });
|
|
43
|
+
};
|
|
44
|
+
const finishLine = () => {
|
|
45
|
+
line += decoder.end();
|
|
46
|
+
if (line.trim()) {
|
|
47
|
+
let request;
|
|
48
|
+
try { request = JSON.parse(line); }
|
|
49
|
+
catch { write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Invalid JSON" } }); }
|
|
50
|
+
if (request !== undefined) write(reply(request));
|
|
85
51
|
}
|
|
86
|
-
|
|
87
|
-
if (active >= 2) throw new Error("Suggestions are busy. Wait for the current request; no extra model call was made");
|
|
88
|
-
active += 1;
|
|
89
|
-
const controller = new AbortController();
|
|
90
|
-
const abort = () => controller.abort(new Error("Suggestion cancelled"));
|
|
91
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
92
|
-
if (signal?.aborted) abort();
|
|
93
|
-
const timer = setTimeout(abort, timeoutMs);
|
|
94
|
-
try {
|
|
95
|
-
const messages = await readContext(threadId, config, { signal: controller.signal });
|
|
96
|
-
controller.signal.throwIfAborted();
|
|
97
|
-
const latest = loadConfig();
|
|
98
|
-
if (!latest.enabled || latest.model !== config.model || latest.adapter_url !== config.adapter_url) throw new Error("Suggestion settings changed; generate again with the current selection");
|
|
99
|
-
const suggestion = await generate(messages, config, { signal: controller.signal });
|
|
100
|
-
controller.signal.throwIfAborted();
|
|
101
|
-
if (!loadConfig().enabled) throw new Error("Suggestions were disabled");
|
|
102
|
-
const candidate_id = suggestion ? randomUUID() : null;
|
|
103
|
-
if (candidate_id) {
|
|
104
|
-
while (candidates.size >= 16) candidates.delete(candidates.keys().next().value);
|
|
105
|
-
candidates.set(candidate_id, { suggestion, model: config.model, threadId, at: now() });
|
|
106
|
-
}
|
|
107
|
-
return result({ suggestion, candidate_id, model: config.model });
|
|
108
|
-
} finally { active -= 1; clearTimeout(timer); signal?.removeEventListener("abort", abort); }
|
|
52
|
+
line = ""; bytes = 0; decoder = new StringDecoder("utf8");
|
|
109
53
|
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function runSuggestionStdio({ input = process.stdin, output = process.stdout, handle = createSuggestionMcp() } = {}) {
|
|
113
|
-
const decoder = new StringDecoder("utf8"), pending = new Map();
|
|
114
|
-
let buffer = "";
|
|
115
|
-
const write = value => { if (!output.destroyed) output.write(JSON.stringify(value) + "\n"); };
|
|
116
|
-
const close = () => { for (const controller of pending.values()) controller.abort(); pending.clear(); };
|
|
117
|
-
input.on("end", close);
|
|
118
54
|
input.on("error", close);
|
|
119
55
|
output.on("error", close);
|
|
56
|
+
output.on("drain", () => { if (!closed) input.resume(); });
|
|
57
|
+
input.on("end", () => { if (!closed && bytes) finishLine(); });
|
|
120
58
|
input.on("data", chunk => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
let
|
|
124
|
-
while (
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
write({ jsonrpc: "2.0", id: typeof id === "string" || typeof id === "number" ? id : null, error: { code: -32600, message: "Invalid or busy request" } }); continue;
|
|
59
|
+
if (closed) return;
|
|
60
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
61
|
+
let start = 0;
|
|
62
|
+
while (start < data.length && !closed) {
|
|
63
|
+
const end = data.indexOf(10, start);
|
|
64
|
+
const limit = end < 0 ? data.length : end;
|
|
65
|
+
bytes += limit - start;
|
|
66
|
+
if (bytes > MAX_LINE_BYTES) {
|
|
67
|
+
write({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "MCP request exceeds 65536 bytes" } });
|
|
68
|
+
close();
|
|
69
|
+
return;
|
|
133
70
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const message = String(error?.message || "Suggestion failed").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0,300);
|
|
139
|
-
write({ jsonrpc: "2.0", id, ...(request.method === "tools/call"
|
|
140
|
-
? { result: { isError: true, content: [{ type: "text", text: message }] } }
|
|
141
|
-
: { error: { code: -32603, message } }) });
|
|
142
|
-
}).finally(() => pending.delete(id));
|
|
71
|
+
line += decoder.write(data.subarray(start, limit));
|
|
72
|
+
if (end < 0) break;
|
|
73
|
+
finishLine();
|
|
74
|
+
start = end + 1;
|
|
143
75
|
}
|
|
144
76
|
});
|
|
145
77
|
}
|
package/src/user-settings.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
5
|
+
import { withFileLockSync } from "./lock.mjs";
|
|
5
6
|
import { AUTO_REVIEW_REASONING_EFFORTS, DEFAULT_CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
|
|
6
7
|
import { loadRuntimeConfig, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
|
|
7
8
|
import {
|
|
@@ -155,7 +156,10 @@ export function decodedBodyLimitPreference({ env = process.env, home = os.homedi
|
|
|
155
156
|
return { bytes: RUNTIME_DEFAULTS.maxDecodedBodyBytes, mib: DECODED_BODY_LIMIT_MIN_MIB, source: "default" };
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
export function writeDecodedBodyLimitMib(mib,
|
|
159
|
+
export function writeDecodedBodyLimitMib(mib, options = {}) {
|
|
160
|
+
return withFileLockSync(`${userSettingsPath(options)}.lock`, () => writeDecodedBodyLimitMibUnlocked(mib, options));
|
|
161
|
+
}
|
|
162
|
+
function writeDecodedBodyLimitMibUnlocked(mib, { env = process.env, home = os.homedir() } = {}) {
|
|
159
163
|
const filePath = userSettingsPath({ env, home });
|
|
160
164
|
const settings = readUserSettings({ env, home, strict: true });
|
|
161
165
|
const next = { ...settings };
|
|
@@ -177,7 +181,10 @@ export function writeDecodedBodyLimitMib(mib, { env = process.env, home = os.hom
|
|
|
177
181
|
return { changed, filePath, ...decodedBodyLimitPreference({ env, home }) };
|
|
178
182
|
}
|
|
179
183
|
|
|
180
|
-
export function writeResponseHistoryLimitMib(mib,
|
|
184
|
+
export function writeResponseHistoryLimitMib(mib, options = {}) {
|
|
185
|
+
return withFileLockSync(`${userSettingsPath(options)}.lock`, () => writeResponseHistoryLimitMibUnlocked(mib, options));
|
|
186
|
+
}
|
|
187
|
+
function writeResponseHistoryLimitMibUnlocked(mib, { env = process.env, home = os.homedir() } = {}) {
|
|
181
188
|
const filePath = userSettingsPath({ env, home });
|
|
182
189
|
const settings = readUserSettings({ env, home, strict: true });
|
|
183
190
|
const next = { ...settings };
|
|
@@ -199,7 +206,10 @@ export function writeResponseHistoryLimitMib(mib, { env = process.env, home = os
|
|
|
199
206
|
return { changed, filePath, ...responseHistoryLimitPreference({ env, home }) };
|
|
200
207
|
}
|
|
201
208
|
|
|
202
|
-
export function writeAutoReviewModel(model,
|
|
209
|
+
export function writeAutoReviewModel(model, options = {}) {
|
|
210
|
+
return withFileLockSync(`${userSettingsPath(options)}.lock`, () => writeAutoReviewModelUnlocked(model, options));
|
|
211
|
+
}
|
|
212
|
+
function writeAutoReviewModelUnlocked(model, { env = process.env, home = os.homedir(), reasoningEffort } = {}) {
|
|
203
213
|
if (reasoningEffort !== undefined && reasoningEffort !== null
|
|
204
214
|
&& !AUTO_REVIEW_REASONING_EFFORTS.includes(reasoningEffort)) {
|
|
205
215
|
throw new Error("Auto-review reasoning effort is invalid");
|
|
@@ -220,7 +230,10 @@ export function writeAutoReviewModel(model, { env = process.env, home = os.homed
|
|
|
220
230
|
return { changed, filePath, model: value || DEFAULT_CODEX_AUTO_REVIEW_MODEL };
|
|
221
231
|
}
|
|
222
232
|
|
|
223
|
-
export function writeTerminalAnimationTheme(theme,
|
|
233
|
+
export function writeTerminalAnimationTheme(theme, options = {}) {
|
|
234
|
+
return withFileLockSync(`${userSettingsPath(options)}.lock`, () => writeTerminalAnimationThemeUnlocked(theme, options));
|
|
235
|
+
}
|
|
236
|
+
function writeTerminalAnimationThemeUnlocked(theme, { env = process.env, home = os.homedir() } = {}) {
|
|
224
237
|
if (!isTerminalAnimationTheme(theme)) {
|
|
225
238
|
const choices = TERMINAL_ANIMATION_THEMES.map(({ id }) => id).join(", ");
|
|
226
239
|
throw new Error(`Terminal animation must be one of: ${choices}`);
|
package/src/version.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { operationError } from "./operation-error.mjs";
|
|
4
5
|
|
|
5
|
-
const GITHUB_LATEST_RELEASE_URL = "https://
|
|
6
|
-
const MAX_RELEASE_BYTES = 256 * 1024;
|
|
6
|
+
const GITHUB_LATEST_RELEASE_URL = "https://github.com/DaleXiao/codex-copilot-dx/releases/latest";
|
|
7
7
|
|
|
8
8
|
export function localPackageVersion() {
|
|
9
9
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -37,62 +37,39 @@ export function githubReleaseVersion(tag) {
|
|
|
37
37
|
return match.slice(1).join(".");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
async function
|
|
41
|
-
const reader = response.body?.getReader?.();
|
|
42
|
-
if (!reader) throw new Error("GitHub release response has no readable body");
|
|
43
|
-
const cancel = () => { void reader.cancel().catch(() => {}); };
|
|
44
|
-
signal.addEventListener("abort", cancel, { once: true });
|
|
45
|
-
const chunks = [];
|
|
46
|
-
let bytes = 0;
|
|
47
|
-
let complete = false;
|
|
48
|
-
try {
|
|
49
|
-
if (Number(response.headers?.get?.("content-length")) > MAX_RELEASE_BYTES) {
|
|
50
|
-
throw new Error("GitHub release response is too large");
|
|
51
|
-
}
|
|
52
|
-
while (!signal.aborted) {
|
|
53
|
-
const { done, value } = await reader.read();
|
|
54
|
-
if (done) {
|
|
55
|
-
complete = true;
|
|
56
|
-
break;
|
|
57
|
-
}
|
|
58
|
-
bytes += value.byteLength;
|
|
59
|
-
if (bytes > MAX_RELEASE_BYTES) throw new Error("GitHub release response is too large");
|
|
60
|
-
chunks.push(Buffer.from(value));
|
|
61
|
-
}
|
|
62
|
-
if (signal.aborted) throw new Error("GitHub release request timed out");
|
|
63
|
-
return JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
|
|
64
|
-
} finally {
|
|
65
|
-
signal.removeEventListener("abort", cancel);
|
|
66
|
-
if (!complete) cancel();
|
|
67
|
-
reader.releaseLock();
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export async function fetchLatestRelease({ fetchImpl = fetch, timeoutMs = 2000 } = {}) {
|
|
40
|
+
export async function lookupLatestRelease({ fetchImpl = fetch, timeoutMs = 2000 } = {}) {
|
|
72
41
|
const controller = new AbortController();
|
|
73
42
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
74
43
|
try {
|
|
75
44
|
const resp = await fetchImpl(GITHUB_LATEST_RELEASE_URL, {
|
|
76
|
-
headers: { Accept: "
|
|
77
|
-
redirect: "
|
|
45
|
+
headers: { Accept: "text/html", "User-Agent": "codex-copilot-dx" },
|
|
46
|
+
redirect: "manual",
|
|
78
47
|
signal: controller.signal,
|
|
79
48
|
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
49
|
+
void resp.body?.cancel?.().catch(() => {});
|
|
50
|
+
if ([403, 429].includes(resp.status)) throw operationError("github_rate_limit", `GitHub release lookup was refused (HTTP ${resp.status}); retry later. No update was installed.`, { status: resp.status });
|
|
51
|
+
if (![301, 302, 303, 307, 308].includes(resp.status)) throw operationError("github_http_error", `GitHub release lookup returned HTTP ${resp.status}; no stable release redirect was received. No update was installed.`, { status: resp.status });
|
|
52
|
+
let url;
|
|
53
|
+
try { url = new URL(resp.headers.get("location"), GITHUB_LATEST_RELEASE_URL); } catch {}
|
|
54
|
+
const match = url?.pathname.match(/^\/DaleXiao\/codex-copilot-dx\/releases\/tag\/([^/]+)$/i);
|
|
55
|
+
const tagName = match?.[1], version = githubReleaseVersion(tagName);
|
|
56
|
+
if (!url || url.origin !== "https://github.com" || url.username || url.password || url.search || url.hash || !version) {
|
|
57
|
+
throw operationError("github_invalid_redirect", "GitHub did not return a valid stable release tag for this repository. No update was installed.");
|
|
83
58
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
59
|
+
return { version, tagName };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error.code?.startsWith("github_")) throw error;
|
|
62
|
+
if (controller.signal.aborted) throw operationError("github_timeout", "GitHub release lookup timed out. No update was installed.");
|
|
63
|
+
throw operationError(error.cause?.code || "github_network_error", "Could not connect to the GitHub release page. Check your network or proxy. No update was installed.");
|
|
91
64
|
} finally {
|
|
92
65
|
clearTimeout(timer);
|
|
93
66
|
}
|
|
94
67
|
}
|
|
95
68
|
|
|
69
|
+
export async function fetchLatestRelease(options = {}) {
|
|
70
|
+
try { return await lookupLatestRelease(options); } catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
96
73
|
export async function fetchLatestVersion(options) {
|
|
97
74
|
return (await fetchLatestRelease(options))?.version || null;
|
|
98
75
|
}
|
package/src/cli-suggestion.mjs
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { createInterface } from "node:readline/promises";
|
|
5
|
-
import { execFile } from "node:child_process";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
|
-
import { atomicWriteFileIfChangedSync, atomicWriteFilePairSync } from "./atomic-file.mjs";
|
|
8
|
-
import { loadAutoReviewModelCatalog } from "./auto-review-model.mjs";
|
|
9
|
-
import { parseAdapterAddressOptions } from "./cli-options.mjs";
|
|
10
|
-
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
11
|
-
import { terminalCell } from "./cli-table.mjs";
|
|
12
|
-
import { resolveSuggestionCodexBinary } from "./suggestion-context.mjs";
|
|
13
|
-
import { suggestionAdapterUrl } from "./suggestion-provider.mjs";
|
|
14
|
-
import { computeSuggestionAutoHook, computeSuggestionMcpConfig, readSuggestionCodexConfig, readSuggestionConfig, suggestionCodexConfigPath, suggestionConfigPath } from "./suggestion-config.mjs";
|
|
15
|
-
|
|
16
|
-
export async function supportsSuggestionHooks(binary) {
|
|
17
|
-
const directory=fs.mkdtempSync(path.join(os.tmpdir(),"ccdx-hook-probe-"));
|
|
18
|
-
try {
|
|
19
|
-
const {stdout}=await promisify(execFile)(binary,["features","list"],{cwd:directory,env:{CODEX_HOME:directory,PATH:process.env.PATH||""},timeout:1500,maxBuffer:128*1024,encoding:"utf8"});
|
|
20
|
-
return /^hooks\s+(?:stable|beta|experimental|under development)\s+true\s*$/m.test(stdout);
|
|
21
|
-
} catch {return false;} finally {fs.rmSync(directory,{recursive:true,force:true});}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function runSuggestionCommand({
|
|
25
|
-
action = "status", model, commandName = "ccdx", env = process.env, home = os.homedir(),
|
|
26
|
-
input = process.stdin, output = process.stdout, prompt,
|
|
27
|
-
loadCatalog = loadAutoReviewModelCatalog, resolveBinary = resolveSuggestionCodexBinary, probeHooks = supportsSuggestionHooks,
|
|
28
|
-
} = {}) {
|
|
29
|
-
const filePath = suggestionConfigPath({ env, home });
|
|
30
|
-
const codexPath = suggestionCodexConfigPath({ env, home });
|
|
31
|
-
const current = readSuggestionConfig({ filePath, strict: true });
|
|
32
|
-
const original = readSuggestionCodexConfig(codexPath);
|
|
33
|
-
const preview = computeSuggestionMcpConfig(original, { filePath, enabled: action === "on" ? true : current.enabled });
|
|
34
|
-
if (action === "status") {
|
|
35
|
-
output.write(`Next-message suggestions: ${current.enabled ? "enabled" : "disabled"}\nModel: ${terminalCell(current.model)}\n`);
|
|
36
|
-
if (preview.skipped) output.write(`${preview.reason}\n`);
|
|
37
|
-
else if (current.enabled && preview.changed) output.write(`MCP registration is missing or disabled. Run ${commandName} suggestion on to repair it.\n`);
|
|
38
|
-
output.write(`Automatic generation: ${current.enabled && current.automatic ? "configured; Codex hook review is required before it can run" : "unavailable or not configured"}\nAutomatic draft insertion: unavailable; choose Add to draft.\n`);
|
|
39
|
-
return { ...current, configured: !preview.changed && !preview.skipped };
|
|
40
|
-
}
|
|
41
|
-
if (!["on", "off", "model"].includes(action)) throw new Error("Suggestion action must be on, off, model, or status");
|
|
42
|
-
if (preview.skipped && action !== "off") {
|
|
43
|
-
output.write(`${preview.reason}. No changes made.\n`);
|
|
44
|
-
return { changed: false, skipped: true };
|
|
45
|
-
}
|
|
46
|
-
const hookPreview=computeSuggestionAutoHook(original,{filePath,enabled:current.enabled&¤t.automatic===true});
|
|
47
|
-
if (action === "on" && current.enabled && current.automatic !== undefined && !preview.changed && !hookPreview.changed) {
|
|
48
|
-
output.write("Suggestions are already configured; kept unchanged.\n");
|
|
49
|
-
return { changed: false, ...current };
|
|
50
|
-
}
|
|
51
|
-
if (action === "off" && !current.enabled && !fs.existsSync(filePath) && !preview.changed) {
|
|
52
|
-
output.write("Suggestions remain disabled; nothing installed.\n");
|
|
53
|
-
return { changed: false, ...current };
|
|
54
|
-
}
|
|
55
|
-
let selected = current.model, reasoning = current.reasoning_effort, catalog;
|
|
56
|
-
if (action !== "off") {
|
|
57
|
-
catalog = await loadCatalog({ env, home });
|
|
58
|
-
const choices = catalog.modelIds.filter(id => /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id));
|
|
59
|
-
if (!choices.length) throw new Error("No selectable suggestion models are available");
|
|
60
|
-
if (action === "model" && !model) {
|
|
61
|
-
if (!prompt && (!input.isTTY || !output.isTTY)) throw new Error(`${commandName} suggestion model needs an interactive terminal or a model ID`);
|
|
62
|
-
output.write("Choose the suggestion model:\n");
|
|
63
|
-
choices.forEach((id, index) => output.write(` ${index + 1}. ${terminalCell(id)}${id === current.model ? " [current]" : ""}\n`));
|
|
64
|
-
const rl = prompt ? null : createInterface({ input, output });
|
|
65
|
-
try {
|
|
66
|
-
const defaultIndex = Math.max(0, choices.indexOf(current.model));
|
|
67
|
-
while (true) {
|
|
68
|
-
const answer = String(await (prompt || (text => rl.question(text)))(`Select [${defaultIndex + 1}], or q to cancel: `)).trim();
|
|
69
|
-
if (answer.toLowerCase() === "q") return { changed: false, cancelled: true };
|
|
70
|
-
const index = answer ? Number(answer) - 1 : defaultIndex;
|
|
71
|
-
if (Number.isInteger(index) && index >= 0 && index < choices.length) { selected = choices[index]; break; }
|
|
72
|
-
output.write("Enter a listed number, or q to cancel.\n");
|
|
73
|
-
}
|
|
74
|
-
} finally { rl?.close(); }
|
|
75
|
-
} else if (action === "model") selected = model;
|
|
76
|
-
if (!choices.includes(selected)) throw new Error(`Model ${terminalCell(selected)} is not available. Use ${commandName} suggestion model to select an available model`);
|
|
77
|
-
reasoning = catalog.reasoningEfforts?.[selected]?.includes("low") ? "low" : null;
|
|
78
|
-
}
|
|
79
|
-
// Re-read both files after asynchronous model lookup/prompts; never overwrite
|
|
80
|
-
// concurrent user edits or unrelated shared settings.
|
|
81
|
-
let binary,hookSupported=false;
|
|
82
|
-
if(action==="on" || action==="model" && current.enabled){
|
|
83
|
-
binary=resolveBinary(env);
|
|
84
|
-
hookSupported=process.platform!=="win32"&&await probeHooks(binary);
|
|
85
|
-
}
|
|
86
|
-
const latest = readSuggestionConfig({ filePath, strict: true });
|
|
87
|
-
if (action !== "model") {
|
|
88
|
-
selected = latest.model;
|
|
89
|
-
if (action === "on" && !catalog.modelIds.includes(selected)) throw new Error("Suggestion model changed during setup; retry with the current selection");
|
|
90
|
-
reasoning = action === "off" ? latest.reasoning_effort : catalog.reasoningEfforts?.[selected]?.includes("low") ? "low" : null;
|
|
91
|
-
}
|
|
92
|
-
const before = readSuggestionCodexConfig(codexPath);
|
|
93
|
-
const enabled = action === "on" ? true : action === "off" ? false : latest.enabled;
|
|
94
|
-
const codex = computeSuggestionMcpConfig(before, { filePath, enabled });
|
|
95
|
-
if (codex.skipped && action !== "off") {
|
|
96
|
-
output.write(`${codex.reason}. No changes made.\n`);
|
|
97
|
-
return { changed: false, skipped: true };
|
|
98
|
-
}
|
|
99
|
-
const hook=computeSuggestionAutoHook(codex.content,{filePath,enabled:enabled&&hookSupported});
|
|
100
|
-
const next = { ...latest, enabled, model: selected, reasoning_effort: reasoning ?? null, automatic:enabled&&hookSupported&&!hook.skipped };
|
|
101
|
-
if (enabled) {
|
|
102
|
-
const address = parseAdapterAddressOptions(env);
|
|
103
|
-
const host = ["0.0.0.0", "localhost"].includes(address.adapterHost) ? "127.0.0.1" : address.adapterHost === "::" ? "::1" : address.adapterHost;
|
|
104
|
-
next.adapter_url = suggestionAdapterUrl(`${adapterBaseUrl(host, address.adapterPort)}/v1`);
|
|
105
|
-
next.codex_binary = binary;
|
|
106
|
-
next.codex_home = path.dirname(codexPath);
|
|
107
|
-
}
|
|
108
|
-
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
109
|
-
let changed;
|
|
110
|
-
if ((codex.changed || hook.changed) && !codex.skipped) {
|
|
111
|
-
atomicWriteFilePairSync(codexPath, hook.content, filePath, serialized, { writeFile: atomicWriteFileIfChangedSync });
|
|
112
|
-
changed = true;
|
|
113
|
-
} else changed = atomicWriteFileIfChangedSync(filePath, serialized);
|
|
114
|
-
output.write(`Suggestions: ${next.enabled ? "enabled" : "disabled"}\nModel: ${terminalCell(next.model)}\n`);
|
|
115
|
-
output.write(`Automatic generation: ${next.automatic?"configured; review this CCDX Stop hook in Codex before it can run":"unavailable or disabled"}\nDraft insertion: choose Add to draft; never sends automatically.\n`);
|
|
116
|
-
if(next.automatic)output.write("Use Codex's hook review screen (or /hooks in Codex CLI). Trust only the CCDX suggestion-auto.mjs command. CCDX never grants hook trust.\n");
|
|
117
|
-
if (codex.skipped) output.write(`${codex.reason}. CCDX suggestion requests are disabled.\n`);
|
|
118
|
-
if (enabled) output.write("Open Next message in the conversation panel, or ask Codex for a next-message suggestion. Add to draft never sends.\nIf the tool is not visible, restart Codex App once. Official prediction settings are not modified.\n");
|
|
119
|
-
return { ...next, changed };
|
|
120
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { isDeepStrictEqual } from "node:util";
|
|
6
|
-
import { parseCodexToml, codexTomlStatements, validateManagedConfigEdit } from "./config-toml.mjs";
|
|
7
|
-
import { userSettingsPath } from "./user-settings.mjs";
|
|
8
|
-
|
|
9
|
-
export const DEFAULT_SUGGESTION_MODEL = "gpt-6-luna";
|
|
10
|
-
export const SUGGESTION_SERVER = "ccdx_suggestion";
|
|
11
|
-
const sectionPath = ["mcp_servers", SUGGESTION_SERVER];
|
|
12
|
-
|
|
13
|
-
export function suggestionConfigPath({ env = process.env, home = os.homedir() } = {}) {
|
|
14
|
-
return path.resolve(env.CCDX_SUGGESTION_CONFIG || path.join(path.dirname(userSettingsPath({ env, home })), "suggestion.json"));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function readSuggestionConfig({ filePath = suggestionConfigPath(), strict = false } = {}) {
|
|
18
|
-
try {
|
|
19
|
-
const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
20
|
-
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
21
|
-
|| typeof value.enabled !== "boolean"
|
|
22
|
-
|| (value.automatic !== undefined && typeof value.automatic !== "boolean")
|
|
23
|
-
|| typeof value.model !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value.model)) throw new Error();
|
|
24
|
-
return value;
|
|
25
|
-
} catch (error) {
|
|
26
|
-
if (error.code === "ENOENT") return { enabled: false, model: DEFAULT_SUGGESTION_MODEL };
|
|
27
|
-
if (strict) throw new Error("Invalid suggestion settings; no changes were written");
|
|
28
|
-
return { enabled: false, model: DEFAULT_SUGGESTION_MODEL };
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function suggestionMcpSection(filePath) {
|
|
33
|
-
return {
|
|
34
|
-
enabled: true,
|
|
35
|
-
command: process.execPath,
|
|
36
|
-
args: [fileURLToPath(new URL("./suggestion-mcp.mjs", import.meta.url))],
|
|
37
|
-
env: { CCDX_SUGGESTION_CONFIG: filePath },
|
|
38
|
-
tool_timeout_sec: 40n,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function computeSuggestionMcpConfig(content, { enabled, filePath }) {
|
|
43
|
-
const parsed = parseCodexToml(content);
|
|
44
|
-
if (Object.values(parsed.profiles || {}).some(profile => Object.hasOwn(profile?.mcp_servers || {}, SUGGESTION_SERVER))) {
|
|
45
|
-
return { content, changed: false, skipped: true, reason: "Existing profile-specific suggestion MCP configuration was preserved" };
|
|
46
|
-
}
|
|
47
|
-
const existing = parsed.mcp_servers?.[SUGGESTION_SERVER];
|
|
48
|
-
const expected = suggestionMcpSection(filePath);
|
|
49
|
-
if (existing !== undefined) {
|
|
50
|
-
if (typeof existing.enabled !== "boolean" || !isDeepStrictEqual({ ...existing, enabled: true }, expected)) {
|
|
51
|
-
return { content, changed: false, skipped: true, reason: "Existing suggestion MCP configuration is user-managed; kept unchanged" };
|
|
52
|
-
}
|
|
53
|
-
if (existing.enabled === enabled) return { content, changed: false };
|
|
54
|
-
const entry = codexTomlStatements(content).find(item => item.kind === "value"
|
|
55
|
-
&& isDeepStrictEqual(item.keys, [...sectionPath, "enabled"]));
|
|
56
|
-
if (!entry) return { content, changed: false, skipped: true, reason: "Existing inline suggestion configuration was preserved" };
|
|
57
|
-
const updated = content.slice(0, entry.valueStart) + String(enabled) + content.slice(entry.valueEnd);
|
|
58
|
-
validateManagedConfigEdit(parsed, updated, [sectionPath]);
|
|
59
|
-
return { content: updated, changed: true };
|
|
60
|
-
}
|
|
61
|
-
if (!enabled) return { content, changed: false };
|
|
62
|
-
const block = `[mcp_servers.${SUGGESTION_SERVER}]\nenabled = true\ncommand = ${JSON.stringify(expected.command)}\nargs = ${JSON.stringify(expected.args)}\nenv = { CCDX_SUGGESTION_CONFIG = ${JSON.stringify(filePath)} }\ntool_timeout_sec = 40\n`;
|
|
63
|
-
const updated = content + (content.endsWith("\n") ? "\n" : "\n\n") + block;
|
|
64
|
-
validateManagedConfigEdit(parsed, updated, [sectionPath]);
|
|
65
|
-
return { content: updated, changed: true };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function computeSuggestionAutoHook(content, { enabled, filePath }) {
|
|
69
|
-
const parsed=parseCodexToml(content);
|
|
70
|
-
const quote=value=>`'${value.replaceAll("'", "'\\''")}'`;
|
|
71
|
-
const command=`CCDX_SUGGESTION_CONFIG=${quote(filePath)} ${quote(process.execPath)} ${quote(fileURLToPath(new URL("./suggestion-auto.mjs",import.meta.url)))}`;
|
|
72
|
-
const start="# ccdx:suggestion-auto:start",end="# ccdx:suggestion-auto:end";
|
|
73
|
-
const block=`${start}\n[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ntype = "command"\ncommand = ${JSON.stringify(command)}\nasync = true\ntimeout = 35\n${end}\n`;
|
|
74
|
-
const owned=content.indexOf(start);
|
|
75
|
-
if(owned>=0){
|
|
76
|
-
if(!content.includes(block)||content.indexOf(start,owned+start.length)!==-1)return {content,changed:false,skipped:true};
|
|
77
|
-
if(enabled)return {content,changed:false};
|
|
78
|
-
const updated=content.replace(block,"");validateManagedConfigEdit(parsed,updated,[["hooks","Stop"]]);return {content:updated,changed:true};
|
|
79
|
-
}
|
|
80
|
-
if(!enabled)return {content,changed:false};
|
|
81
|
-
if(process.platform==="win32")return {content,changed:false,skipped:true};
|
|
82
|
-
const statements=codexTomlStatements(content);
|
|
83
|
-
if(statements.some(item=>item.kind==="value"&&item.keys[0]==="hooks"&&(item.keys.length===1||item.keys.length===2&&item.keys[1]==="Stop")))return {content,changed:false,skipped:true};
|
|
84
|
-
const updated=content+(content.endsWith("\n")?"\n":"\n\n")+block;
|
|
85
|
-
validateManagedConfigEdit(parsed,updated,[["hooks","Stop"]]);return {content:updated,changed:true};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function suggestionCodexConfigPath({ env = process.env, home = os.homedir() } = {}) {
|
|
89
|
-
return path.join(env.CODEX_HOME || path.join(home, ".codex"), "config.toml");
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function readSuggestionCodexConfig(filePath) {
|
|
93
|
-
try { return new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(filePath)); }
|
|
94
|
-
catch (error) {
|
|
95
|
-
if (error.code === "ENOENT") return "";
|
|
96
|
-
throw new Error("Cannot read Codex configuration; no changes were written");
|
|
97
|
-
}
|
|
98
|
-
}
|