@bitkyc08/opencodex 2.6.31-preview.20260707 → 2.7.0

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 (59) hide show
  1. package/README.ko.md +19 -3
  2. package/README.md +17 -2
  3. package/README.zh-CN.md +16 -3
  4. package/gui/dist/assets/index-BGdxwydf.js +34 -0
  5. package/gui/dist/assets/index-DANCQ2Jt.css +1 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +62 -1
  9. package/src/adapters/cursor/cursor-errors.ts +28 -1
  10. package/src/adapters/cursor/discovery.ts +56 -10
  11. package/src/adapters/cursor/effort-map.ts +35 -7
  12. package/src/adapters/cursor/live-models.ts +3 -0
  13. package/src/adapters/cursor/live-transport.ts +136 -7
  14. package/src/adapters/cursor/protobuf-request.ts +24 -1
  15. package/src/adapters/cursor/request-builder.ts +6 -5
  16. package/src/adapters/cursor/transport-retry.ts +22 -3
  17. package/src/adapters/cursor.ts +2 -1
  18. package/src/adapters/kiro.ts +1 -1
  19. package/src/adapters/openai-chat.ts +75 -26
  20. package/src/bridge.ts +50 -4
  21. package/src/cli/debug.ts +203 -0
  22. package/src/cli/doctor.ts +11 -0
  23. package/src/cli/help.ts +11 -0
  24. package/src/cli/index.ts +10 -0
  25. package/src/cli/v2.ts +131 -0
  26. package/src/codex/account-store.ts +42 -1
  27. package/src/codex/auth-api.ts +43 -0
  28. package/src/codex/catalog.ts +356 -29
  29. package/src/codex/data/upstream-models.json +830 -0
  30. package/src/codex/features.ts +178 -0
  31. package/src/codex/project-config-warnings.ts +388 -0
  32. package/src/codex/sync.ts +8 -0
  33. package/src/codex/warmup.ts +193 -0
  34. package/src/config.ts +7 -5
  35. package/src/lib/debug-log-buffer.ts +42 -0
  36. package/src/lib/debug-settings.ts +84 -0
  37. package/src/lib/debug.ts +18 -9
  38. package/src/lib/errors.ts +104 -1
  39. package/src/oauth/cursor.ts +35 -12
  40. package/src/oauth/store.ts +4 -3
  41. package/src/oauth/token-guardian.ts +32 -7
  42. package/src/providers/derive.ts +8 -0
  43. package/src/providers/kiro-models.ts +3 -3
  44. package/src/providers/registry.ts +77 -56
  45. package/src/reasoning-effort.ts +34 -12
  46. package/src/responses/parser.ts +7 -2
  47. package/src/router.ts +7 -3
  48. package/src/server/adapter-resolve.ts +1 -1
  49. package/src/server/index.ts +27 -3
  50. package/src/server/management-api.ts +168 -7
  51. package/src/server/relay.ts +2 -2
  52. package/src/server/request-log.ts +86 -2
  53. package/src/server/responses.ts +209 -0
  54. package/src/types.ts +38 -2
  55. package/src/usage/debug.ts +32 -5
  56. package/src/usage/summary.ts +6 -6
  57. package/src/web-search/index.ts +1 -1
  58. package/gui/dist/assets/index-ByGC8-Bm.css +0 -1
  59. package/gui/dist/assets/index-CWujz83O.js +0 -15
package/src/bridge.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AdapterEvent, OcxUsage } from "./types";
2
- import { classifyError, type OcxErrorPayload } from "./lib/errors";
2
+ import { adapterFailureFromMessage, classifyError, type OcxErrorPayload } from "./lib/errors";
3
3
  import { encodeCompactionSummary } from "./responses/compaction";
4
4
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
5
5
  import { usageDisplayTotalTokens, usageInputTokensWithCacheDetail } from "./usage/totals";
@@ -20,8 +20,15 @@ function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
20
20
  output_tokens: usage.outputTokens,
21
21
  total_tokens: usageDisplayTotalTokens(usage) ?? inputTokens + usage.outputTokens,
22
22
  };
23
+ const inputDetails: Record<string, number> = {};
23
24
  if (usage.cachedInputTokens !== undefined) {
24
- out.input_tokens_details = { cached_tokens: usage.cachedInputTokens };
25
+ inputDetails.cached_tokens = usage.cachedInputTokens;
26
+ }
27
+ if (usage.cacheCreationInputTokens !== undefined) {
28
+ inputDetails.cache_write_tokens = usage.cacheCreationInputTokens;
29
+ }
30
+ if (Object.keys(inputDetails).length > 0) {
31
+ out.input_tokens_details = inputDetails;
25
32
  }
26
33
  if (usage.reasoningOutputTokens !== undefined) {
27
34
  out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens };
@@ -33,6 +40,8 @@ function responseError(status: number, type: string, message: string): OcxErrorP
33
40
  return classifyError(status, type, message);
34
41
  }
35
42
 
43
+ export { adapterFailureFromMessage } from "./lib/errors";
44
+
36
45
  /**
37
46
  * Build the native `WebSearchAction::Search` payload from the queries that ran. codex-rs prefers a
38
47
  * non-empty `query` over `queries` for the cell label, and only renders "<first> ..." when `query`
@@ -177,6 +186,7 @@ export function bridgeToResponsesSSE(
177
186
  if (currentMsg) closeCurrentMessage();
178
187
  if (currentReasoning) closeCurrentReasoning();
179
188
  if (currentRawReasoning) closeCurrentRawReasoning();
189
+ flushHiddenRawReasoning();
180
190
  if (currentToolCall) closeCurrentToolCall();
181
191
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
182
192
  emit("response.incomplete", {
@@ -229,6 +239,23 @@ export function bridgeToResponsesSSE(
229
239
  finishedItems.push(item as OutputItem);
230
240
  outputIndex++;
231
241
  };
242
+ // hideThinkingSummary for RAW reasoning (openai-chat reasoning_content, kiro tags): no
243
+ // visible reasoning item is emitted — the app renders nothing, so tool cells keep grouping
244
+ // like native models — but the text still round-trips in a txt-only ocxr1 envelope so
245
+ // preserveReasoningContentModels replay (GLM interleaved thinking) keeps working. Direct
246
+ // encodeReasoningEnvelope: takeReasoningEnvelope's sig/red guard would drop txt-only.
247
+ let hiddenRawReasoningText = "";
248
+ const flushHiddenRawReasoning = () => {
249
+ if (!hiddenRawReasoningText) return;
250
+ const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText });
251
+ hiddenRawReasoningText = "";
252
+ const itemId = `rs_${uuid()}`;
253
+ const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
254
+ emit("response.output_item.added", { output_index: outputIndex, item });
255
+ emit("response.output_item.done", { output_index: outputIndex, item });
256
+ finishedItems.push(item as OutputItem);
257
+ outputIndex++;
258
+ };
232
259
  // Full assistant text of a compaction turn (across message boundaries) — becomes the
233
260
  // synthetic compaction item's payload on done.
234
261
  let compactionText = "";
@@ -385,6 +412,7 @@ export function bridgeToResponsesSSE(
385
412
  case "text_delta": {
386
413
  if (currentReasoning) closeCurrentReasoning();
387
414
  if (currentRawReasoning) closeCurrentRawReasoning();
415
+ flushHiddenRawReasoning();
388
416
  if (currentToolCall) closeCurrentToolCall();
389
417
  if (!currentMsg) {
390
418
  const itemId = `msg_${uuid()}`;
@@ -410,6 +438,7 @@ export function bridgeToResponsesSSE(
410
438
  if (options?.hideThinkingSummary) { hiddenThinkingText += event.thinking; break; }
411
439
  if (currentMsg) closeCurrentMessage();
412
440
  if (currentRawReasoning) closeCurrentRawReasoning();
441
+ flushHiddenRawReasoning();
413
442
  if (currentToolCall) closeCurrentToolCall();
414
443
  if (!currentReasoning) {
415
444
  const itemId = `rs_${uuid()}`;
@@ -441,6 +470,7 @@ export function bridgeToResponsesSSE(
441
470
  break;
442
471
  }
443
472
  case "reasoning_raw_delta": {
473
+ if (options?.hideThinkingSummary) { hiddenRawReasoningText += event.text; break; }
444
474
  if (currentMsg) closeCurrentMessage();
445
475
  if (currentReasoning) closeCurrentReasoning();
446
476
  if (currentToolCall) closeCurrentToolCall();
@@ -461,6 +491,7 @@ export function bridgeToResponsesSSE(
461
491
  if (currentMsg) closeCurrentMessage();
462
492
  if (currentReasoning) closeCurrentReasoning();
463
493
  if (currentRawReasoning) closeCurrentRawReasoning();
494
+ flushHiddenRawReasoning();
464
495
  if (currentToolCall) closeCurrentToolCall();
465
496
  const itemId = `fc_${uuid()}`;
466
497
  const mapped = toolNsMap?.get(event.name);
@@ -515,6 +546,7 @@ export function bridgeToResponsesSSE(
515
546
  if (currentMsg) closeCurrentMessage();
516
547
  if (currentReasoning) closeCurrentReasoning();
517
548
  if (currentRawReasoning) closeCurrentRawReasoning();
549
+ flushHiddenRawReasoning();
518
550
  if (currentToolCall) closeCurrentToolCall();
519
551
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
520
552
  emit("response.output_item.added", {
@@ -549,6 +581,7 @@ export function bridgeToResponsesSSE(
549
581
  if (currentMsg) closeCurrentMessage();
550
582
  if (currentReasoning) closeCurrentReasoning();
551
583
  if (currentRawReasoning) closeCurrentRawReasoning();
584
+ flushHiddenRawReasoning();
552
585
  if (currentToolCall) closeCurrentToolCall();
553
586
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
554
587
  // Redacted-only turns (or hidden thinking without a trailing signature event) still
@@ -577,16 +610,18 @@ export function bridgeToResponsesSSE(
577
610
  if (currentMsg) closeCurrentMessage();
578
611
  if (currentReasoning) closeCurrentReasoning();
579
612
  if (currentRawReasoning) closeCurrentRawReasoning();
613
+ flushHiddenRawReasoning();
580
614
  if (currentToolCall) closeCurrentToolCall();
581
615
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
616
+ const failure = adapterFailureFromMessage(event.message);
582
617
  emit("response.failed", {
583
618
  response: {
584
619
  ...responseSnapshot("failed", finishedItems),
585
620
  // Partial consumption from a mid-stream upstream failure: surfaced so the request
586
621
  // log can record real tokens instead of usageStatus "unreported" with 0.
587
622
  ...(event.usage ? { usage: responsesUsage(event.usage) } : {}),
588
- error: responseError(502, "upstream_error", event.message),
589
- last_error: responseError(502, "upstream_error", event.message),
623
+ error: failure.error,
624
+ last_error: failure.error,
590
625
  },
591
626
  });
592
627
  reportTerminal("failed");
@@ -596,6 +631,7 @@ export function bridgeToResponsesSSE(
596
631
  }
597
632
  }
598
633
  } catch (err) {
634
+ flushHiddenRawReasoning();
599
635
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
600
636
  emit("response.failed", {
601
637
  response: {
@@ -616,6 +652,7 @@ export function bridgeToResponsesSSE(
616
652
  if (currentMsg) closeCurrentMessage();
617
653
  if (currentReasoning) closeCurrentReasoning();
618
654
  if (currentRawReasoning) closeCurrentRawReasoning();
655
+ flushHiddenRawReasoning();
619
656
  if (currentToolCall) closeCurrentToolCall();
620
657
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
621
658
  emit("response.incomplete", {
@@ -716,6 +753,15 @@ export function buildResponseJSON(
716
753
  };
717
754
  const flushRawReasoning = () => {
718
755
  if (!currentRawReasoning) return;
756
+ if (options?.hideThinkingSummary === true) {
757
+ // Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip.
758
+ output.push({
759
+ type: "reasoning", id: `rs_${uuid()}`, summary: [],
760
+ encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }),
761
+ });
762
+ currentRawReasoning = "";
763
+ return;
764
+ }
719
765
  output.push({
720
766
  type: "reasoning", id: `rs_${uuid()}`, summary: [],
721
767
  content: [{ type: "reasoning_text", text: currentRawReasoning }],
@@ -0,0 +1,203 @@
1
+ import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
2
+ import { DEBUG_ENV, type DebugSettingsView } from "../lib/debug-settings";
3
+ import { runningProxyUpdateHeaders } from "../oauth/login-cli";
4
+
5
+ type DebugScope = "provider" | "usage";
6
+
7
+ async function requireLiveProxy() {
8
+ const live = await findLiveProxy();
9
+ if (!live) {
10
+ console.error("Proxy is not running. Start it with: ocx start");
11
+ process.exit(1);
12
+ }
13
+ return live;
14
+ }
15
+
16
+ async function fetchDebugSettings(): Promise<DebugSettingsView> {
17
+ const live = await requireLiveProxy();
18
+ try {
19
+ const res = await fetch(`http://${probeHostname(live.hostname)}:${live.port}/api/debug`, {
20
+ headers: runningProxyUpdateHeaders(),
21
+ });
22
+ if (!res.ok) {
23
+ console.error(`Failed to read debug settings (${res.status})`);
24
+ process.exit(1);
25
+ }
26
+ return await res.json() as DebugSettingsView;
27
+ } catch (err) {
28
+ console.error(`Proxy is running but /api/debug is unreachable: ${err instanceof Error ? err.message : String(err)}`);
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ async function putDebugSettings(body: Record<string, unknown>): Promise<DebugSettingsView> {
34
+ const live = await requireLiveProxy();
35
+ const res = await fetch(`http://${probeHostname(live.hostname)}:${live.port}/api/debug`, {
36
+ method: "PUT",
37
+ headers: runningProxyUpdateHeaders(),
38
+ body: JSON.stringify(body),
39
+ });
40
+ if (!res.ok) {
41
+ const text = await res.text().catch(() => "");
42
+ console.error(`Failed to update debug settings (${res.status})${text ? `: ${text.slice(0, 200)}` : ""}`);
43
+ process.exit(1);
44
+ }
45
+ return await res.json() as DebugSettingsView;
46
+ }
47
+
48
+ function printScopeStatus(scope: DebugScope, view: DebugSettingsView): void {
49
+ if (scope === "provider") {
50
+ console.log(`Provider debug: ${view.enabled ? "ON" : "off"}`);
51
+ console.log(` env=${view.env.debug ? "on" : "off"}, runtime=${view.runtimeOverride.debug === undefined ? "env/default" : view.runtimeOverride.debug ? "on" : "off"}`);
52
+ console.log(" Tail: ocx debug provider logs [-f]");
53
+ } else {
54
+ console.log(`Usage debug: ${view.usage ? "ON" : "off"}`);
55
+ console.log(` env=${view.env.usage ? "on" : "off"}, runtime=${view.runtimeOverride.usage === undefined ? "env/default" : view.runtimeOverride.usage ? "on" : "off"}`);
56
+ console.log(" Tail: ocx debug usage logs [-f] (via running proxy API)");
57
+ }
58
+ }
59
+
60
+ function envDebugEnabled(): boolean {
61
+ return process.env.OCX_DEBUG === "1"
62
+ || process.env.OCX_DEBUG_FRAMES === "1";
63
+ }
64
+
65
+ async function printProviderLogs(follow: boolean): Promise<void> {
66
+ const live = await requireLiveProxy();
67
+ const base = `http://${probeHostname(live.hostname)}:${live.port}/api/debug/logs`;
68
+
69
+ let after = 0;
70
+ try {
71
+ const res = await fetch(`${base}?limit=500`, { headers: runningProxyUpdateHeaders() });
72
+ if (!res.ok) {
73
+ console.error(`Failed to read debug logs (${res.status})`);
74
+ process.exit(1);
75
+ }
76
+ const entries = await res.json() as { seq: number; line: string }[];
77
+ for (const entry of entries) console.log(entry.line);
78
+ if (entries.length > 0) after = entries[entries.length - 1]!.seq;
79
+ } catch (err) {
80
+ console.error(`Failed to read debug logs: ${err instanceof Error ? err.message : String(err)}`);
81
+ process.exit(1);
82
+ }
83
+
84
+ if (!follow) return;
85
+
86
+ while (true) {
87
+ await new Promise(resolve => setTimeout(resolve, 1000));
88
+ try {
89
+ const res = await fetch(`${base}?after=${after}&limit=500`, { headers: runningProxyUpdateHeaders() });
90
+ if (!res.ok) continue;
91
+ const entries = await res.json() as { seq: number; line: string }[];
92
+ for (const entry of entries) console.log(entry.line);
93
+ if (entries.length > 0) after = entries[entries.length - 1]!.seq;
94
+ } catch {
95
+ /* keep following */
96
+ }
97
+ }
98
+ }
99
+
100
+ async function printUsageLogs(follow: boolean): Promise<void> {
101
+ const live = await requireLiveProxy();
102
+ const base = `http://${probeHostname(live.hostname)}:${live.port}/api/debug/usage-logs`;
103
+
104
+ let after = 0;
105
+ try {
106
+ const res = await fetch(`${base}?limit=500`, { headers: runningProxyUpdateHeaders() });
107
+ if (!res.ok) {
108
+ console.error(`Failed to read usage debug logs (${res.status})`);
109
+ process.exit(1);
110
+ }
111
+ const entries = await res.json() as { seq: number; line: string }[];
112
+ for (const entry of entries) console.log(entry.line);
113
+ if (entries.length === 0) console.log("(empty — enable with: ocx debug usage on)");
114
+ if (entries.length > 0) after = entries[entries.length - 1]!.seq;
115
+ } catch (err) {
116
+ console.error(`Failed to read usage debug logs: ${err instanceof Error ? err.message : String(err)}`);
117
+ process.exit(1);
118
+ }
119
+
120
+ if (!follow) return;
121
+
122
+ while (true) {
123
+ await new Promise(resolve => setTimeout(resolve, 1000));
124
+ try {
125
+ const res = await fetch(`${base}?after=${after}&limit=500`, { headers: runningProxyUpdateHeaders() });
126
+ if (!res.ok) continue;
127
+ const entries = await res.json() as { seq: number; line: string }[];
128
+ for (const entry of entries) console.log(entry.line);
129
+ if (entries.length > 0) after = entries[entries.length - 1]!.seq;
130
+ } catch {
131
+ /* keep following */
132
+ }
133
+ }
134
+ }
135
+
136
+ async function handleScopeCommand(scope: DebugScope, actionArgv: string[]): Promise<void> {
137
+ const action = (actionArgv[0] ?? "status").trim().toLowerCase();
138
+
139
+ if (action === "on" || action === "off") {
140
+ const enabled = action === "on";
141
+ const body = scope === "provider" ? { debug: enabled } : { usage: enabled };
142
+ printScopeStatus(scope, await putDebugSettings(body));
143
+ console.log(`\n${scope} debug is now ${enabled ? "enabled" : "disabled"}.`);
144
+ return;
145
+ }
146
+
147
+ if (action === "status") {
148
+ printScopeStatus(scope, await fetchDebugSettings());
149
+ return;
150
+ }
151
+
152
+ if (action === "reset") {
153
+ const resetKey = scope === "provider" ? "provider" : "usage";
154
+ printScopeStatus(scope, await putDebugSettings({ reset: resetKey }));
155
+ console.log(`\nRuntime override cleared for ${scope}; effective value follows env again.`);
156
+ return;
157
+ }
158
+
159
+ if (action === "logs") {
160
+ const follow = actionArgv.slice(1).some(arg => arg === "-f" || arg === "--follow");
161
+ if (scope === "provider") await printProviderLogs(follow);
162
+ else await printUsageLogs(follow);
163
+ return;
164
+ }
165
+
166
+ console.error(`Usage: ocx debug ${scope} on|off|status|reset|logs [-f]`);
167
+ process.exit(1);
168
+ }
169
+
170
+ function printTopLevelHelp(): void {
171
+ console.log("Debug commands (proxy must be running):");
172
+ console.log("");
173
+ console.log(" ocx debug provider on|off|status|reset|logs [-f]");
174
+ console.log(" ocx debug usage on|off|status|reset|logs [-f]");
175
+ console.log("");
176
+ console.log("Env defaults on start:");
177
+ console.log(" provider → OCX_DEBUG=1 (legacy OCX_DEBUG_FRAMES still works)");
178
+ console.log(` usage → ${DEBUG_ENV.usage}=1`);
179
+ }
180
+
181
+ export async function handleDebugCommand(argv: string[]): Promise<void> {
182
+ const sub = (argv[0] ?? "").trim().toLowerCase();
183
+
184
+ if (sub === "provider" || sub === "usage") {
185
+ await handleScopeCommand(sub, argv.slice(1));
186
+ return;
187
+ }
188
+
189
+ if (sub === "" || sub === "help" || sub === "--help" || sub === "-h") {
190
+ const live = await findLiveProxy();
191
+ if (!live) {
192
+ console.log("Proxy is not running — env defaults for the next start:");
193
+ console.log(` provider → OCX_DEBUG = ${envDebugEnabled() ? "on" : "off"}`);
194
+ console.log(` usage → ${DEBUG_ENV.usage} = ${process.env[DEBUG_ENV.usage] === "1" ? "on" : "off"}`);
195
+ console.log("");
196
+ }
197
+ printTopLevelHelp();
198
+ return;
199
+ }
200
+
201
+ printTopLevelHelp();
202
+ process.exit(1);
203
+ }
package/src/cli/doctor.ts CHANGED
@@ -15,6 +15,7 @@ import { readCodexTokens } from "../codex/auth-collision";
15
15
  import { resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
16
16
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
17
17
  import { countPendingOpencodexHistory } from "../codex/history-provider";
18
+ import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings";
18
19
  export { resolveCodexHomeDir } from "../codex/home";
19
20
 
20
21
  const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
@@ -373,6 +374,16 @@ export async function runDoctor(): Promise<void> {
373
374
  console.log(` -- ${pending.pendingRows} thread(s) still tagged opencodex, ${pending.backupEntries} backup manifest entr${pending.backupEntries === 1 ? "y" : "ies"}`);
374
375
  }
375
376
 
377
+ console.log("\nProject Codex configs");
378
+ const projectWarnings = collectProjectCodexConfigWarnings();
379
+ if (projectWarnings.length === 0) {
380
+ console.log(" ok no project-local provider bypass detected");
381
+ } else {
382
+ for (const line of formatProjectCodexConfigWarningsForDoctor(projectWarnings)) {
383
+ console.log(line);
384
+ }
385
+ }
386
+
376
387
  const dual = collectWslDualInstall();
377
388
  if (dual.wsl) {
378
389
  console.log("\nWSL Codex installs");
package/src/cli/help.ts CHANGED
@@ -54,6 +54,15 @@ const helpEntries: Record<string, HelpEntry> = {
54
54
  "sync-cache": { usage: "ocx sync-cache", summary: "Refresh Codex's model cache from the active catalog." },
55
55
  status: { usage: "ocx status", summary: "Check proxy server status." },
56
56
  doctor: { usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability)." },
57
+ debug: {
58
+ usage: "ocx debug [provider on|off|status|reset|logs [-f]|usage on|off|status|reset|logs [-f]]",
59
+ summary: "Show or toggle runtime provider debug logging on the running proxy.",
60
+ details: [
61
+ "Provider: ocx debug provider on | off | status | reset | logs [-f]",
62
+ "Usage JSONL: ocx debug usage on | off | status | reset | logs [-f]",
63
+ "Env default: OCX_DEBUG=1 (legacy OCX_DEBUG_FRAMES still works)",
64
+ ],
65
+ },
57
66
  login: { usage: "ocx login <provider>", summary: "OAuth or API-key login for a provider." },
58
67
  logout: { usage: "ocx logout <provider>", summary: "Remove a stored provider login." },
59
68
  gui: { usage: "ocx gui", summary: "Open the opencodex dashboard." },
@@ -115,6 +124,8 @@ Usage:
115
124
  ocx sync-cache Refresh Codex's model cache from the active catalog
116
125
  ocx status Check proxy server status
117
126
  ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
127
+ ocx debug [provider|usage ...]
128
+ provider/usage on|off|status|reset|logs [-f]
118
129
  ocx login <provider> OAuth login (xai) — opens browser, stores token in ~/.opencodex/auth.json
119
130
  ocx logout <provider> Remove a stored OAuth login
120
131
  ocx gui Open the opencodex dashboard
package/src/cli/index.ts CHANGED
@@ -449,6 +449,11 @@ switch (command) {
449
449
  await runDoctor();
450
450
  break;
451
451
  }
452
+ case "debug": {
453
+ const { handleDebugCommand } = await import("./debug");
454
+ await handleDebugCommand(args.slice(1));
455
+ break;
456
+ }
452
457
  case "ensure":
453
458
  await handleEnsure();
454
459
  break;
@@ -468,6 +473,11 @@ switch (command) {
468
473
  await syncModelsToCodex((await findLiveProxy())?.port);
469
474
  break;
470
475
  }
476
+ case "v2": {
477
+ const { cmdV2 } = await import("./v2");
478
+ process.exitCode = await cmdV2(args.slice(1), {}, async () => (await findLiveProxy())?.port);
479
+ break;
480
+ }
471
481
  case "sync-cache": {
472
482
  const { invalidateCodexModelsCache } = await import("../codex/catalog");
473
483
  invalidateCodexModelsCache();
package/src/cli/v2.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `ocx v2 status|on|off` — toggle/report the codex `multi_agent_v2` feature that
3
+ * controls the multi-agent surface (v1 vs v2 collab mode).
4
+ *
5
+ * Contract:
6
+ * - config.toml writes go through the official `codex features enable|disable`
7
+ * CLI only (format-preserving TOML edit stays upstream-owned).
8
+ * - after a successful flip the catalog is RESYNCED so model metadata stays fresh.
9
+ * - `on` warns when [agents] max_threads is still present (codex-rs refuses to
10
+ * boot with it while v2 is enabled) — ocx never edits that key itself.
11
+ * - nothing in the catalog build path calls this module; no auto-flip exists.
12
+ */
13
+ import { execFileSync } from "node:child_process";
14
+ import { getMaxConcurrentThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMaxConcurrentThreads } from "../codex/features";
15
+
16
+ import { loadConfig, saveConfig } from "../config";
17
+
18
+ export interface V2CliDeps {
19
+ execFile?: (file: string, args: string[]) => void;
20
+ isEnabled?: typeof isMultiAgentV2Enabled;
21
+ hasMaxThreads?: typeof hasAgentsMaxThreads;
22
+ sync?: (port?: number) => Promise<unknown>;
23
+ log?: Pick<Console, "log" | "error">;
24
+ }
25
+
26
+ function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps): void {
27
+ const exec = deps.execFile ?? ((file: string, args: string[]) => {
28
+ execFileSync(file, args, { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
29
+ });
30
+ const command = process.env.CODEX_CLI_PATH?.trim() || "codex";
31
+ exec(command, ["features", action, "multi_agent_v2"]);
32
+ }
33
+
34
+ export function v2StatusLine(enabled: boolean): string {
35
+ return enabled
36
+ ? "multi_agent_v2: ON — v2 multi-agent surface active"
37
+ : "multi_agent_v2: OFF — v1 multi-agent surface (default install)";
38
+ }
39
+
40
+ export function multiAgentModeLine(mode: string): string {
41
+ switch (mode) {
42
+ case "v1": return "multi_agent_mode: v1 — ALL models forced to v1 surface (upstream pins overridden)";
43
+ case "v2": return "multi_agent_mode: v2 — ALL models forced to v2 surface (upstream pins overridden)";
44
+ default: return "multi_agent_mode: default — upstream model pins respected (sol/terra=v2, luna=v1, rest=codex flag)";
45
+ }
46
+ }
47
+
48
+ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () => Promise<number | undefined>): Promise<number> {
49
+ const log = deps.log ?? console;
50
+ const isEnabled = deps.isEnabled ?? isMultiAgentV2Enabled;
51
+ const hasMaxThreads = deps.hasMaxThreads ?? hasAgentsMaxThreads;
52
+ const verb = (args[0] ?? "status").trim().toLowerCase();
53
+
54
+ if (verb === "status") {
55
+ log.log(v2StatusLine(isEnabled()));
56
+ const cfg = loadConfig();
57
+ log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default"));
58
+ const threads = getMaxConcurrentThreads();
59
+ log.log(`max_concurrent_threads_per_session: ${threads ?? "(unset — codex default)"}`);
60
+ if (isEnabled() && hasMaxThreads()) {
61
+ log.log("WARNING: [agents] max_threads is set — codex refuses to start while multi_agent_v2 is enabled. Remove it from config.toml (concurrency lives in features.multi_agent_v2.max_concurrent_threads_per_session).");
62
+ }
63
+ return 0;
64
+ }
65
+ if (verb === "threads") {
66
+ const value = Number((args[1] ?? "").trim());
67
+ if (!Number.isInteger(value) || value < 1) {
68
+ log.error("v2 threads: pass an integer >= 1 (features.multi_agent_v2.max_concurrent_threads_per_session)");
69
+ return 1;
70
+ }
71
+ const result = setMaxConcurrentThreads(value);
72
+ if (!result.ok) { log.error(`v2 threads: ${result.error}`); return 1; }
73
+ log.log(result.changed
74
+ ? `max_concurrent_threads_per_session = ${value} — applies to new sessions.`
75
+ : `max_concurrent_threads_per_session already ${value} — nothing to do.`);
76
+ return 0;
77
+ }
78
+ if (verb === "mode") {
79
+ const modeArg = (args[1] ?? "").trim().toLowerCase();
80
+ if (modeArg !== "v1" && modeArg !== "default" && modeArg !== "v2") {
81
+ log.error("v2 mode: expected v1|default|v2");
82
+ return 1;
83
+ }
84
+ const cfg = loadConfig();
85
+ if (modeArg === "default") delete cfg.multiAgentMode;
86
+ else cfg.multiAgentMode = modeArg as "v1" | "v2";
87
+ saveConfig(cfg);
88
+ try {
89
+ const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
90
+ await sync(findPort ? await findPort() : undefined);
91
+ } catch (err) {
92
+ log.error(`catalog resync failed: ${err instanceof Error ? err.message : String(err)} — run 'ocx sync' manually.`);
93
+ return 1;
94
+ }
95
+ log.log(multiAgentModeLine(modeArg));
96
+ log.log("Applies to NEW sessions; running sessions keep their pinned multi-agent version.");
97
+ return 0;
98
+ }
99
+ if (verb !== "on" && verb !== "off") {
100
+ log.error(`v2: unknown verb '${verb}' (expected status|on|off|mode <v1|default|v2>|threads <n>)`);
101
+ return 1;
102
+ }
103
+
104
+ const want = verb === "on";
105
+ if (isEnabled() === want) {
106
+ log.log(`multi_agent_v2 already ${want ? "ON" : "OFF"} — nothing to do.`);
107
+ return 0;
108
+ }
109
+ try {
110
+ runCodexFeatures(want ? "enable" : "disable", deps);
111
+ } catch (err) {
112
+ log.error(`codex features ${want ? "enable" : "disable"} multi_agent_v2 failed: ${err instanceof Error ? err.message : String(err)}`);
113
+ return 1;
114
+ }
115
+ if (want && hasMaxThreads()) {
116
+ log.log("WARNING: [agents] max_threads is still set — codex will REFUSE to start until you remove it (features.multi_agent_v2.max_concurrent_threads_per_session replaces it).");
117
+ }
118
+
119
+ // Resync catalog so multi-agent surface metadata stays fresh in both the
120
+ // on-disk catalog and models_cache.json after the toggle flip.
121
+ try {
122
+ const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
123
+ await sync(findPort ? await findPort() : undefined);
124
+ } catch (err) {
125
+ log.error(`catalog resync failed (flag IS flipped): ${err instanceof Error ? err.message : String(err)} — run 'ocx sync' manually.`);
126
+ return 1;
127
+ }
128
+ log.log(v2StatusLine(want));
129
+ log.log("Applies to NEW sessions; running sessions keep their pinned multi-agent version. Restart the Codex app (or wait out its picker cache) to see the ladder change.");
130
+ return 0;
131
+ }
@@ -44,7 +44,10 @@ function isCredentialRecord(value: unknown): value is CodexAccountCredentialReco
44
44
  && (value.credential === undefined || isCredential(value.credential))
45
45
  && (value.refreshGrantFingerprint === undefined || typeof value.refreshGrantFingerprint === "string")
46
46
  && (value.deletedAt === undefined || typeof value.deletedAt === "number")
47
- && (value.replacedAt === undefined || typeof value.replacedAt === "number");
47
+ && (value.replacedAt === undefined || typeof value.replacedAt === "number")
48
+ && (value.lastCodexValidatedAt === undefined || typeof value.lastCodexValidatedAt === "number")
49
+ && (value.lastCodexValidationStatus === undefined || value.lastCodexValidationStatus === "ok" || value.lastCodexValidationStatus === "failed")
50
+ && (value.lastCodexValidationError === undefined || typeof value.lastCodexValidationError === "string");
48
51
  }
49
52
 
50
53
  export function refreshGrantFingerprintForToken(refreshToken: string): string {
@@ -98,6 +101,17 @@ function persist(store: CodexAccountStore): void {
98
101
  atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
99
102
  }
100
103
 
104
+ function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick<
105
+ CodexAccountCredentialRecord,
106
+ "lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError"
107
+ > {
108
+ return {
109
+ ...(record?.lastCodexValidatedAt !== undefined ? { lastCodexValidatedAt: record.lastCodexValidatedAt } : {}),
110
+ ...(record?.lastCodexValidationStatus !== undefined ? { lastCodexValidationStatus: record.lastCodexValidationStatus } : {}),
111
+ ...(record?.lastCodexValidationError !== undefined ? { lastCodexValidationError: record.lastCodexValidationError } : {}),
112
+ };
113
+ }
114
+
101
115
  export function getCodexAccountCredential(id: string): CodexAccountCredentials | null {
102
116
  const record = readCodexAccountRecord(id);
103
117
  if (!record || record.deletedAt != null) return null;
@@ -115,6 +129,32 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent
115
129
  generation: (current?.generation ?? 0) + 1,
116
130
  refreshGrantFingerprint,
117
131
  replacedAt: current ? Date.now() : undefined,
132
+ ...preservedValidationMetadata(current),
133
+ };
134
+ persist(store);
135
+ }
136
+
137
+ export function markCodexAccountValidated(id: string, atMs: number = Date.now()): void {
138
+ const store = loadCodexAccountRecordStore();
139
+ const current = store[id];
140
+ if (!current || current.deletedAt != null || !current.credential) return;
141
+ store[id] = {
142
+ ...current,
143
+ lastCodexValidatedAt: atMs,
144
+ lastCodexValidationStatus: "ok",
145
+ lastCodexValidationError: undefined,
146
+ };
147
+ persist(store);
148
+ }
149
+
150
+ export function markCodexAccountValidationFailed(id: string, reason: string): void {
151
+ const store = loadCodexAccountRecordStore();
152
+ const current = store[id];
153
+ if (!current || current.deletedAt != null || !current.credential) return;
154
+ store[id] = {
155
+ ...current,
156
+ lastCodexValidationStatus: "failed",
157
+ lastCodexValidationError: reason,
118
158
  };
119
159
  persist(store);
120
160
  }
@@ -154,6 +194,7 @@ export function saveCodexAccountCredentialIfGeneration(
154
194
  generation: generation + 1,
155
195
  refreshGrantFingerprint,
156
196
  replacedAt: current.replacedAt,
197
+ ...preservedValidationMetadata(current),
157
198
  };
158
199
  persist(store);
159
200
  return true;