codex-copilot-dx 0.9.22 → 0.9.23
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 +4 -2
- package/docs/REFERENCE.md +29 -3
- package/package.json +1 -1
- package/src/adapter.mjs +1 -1
- package/src/cli-suggestion.mjs +27 -8
- package/src/dashboard/index.html +6 -0
- package/src/dashboard/language.js +21 -0
- package/src/dashboard/ui.js +20 -1
- package/src/dashboard-api.mjs +12 -0
- package/src/suggestion-auto.mjs +107 -0
- package/src/suggestion-config.mjs +21 -0
- package/src/suggestion-context.mjs +3 -2
- package/src/suggestion-mcp.mjs +23 -1
- package/src/suggestion-ui.html +1 -1
package/README.md
CHANGED
|
@@ -71,8 +71,10 @@ The dashboard does not poll in the background; refresh when needed.
|
|
|
71
71
|
`gpt-6-luna`, with `low` reasoning when advertised. Requires a running local
|
|
72
72
|
adapter and a recent Codex with paginated thread reads and MCP draft support.
|
|
73
73
|
- Suggestions read only a bounded page from the host-identified current thread.
|
|
74
|
-
Images, reasoning items and tool outputs are excluded.
|
|
75
|
-
|
|
74
|
+
Images, reasoning items and tool outputs are excluded. On supported runtimes,
|
|
75
|
+
`suggestion on` configures an async Stop hook; review it in Codex before it runs.
|
|
76
|
+
CCDX never grants trust. The panel reads saved suggestions; adding drafts is manual.
|
|
77
|
+
The dashboard shows settings and generation status. Model selection alone stays off.
|
|
76
78
|
Existing user-managed MCP/profile configuration is preserved. Native prediction
|
|
77
79
|
settings are untouched; account/remote defaults in all future clients cannot be
|
|
78
80
|
reliably detected. Use `ccdx suggestion off` when preferring an official feature.
|
package/docs/REFERENCE.md
CHANGED
|
@@ -9,7 +9,7 @@ CCDX is a local compatibility adapter for Codex App. It configures Codex to use
|
|
|
9
9
|
### Optional next-message suggestions
|
|
10
10
|
|
|
11
11
|
Suggestions are disabled by default. No suggestion MCP or skill is installed at
|
|
12
|
-
startup, and the primary adapter does not
|
|
12
|
+
startup, and the primary adapter does not start a predictor.
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
ccdx suggestion on
|
|
@@ -23,7 +23,7 @@ Start CCDX, enable suggestions, then open **Next message / 下一句** from a lo
|
|
|
23
23
|
conversation panel, or ask Codex for a next-message suggestion. **Add to draft /
|
|
24
24
|
加入草稿** appends ordinary text and never sends. The user still presses Send.
|
|
25
25
|
If the MCP has not appeared in an open App, restart the App once. There is no
|
|
26
|
-
client patch, grey ghost text, automatic
|
|
26
|
+
client patch, grey ghost text, automatic draft insertion, or auto-approval.
|
|
27
27
|
Model selection alone keeps the feature disabled. The initial model is
|
|
28
28
|
`gpt-6-luna`; `low` reasoning is used when advertised. No service tier is forced.
|
|
29
29
|
|
|
@@ -44,7 +44,8 @@ The MCP uses the host-supplied thread ID and the installed Codex's paginated
|
|
|
44
44
|
user/assistant text excerpts and 16,000 characters. Images, reasoning items and
|
|
45
45
|
tool outputs are excluded; there is no transcript scan, fork or resumed turn.
|
|
46
46
|
Recent text is sent through the local CCDX adapter to the selected Copilot model
|
|
47
|
-
|
|
47
|
+
when requested manually, or after a reply through the separately reviewed optional
|
|
48
|
+
hook described below. Provider data policies still apply.
|
|
48
49
|
Context lookup is capped at 5 seconds/2 MiB, the operation at 30 seconds, output
|
|
49
50
|
at 64 KiB/300 characters, concurrent work at two, and in-memory candidates at 16.
|
|
50
51
|
Candidates expire for adoption after 10 minutes and are bound to their thread
|
|
@@ -53,6 +54,31 @@ No additional retry is added by the suggestion feature. Existing adapter policie
|
|
|
53
54
|
remain unchanged. App support requires MCP draft insertion; the executable can
|
|
54
55
|
be selected explicitly with `CCDX_CODEX_BINARY` when automatic discovery fails.
|
|
55
56
|
|
|
57
|
+
On supported macOS/Linux Codex runtimes, `suggestion on` also registers an
|
|
58
|
+
asynchronous `Stop` hook. It generates after a finished reply only after the user
|
|
59
|
+
reviews and trusts that exact `suggestion-auto.mjs` command through Codex's normal
|
|
60
|
+
hook review screen (`/hooks` in Codex CLI). CCDX never writes hook trust or bypasses
|
|
61
|
+
review. Existing handlers and native prediction settings are preserved. Windows,
|
|
62
|
+
unsupported hook runtimes and conflicting hook declarations keep the manual panel.
|
|
63
|
+
Existing 0.9.22 users can run `ccdx suggestion on` once to configure the hook.
|
|
64
|
+
|
|
65
|
+
Automatic work has one local slot, a 30-second deadline, no added retries and one
|
|
66
|
+
attempt per observed turn. It checks the latest turn before and after inference;
|
|
67
|
+
newer turns or changed settings discard the result. The hook does not start a new
|
|
68
|
+
turn, send a message, edit a draft or wait on the foreground reply. Enabled users
|
|
69
|
+
incur additional selected-model usage after replies. `suggestion off` removes only
|
|
70
|
+
the unchanged CCDX hook and disables both automatic and manual requests.
|
|
71
|
+
|
|
72
|
+
Only the candidate and generation metadata are saved locally in `suggestion-state`
|
|
73
|
+
beside settings, with at most 32 entries. Candidates expire for adoption after ten
|
|
74
|
+
minutes; stale files are pruned on the next automatic operation. No context excerpts
|
|
75
|
+
are saved there. The loopback dashboard reads settings and safe recent status only;
|
|
76
|
+
it does not invoke a model or confirm hook trust. Refresh it after changing settings.
|
|
77
|
+
Opening Next message reads a current cached candidate without a second model call.
|
|
78
|
+
The user must still choose **Add to draft**, then Send. Closed panels cannot access
|
|
79
|
+
the composer, and the host exposes no atomic empty-draft insertion; automatic
|
|
80
|
+
draft filling is therefore intentionally unavailable.
|
|
81
|
+
|
|
56
82
|
### Core adapter
|
|
57
83
|
|
|
58
84
|
One in-process adapter listens on `127.0.0.1:2026` by default and exposes:
|
package/package.json
CHANGED
package/src/adapter.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export {
|
|
|
64
64
|
|
|
65
65
|
const ADAPTER_RUNTIME_CONFIG = loadRuntimeConfig();
|
|
66
66
|
const DASHBOARD_PATHS = new Set(["/", "/ui.css", "/theme.js", "/language.js", "/ui.js"]);
|
|
67
|
-
const DASHBOARD_API_PATHS = new Set(["/_ccdx/ui/animation", "/_ccdx/ui/models/live", "/_ccdx/ui/usage", "/_ccdx/ui/auth"]);
|
|
67
|
+
const DASHBOARD_API_PATHS = new Set(["/_ccdx/ui/animation", "/_ccdx/ui/models/live", "/_ccdx/ui/usage", "/_ccdx/ui/auth", "/_ccdx/ui/suggestion"]);
|
|
68
68
|
|
|
69
69
|
export function requestPath(reqUrl) {
|
|
70
70
|
return new URL(reqUrl || "/", "http://localhost").pathname;
|
package/src/cli-suggestion.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
5
7
|
import { atomicWriteFileIfChangedSync, atomicWriteFilePairSync } from "./atomic-file.mjs";
|
|
6
8
|
import { loadAutoReviewModelCatalog } from "./auto-review-model.mjs";
|
|
7
9
|
import { parseAdapterAddressOptions } from "./cli-options.mjs";
|
|
@@ -9,12 +11,20 @@ import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
|
9
11
|
import { terminalCell } from "./cli-table.mjs";
|
|
10
12
|
import { resolveSuggestionCodexBinary } from "./suggestion-context.mjs";
|
|
11
13
|
import { suggestionAdapterUrl } from "./suggestion-provider.mjs";
|
|
12
|
-
import { computeSuggestionMcpConfig, readSuggestionCodexConfig, readSuggestionConfig, suggestionCodexConfigPath, suggestionConfigPath } from "./suggestion-config.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
|
+
}
|
|
13
23
|
|
|
14
24
|
export async function runSuggestionCommand({
|
|
15
25
|
action = "status", model, commandName = "ccdx", env = process.env, home = os.homedir(),
|
|
16
26
|
input = process.stdin, output = process.stdout, prompt,
|
|
17
|
-
loadCatalog = loadAutoReviewModelCatalog, resolveBinary = resolveSuggestionCodexBinary,
|
|
27
|
+
loadCatalog = loadAutoReviewModelCatalog, resolveBinary = resolveSuggestionCodexBinary, probeHooks = supportsSuggestionHooks,
|
|
18
28
|
} = {}) {
|
|
19
29
|
const filePath = suggestionConfigPath({ env, home });
|
|
20
30
|
const codexPath = suggestionCodexConfigPath({ env, home });
|
|
@@ -25,7 +35,7 @@ export async function runSuggestionCommand({
|
|
|
25
35
|
output.write(`Next-message suggestions: ${current.enabled ? "enabled" : "disabled"}\nModel: ${terminalCell(current.model)}\n`);
|
|
26
36
|
if (preview.skipped) output.write(`${preview.reason}\n`);
|
|
27
37
|
else if (current.enabled && preview.changed) output.write(`MCP registration is missing or disabled. Run ${commandName} suggestion on to repair it.\n`);
|
|
28
|
-
output.write(
|
|
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`);
|
|
29
39
|
return { ...current, configured: !preview.changed && !preview.skipped };
|
|
30
40
|
}
|
|
31
41
|
if (!["on", "off", "model"].includes(action)) throw new Error("Suggestion action must be on, off, model, or status");
|
|
@@ -33,7 +43,8 @@ export async function runSuggestionCommand({
|
|
|
33
43
|
output.write(`${preview.reason}. No changes made.\n`);
|
|
34
44
|
return { changed: false, skipped: true };
|
|
35
45
|
}
|
|
36
|
-
|
|
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) {
|
|
37
48
|
output.write("Suggestions are already configured; kept unchanged.\n");
|
|
38
49
|
return { changed: false, ...current };
|
|
39
50
|
}
|
|
@@ -67,6 +78,11 @@ export async function runSuggestionCommand({
|
|
|
67
78
|
}
|
|
68
79
|
// Re-read both files after asynchronous model lookup/prompts; never overwrite
|
|
69
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
|
+
}
|
|
70
86
|
const latest = readSuggestionConfig({ filePath, strict: true });
|
|
71
87
|
if (action !== "model") {
|
|
72
88
|
selected = latest.model;
|
|
@@ -80,21 +96,24 @@ export async function runSuggestionCommand({
|
|
|
80
96
|
output.write(`${codex.reason}. No changes made.\n`);
|
|
81
97
|
return { changed: false, skipped: true };
|
|
82
98
|
}
|
|
83
|
-
const
|
|
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 };
|
|
84
101
|
if (enabled) {
|
|
85
102
|
const address = parseAdapterAddressOptions(env);
|
|
86
103
|
const host = ["0.0.0.0", "localhost"].includes(address.adapterHost) ? "127.0.0.1" : address.adapterHost === "::" ? "::1" : address.adapterHost;
|
|
87
104
|
next.adapter_url = suggestionAdapterUrl(`${adapterBaseUrl(host, address.adapterPort)}/v1`);
|
|
88
|
-
next.codex_binary =
|
|
105
|
+
next.codex_binary = binary;
|
|
89
106
|
next.codex_home = path.dirname(codexPath);
|
|
90
107
|
}
|
|
91
108
|
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
92
109
|
let changed;
|
|
93
|
-
if (codex.changed && !codex.skipped) {
|
|
94
|
-
atomicWriteFilePairSync(codexPath,
|
|
110
|
+
if ((codex.changed || hook.changed) && !codex.skipped) {
|
|
111
|
+
atomicWriteFilePairSync(codexPath, hook.content, filePath, serialized, { writeFile: atomicWriteFileIfChangedSync });
|
|
95
112
|
changed = true;
|
|
96
113
|
} else changed = atomicWriteFileIfChangedSync(filePath, serialized);
|
|
97
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");
|
|
98
117
|
if (codex.skipped) output.write(`${codex.reason}. CCDX suggestion requests are disabled.\n`);
|
|
99
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");
|
|
100
119
|
return { ...next, changed };
|
package/src/dashboard/index.html
CHANGED
|
@@ -50,6 +50,12 @@
|
|
|
50
50
|
</article>
|
|
51
51
|
</section>
|
|
52
52
|
|
|
53
|
+
<section class="panel dashboard-section" id="suggestion-panel">
|
|
54
|
+
<div class="panel-heading"><h2 data-i18n>NEXT MESSAGE</h2><span data-i18n>OPTIONAL</span></div>
|
|
55
|
+
<dl class="stats"><div><dt data-i18n>Status</dt><dd id="suggestion-enabled">—</dd></div><div><dt data-i18n>Model</dt><dd id="suggestion-model">—</dd></div><div><dt data-i18n>Auto generation</dt><dd id="suggestion-auto">—</dd></div><div><dt data-i18n>Draft insertion</dt><dd data-i18n>Choose Add to draft</dd></div></dl>
|
|
56
|
+
<p id="suggestion-recent" class="hint" role="status">—</p><p id="suggestion-detail" class="hint">—</p><p class="hint"><span data-i18n>Commands</span>: <code>ccdx suggestion on|off|model|status</code></p>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
53
59
|
<details class="panel dashboard-section" id="request-context">
|
|
54
60
|
<summary data-i18n>RECENT REQUEST CONTEXT</summary>
|
|
55
61
|
<p id="context-note" class="table-note">—</p>
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
(() => {
|
|
2
2
|
const translations = {
|
|
3
|
+
"NEXT MESSAGE":["NEXT MESSAGE","下一句"],
|
|
4
|
+
"Status":["Status","状态"],
|
|
5
|
+
"Auto generation":["Auto generation","自动生成"],
|
|
6
|
+
"Configured; review hook":["Configured; review hook","已配置,需审核"],
|
|
7
|
+
"{time} / {reason}":["{time} / {reason}","{time} / {reason}"],
|
|
8
|
+
"Context unavailable":["Context unavailable","无法读取对话。"],
|
|
9
|
+
"Model request failed":["Model request failed","模型请求失败。"],
|
|
10
|
+
"Settings changed":["Settings changed","设置已变化。"],
|
|
11
|
+
"Conversation changed":["Conversation changed","对话已变化。"],
|
|
12
|
+
"Suggestion timed out":["Suggestion timed out","建议生成超时。"],
|
|
13
|
+
"No recorded error":["No recorded error","暂无错误记录。"],
|
|
14
|
+
"Draft insertion":["Draft insertion","加入草稿"],
|
|
15
|
+
"Choose Add to draft":["Click to add","点击加入"],
|
|
16
|
+
"Commands":["Commands","命令"],
|
|
17
|
+
"No automatic suggestion yet":["No automatic suggestion yet.","暂无自动建议。"],
|
|
18
|
+
"Generating suggestion":["Generating…","正在生成。"],
|
|
19
|
+
"Suggestion ready; open Next message":["Ready. Open Next message.","已生成,请打开下一句。"],
|
|
20
|
+
"No useful suggestion":["No useful suggestion.","暂无合适建议。"],
|
|
21
|
+
"Suggestion failed; retry manually":["Failed. Retry in Next message.","生成失败,请手动重试。"],
|
|
22
|
+
"Suggestion cancelled":["Cancelled.","已取消。"],
|
|
23
|
+
"Run ccdx suggestion status":["Run ccdx suggestion status.","请用命令检查设置。"],
|
|
3
24
|
"First output": ["First output", "首次输出"],
|
|
4
25
|
"Token time": ["Token time", "输出耗时"],
|
|
5
26
|
"Gateway timing. Token time is estimated.": ["Observed at the gateway. Token time is an estimate.", "网关观测时间。\n每Token耗时为估算值。"],
|
package/src/dashboard/ui.js
CHANGED
|
@@ -16,6 +16,7 @@ const finite = (value) => value !== null && value !== undefined && Number.isFini
|
|
|
16
16
|
const number = (value) => finite(value) ? Math.max(0, Math.round(Number(value))).toLocaleString() : "—";
|
|
17
17
|
const mib = (value) => finite(value) ? `${(Number(value) / 1048576).toFixed(1)} MiB` : "—";
|
|
18
18
|
let authLoading = false;
|
|
19
|
+
let suggestionLoading=false;
|
|
19
20
|
let contextRequests = [];
|
|
20
21
|
let contextCompactions = {};
|
|
21
22
|
|
|
@@ -40,6 +41,23 @@ async function loadAuth() {
|
|
|
40
41
|
} finally { authLoading = false; }
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
async function loadSuggestion(){
|
|
45
|
+
if(suggestionLoading)return;
|
|
46
|
+
suggestionLoading=true;
|
|
47
|
+
try{
|
|
48
|
+
const response=await fetch("/_ccdx/ui/suggestion",{cache:"no-store",headers:{"X-CCDX-Dashboard":"1"},signal:AbortSignal.timeout(5000)});
|
|
49
|
+
const data=await response.json();
|
|
50
|
+
if(!response.ok||data.source!=="local_suggestion_settings")throw new Error();
|
|
51
|
+
text("suggestion-enabled",data.enabled?"ENABLED":"DISABLED");
|
|
52
|
+
element("suggestion-model").textContent=data.model;
|
|
53
|
+
text("suggestion-auto",data.automatic_generation_configured?"Configured; review hook":"DISABLED");
|
|
54
|
+
const state={idle:"No automatic suggestion yet",generating:"Generating suggestion",ready:"Suggestion ready; open Next message",empty:"No useful suggestion",failed:"Suggestion failed; retry manually",cancelled:"Suggestion cancelled",unknown:"Unknown"}[data.recent?.state]||"Unknown";
|
|
55
|
+
text("suggestion-recent",state);
|
|
56
|
+
text("suggestion-detail","{time} / {reason}",{time:data.recent?.at?new Date(data.recent.at).toLocaleTimeString():"—",reason:phrase({context_unavailable:"Context unavailable",model_failed:"Model request failed",settings_changed:"Settings changed",turn_changed:"Conversation changed",timeout:"Suggestion timed out"}[data.recent?.reason]||"No recorded error")});
|
|
57
|
+
}catch{text("suggestion-enabled","UNAVAILABLE");element("suggestion-model").textContent="—";text("suggestion-auto","UNAVAILABLE");text("suggestion-recent","Run ccdx suggestion status");element("suggestion-detail").textContent="—";}
|
|
58
|
+
finally{suggestionLoading=false;}
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
function duration(value) {
|
|
44
62
|
if (!finite(value)) return "—";
|
|
45
63
|
const seconds = Math.max(0, Math.round(Number(value) / 1000));
|
|
@@ -223,10 +241,11 @@ async function refresh() {
|
|
|
223
241
|
}
|
|
224
242
|
}
|
|
225
243
|
|
|
226
|
-
element("refresh").addEventListener("click", () => { refresh(); loadAuth(); });
|
|
244
|
+
element("refresh").addEventListener("click", () => { refresh(); loadAuth(); loadSuggestion(); });
|
|
227
245
|
element("request-context").addEventListener("toggle", () => { if (element("request-context").open) renderContext(); });
|
|
228
246
|
refresh();
|
|
229
247
|
loadAuth();
|
|
248
|
+
loadSuggestion();
|
|
230
249
|
|
|
231
250
|
const animationRows = new Map();
|
|
232
251
|
let animationTimelines = [];
|
package/src/dashboard-api.mjs
CHANGED
|
@@ -15,11 +15,14 @@ import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate } from "./usage-
|
|
|
15
15
|
import { createUsageAnalytics } from "./usage-analytics.mjs";
|
|
16
16
|
import { analyzeUsageLogs } from "./usage-analysis.mjs";
|
|
17
17
|
import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
|
|
18
|
+
import { readSuggestionConfig, suggestionConfigPath } from "./suggestion-config.mjs";
|
|
19
|
+
import { automaticSuggestionStatus } from "./suggestion-auto.mjs";
|
|
18
20
|
|
|
19
21
|
const ANIMATION_PATH = "/_ccdx/ui/animation";
|
|
20
22
|
const MODELS_PATH = "/_ccdx/ui/models/live";
|
|
21
23
|
const USAGE_PATH = "/_ccdx/ui/usage";
|
|
22
24
|
const AUTH_PATH = "/_ccdx/ui/auth";
|
|
25
|
+
const SUGGESTION_PATH = "/_ccdx/ui/suggestion";
|
|
23
26
|
const DISABLED_ANIMATION = /^(0|false|no|off)$/i;
|
|
24
27
|
|
|
25
28
|
function sendJson(res, statusCode, value) {
|
|
@@ -146,6 +149,15 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
146
149
|
}
|
|
147
150
|
return;
|
|
148
151
|
}
|
|
152
|
+
if(pathname===SUGGESTION_PATH && req.method==="GET"){
|
|
153
|
+
try{
|
|
154
|
+
const filePath=suggestionConfigPath({env,home}),config=readSuggestionConfig({filePath,strict:true});
|
|
155
|
+
sendJson(res,200,{source:"local_suggestion_settings",enabled:config.enabled,model:config.model,
|
|
156
|
+
automatic_generation_configured:config.enabled&&config.automatic===true,hook_trust:"unknown",automatic_insertion:false,
|
|
157
|
+
recent:config.enabled?automaticSuggestionStatus(filePath):{state:"idle",at:null,reason:null}});
|
|
158
|
+
}catch{sendJson(res,409,{error:"Suggestion settings are invalid; run ccdx suggestion status"})}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
149
161
|
|
|
150
162
|
if (pathname === ANIMATION_PATH && req.method === "GET") {
|
|
151
163
|
try {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
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
|
+
|
|
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);
|
|
105
|
+
process.stdout.write("{}\n");
|
|
106
|
+
}
|
|
107
|
+
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main().catch(()=>{process.stdout.write("{}\n")});
|
|
@@ -19,6 +19,7 @@ export function readSuggestionConfig({ filePath = suggestionConfigPath(), strict
|
|
|
19
19
|
const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
20
20
|
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
21
21
|
|| typeof value.enabled !== "boolean"
|
|
22
|
+
|| (value.automatic !== undefined && typeof value.automatic !== "boolean")
|
|
22
23
|
|| typeof value.model !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value.model)) throw new Error();
|
|
23
24
|
return value;
|
|
24
25
|
} catch (error) {
|
|
@@ -64,6 +65,26 @@ export function computeSuggestionMcpConfig(content, { enabled, filePath }) {
|
|
|
64
65
|
return { content: updated, changed: true };
|
|
65
66
|
}
|
|
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
|
+
|
|
67
88
|
export function suggestionCodexConfigPath({ env = process.env, home = os.homedir() } = {}) {
|
|
68
89
|
return path.join(env.CODEX_HOME || path.join(home, ".codex"), "config.toml");
|
|
69
90
|
}
|
|
@@ -47,7 +47,7 @@ export function contextMessages(entries) {
|
|
|
47
47
|
|
|
48
48
|
// Use the client's paginated reader: no transcript scans, SQLite access, forks,
|
|
49
49
|
// turn starts, or writes to the user's conversation. Zstandard remains its job.
|
|
50
|
-
export function readSuggestionContext(threadId, config, { signal, spawnFn = spawn } = {}) {
|
|
50
|
+
export function readSuggestionContext(threadId, config, { signal, spawnFn = spawn, includePage = false } = {}) {
|
|
51
51
|
if (!UUID.test(threadId)) return Promise.reject(new Error("Invalid conversation ID"));
|
|
52
52
|
return new Promise((resolve, reject) => {
|
|
53
53
|
const decoder = new StringDecoder("utf8");
|
|
@@ -95,7 +95,8 @@ export function readSuggestionContext(threadId, config, { signal, spawnFn = spaw
|
|
|
95
95
|
} else {
|
|
96
96
|
if (!Array.isArray(reply.result?.data)) { finish(new Error("Invalid conversation page")); return; }
|
|
97
97
|
const messages = contextMessages(reply.result.data);
|
|
98
|
-
finish(messages.length ? null : new Error("No recent text messages are available for a suggestion"),
|
|
98
|
+
finish(messages.length ? null : new Error("No recent text messages are available for a suggestion"), includePage
|
|
99
|
+
? { messages, latestTurnId: reply.result.data[0]?.turnId || null } : messages);
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
});
|
package/src/suggestion-mcp.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
5
5
|
import { readSuggestionConfig, suggestionConfigPath } from "./suggestion-config.mjs";
|
|
6
6
|
import { readSuggestionContext, suggestionThreadId } from "./suggestion-context.mjs";
|
|
7
7
|
import { generateSuggestion } from "./suggestion-provider.mjs";
|
|
8
|
+
import { readAutomaticSuggestion } from "./suggestion-auto.mjs";
|
|
8
9
|
|
|
9
10
|
const URI = "ui://ccdx-suggestion/panel.html";
|
|
10
11
|
const protocolVersions = new Set(["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"]);
|
|
@@ -23,12 +24,19 @@ export const SUGGESTION_TOOLS = [
|
|
|
23
24
|
annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false },
|
|
24
25
|
_meta: { ui: { visibility: ["app"] } },
|
|
25
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
|
+
},
|
|
26
33
|
];
|
|
27
34
|
|
|
28
35
|
export function createSuggestionMcp({
|
|
29
36
|
loadConfig = () => readSuggestionConfig({ filePath: suggestionConfigPath() }),
|
|
30
37
|
readContext = readSuggestionContext, generate = generateSuggestion,
|
|
31
38
|
now = Date.now, timeoutMs = 30_000,
|
|
39
|
+
loadAutomatic = threadId => readAutomaticSuggestion(suggestionConfigPath(),threadId),
|
|
32
40
|
} = {}) {
|
|
33
41
|
const candidates = new Map();
|
|
34
42
|
let active = 0;
|
|
@@ -55,10 +63,24 @@ export function createSuggestionMcp({
|
|
|
55
63
|
const args = request.params?.arguments || {};
|
|
56
64
|
if (typeof args !== "object" || Array.isArray(args)) throw new Error("Invalid suggestion parameters");
|
|
57
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});
|
|
74
|
+
}
|
|
58
75
|
if (request.params.name === "prepare_suggestion_draft") {
|
|
59
76
|
if (Object.keys(args).some(key => key !== "candidate_id")) throw new Error("Invalid draft parameters");
|
|
60
|
-
const
|
|
77
|
+
const stored=automatic();
|
|
78
|
+
const item = candidates.get(args.candidate_id) || (stored?.candidate_id===args.candidate_id?{...stored,threadId}:null);
|
|
61
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
|
+
}
|
|
62
84
|
return result({ candidate_id: args.candidate_id, suggestion: item.suggestion, model: item.model });
|
|
63
85
|
}
|
|
64
86
|
if (request.params.name !== "suggest_next_message" || Object.keys(args).length) throw new Error("Invalid suggestion tool or parameters");
|
package/src/suggestion-ui.html
CHANGED
|
@@ -21,6 +21,6 @@ addEventListener("message",event=>{if(event.source!==parent)return;const message
|
|
|
21
21
|
$("generate").onclick=async()=>{if(busy||!ready)return;busy=true;buttons();$("status").textContent=words().generating;try{render(data(await request("tools/call",{name:"suggest_next_message",arguments:{}})));$("status").textContent=supportsDraft?"":words().unsupported}catch(error){$("status").textContent=error.message}finally{busy=false;buttons()}};
|
|
22
22
|
$("adopt").onclick=async()=>{if(busy||!current?.candidate_id||!supportsDraft)return;busy=true;buttons();const id=current.candidate_id;try{const checked=data(await request("tools/call",{name:"prepare_suggestion_draft",arguments:{candidate_id:id}}));if(checked.candidate_id!==id||checked.suggestion!==current.suggestion)throw new Error(words().failed);await request("ui/message",{role:"user",content:[{type:"text",text:checked.suggestion}],_meta:{"openai/message":{target:"active",send:false}}});applied=id;$("status").textContent=words().added}catch(error){$("status").textContent=error.message}finally{busy=false;buttons()}};
|
|
23
23
|
labels();$("status").textContent=words().connecting;
|
|
24
|
-
request("ui/initialize",{appInfo:{name:"CCDX Next message",version:"1"},appCapabilities:{},protocolVersion:"2026-01-26"}).then(result=>{if(result.hostContext?.locale)zh=/^zh/i.test(result.hostContext.locale);labels();supportsDraft=Boolean(result.hostCapabilities?.experimental?.["openai/message"]?.draft);parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/initialized",params:{}},"*");ready=true;$("status").textContent=supportsDraft?"":words().unsupported;if(!current)$("suggestion").textContent=words().empty
|
|
24
|
+
request("ui/initialize",{appInfo:{name:"CCDX Next message",version:"1"},appCapabilities:{},protocolVersion:"2026-01-26"}).then(async result=>{if(result.hostContext?.locale)zh=/^zh/i.test(result.hostContext.locale);labels();supportsDraft=Boolean(result.hostCapabilities?.experimental?.["openai/message"]?.draft);parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/initialized",params:{}},"*");ready=true;$("status").textContent=supportsDraft?"":words().unsupported;if(!current){try{render(data(await request("tools/call",{name:"latest_suggestion",arguments:{}})))}catch{$("suggestion").textContent=words().empty}}buttons()}).catch(error=>{$("status").textContent=error.message});
|
|
25
25
|
addEventListener("pagehide",()=>{for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error("Panel closed"))}pending.clear()});
|
|
26
26
|
</script></html>
|