codex-copilot-dx 0.9.23 → 0.9.24
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 +13 -16
- package/bin/cli.mjs +47 -5
- package/docs/REFERENCE.md +76 -68
- package/package.json +1 -1
- package/src/adapter.mjs +4 -2
- package/src/cli-options.mjs +13 -4
- package/src/dashboard/index.html +0 -6
- package/src/dashboard/language.js +0 -21
- package/src/dashboard/ui.js +1 -20
- package/src/dashboard-api.mjs +0 -12
- package/src/launcher.mjs +7 -2
- package/src/native-prediction-overlay.mjs +92 -0
- package/src/native-prediction-profile.mjs +181 -0
- package/src/native-prediction-transport.mjs +242 -0
- package/src/native-prediction.mjs +146 -0
- package/src/prediction-retirement.mjs +75 -0
- package/src/suggestion-auto.mjs +3 -103
- package/src/suggestion-mcp.mjs +64 -132
- 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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { predictionPaths, readPredictionSettings, writePredictionSettings, assertPredictionProxyCompatible,
|
|
5
|
+
assertCodexClosed, installedPredictionApp, predictionAppMatches, readPredictionCertificate,
|
|
6
|
+
createPredictionCertificate, updatePredictionTrust } from "./native-prediction-profile.mjs";
|
|
7
|
+
import { retirePredictionIntegration } from "./prediction-retirement.mjs";
|
|
8
|
+
|
|
9
|
+
function systemProxy(exec = execFileSync) {
|
|
10
|
+
return exec("/usr/sbin/scutil", ["--proxy"], { encoding: "utf8", timeout: 2000, maxBuffer: 64 * 1024 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function certificateDirectory(settings, paths) {
|
|
14
|
+
if (!settings.credentialsDirectory) return paths.directory;
|
|
15
|
+
const directory = path.resolve(settings.credentialsDirectory);
|
|
16
|
+
if (path.dirname(directory) !== paths.directory || !/^credentials-[A-Za-z0-9]+$/.test(path.basename(directory))) {
|
|
17
|
+
throw new Error("Unexpected prediction key directory; it was not changed");
|
|
18
|
+
}
|
|
19
|
+
return directory;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runPredictionCommand({ action = "status", env = process.env,
|
|
23
|
+
platform = process.platform, output = console.log, resolveApp = installedPredictionApp,
|
|
24
|
+
writeSettings = writePredictionSettings, ...options } = {}) {
|
|
25
|
+
const opts = { ...options, env };
|
|
26
|
+
const settings = readPredictionSettings(opts);
|
|
27
|
+
if (action === "status") {
|
|
28
|
+
output(`Native prediction preference: ${settings.enabled ? "on" : "off"} (default: off)`);
|
|
29
|
+
output("Uses the conversation's model. No prediction MCP, Hook, or panel.");
|
|
30
|
+
if (settings.enabled && !predictionAppMatches(settings.app)) output("Codex App changed; quit it and run ccdx prediction on to recheck compatibility.");
|
|
31
|
+
return settings;
|
|
32
|
+
}
|
|
33
|
+
if (!["on", "off"].includes(action)) throw new Error("Prediction action must be on, off, or status");
|
|
34
|
+
if (platform !== "darwin") throw new Error("Native prediction setup currently supports macOS only; no configuration changed");
|
|
35
|
+
const paths = predictionPaths(opts);
|
|
36
|
+
if (action === "off") {
|
|
37
|
+
// Disable the response overlay first, even when App-owned trust is busy.
|
|
38
|
+
if (settings.enabled) writeSettings({ ...settings, enabled: false }, opts);
|
|
39
|
+
if (settings.certificateHash && settings.store) {
|
|
40
|
+
try {
|
|
41
|
+
assertCodexClosed(options);
|
|
42
|
+
const certificate = readPredictionCertificate(certificateDirectory(settings, paths), { allowExpired: true });
|
|
43
|
+
if (certificate.hash !== settings.certificateHash) throw new Error("Prediction certificate changed; trust cleanup was skipped");
|
|
44
|
+
await updatePredictionTrust(settings.store, certificate, { remove: true });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
output(`Predictions disabled. ${error.message}. Quit Codex and run ccdx prediction off again to finish cleanup.`);
|
|
47
|
+
return { ...settings, enabled: false, cleanupPending: true };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const { store, certificateHash, app, ...disabled } = settings;
|
|
51
|
+
if (store || certificateHash || app) writeSettings({ ...disabled, enabled: false }, opts);
|
|
52
|
+
try { retirePredictionIntegration(opts); }
|
|
53
|
+
catch (error) { output(`Legacy prediction cleanup skipped: ${error.message}`); }
|
|
54
|
+
output("Native predictions off. Restart Codex to clear cached predictions and its temporary route.");
|
|
55
|
+
return { ...settings, enabled: false };
|
|
56
|
+
}
|
|
57
|
+
assertCodexClosed(options);
|
|
58
|
+
assertPredictionProxyCompatible({ env, systemProxy: systemProxy(options.exec) });
|
|
59
|
+
const app = resolveApp(options);
|
|
60
|
+
if (settings.store && settings.store !== paths.store) throw new Error("Codex profile changed; disable the previous profile before enabling another");
|
|
61
|
+
let certificate, previous, generated = false;
|
|
62
|
+
let credentialsDirectory = certificateDirectory(settings, paths);
|
|
63
|
+
try { certificate = readPredictionCertificate(credentialsDirectory); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code !== "ENOENT" && !error.message.includes("needs renewal")) throw error;
|
|
66
|
+
if (settings.certificateHash) {
|
|
67
|
+
previous = readPredictionCertificate(credentialsDirectory, { allowExpired: true });
|
|
68
|
+
if (previous.hash !== settings.certificateHash) throw new Error("Existing prediction certificate changed; it was preserved");
|
|
69
|
+
}
|
|
70
|
+
fs.mkdirSync(paths.directory, { recursive: true, mode: 0o700 });
|
|
71
|
+
credentialsDirectory = fs.mkdtempSync(path.join(paths.directory, "credentials-"));
|
|
72
|
+
generated = true;
|
|
73
|
+
try { certificate = createPredictionCertificate(credentialsDirectory, options); }
|
|
74
|
+
catch (failure) { fs.rmSync(credentialsDirectory, { recursive: true, force: true }); throw failure; }
|
|
75
|
+
}
|
|
76
|
+
const next = { version: 1, enabled: true, app, store: paths.store, certificateHash: certificate.hash, credentialsDirectory };
|
|
77
|
+
let trustAdded = false;
|
|
78
|
+
try {
|
|
79
|
+
// Add the new trust before committing activation; retain the old record
|
|
80
|
+
// until the commit succeeds so a failed write cannot break an existing setup.
|
|
81
|
+
trustAdded = await updatePredictionTrust(paths.store, certificate);
|
|
82
|
+
writeSettings(next, opts);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (trustAdded) await updatePredictionTrust(paths.store, certificate, { remove: true }).catch(() => {});
|
|
86
|
+
if (generated) fs.rmSync(credentialsDirectory, { recursive: true, force: true });
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (previous && previous.hash !== certificate.hash) {
|
|
90
|
+
await updatePredictionTrust(paths.store, previous, { remove: true });
|
|
91
|
+
const previousDirectory = certificateDirectory(settings, paths);
|
|
92
|
+
if (previousDirectory !== paths.directory) fs.rmSync(previousDirectory, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
try { retirePredictionIntegration(opts); }
|
|
95
|
+
catch (error) { output(`Legacy prediction cleanup skipped: ${error.message}`); }
|
|
96
|
+
output("Native predictions on. Restart ccdx to launch Codex with the local bridge.");
|
|
97
|
+
output("Only chatgpt.com and ab.chatgpt.com use the App-only route. Their HTTPS traffic is decrypted locally; only prediction configuration is changed. No credentials are stored, no system certificate is installed, and client program files are unchanged.");
|
|
98
|
+
output("Existing official prediction configuration is preserved. The conversation's model generates predictions through your configured CCDX API.");
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function startNativePrediction({ env = process.env, platform = process.platform,
|
|
103
|
+
warning = () => {}, ...options } = {}) {
|
|
104
|
+
const opts = { ...options, env };
|
|
105
|
+
const settings = readPredictionSettings(opts);
|
|
106
|
+
if (!settings.enabled) return null;
|
|
107
|
+
if (platform !== "darwin") throw new Error("Native predictions were skipped: unsupported platform");
|
|
108
|
+
assertPredictionProxyCompatible({ env, systemProxy: systemProxy(options.exec) });
|
|
109
|
+
if (!predictionAppMatches(settings.app)) throw new Error("Codex App changed; run ccdx prediction on after quitting it to recheck compatibility");
|
|
110
|
+
const paths = predictionPaths(opts);
|
|
111
|
+
if (settings.store !== paths.store || !fs.existsSync(paths.store)) throw new Error("Prediction profile trust is unavailable; run ccdx prediction on after quitting Codex");
|
|
112
|
+
const certificate = readPredictionCertificate(certificateDirectory(settings, paths));
|
|
113
|
+
if (certificate.hash !== settings.certificateHash) throw new Error("Prediction certificate changed; setup must be checked again");
|
|
114
|
+
const [{ startPredictionTransport }, { applyNativePredictionOverlay, normalizeStatsigRequest }] = await Promise.all([
|
|
115
|
+
import("./native-prediction-transport.mjs"), import("./native-prediction-overlay.mjs"),
|
|
116
|
+
]);
|
|
117
|
+
// An in-memory switch is refreshed independently of request bodies and never
|
|
118
|
+
// touches the model path. A failed read turns off this optional overlay.
|
|
119
|
+
let enabled = true;
|
|
120
|
+
const timer = setInterval(() => {
|
|
121
|
+
try { enabled = readPredictionSettings(opts).enabled; }
|
|
122
|
+
catch { enabled = false; }
|
|
123
|
+
}, 1000);
|
|
124
|
+
timer.unref();
|
|
125
|
+
let transport;
|
|
126
|
+
try {
|
|
127
|
+
transport = await startPredictionTransport({ ...certificate, enabled: () => enabled,
|
|
128
|
+
overlay: ({ host, pathname, status, body, enabled }) => {
|
|
129
|
+
const result = applyNativePredictionOverlay({ hostname: host, pathname, statusCode: status, body, enabled });
|
|
130
|
+
return result.changed ? { body: result.body, status: result.statusCode } : null;
|
|
131
|
+
},
|
|
132
|
+
normalizeRequest: (parsed) => {
|
|
133
|
+
const result = normalizeStatsigRequest({ hostname: "ab.chatgpt.com", pathname: "/v1/initialize", body: JSON.stringify(parsed), enabled });
|
|
134
|
+
return result.changed ? JSON.parse(result.body) : parsed;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
} catch (error) { clearInterval(timer); throw error; }
|
|
138
|
+
warning("Native prediction bridge enabled; only the two configuration hosts use its App-only route.");
|
|
139
|
+
warning("If Codex is already running, quit it and run ccdx again to apply the route.");
|
|
140
|
+
return {
|
|
141
|
+
app: settings.app.app,
|
|
142
|
+
pacUrl: transport.pacUrl,
|
|
143
|
+
args: [`--proxy-pac-url=${transport.pacUrl}`],
|
|
144
|
+
async close() { clearInterval(timer); await transport.close(); },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { atomicWriteFileIfChangedSync, atomicWriteFilePairSync } from "./atomic-file.mjs";
|
|
7
|
+
import { codexTomlStatements, parseCodexToml, validateManagedConfigEdit } from "./config-toml.mjs";
|
|
8
|
+
import { userSettingsPath } from "./user-settings.mjs";
|
|
9
|
+
|
|
10
|
+
const SERVER_PATH = ["mcp_servers", "ccdx_suggestion"];
|
|
11
|
+
const same = (left, right) => isDeepStrictEqual(left, right);
|
|
12
|
+
|
|
13
|
+
export function computeRetiredPredictionConfig(content, filePath) {
|
|
14
|
+
const before = parseCodexToml(content);
|
|
15
|
+
const expected = {
|
|
16
|
+
command: process.execPath,
|
|
17
|
+
args: [fileURLToPath(new URL("./suggestion-mcp.mjs", import.meta.url))],
|
|
18
|
+
env: { CCDX_SUGGESTION_CONFIG: filePath },
|
|
19
|
+
tool_timeout_sec: 40n,
|
|
20
|
+
};
|
|
21
|
+
const existing = before.mcp_servers?.ccdx_suggestion;
|
|
22
|
+
const owned = existing && typeof existing.enabled === "boolean"
|
|
23
|
+
&& same({ ...existing, enabled: undefined }, { ...expected, enabled: undefined });
|
|
24
|
+
const statements = codexTomlStatements(content);
|
|
25
|
+
const declarations = statements.filter(item => ["table", "value"].includes(item.kind)
|
|
26
|
+
&& same(item.keys.slice(0, 2), SERVER_PATH));
|
|
27
|
+
const ranges = owned ? declarations.map(item => {
|
|
28
|
+
const start = content.lastIndexOf("\n", item.start - 1) + 1;
|
|
29
|
+
const end = content.indexOf("\n", item.end);
|
|
30
|
+
return [start, end < 0 ? content.length : end + 1];
|
|
31
|
+
}) : [];
|
|
32
|
+
// Only remove the exact block emitted by 0.9.23. Modified handlers and
|
|
33
|
+
// Codex's own trust records remain untouched.
|
|
34
|
+
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
35
|
+
const command = `CCDX_SUGGESTION_CONFIG=${quote(filePath)} ${quote(process.execPath)} ${quote(fileURLToPath(new URL("./suggestion-auto.mjs", import.meta.url)))}`;
|
|
36
|
+
const block = `# ccdx:suggestion-auto:start\n[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ntype = "command"\ncommand = ${JSON.stringify(command)}\nasync = true\ntimeout = 35\n# ccdx:suggestion-auto:end\n`;
|
|
37
|
+
const hookStart = content.indexOf(block);
|
|
38
|
+
const hookOwned = hookStart >= 0 && content.indexOf(block, hookStart + block.length) < 0
|
|
39
|
+
&& statements.some(item => item.kind === "comment" && item.start === hookStart && item.source === "# ccdx:suggestion-auto:start");
|
|
40
|
+
if (hookOwned) ranges.push([hookStart, hookStart + block.length]);
|
|
41
|
+
let updated = content;
|
|
42
|
+
for (const [start, end] of ranges.sort((a, b) => b[0] - a[0])) updated = updated.slice(0, start) + updated.slice(end);
|
|
43
|
+
validateManagedConfigEdit(before, updated, [SERVER_PATH, ["hooks", "Stop"]]);
|
|
44
|
+
const after = parseCodexToml(updated);
|
|
45
|
+
const remaining = [...(before.hooks?.Stop || [])];
|
|
46
|
+
if (hookOwned) {
|
|
47
|
+
const index = remaining.findIndex(group => same(group, { hooks: [{ type: "command", command, async: true, timeout: 35n }] }));
|
|
48
|
+
if (index < 0) throw new Error("Retired prediction hook could not be safely removed");
|
|
49
|
+
remaining.splice(index, 1);
|
|
50
|
+
}
|
|
51
|
+
if (!same(after.hooks?.Stop || [], remaining)) throw new Error("Unrelated hooks changed; prediction cleanup was skipped");
|
|
52
|
+
return { content: updated, changed: updated !== content, preserved: Boolean(existing && !owned) || (content.includes("# ccdx:suggestion-auto:start") && !hookOwned) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function retirePredictionIntegration({ env = process.env, home = os.homedir(), dryRun = false } = {}) {
|
|
56
|
+
const settings = path.resolve(env.CCDX_SUGGESTION_CONFIG || path.join(path.dirname(userSettingsPath({ env, home })), "suggestion.json"));
|
|
57
|
+
const codex = path.join(env.CODEX_HOME || path.join(home, ".codex"), "config.toml");
|
|
58
|
+
let content;
|
|
59
|
+
try { content = new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(codex)); }
|
|
60
|
+
catch (error) { if (error.code !== "ENOENT") throw new Error("Cannot safely read prediction configuration"); }
|
|
61
|
+
let serialized;
|
|
62
|
+
try {
|
|
63
|
+
const preference = JSON.parse(fs.readFileSync(settings, "utf8"));
|
|
64
|
+
if (!preference || typeof preference !== "object" || Array.isArray(preference)
|
|
65
|
+
|| typeof preference.enabled !== "boolean" || typeof preference.model !== "string") throw new Error();
|
|
66
|
+
if (preference.enabled || preference.automatic) serialized = JSON.stringify({ ...preference, enabled: false, automatic: false }, null, 2) + "\n";
|
|
67
|
+
} catch (error) { if (error.code !== "ENOENT") throw new Error("Prediction settings are invalid; cleanup was skipped"); }
|
|
68
|
+
const result = content?.includes("ccdx_suggestion") || content?.includes("ccdx:suggestion-auto:start")
|
|
69
|
+
? computeRetiredPredictionConfig(content, settings) : { content, changed: false, preserved: false };
|
|
70
|
+
if (dryRun) return { changed: result.changed || Boolean(serialized), preserved: result.preserved };
|
|
71
|
+
if (result.changed && serialized) atomicWriteFilePairSync(codex, result.content, settings, serialized);
|
|
72
|
+
else if (result.changed) atomicWriteFileIfChangedSync(codex, result.content);
|
|
73
|
+
else if (serialized) atomicWriteFileIfChangedSync(settings, serialized);
|
|
74
|
+
return { changed: result.changed || Boolean(serialized), preserved: result.preserved };
|
|
75
|
+
}
|
package/src/suggestion-auto.mjs
CHANGED
|
@@ -1,107 +1,7 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
4
1
|
import { pathToFileURL } from "node:url";
|
|
5
|
-
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
|
-
import { readSuggestionConfig, suggestionConfigPath } from "./suggestion-config.mjs";
|
|
7
|
-
import { readSuggestionContext } from "./suggestion-context.mjs";
|
|
8
|
-
import { generateSuggestion } from "./suggestion-provider.mjs";
|
|
9
2
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function keyFile(filePath, threadId) {
|
|
14
|
-
if (!UUID.test(threadId)) throw new Error("Invalid suggestion conversation ID");
|
|
15
|
-
return path.join(storeDirectory(filePath), `${createHash("sha256").update(threadId).digest("hex")}.json`);
|
|
16
|
-
}
|
|
17
|
-
function readRecord(file) {
|
|
18
|
-
try {
|
|
19
|
-
const stat=fs.lstatSync(file);
|
|
20
|
-
if (!stat.isFile() || stat.size > 8192) return null;
|
|
21
|
-
const record = JSON.parse(fs.readFileSync(file,"utf8"));
|
|
22
|
-
return record && typeof record === "object" && !Array.isArray(record) ? record : null;
|
|
23
|
-
} catch { return null; }
|
|
24
|
-
}
|
|
25
|
-
export function readAutomaticSuggestion(filePath, threadId) { return readRecord(keyFile(filePath, threadId)); }
|
|
26
|
-
function save(filePath, threadId, record) { atomicWriteFileIfChangedSync(keyFile(filePath,threadId),JSON.stringify(record)+"\n"); }
|
|
27
|
-
function cleanup(directory, now) {
|
|
28
|
-
const files = fs.readdirSync(directory).filter(name=>/^[a-f0-9]{64}\.json$/.test(name));
|
|
29
|
-
const retained = [];
|
|
30
|
-
for (const name of files) {
|
|
31
|
-
const file=path.join(directory,name),record=readRecord(file);
|
|
32
|
-
if (!record || !Number.isFinite(record.at) || now-record.at>RETENTION_MS) fs.unlinkSync(file);
|
|
33
|
-
else retained.push({file,at:record.at});
|
|
34
|
-
}
|
|
35
|
-
retained.sort((a,b)=>b.at-a.at);
|
|
36
|
-
for(const record of retained.slice(31))fs.unlinkSync(record.file);
|
|
37
|
-
}
|
|
38
|
-
export function automaticSuggestionStatus(filePath, now = Date.now()) {
|
|
39
|
-
try {
|
|
40
|
-
const directory=storeDirectory(filePath);
|
|
41
|
-
const records=fs.readdirSync(directory).filter(name=>/^[a-f0-9]{64}\.json$/.test(name)).slice(0,64)
|
|
42
|
-
.map(name=>readRecord(path.join(directory,name))).filter(record=>record && Number.isFinite(record.at)&&now-record.at<=RETENTION_MS);
|
|
43
|
-
const recent=records.sort((a,b)=>b.at-a.at)[0];
|
|
44
|
-
return recent ? { state:["generating","ready","empty","failed","cancelled"].includes(recent.state)?recent.state:"unknown",
|
|
45
|
-
at:new Date(recent.at).toISOString(),reason:["context_unavailable","model_failed","settings_changed","turn_changed","timeout"].includes(recent.reason)?recent.reason:null } : {state:"idle",at:null,reason:null};
|
|
46
|
-
} catch { return {state:"idle",at:null,reason:null}; }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function runAutomaticSuggestion(event, {
|
|
50
|
-
filePath = suggestionConfigPath(), readContext = readSuggestionContext, generate = generateSuggestion,
|
|
51
|
-
now = Date.now, timeoutMs = 30_000,
|
|
52
|
-
} = {}) {
|
|
53
|
-
const config=readSuggestionConfig({filePath});
|
|
54
|
-
if(!config.enabled || !config.automatic || event?.hook_event_name!=="Stop" || event.stop_hook_active===true
|
|
55
|
-
|| !UUID.test(event.session_id||"") || !UUID.test(event.turn_id||"")) return {skipped:true};
|
|
56
|
-
const threadId=event.session_id,turnId=event.turn_id,directory=storeDirectory(filePath);
|
|
57
|
-
fs.mkdirSync(directory,{recursive:true,mode:0o700});
|
|
58
|
-
if(!fs.lstatSync(directory).isDirectory())return {skipped:true};
|
|
59
|
-
cleanup(directory,now());
|
|
60
|
-
const prior=readAutomaticSuggestion(filePath,threadId);
|
|
61
|
-
if(prior?.turnId===turnId)return {skipped:true};
|
|
62
|
-
// One local slot bounds independent asynchronous hook processes. Only owned
|
|
63
|
-
// lock files are reclaimed after their 30-second operation deadline.
|
|
64
|
-
let lock;
|
|
65
|
-
for(let slot=0;slot<1;slot++){
|
|
66
|
-
const file=path.join(directory,`slot-${slot}.lock`);
|
|
67
|
-
try{if(now()-fs.statSync(file).mtimeMs>60_000)fs.unlinkSync(file)}catch(error){if(error.code!=="ENOENT")return {skipped:true}}
|
|
68
|
-
try{const fd=fs.openSync(file,"wx",0o600);fs.closeSync(fd);lock=file;break}catch(error){if(error.code!=="EEXIST")return {skipped:true}}
|
|
69
|
-
}
|
|
70
|
-
if(!lock)return {skipped:true,busy:true};
|
|
71
|
-
const abort=new AbortController(),timer=setTimeout(()=>abort.abort(),timeoutMs);
|
|
72
|
-
let stage="context_unavailable";
|
|
73
|
-
try {
|
|
74
|
-
// The slot acquisition and marker commit are synchronous within this process.
|
|
75
|
-
if(readAutomaticSuggestion(filePath,threadId)?.turnId===turnId)return {skipped:true};
|
|
76
|
-
save(filePath,threadId,{state:"generating",at:now(),turnId,model:config.model});
|
|
77
|
-
const page=await readContext(threadId,config,{signal:abort.signal,includePage:true});
|
|
78
|
-
if(page.latestTurnId!==turnId){save(filePath,threadId,{state:"cancelled",at:now(),turnId,model:config.model,reason:"turn_changed"});return {skipped:true};}
|
|
79
|
-
const current=readSuggestionConfig({filePath});
|
|
80
|
-
if(!current.enabled||!current.automatic||current.model!==config.model||current.adapter_url!==config.adapter_url){stage="settings_changed";throw new Error();}
|
|
81
|
-
stage="model_failed";
|
|
82
|
-
const suggestion=await generate(page.messages,config,{signal:abort.signal});
|
|
83
|
-
abort.signal.throwIfAborted();
|
|
84
|
-
const latest=readSuggestionConfig({filePath});
|
|
85
|
-
if(!latest.enabled||!latest.automatic||latest.model!==config.model||latest.adapter_url!==config.adapter_url){stage="settings_changed";throw new Error();}
|
|
86
|
-
stage="context_unavailable";
|
|
87
|
-
const finalPage=await readContext(threadId,config,{signal:abort.signal,includePage:true});
|
|
88
|
-
if(finalPage.latestTurnId!==turnId){stage="turn_changed";throw new Error();}
|
|
89
|
-
const result={state:suggestion?"ready":"empty",at:now(),turnId,model:config.model,suggestion,candidate_id:suggestion?randomUUID():null};
|
|
90
|
-
save(filePath,threadId,result);return result;
|
|
91
|
-
} catch {
|
|
92
|
-
const result={state:abort.signal.aborted?"cancelled":"failed",at:now(),turnId,model:config.model,reason:abort.signal.aborted?"timeout":stage};
|
|
93
|
-
// Don't overwrite a newer hook's marker.
|
|
94
|
-
if(readAutomaticSuggestion(filePath,threadId)?.turnId===turnId)save(filePath,threadId,result);
|
|
95
|
-
return result;
|
|
96
|
-
} finally {clearTimeout(timer);try{fs.unlinkSync(lock)}catch{}}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function main(){
|
|
100
|
-
let chunks=[],bytes=0;
|
|
101
|
-
for await(const chunk of process.stdin){bytes+=chunk.length;if(bytes>64*1024)return;chunks.push(chunk)}
|
|
102
|
-
let event;try{event=JSON.parse(Buffer.concat(chunks).toString("utf8"))}catch{return}
|
|
103
|
-
// Informational hook: no continuation, warnings, approval or user messages.
|
|
104
|
-
await runAutomaticSuggestion(event);
|
|
3
|
+
// Older configurations may still invoke this retired hook after an upgrade.
|
|
4
|
+
// Do not consume stdin, read session data, or request another model response.
|
|
5
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
105
6
|
process.stdout.write("{}\n");
|
|
106
7
|
}
|
|
107
|
-
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main().catch(()=>{process.stdout.write("{}\n")});
|
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
|
}
|