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.
@@ -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
+ }
@@ -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
- const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
11
- const RETENTION_MS = 10 * 60_000;
12
- function storeDirectory(filePath) { return path.join(path.dirname(filePath), "suggestion-state"); }
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")});
@@ -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 { readSuggestionConfig, suggestionConfigPath } from "./suggestion-config.mjs";
6
- import { readSuggestionContext, suggestionThreadId } from "./suggestion-context.mjs";
7
- import { generateSuggestion } from "./suggestion-provider.mjs";
8
- import { readAutomaticSuggestion } from "./suggestion-auto.mjs";
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
- const URI = "ui://ccdx-suggestion/panel.html";
11
- const protocolVersions = new Set(["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"]);
12
- export const SUGGESTION_TOOLS = [
13
- {
14
- name: "suggest_next_message", title: "Next message / 下一句",
15
- description: "Open an optional next-message suggestion for this local conversation. Use only when the user asks for a suggestion. Reads a bounded page of recent user/assistant text and makes one request to the user's selected model through local CCDX. Does not send a message, modify a draft, execute tools, or grant approval. The user can add the suggestion to their ordinary draft in the panel.",
16
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
17
- annotations: { readOnlyHint: true, openWorldHint: true, destructiveHint: false },
18
- _meta: { ui: { resourceUri: URI }, "openai/ui": { entrypoints: [{ type: "thread" }] } },
19
- },
20
- {
21
- name: "prepare_suggestion_draft", title: "Check draft destination",
22
- description: "Validate a suggestion's originating conversation before the user explicitly adds it to their draft. Does not send or write anything.",
23
- inputSchema: { type: "object", properties: { candidate_id: { type: "string" } }, required: ["candidate_id"], additionalProperties: false },
24
- annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false },
25
- _meta: { ui: { visibility: ["app"] } },
26
- },
27
- {
28
- name:"latest_suggestion",title:"Read latest suggestion",
29
- description:"Read an already-generated candidate for this local conversation. Does not invoke a model or edit a draft.",
30
- inputSchema:{type:"object",properties:{},additionalProperties:false},
31
- annotations:{readOnlyHint:true,openWorldHint:false,destructiveHint:false},_meta:{ui:{visibility:["app"]}},
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 createSuggestionMcp({
36
- loadConfig = () => readSuggestionConfig({ filePath: suggestionConfigPath() }),
37
- readContext = readSuggestionContext, generate = generateSuggestion,
38
- now = Date.now, timeoutMs = 30_000,
39
- loadAutomatic = threadId => readAutomaticSuggestion(suggestionConfigPath(),threadId),
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
- if (request.params.name === "prepare_suggestion_draft") {
76
- if (Object.keys(args).some(key => key !== "candidate_id")) throw new Error("Invalid draft parameters");
77
- const stored=automatic();
78
- const item = candidates.get(args.candidate_id) || (stored?.candidate_id===args.candidate_id?{...stored,threadId}:null);
79
- if (!item || item.threadId !== threadId || item.model !== config.model) throw new Error("Suggestion expired or conversation changed; generate a new suggestion");
80
- if(stored?.candidate_id===args.candidate_id){
81
- const page=await readContext(threadId,config,{signal,includePage:true});
82
- if(page.latestTurnId!==stored.turnId)throw new Error("Conversation changed; generate a new suggestion");
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
- if (request.params.name !== "suggest_next_message" || Object.keys(args).length) throw new Error("Invalid suggestion tool or parameters");
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
- buffer += decoder.write(chunk);
122
- if (Buffer.byteLength(buffer) > 64 * 1024) { close(); input.destroy(); return; }
123
- let end;
124
- while ((end = buffer.indexOf("\n")) >= 0) {
125
- const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
126
- let request;
127
- try { request = JSON.parse(line); } catch { write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Invalid JSON" } }); continue; }
128
- if (request?.method === "notifications/cancelled") { pending.get(request.params?.requestId)?.abort(); continue; }
129
- if (request?.id === undefined) continue;
130
- const id = request.id;
131
- if (request.jsonrpc !== "2.0" || !["string", "number"].includes(typeof id) || typeof request.method !== "string" || pending.has(id) || pending.size >= 16) {
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
- const controller = new AbortController(); pending.set(id, controller);
135
- Promise.resolve().then(() => handle(request, { signal: controller.signal }))
136
- .then(result => write({ jsonrpc: "2.0", id, result }), error => {
137
- // Never expose raw upstream responses, prompts, stack traces or config.
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
  }