codex-copilot-dx 0.9.23 → 0.9.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +20 -20
  2. package/bin/cli.mjs +51 -19
  3. package/docs/ARCHITECTURE.md +50 -0
  4. package/docs/README.md +4 -0
  5. package/docs/REFERENCE.md +124 -71
  6. package/package.json +3 -2
  7. package/src/adapter.mjs +5 -2
  8. package/src/cli-image.mjs +13 -10
  9. package/src/cli-options.mjs +13 -4
  10. package/src/cli-status.mjs +5 -0
  11. package/src/client-integration.mjs +41 -0
  12. package/src/codex-config-doctor.mjs +3 -3
  13. package/src/codex-paths.mjs +10 -0
  14. package/src/codex-thread-compression.mjs +11 -6
  15. package/src/config.mjs +10 -6
  16. package/src/copilot-responses-policy.mjs +4 -2
  17. package/src/dashboard/index.html +7 -6
  18. package/src/dashboard/language.js +13 -21
  19. package/src/dashboard/ui.js +10 -20
  20. package/src/dashboard-api.mjs +0 -12
  21. package/src/launcher.mjs +7 -2
  22. package/src/lock.mjs +29 -0
  23. package/src/native-prediction-overlay.mjs +92 -0
  24. package/src/native-prediction-profile.mjs +178 -0
  25. package/src/native-prediction-state.mjs +29 -0
  26. package/src/native-prediction-transport.mjs +255 -0
  27. package/src/native-prediction.mjs +170 -0
  28. package/src/network-route.mjs +102 -0
  29. package/src/observability.mjs +2 -0
  30. package/src/operation-error.mjs +37 -0
  31. package/src/package-update.mjs +2 -3
  32. package/src/prediction-retirement.mjs +86 -0
  33. package/src/proxy-tunnel.mjs +102 -0
  34. package/src/responses-request.mjs +5 -2
  35. package/src/suggestion-auto.mjs +3 -103
  36. package/src/suggestion-mcp.mjs +64 -132
  37. package/src/user-settings.mjs +17 -4
  38. package/src/version.mjs +23 -46
  39. package/src/cli-suggestion.mjs +0 -120
  40. package/src/suggestion-config.mjs +0 -98
  41. package/src/suggestion-context.mjs +0 -108
  42. package/src/suggestion-provider.mjs +0 -41
  43. package/src/suggestion-ui.html +0 -26
package/src/cli-image.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
- import path from "node:path";
4
3
  import readline from "node:readline";
5
4
  import { createInterface } from "node:readline/promises";
5
+ import { codexPaths } from "./codex-paths.mjs";
6
+ import { withFileLockSync } from "./lock.mjs";
6
7
  import { atomicWriteFileIfChangedSync, atomicWriteFilePairSync } from "./atomic-file.mjs";
7
8
  import { terminalCell } from "./cli-table.mjs";
8
9
  import { computeImageMcpCodexConfig } from "./config.mjs";
@@ -15,11 +16,6 @@ import {
15
16
  readImageProviderConfig,
16
17
  validateImageProviderConfig,
17
18
  } from "./image-provider-config.mjs";
18
-
19
- function defaultCodexConfigPath(home) {
20
- return path.join(home, ".codex", "config.toml");
21
- }
22
-
23
19
  async function visibleQuestion(input, output, question) {
24
20
  const prompt = createInterface({ input, output });
25
21
  try { return await prompt.question(question); }
@@ -102,7 +98,10 @@ function configSummary(config) {
102
98
  };
103
99
  }
104
100
 
105
- function enableImageProvider({ providerConfig, providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
101
+ function enableImageProvider(options) {
102
+ return withFileLockSync(`${options.codexPath}.ccdx.lock`, () => enableImageProviderUnlocked(options));
103
+ }
104
+ function enableImageProviderUnlocked({ providerConfig, providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
106
105
  const codex = computeImageMcpCodexConfig(codexContent, {
107
106
  enabled: true,
108
107
  adapterPort,
@@ -128,7 +127,11 @@ function enableImageProvider({ providerConfig, providerPath, codexPath, codexCon
128
127
  return { codexChanged: codex.changed, config: normalized };
129
128
  }
130
129
 
131
- function disableImageProvider({ providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
130
+ function disableImageProvider(options) {
131
+ if (!fs.existsSync(options.codexPath)) return disableImageProviderUnlocked(options);
132
+ return withFileLockSync(`${options.codexPath}.ccdx.lock`, () => disableImageProviderUnlocked(options));
133
+ }
134
+ function disableImageProviderUnlocked({ providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
132
135
  const codex = computeImageMcpCodexConfig(codexContent, {
133
136
  enabled: false,
134
137
  adapterPort,
@@ -154,7 +157,7 @@ function disableImageProvider({ providerPath, codexPath, codexContent, adapterPo
154
157
  return { changed: providerExists || codex.changed || skill.changed, preservedGuidance: Boolean(skill.preserved) };
155
158
  }
156
159
 
157
- export function syncEnabledImageSkill({ home = os.homedir(), codexPath = defaultCodexConfigPath(home), ...options } = {}) {
160
+ export function syncEnabledImageSkill({ home = os.homedir(), env = process.env, codexPath = codexPaths({ env, home }).config, ...options } = {}) {
158
161
  return updateImageSkill({ ...options, enabled: true, codexPath });
159
162
  }
160
163
 
@@ -179,7 +182,7 @@ export async function runImageCommand({
179
182
  probeFetchImpl = fetch,
180
183
  adapterPort = 2026,
181
184
  adapterHost = "127.0.0.1",
182
- codexPath = defaultCodexConfigPath(home),
185
+ codexPath = codexPaths({ env, home }).config,
183
186
  } = {}) {
184
187
  const providerPath = imageProviderConfigPath({ env, home });
185
188
  if (action === "status") {
@@ -28,6 +28,7 @@ const HELP_TOPICS = new Set([
28
28
  "image-status",
29
29
  "auto-review-model",
30
30
  "suggestion",
31
+ "prediction",
31
32
  "update",
32
33
  "version",
33
34
  ]);
@@ -252,11 +253,18 @@ export function parseCliArgs(args = []) {
252
253
  if (rest.length) unexpectedArgs(rest);
253
254
  return baseCommand("animation");
254
255
  }
256
+ if (command === "prediction") {
257
+ const [action = "status", ...extra] = rest;
258
+ if (!["on", "off", "status"].includes(action)) throw new Error("Prediction action must be on, off, or status");
259
+ if (extra.length) unexpectedArgs(extra);
260
+ return baseCommand("prediction", { action });
261
+ }
255
262
  if (command === "suggestion") {
256
263
  const [action = "status", model, ...extra] = rest;
257
264
  if (!["on", "off", "model", "status"].includes(action)) throw new Error("Suggestion action must be on, off, model, or status");
258
265
  if (extra.length || (model !== undefined && action !== "model")) unexpectedArgs(rest.slice(1));
259
- return baseCommand("suggestion", { action, ...(model !== undefined ? { model } : {}) });
266
+ if (["on", "model"].includes(action)) throw new Error("The CCDX prediction panel has been retired; use ccdx prediction for the separate optional native feature");
267
+ return baseCommand("suggestion", { action });
260
268
  }
261
269
  if (command === "enable-image" || command === "disable-image" || command === "image-status") {
262
270
  if (rest.length) unexpectedArgs(rest);
@@ -363,7 +371,8 @@ function topicHelp(name, topic) {
363
371
  "disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential and unmodified CCDX image guidance.`,
364
372
  "image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and locally advertised generation/editing tools without generating an image or exposing its API key.`,
365
373
  "auto-review-model": `Usage:\n ${name} auto-review-model\n\nSelect a Responses model, then an advertised reasoning effort. Enter keeps the default/current choice; q cancels both steps without saving. Effort 0 follows the client (low when omitted). Settings apply only to Auto-review on the next request; no client patch or restart is required for adapters running 0.9.13 or newer.`,
366
- "suggestion": `Usage:\n ${name} suggestion on\n ${name} suggestion off\n ${name} suggestion model [MODEL_ID]\n ${name} suggestion status\n\nOptional next-message panel; disabled by default. Open it in a local Codex conversation to generate a suggestion, then choose Add to draft. Ordinary text only: no client patch, background prediction, automatic insertion or sending. Model selection alone does not enable the feature. Requires a running local adapter and recent Codex. Existing user-owned MCP configuration is preserved; native prediction settings are never modified. Future account/remote defaults cannot be universally detected.`,
374
+ "suggestion": `The CCDX prediction panel has been retired. Run ${name} suggestion off to safely remove unchanged CCDX registrations. This cleanup does not change native predictions; see ${name} prediction --help for that separate opt-in feature.`,
375
+ prediction: `Usage:\n ${name} prediction [status|on|off]\n\nOptional native next-message predictions; off by default. macOS only. Quit Codex before on/off, then restart ccdx. On adds private App-profile certificate trust and an App-only route for chatgpt.com and ab.chatgpt.com. HTTPS on those hosts is decrypted locally; only prediction configuration is changed. No client program files, OS trust, account permissions, MCP or Hooks are changed. Static HTTP/HTTPS/SOCKS5 proxies and bypass rules are preserved; dynamic PAC/WPAD and CIDR bypass rules are reported as unsupported. Official prediction configuration is preserved; the conversation's model is used. App updates require another compatibility check. Status distinguishes saved preference and observed config traffic from unverified candidate visibility.`,
367
376
  update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or the latest stable GitHub release. With no source, an interactive terminal prompts for one.`,
368
377
  version: `Usage:\n ${name} --version`,
369
378
  };
@@ -389,7 +398,7 @@ export function cliHelp(commandName = "ccdx", topic = "") {
389
398
  ${name} disable-image
390
399
  ${name} image-status
391
400
  ${name} auto-review-model
392
- ${name} suggestion on|off|model|status
401
+ ${name} prediction [status|on|off]
393
402
  ${name} update [npm|github]
394
403
  ${name} --version
395
404
  ${name} --help
@@ -408,7 +417,7 @@ Commands:
408
417
  disable-image Disable the image provider and remove its credential
409
418
  image-status Show the optional image provider status
410
419
  auto-review-model Select the Codex Auto-review model and reasoning effort
411
- suggestion Optional next-message suggestions (off by default)
420
+ prediction Toggle optional native next-message predictions
412
421
  update Update the global package from npm or GitHub
413
422
 
414
423
  Run ${name} <command> --help for command details.`;
@@ -188,6 +188,11 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
188
188
  }
189
189
  }
190
190
  lines.push(status("info", `Models: ${count(models.models)} total; Copilot token ${copilot.token_cached ? `cached (${duration(copilot.token_expires_in_ms)} remaining)` : "not cached"}`));
191
+ const prediction = data.client_integrations?.prediction;
192
+ if (prediction) {
193
+ lines.push(status("info", `Native predictions: ${prediction.configured ? "on" : "off"}; bridge ${prediction.bridge}; client route ${prediction.client_route}; visible candidate unknown`));
194
+ if (prediction.reason) lines.push(status("warn", terminalCell(prediction.reason).slice(0, 400)));
195
+ }
191
196
  if (data.profiles?.codex) {
192
197
  lines.push(status("info", `Profile: ${profileSummary("Codex", data.profiles.codex)}`));
193
198
  }
@@ -0,0 +1,41 @@
1
+ import { ensureCodexConfig } from "./config.mjs";
2
+ import { readImageProviderConfig } from "./image-provider-config.mjs";
3
+ import { ensureCodexThreadCompression } from "./codex-thread-compression.mjs";
4
+ import { runInBackground } from "./startup.mjs";
5
+ import { createPredictionState } from "./native-prediction-state.mjs";
6
+
7
+ // The CLI composes the services; this module owns optional client integration
8
+ // lifecycle. It never forwards model requests or depends on dashboard rendering.
9
+ export function createClientIntegration({ port, host, info = () => {}, warning = () => {} } = {}) {
10
+ const prediction = createPredictionState();
11
+ let native = null;
12
+ return {
13
+ async configure() {
14
+ try {
15
+ const { retirePredictionIntegration } = await import("./prediction-retirement.mjs");
16
+ const result = retirePredictionIntegration();
17
+ if (result.changed) info("Removed the retired CCDX prediction integration");
18
+ if (result.preserved) warning("Modified prediction settings were preserved");
19
+ } catch (error) { warning(`${error.message}. Core startup will continue.`); }
20
+ const enabled = Boolean(readImageProviderConfig());
21
+ const { imageMcpSkipped } = ensureCodexConfig(port, { host, imageProviderEnabled: enabled });
22
+ if (enabled && !imageMcpSkipped) {
23
+ try {
24
+ const { syncEnabledImageSkill } = await import("./cli-image.mjs");
25
+ syncEnabledImageSkill({ adapterPort: port, adapterHost: host });
26
+ } catch (error) { warning(`Image guidance could not be updated: ${error.message}`); }
27
+ }
28
+ return { compressionSetup: runInBackground(ensureCodexThreadCompression) };
29
+ },
30
+ async start() {
31
+ try {
32
+ const { startNativePrediction } = await import("./native-prediction.mjs");
33
+ native = await startNativePrediction({ state: prediction, warning: info });
34
+ } catch (error) { prediction.unavailable(error); warning(`${error.message}. Core startup will continue.`); }
35
+ return native;
36
+ },
37
+ pacUrl() { return native?.pacUrl || null; },
38
+ snapshot() { return { prediction: prediction.snapshot() }; },
39
+ async close() { await native?.close(); native = null; },
40
+ };
41
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
- import path from "node:path";
3
+ import { codexPaths } from "./codex-paths.mjs";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { parse } from "smol-toml";
6
6
  import { computeUpdatedCodexConfig, initialCodexConfig } from "./config.mjs";
@@ -87,8 +87,8 @@ function checkUrl(value, expected, label) {
87
87
  : { kind: "warn", message: `${label} differs from the CCDX address ${expected}` };
88
88
  }
89
89
 
90
- export function inspectCodexConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
91
- const filePath = path.join(home, ".codex", "config.toml");
90
+ export function inspectCodexConfig({ home = os.homedir(), env: processEnv = process.env, host = "127.0.0.1", port = 2026 } = {}) {
91
+ const filePath = codexPaths({ env: processEnv, home }).config;
92
92
  let content;
93
93
  try {
94
94
  content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath));
@@ -0,0 +1,10 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export function codexPaths({ env = process.env, home = os.homedir() } = {}) {
5
+ const directory = path.resolve(String(env.CODEX_HOME || "").trim() || path.join(home, ".codex"));
6
+ const profile = path.resolve(String(env.CODEX_ELECTRON_USER_DATA_PATH || "").trim()
7
+ || path.join(home, "Library", "Application Support", "Codex"));
8
+ return { directory, config: path.join(directory, "config.toml"), profile,
9
+ certificateStore: path.join(profile, "Default", "ServerCertificate") };
10
+ }
@@ -6,6 +6,8 @@ import { promisify } from "node:util";
6
6
  import { CODEX_APP_BINARY_PATHS } from "./codex-model-catalog.mjs";
7
7
  import { parseCodexToml, codexTomlStatements, validateManagedConfigEdit } from "./config-toml.mjs";
8
8
  import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
9
+ import { codexPaths } from "./codex-paths.mjs";
10
+ import { withFileLockSync } from "./lock.mjs";
9
11
  import { status } from "./status.mjs";
10
12
 
11
13
  const FEATURE = "local_thread_store_compression";
@@ -70,7 +72,8 @@ export async function codexNeedsThreadCompressionDefault({
70
72
  }
71
73
 
72
74
  export async function ensureCodexThreadCompression({
73
- filePath = path.join(os.homedir(), ".codex", "config.toml"),
75
+ env = process.env, home = os.homedir(),
76
+ filePath = codexPaths({ env, home }).config,
74
77
  probe = codexNeedsThreadCompressionDefault,
75
78
  } = {}) {
76
79
  try {
@@ -79,11 +82,13 @@ export async function ensureCodexThreadCompression({
79
82
  if (!await probe()) return;
80
83
  // The client/user may edit the shared file while the asynchronous probe runs.
81
84
  // Recompute against its current bytes; explicit opt-outs always win.
82
- const updated = computeThreadCompressionDefault(read());
83
- if (updated.changed) {
84
- atomicWriteFileIfChangedSync(filePath, updated.content);
85
- console.log(status("ok", "Enabled Codex local history compression (experimental)"));
86
- }
85
+ withFileLockSync(`${filePath}.ccdx.lock`, () => {
86
+ const updated = computeThreadCompressionDefault(read());
87
+ if (updated.changed) {
88
+ atomicWriteFileIfChangedSync(filePath, updated.content);
89
+ console.log(status("ok", "Enabled Codex local history compression (experimental)"));
90
+ }
91
+ });
87
92
  } catch {
88
93
  console.warn(status("warn", "Optional Codex history compression setup was skipped; core proxy remains available"));
89
94
  }
package/src/config.mjs CHANGED
@@ -1,13 +1,12 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
- import os from "node:os";
4
2
  import { status } from "./status.mjs";
5
3
  import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
6
4
  import { adapterBaseUrl } from "./running-adapter.mjs";
7
5
  import { isLoopbackHost } from "./security.mjs";
8
6
  import { codexTomlStatements, configValue, parseCodexToml, validateManagedConfigEdit } from "./config-toml.mjs";
7
+ import { codexPaths } from "./codex-paths.mjs";
8
+ import { withFileLockSync } from "./lock.mjs";
9
9
 
10
- const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
11
10
  const MODEL_CONTEXT_WINDOW = 1_000_000;
12
11
  const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
13
12
  const IMAGE_MCP_START = "# ccdx:image-mcp:start";
@@ -278,8 +277,13 @@ export function computeImageMcpCodexConfig(content, options = {}) {
278
277
  return computeImageConfig(content, options);
279
278
  }
280
279
 
281
- export function ensureCodexConfig(adapterPort = 2026, {
282
- filePath = CONFIG_PATH,
280
+ export function ensureCodexConfig(adapterPort = 2026, options = {}) {
281
+ const filePath = options.filePath || codexPaths(options).config;
282
+ return withFileLockSync(`${filePath}.ccdx.lock`, () => ensureCodexConfigUnlocked(adapterPort, { ...options, filePath }));
283
+ }
284
+
285
+ function ensureCodexConfigUnlocked(adapterPort = 2026, {
286
+ filePath,
283
287
  host = "127.0.0.1",
284
288
  imageProviderEnabled = false,
285
289
  } = {}) {
@@ -303,7 +307,7 @@ export function ensureCodexConfig(adapterPort = 2026, {
303
307
  console.log(status("ok", `Codex already points to ${baseUrl}`));
304
308
  } else {
305
309
  atomicWriteFileIfChangedSync(filePath, core.content);
306
- console.log(status("ok", content === undefined ? "Created ~/.codex/config.toml" : `Configured Codex base URL: ${baseUrl}`));
310
+ console.log(status("ok", content === undefined ? `Created Codex configuration: ${filePath}` : `Configured Codex base URL: ${baseUrl}`));
307
311
  }
308
312
  // Disabled means no image maintenance, including no implicit removal of old
309
313
  // or user-owned entries. Explicit disable-image owns the cleanup operation.
@@ -1,10 +1,12 @@
1
+ import { errorEvidenceText } from "./operation-error.mjs";
2
+
1
3
  function cloneJson(value) {
2
4
  return value === undefined ? undefined : structuredClone(value);
3
5
  }
4
6
 
5
7
  export function isEncryptedContentVerificationError(statusCode, text) {
6
8
  if (statusCode < 400 || !text) return false;
7
- const lower = String(text).toLowerCase();
9
+ const lower = errorEvidenceText(text).toLowerCase();
8
10
  const reasoningFailure = lower.includes("encrypted content")
9
11
  && lower.includes("could not be verified")
10
12
  && (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
@@ -17,7 +19,7 @@ export function isEncryptedContentVerificationError(statusCode, text) {
17
19
 
18
20
  export function isImageNamespaceCollisionError(statusCode, text) {
19
21
  if (statusCode < 400 || !text) return false;
20
- const lower = String(text).toLowerCase();
22
+ const lower = errorEvidenceText(text).toLowerCase();
21
23
  return lower.includes("namespace")
22
24
  && lower.includes("image_gen")
23
25
  && lower.includes("collid");
@@ -50,12 +50,6 @@
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
-
59
53
  <details class="panel dashboard-section" id="request-context">
60
54
  <summary data-i18n>RECENT REQUEST CONTEXT</summary>
61
55
  <p id="context-note" class="table-note">—</p>
@@ -63,6 +57,13 @@
63
57
  <p class="hint" data-i18n>Recent upstream request metadata, not exact live session occupancy. Missing values are unknown. No prompt or image content is retained.</p>
64
58
  </details>
65
59
 
60
+ <section class="panel dashboard-section" aria-label="CLIENT FEATURES" data-i18n-label>
61
+ <div class="panel-heading"><h2 data-i18n>CLIENT FEATURES</h2><span data-i18n>OBSERVED STATE</span></div>
62
+ <dl class="stats"><div><dt data-i18n>Native predictions</dt><dd id="prediction-preference">—</dd></div><div><dt data-i18n>Local bridge</dt><dd id="prediction-bridge">—</dd></div><div><dt data-i18n>Client route</dt><dd id="prediction-route">—</dd></div><div><dt data-i18n>Visible candidate</dt><dd id="prediction-candidate">—</dd></div></dl>
63
+ <p id="prediction-note" class="hint">—</p>
64
+ <p class="hint" data-i18n>Configured does not mean displayed. Use ccdx prediction status for details.</p>
65
+ </section>
66
+
66
67
  <section class="panel dashboard-section" aria-label="Live upstream models" data-i18n-label>
67
68
  <div class="panel-heading"><h2 data-i18n>UPSTREAM GPT MODELS</h2><div class="heading-actions"><span id="models-state" data-i18n>CHECKING LIVE</span><button id="refresh-models" type="button" data-i18n>Refresh</button></div></div>
68
69
  <p id="models-note" class="table-note" data-i18n>Querying GitHub Copilot…</p>
@@ -1,26 +1,18 @@
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
+ "CLIENT FEATURES": ["CLIENT FEATURES", "接入能力"],
4
+ "OBSERVED STATE": ["OBSERVED STATE", "实际状态"],
5
+ "Native predictions": ["Native predictions", "输入建议"],
6
+ "Local bridge": ["Local bridge", "本地接入"],
7
+ "Client route": ["Client route", "配置请求"],
8
+ "Visible candidate": ["Visible candidate", "显示结果"],
9
+ "Configured does not mean displayed. Use ccdx prediction status for details.": ["Enabled is not proof of a visible suggestion. Check ccdx prediction status.", "开启不等于已显示。\n终端命令可查看详情。"],
10
+ "Enabled": ["Enabled", "已开启"],
11
+ "Disabled": ["Disabled", "已关闭"],
12
+ "Running": ["Running", "运行中"],
13
+ "Stopped": ["Stopped", "未运行"],
14
+ "Unavailable": ["Unavailable", "不可用"],
15
+ "Observed": ["Observed", "已收到配置请求"],
24
16
  "First output": ["First output", "首次输出"],
25
17
  "Token time": ["Token time", "输出耗时"],
26
18
  "Gateway timing. Token time is estimated.": ["Observed at the gateway. Token time is an estimate.", "网关观测时间。\n每Token耗时为估算值。"],
@@ -16,7 +16,6 @@ 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;
20
19
  let contextRequests = [];
21
20
  let contextCompactions = {};
22
21
 
@@ -41,23 +40,6 @@ async function loadAuth() {
41
40
  } finally { authLoading = false; }
42
41
  }
43
42
 
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
-
61
43
  function duration(value) {
62
44
  if (!finite(value)) return "—";
63
45
  const seconds = Math.max(0, Math.round(Number(value) / 1000));
@@ -186,6 +168,15 @@ function render(data) {
186
168
 
187
169
  renderFailures(data.response_failures?.recent, data.stream_performance?.recent_requests);
188
170
  renderContext(data.stream_performance);
171
+ if (element("prediction-preference")) {
172
+ const prediction = data.client_integrations?.prediction;
173
+ text("prediction-preference", prediction ? prediction.configured ? "Enabled" : "Disabled" : "Unknown");
174
+ text("prediction-bridge", ({ running: "Running", stopped: "Stopped", unavailable: "Unavailable" })[prediction?.bridge] || "Unknown");
175
+ text("prediction-route", prediction?.client_route === "observed" ? "Observed" : "Unknown");
176
+ text("prediction-candidate", "Unknown");
177
+ const note = element("prediction-note");
178
+ if (note) note.textContent = prediction?.reason || "";
179
+ }
189
180
  text("updated", "Snapshot {time}", { time: new Date().toLocaleTimeString() });
190
181
  }
191
182
 
@@ -241,11 +232,10 @@ async function refresh() {
241
232
  }
242
233
  }
243
234
 
244
- element("refresh").addEventListener("click", () => { refresh(); loadAuth(); loadSuggestion(); });
235
+ element("refresh").addEventListener("click", () => { refresh(); loadAuth(); });
245
236
  element("request-context").addEventListener("toggle", () => { if (element("request-context").open) renderContext(); });
246
237
  refresh();
247
238
  loadAuth();
248
- loadSuggestion();
249
239
 
250
240
  const animationRows = new Map();
251
241
  let animationTimelines = [];
@@ -15,14 +15,11 @@ 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";
20
18
 
21
19
  const ANIMATION_PATH = "/_ccdx/ui/animation";
22
20
  const MODELS_PATH = "/_ccdx/ui/models/live";
23
21
  const USAGE_PATH = "/_ccdx/ui/usage";
24
22
  const AUTH_PATH = "/_ccdx/ui/auth";
25
- const SUGGESTION_PATH = "/_ccdx/ui/suggestion";
26
23
  const DISABLED_ANIMATION = /^(0|false|no|off)$/i;
27
24
 
28
25
  function sendJson(res, statusCode, value) {
@@ -149,15 +146,6 @@ export async function handleDashboardApi(req, res, pathname, {
149
146
  }
150
147
  return;
151
148
  }
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
- }
161
149
 
162
150
  if (pathname === ANIMATION_PATH && req.method === "GET") {
163
151
  try {
package/src/launcher.mjs CHANGED
@@ -36,7 +36,7 @@ export function launchAttempts() {
36
36
  return [["-b", CODEX_BUNDLE_ID], ...APP_PATH_CANDIDATES.map((path) => [path])];
37
37
  }
38
38
 
39
- export async function openCodex({ env = process.env, spawnImpl = spawn, platform = process.platform } = {}) {
39
+ export async function openCodex({ env = process.env, spawnImpl = spawn, platform = process.platform, predictionPacUrl = null, predictionApp = null } = {}) {
40
40
  if (platform !== "darwin") {
41
41
  console.log(status("warn", "Auto-launch is only supported on macOS"));
42
42
  console.log(status("info", "Open Codex App manually; it will connect to the adapter."));
@@ -48,7 +48,12 @@ export async function openCodex({ env = process.env, spawnImpl = spawn, platform
48
48
  return false;
49
49
  }
50
50
 
51
- for (const args of launchAttempts()) {
51
+ const extraArgs = typeof predictionPacUrl === "string" && /^http:\/\/127\.0\.0\.1:\d{1,5}\/proxy\.pac$/.test(predictionPacUrl)
52
+ && APP_PATH_CANDIDATES.includes(predictionApp)
53
+ ? ["--args", `--proxy-pac-url=${predictionPacUrl}`] : [];
54
+ const attempts = extraArgs.length ? [[predictionApp]] : launchAttempts();
55
+ for (const attempt of attempts) {
56
+ const args = [...attempt, ...extraArgs];
52
57
  if (await tryOpen(args, { spawnImpl })) {
53
58
  console.log(status("ok", "Codex app launched"));
54
59
  return true;
package/src/lock.mjs CHANGED
@@ -6,6 +6,35 @@ function sleep(ms) {
6
6
  return new Promise((resolve) => setTimeout(resolve, ms));
7
7
  }
8
8
 
9
+ // Synchronous configuration writers share the same cross-process lock format.
10
+ // Readers never take the lock. A live lock is never stolen on timeout.
11
+ export function withFileLockSync(lockPath, fn, { timeoutMs = 1000, staleMs = 60_000 } = {}) {
12
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
13
+ const started = Date.now(), owner = randomUUID();
14
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
15
+ while (true) {
16
+ if (lockPathExists(reclaimGuardPath(lockPath), fs)) {
17
+ if (Date.now() - started >= timeoutMs) throw new Error("Configuration lock recovery is in progress; retry shortly");
18
+ Atomics.wait(sleeper, 0, 0, 10);
19
+ continue;
20
+ }
21
+ try {
22
+ const fd = writeLockRecord(lockPath, { pid: process.pid, owner, created_at: new Date().toISOString() }, fs);
23
+ fs.closeSync(fd);
24
+ if (lockPathExists(reclaimGuardPath(lockPath), fs)) { removeOwnedLock(lockPath, owner, fs); continue; }
25
+ break;
26
+ } catch (error) {
27
+ if (error.code !== "EEXIST") throw error;
28
+ if (maybeRemoveStaleLock(lockPath, staleMs, Date.now(), fs, processIsAlive)) continue;
29
+ let sameProcess = false;
30
+ try { sameProcess = readLockSnapshot(lockPath).pid === process.pid; } catch {}
31
+ if (sameProcess || Date.now() - started >= timeoutMs) throw new Error("Configuration is being updated by another operation; retry shortly");
32
+ Atomics.wait(sleeper, 0, 0, 10);
33
+ }
34
+ }
35
+ try { return fn(); } finally { removeOwnedLock(lockPath, owner, fs); }
36
+ }
37
+
9
38
  function readLockSnapshot(lockPath) {
10
39
  const stat = fs.statSync(lockPath);
11
40
  let record = {};
@@ -0,0 +1,92 @@
1
+ export const NATIVE_PREDICTION_GATE = "4290031608";
2
+
3
+ const LOCAL_PREDICTION_CONFIG = Object.freeze({
4
+ is_enabled: true,
5
+ prompt: 'Predict one useful next message the user may send, based on the conversation. Write from the user\'s perspective and use the user\'s language. Do not answer the message, call tools, or invent new requests, consent, or permission. Prefer a short, natural follow-up. If no useful follow-up is apparent, use an empty string. Return only a JSON object with exactly one key: {"suggestion":"the predicted user message"}.',
6
+ prompt_version: "ccdx-native-prediction-v1",
7
+ prediction_reasoning_effort: "low",
8
+ });
9
+
10
+ function isObject(value) {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value);
12
+ }
13
+
14
+ function parseBody(body) {
15
+ if (typeof body !== "string" && !Buffer.isBuffer(body)) return null;
16
+ try { return JSON.parse(body.toString()); } catch { return null; }
17
+ }
18
+
19
+ function encodeBody(value, original) {
20
+ const text = JSON.stringify(value);
21
+ return Buffer.isBuffer(original) ? Buffer.from(text) : text;
22
+ }
23
+
24
+ export function classifyNativePredictionPath({ hostname, pathname } = {}) {
25
+ const host = typeof hostname === "string" ? hostname.toLowerCase() : "";
26
+ if (host === "chatgpt.com") {
27
+ if (pathname === "/backend-api/wham/statsig/bootstrap") return "bootstrap";
28
+ if (pathname === "/backend-api/wham/predictions/config") return "config";
29
+ }
30
+ if (host === "ab.chatgpt.com" && pathname === "/v1/initialize") return "refresh";
31
+ return null;
32
+ }
33
+
34
+ function enablePredictionGate(payload) {
35
+ if (!isObject(payload) || (payload.response_mode === "live_overlay" ? payload.has_updates === false : payload.has_updates !== true) ||
36
+ (payload.is_delta !== undefined && payload.is_delta !== false) ||
37
+ (payload.response_mode !== undefined && !["full", "live_overlay"].includes(payload.response_mode)) ||
38
+ !isObject(payload.feature_gates)) return false;
39
+ // Statsig V1 first resolves the literal name, then its djb2 lookup key.
40
+ let hash = 0;
41
+ for (const char of NATIVE_PREDICTION_GATE) hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0;
42
+ const hashedKey = String(hash >>> 0);
43
+ const key = Object.hasOwn(payload.feature_gates, NATIVE_PREDICTION_GATE) ? NATIVE_PREDICTION_GATE
44
+ : Object.hasOwn(payload.feature_gates, hashedKey) ? hashedKey : NATIVE_PREDICTION_GATE;
45
+ const gate = payload.feature_gates[key];
46
+ if (gate !== undefined && (!isObject(gate) || typeof gate.value !== "boolean" || gate.value === true)) return false;
47
+ if (payload.response_mode === "live_overlay" && !Array.isArray(payload.live_entity_names?.feature_gates)) return false;
48
+ if (gate) gate.value = true;
49
+ else payload.feature_gates[key] = { name: key, value: true, rule_id: "ccdx-local-compatibility", secondary_exposures: [] };
50
+ if (payload.response_mode === "live_overlay" && !payload.live_entity_names.feature_gates.includes(key)) {
51
+ payload.live_entity_names.feature_gates.push(key);
52
+ }
53
+ return true;
54
+ }
55
+
56
+ // No-op results retain the original bytes; callers own HTTP headers and decoding.
57
+ export function applyNativePredictionOverlay({ hostname, pathname, statusCode, body, enabled = false } = {}) {
58
+ const kind = classifyNativePredictionPath({ hostname, pathname });
59
+ const result = { body, statusCode, changed: false, official: false, kind };
60
+ if (!enabled || !kind) return result;
61
+ if (kind === "config" && statusCode === 404) {
62
+ return { ...result, statusCode: 200, body: encodeBody(LOCAL_PREDICTION_CONFIG, body), changed: true };
63
+ }
64
+ if (statusCode !== 200) return result;
65
+ const parsed = parseBody(body);
66
+ if (!isObject(parsed)) return result;
67
+ if (kind === "config") {
68
+ if (parsed.is_enabled === true) return { ...result, official: true };
69
+ if (parsed.is_enabled !== false || typeof parsed.prompt !== "string" || !parsed.prompt.trim()) return result;
70
+ parsed.is_enabled = true;
71
+ } else if (kind === "bootstrap") {
72
+ const stringPayload = typeof parsed.statsigPayload === "string";
73
+ const payload = stringPayload ? parseBody(parsed.statsigPayload) : parsed.statsigPayload;
74
+ if (!enablePredictionGate(payload)) return result;
75
+ if (stringPayload) parsed.statsigPayload = JSON.stringify(payload);
76
+ } else if (!enablePredictionGate(parsed)) return result;
77
+ return { ...result, body: encodeBody(parsed, body), changed: true };
78
+ }
79
+
80
+ // Overlaying a delta would invalidate its checksum. Ask for a full response instead.
81
+ export function normalizeStatsigRequest({ hostname, pathname, body, enabled = false } = {}) {
82
+ const result = { body, changed: false };
83
+ if (!enabled || classifyNativePredictionPath({ hostname, pathname }) !== "refresh") return result;
84
+ const parsed = parseBody(body);
85
+ if (!isObject(parsed) || !isObject(parsed.user) ||
86
+ (parsed.responseMode !== undefined && !["full", "live_overlay"].includes(parsed.responseMode))) return result;
87
+ if (parsed.deltasResponseRequested === false && parsed.full_checksum === null && parsed.sinceTime === 0) return result;
88
+ parsed.deltasResponseRequested = false;
89
+ parsed.full_checksum = null;
90
+ parsed.sinceTime = 0;
91
+ return { body: encodeBody(parsed, body), changed: true };
92
+ }