@bitkyc08/opencodex 2.7.35 → 2.7.36

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 (80) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +4 -2
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +52 -0
  7. package/gui/dist/assets/index-BpX-hoSd.css +1 -0
  8. package/gui/dist/assets/index-ZmFopEYw.js +52 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/cursor/cursor-errors.ts +38 -1
  12. package/src/adapters/cursor/discovery.ts +1 -0
  13. package/src/adapters/cursor/effort-map.ts +1 -0
  14. package/src/adapters/cursor/live-models.ts +22 -5
  15. package/src/adapters/cursor/live-transport.ts +82 -7
  16. package/src/adapters/cursor/transport.ts +2 -0
  17. package/src/adapters/cursor.ts +5 -2
  18. package/src/adapters/openai-responses.ts +64 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +10 -0
  21. package/src/cli/index.ts +88 -9
  22. package/src/cli/internal-dispatch.ts +20 -0
  23. package/src/cli/status.ts +15 -4
  24. package/src/cli/tray-proxy.ts +52 -0
  25. package/src/codex/auth-api.ts +46 -5
  26. package/src/codex/autostart-health.ts +149 -0
  27. package/src/codex/catalog/aggregation.ts +268 -0
  28. package/src/codex/catalog/bundled.ts +188 -0
  29. package/src/codex/catalog/effort.ts +263 -0
  30. package/src/codex/catalog/metadata.ts +176 -0
  31. package/src/codex/catalog/parsing.ts +399 -0
  32. package/src/codex/catalog/provider-fetch.ts +609 -0
  33. package/src/codex/catalog/sync.ts +540 -0
  34. package/src/codex/catalog.ts +11 -2426
  35. package/src/codex/inject.ts +165 -3
  36. package/src/codex/shim.ts +141 -8
  37. package/src/codex/sync.ts +17 -2
  38. package/src/config.ts +23 -0
  39. package/src/lib/errors.ts +11 -0
  40. package/src/providers/antigravity-models.ts +33 -0
  41. package/src/providers/kiro-models.ts +2 -0
  42. package/src/providers/registry.ts +2 -2
  43. package/src/responses/state.ts +69 -6
  44. package/src/server/auth-cors.ts +3 -0
  45. package/src/server/management/agent-settings-routes.ts +536 -0
  46. package/src/server/management/combo-routes.ts +210 -0
  47. package/src/server/management/config-routes.ts +302 -0
  48. package/src/server/management/context.ts +21 -0
  49. package/src/server/management/logs-usage-routes.ts +176 -0
  50. package/src/server/management/model-routes.ts +253 -0
  51. package/src/server/management/oauth-account-routes.ts +301 -0
  52. package/src/server/management/provider-routes.ts +408 -0
  53. package/src/server/management/shared.ts +186 -0
  54. package/src/server/management-api.ts +23 -1806
  55. package/src/server/responses/collaboration.ts +300 -0
  56. package/src/server/responses/compact.ts +342 -0
  57. package/src/server/responses/core.ts +1498 -0
  58. package/src/server/responses/encrypted-payload.ts +231 -0
  59. package/src/server/responses/fetch-helpers.ts +157 -0
  60. package/src/server/responses.ts +9 -2172
  61. package/src/server/startup-action-control.ts +41 -0
  62. package/src/server/startup-health-cache.ts +100 -0
  63. package/src/server/windows-tray-control.ts +41 -0
  64. package/src/service.ts +171 -19
  65. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  66. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  67. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  68. package/src/tray/assets/opencodex-tray.png +0 -0
  69. package/src/tray/windows-tray.ps1 +290 -0
  70. package/src/tray/windows.ts +628 -0
  71. package/src/types.ts +5 -0
  72. package/src/update/index.ts +43 -0
  73. package/src/update/job.ts +46 -0
  74. package/src/update/tray-update-plan.d.mts +18 -0
  75. package/src/update/tray-update-plan.mjs +38 -0
  76. package/src/usage/cost.ts +0 -0
  77. package/src/usage/expected-prices.ts +9 -2
  78. package/src/usage/summary.ts +42 -7
  79. package/gui/dist/assets/index-BunUANVE.js +0 -52
  80. package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BunUANVE.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-Sg-7L_oZ.css">
19
+ <script type="module" crossorigin src="/assets/index-ZmFopEYw.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BpX-hoSd.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.35",
3
+ "version": "2.7.36",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -47,6 +47,34 @@ export function isCursorInvalidArgumentError(value: unknown): boolean {
47
47
  return message.includes("invalid_argument");
48
48
  }
49
49
 
50
+ const QUOTA_RATE_CUES = ["too many requests", "quota", "rate limit", "rate-limit", "throttl"];
51
+ const REQUEST_TOO_LARGE_PATTERNS: (string | RegExp)[] = [
52
+ "tool catalog too large",
53
+ "tool registration too large",
54
+ "too many tools",
55
+ "message too large",
56
+ "payload too large",
57
+ "request too large",
58
+ // Size cue required somewhere: a bare "request exceeds ... limit" (concurrency/quota
59
+ // shape) must NOT match, but "request body/size exceeds ... limit" and
60
+ // "request exceeds maximum allowed size" are deterministic overflow (WP3 r1/r2).
61
+ /request exceeds .*size/,
62
+ /request (?:body|size) exceeds .*(?:size|limit)/,
63
+ "maximum allowed size",
64
+ ];
65
+
66
+ /**
67
+ * True when a resource_exhausted detail names a request-size overflow rather than quota.
68
+ * Quota/rate cues are rejected FIRST: "resource_exhausted while loading tool catalog: quota
69
+ * exhausted" is rate limiting, not a too-large request, even though it mentions the catalog.
70
+ */
71
+ export function isCursorRequestTooLargeDetail(lowerMessage: string): boolean {
72
+ if (QUOTA_RATE_CUES.some(cue => lowerMessage.includes(cue))) return false;
73
+ return REQUEST_TOO_LARGE_PATTERNS.some(pattern =>
74
+ typeof pattern === "string" ? lowerMessage.includes(pattern) : pattern.test(lowerMessage),
75
+ );
76
+ }
77
+
50
78
  /**
51
79
  * Classify a Cursor transport/Connect/gRPC error message into an actionable category.
52
80
  * The returned prefix string is recognized by `src/lib/errors.ts` `classifyError` keywords,
@@ -60,7 +88,16 @@ export function classifyCursorError(message: string): string {
60
88
  if (
61
89
  lower.includes("resource_exhausted") ||
62
90
  lower.includes("resource exhausted")
63
- ) return "Cursor resource limit exceeded";
91
+ ) {
92
+ // gRPC RESOURCE_EXHAUSTED is quota/rate exhaustion unless the detail names a
93
+ // request-size overflow (tool catalog/registration). Only the latter is a
94
+ // client-fixable 400; everything else surfaces as a 429 so Codex backs off
95
+ // instead of hammering retries (live evidence: 6x 400 retry storm, devlog
96
+ // 260723_cursor_context_continuity/000_plan.md).
97
+ return isCursorRequestTooLargeDetail(lower)
98
+ ? "Cursor resource limit exceeded"
99
+ : "Cursor rate limit exceeded";
100
+ }
64
101
 
65
102
  if (
66
103
  lower.includes("rate limit") ||
@@ -182,6 +182,7 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
182
182
  // opus-4-7-fast: effort-suffix tiers unverified -> no tier picker; sent bare like live-only ids.
183
183
  { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K },
184
184
  { id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
185
+ { id: "claude-opus-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
185
186
  { id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
186
187
 
187
188
  { id: "composer-1", contextWindow: CONTEXT_200K },
@@ -24,6 +24,7 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
24
24
  "claude-fable-5": ["low", "medium", "high", "xhigh", "max"],
25
25
  "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"],
26
26
  "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"],
27
+ "claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
27
28
  "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
28
29
  "glm-5.2": ["high", "max"],
29
30
  // GetUsableModels (2026-07-09) lists grok-4.5-{medium,high,xhigh} and grok-4.5-fast-{medium,high,xhigh};
@@ -28,9 +28,26 @@ export interface CursorUsableModelsOptions {
28
28
 
29
29
  export type CursorUsableModelsResult =
30
30
  | { ok: true; models: string[] }
31
- | { ok: false; error: "auth" | "http" | "timeout" | "decode" | "empty"; detail?: string };
31
+ | { ok: false; error: "auth" | "http" | "transport" | "timeout" | "decode" | "empty"; detail?: string };
32
32
 
33
+ const RETRYABLE_DISCOVERY_ERRORS = new Set(["timeout", "transport"]);
34
+ const DISCOVERY_RETRY_TIMEOUT_MS = 3_000;
35
+
36
+ /**
37
+ * Live discovery with ONE bounded retry for transient pre-response failures (fresh HTTP/2
38
+ * session each attempt). Completed non-2xx responses ("http"), auth, decode, and empty are
39
+ * deterministic and never retried. The retry attempt's deadline is capped so a cache-miss
40
+ * catalog poll cannot stall much past the primary timeout (~11.5s worst case, accepted in
41
+ * devlog 260723_cursor_context_continuity/030).
42
+ */
33
43
  export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
44
+ const first = await fetchCursorUsableModelsOnce(opts);
45
+ if (first.ok || !RETRYABLE_DISCOVERY_ERRORS.has(first.error)) return first;
46
+ await new Promise(resolve => setTimeout(resolve, 250 + Math.floor(Math.random() * 250)));
47
+ return fetchCursorUsableModelsOnce({ ...opts, timeoutMs: Math.min(opts.timeoutMs ?? 8000, DISCOVERY_RETRY_TIMEOUT_MS) });
48
+ }
49
+
50
+ async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
34
51
  const baseUrl = (opts.baseUrl ?? "https://api2.cursor.sh").replace(/\/+$/, "");
35
52
  const timeoutMs = opts.timeoutMs ?? 8000;
36
53
 
@@ -46,7 +63,7 @@ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions):
46
63
  try {
47
64
  client = http2.connect(baseUrl);
48
65
  } catch {
49
- return finish({ ok: false, error: "http", detail: "HTTP/2 connection setup failed" });
66
+ return finish({ ok: false, error: "transport", detail: "HTTP/2 connection setup failed" });
50
67
  }
51
68
 
52
69
  const timer = setTimeout(() => {
@@ -59,7 +76,7 @@ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions):
59
76
  finish(value);
60
77
  };
61
78
 
62
- client.on("error", () => close({ ok: false, error: "http", detail: "HTTP/2 session failed" }));
79
+ client.on("error", () => close({ ok: false, error: "transport", detail: "HTTP/2 session failed" }));
63
80
 
64
81
  let req: http2.ClientHttp2Stream;
65
82
  try {
@@ -75,7 +92,7 @@ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions):
75
92
  "x-session-id": crypto.randomUUID(),
76
93
  });
77
94
  } catch {
78
- return close({ ok: false, error: "http", detail: "HTTP/2 request setup failed" });
95
+ return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" });
79
96
  }
80
97
 
81
98
  let status = 0;
@@ -84,7 +101,7 @@ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions):
84
101
  status = Number(headers[":status"] ?? 0);
85
102
  });
86
103
  req.on("data", (chunk: Buffer) => chunks.push(chunk));
87
- req.on("error", () => close({ ok: false, error: "http", detail: "HTTP/2 request failed" }));
104
+ req.on("error", () => close({ ok: false, error: "transport", detail: "HTTP/2 request failed" }));
88
105
  req.on("end", () => {
89
106
  if (status === 401 || status === 403) {
90
107
  return close({ ok: false, error: "auth", detail: `HTTP ${status}` });
@@ -64,12 +64,60 @@ const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run";
64
64
  const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a";
65
65
  const HEARTBEAT_MS = 5_000;
66
66
  const CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000;
67
+ const CURSOR_TIMEOUT_DESTROY_GRACE_MS = 1_000;
67
68
  const CLIENT_TOOL_FINALIZE_GRACE_MS = 50;
68
69
  const GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS = 750;
69
70
  const GENERIC_TOOL_COUNT_MAX_FINALIZE_GRACE_MS = 1_800;
70
71
  const GENERIC_TOOL_COUNT_PER_TOOL_GRACE_MS = 125;
71
72
  const cursorContextUsageTracker = createCursorContextUsageTracker();
72
73
 
74
+ /**
75
+ * Single-shot terminal settlement for one Cursor turn: whichever of fail/finish wins first owns
76
+ * the terminal; later calls are no-ops. Prevents double-terminal mutation when multiple sources
77
+ * race (stream error + session error, end + late session error, timeout + destroy error).
78
+ * Exported for direct unit testing — the callbacks are otherwise private to run()/open().
79
+ */
80
+ export function createTerminalSettler(hooks: {
81
+ fail: (error: Error) => void;
82
+ finish: () => void;
83
+ clearTimer: () => void;
84
+ }): { settleFail: (error: Error) => void; settleFinish: () => void; settled: () => boolean } {
85
+ let settled = false;
86
+ return {
87
+ settleFail(error) {
88
+ if (settled) return;
89
+ settled = true;
90
+ hooks.clearTimer();
91
+ hooks.fail(error);
92
+ },
93
+ settleFinish() {
94
+ if (settled) return;
95
+ settled = true;
96
+ hooks.clearTimer();
97
+ hooks.finish();
98
+ },
99
+ settled: () => settled,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Arm the post-close destroy fallback for a timed-out turn: close() waits for in-flight frames,
105
+ * but a dead socket can ignore it, leaving a stalled TLS session past the timeout. Exported for
106
+ * unit testing with fakes. The timer is unref'd so it never holds the process open.
107
+ */
108
+ export function armTimeoutDestroyFallback(
109
+ stream: { destroyed: boolean; destroy: () => void },
110
+ session: { destroyed: boolean; destroy: () => void },
111
+ graceMs: number,
112
+ ): ReturnType<typeof setTimeout> {
113
+ const timer = setTimeout(() => {
114
+ try { if (!stream.destroyed) stream.destroy(); } catch { /* gone */ }
115
+ try { if (!session.destroyed) session.destroy(); } catch { /* gone */ }
116
+ }, graceMs);
117
+ timer.unref?.();
118
+ return timer;
119
+ }
120
+
73
121
  /** Carry context-usage totals across conversation-id rotation for external-model replay. */
74
122
  export function rekeyCursorContextUsage(fromConversationId: string, toConversationId: string): void {
75
123
  cursorContextUsageTracker.rekey(fromConversationId, toConversationId);
@@ -616,10 +664,15 @@ class LiveCursorTransport implements CursorTransport {
616
664
  "x-session-id": this.sessionId,
617
665
  });
618
666
 
619
- // Single owner of the pre-first-frame deadline. Cleared by the first server frame/end-stream and
620
- // by every terminal path (trailers, error, end, abort, close) so it can never leak.
667
+ // Single-shot terminal owner for this turn (createTerminalSettler): stream error, session
668
+ // error, trailers, end, abort, and the first-frame timeout all race into it, and only the
669
+ // first wins. The first-frame timer is cleared by any settlement so it can never leak.
670
+ const settler = createTerminalSettler({
671
+ fail,
672
+ finish,
673
+ clearTimer: () => this.clearFirstFrameTimer(),
674
+ });
621
675
  const failAndClear = (error: Error) => {
622
- this.clearFirstFrameTimer();
623
676
  if (this.expectedClose) {
624
677
  // We already emitted a terminal `done` and cancelled the run (client-tool suspension). The
625
678
  // RST_STREAM CANCEL surfaces here as a stream error/abort; it is expected, not a failure.
@@ -629,19 +682,33 @@ class LiveCursorTransport implements CursorTransport {
629
682
  framesReceived: this.framesReceived,
630
683
  elapsedMs: Date.now() - this.turnStartedAt,
631
684
  });
632
- finish();
685
+ settler.settleFinish();
633
686
  return;
634
687
  }
635
- fail(error);
688
+ settler.settleFail(error);
636
689
  };
637
690
  const session = this.session;
638
691
  const stream = this.stream;
692
+ // Session-level errors (TLS/socket/GOAWAY) do not always propagate to the stream listener;
693
+ // without this handler they could bypass orderly failure reporting entirely.
694
+ session.on("error", err => {
695
+ const realErr = err instanceof Error ? err : new Error(String(err));
696
+ debugProviderDiagnostic("cursor", "session-error", {
697
+ code: String((realErr as { code?: unknown }).code ?? ""),
698
+ message: redactCursorForLog(realErr.message),
699
+ elapsedMs: Date.now() - this.turnStartedAt,
700
+ });
701
+ failAndClear(realErr);
702
+ });
639
703
  this.firstFrameTimer = setTimeout(() => {
640
704
  this.firstFrameTimer = undefined;
641
705
  debugProviderDiagnostic("cursor", "first-frame-timeout", { timeoutMs: this.input.firstFrameTimeoutMs ?? CURSOR_FIRST_FRAME_TIMEOUT_MS });
642
706
  try { stream.close(); } catch { /* already closing */ }
643
707
  try { session.close(); } catch { /* already closing */ }
644
- fail(new Error("Cursor transport timed out before first response"));
708
+ // close() waits for in-flight frames; a dead socket can ignore it — force-destroy shortly
709
+ // after so a stalled TLS session cannot linger past the timeout.
710
+ armTimeoutDestroyFallback(stream, session, this.input.timeoutDestroyGraceMs ?? CURSOR_TIMEOUT_DESTROY_GRACE_MS);
711
+ settler.settleFail(new Error("Cursor transport timed out before first response"));
645
712
  }, this.input.firstFrameTimeoutMs ?? CURSOR_FIRST_FRAME_TIMEOUT_MS);
646
713
 
647
714
  let pending: Uint8Array<ArrayBufferLike> = new Uint8Array();
@@ -712,7 +779,15 @@ class LiveCursorTransport implements CursorTransport {
712
779
  expectedClose: this.expectedClose,
713
780
  elapsedMs: Date.now() - this.turnStartedAt,
714
781
  });
715
- finish();
782
+ // A zero-frame end without an expected close is an unexpected EOF (peer dropped the
783
+ // connection before any response frame) — surfacing it as success would silently
784
+ // swallow the turn (WP4 review blocker 1). With frames, the protobuf event state
785
+ // owns terminal semantics as before.
786
+ if (this.framesReceived === 0 && !this.expectedClose) {
787
+ settler.settleFail(new Error("Cursor stream ended before any response frame (unexpected EOF)"));
788
+ return;
789
+ }
790
+ settler.settleFinish();
716
791
  });
717
792
 
718
793
  signal?.addEventListener("abort", () => {
@@ -18,6 +18,8 @@ export interface CursorTransportFactoryInput {
18
18
  headers?: Headers;
19
19
  /** Pre-first-frame deadline (dial + first server frame). Defaults to 30s when omitted. */
20
20
  firstFrameTimeoutMs?: number;
21
+ /** Grace (ms) between close() and the force-destroy fallback after a first-frame timeout. Defaults to 1s. */
22
+ timeoutDestroyGraceMs?: number;
21
23
  /**
22
24
  * Grace window (ms) before a drained client-tool turn is finalized, so a sibling tool call
23
25
  * announced in a later receive chunk can revoke a premature finalize. Defaults to 50ms.
@@ -34,6 +34,8 @@ const CURSOR_TRANSPORT_DISABLED_MESSAGE = [
34
34
  export interface CursorAdapterDeps {
35
35
  createTransport?: CursorTransportFactory;
36
36
  kv?: CursorKvStore;
37
+ /** Test seam: observe/replace context-usage rekeying on conversation-id rotation. */
38
+ rekeyContextUsage?: (fromConversationId: string, toConversationId: string) => void;
37
39
  }
38
40
 
39
41
  function safeCursorTransportError(err: unknown): string {
@@ -74,13 +76,14 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
74
76
  try {
75
77
  const makeTransport = deps.createTransport ?? createLiveCursorTransport;
76
78
  const kv = deps.kv ?? createCursorKvStore();
79
+ const rekeyContextUsage = deps.rekeyContextUsage ?? rekeyCursorContextUsage;
77
80
  _parsed._cursorConversationId ??= generatedCursorConversationId();
78
81
  const previousConversationId = _parsed._cursorConversationId;
79
82
  let request = createCursorRequest(_parsed);
80
83
  // Keep remembered conversation id in sync when the request builder mints a fresh id
81
84
  // for external-model tool-result continuations (stateless replay).
82
85
  if (request.conversationId !== previousConversationId) {
83
- rekeyCursorContextUsage(previousConversationId, request.conversationId);
86
+ rekeyContextUsage(previousConversationId, request.conversationId);
84
87
  }
85
88
  _parsed._cursorConversationId = request.conversationId;
86
89
  let emittedOutput = false;
@@ -133,7 +136,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
133
136
  const failedConversationId = request.conversationId;
134
137
  _parsed._cursorConversationId = undefined;
135
138
  request = createCursorRequest(_parsed, { forceFreshConversation: true });
136
- rekeyCursorContextUsage(failedConversationId, request.conversationId);
139
+ rekeyContextUsage(failedConversationId, request.conversationId);
137
140
  _parsed._cursorConversationId = request.conversationId;
138
141
  await runOnce(request);
139
142
  }
@@ -1,7 +1,9 @@
1
1
  import type { IncomingMeta, ProviderAdapter } from "./base";
2
2
  import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types";
3
+ import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
3
4
  import { decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
4
5
  import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
6
+ import { modelRecordValue } from "../reasoning-effort";
5
7
 
6
8
  // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
7
9
  // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
@@ -52,6 +54,19 @@ export function sanitizeReasoningInputContent(body: unknown): unknown {
52
54
  return changed ? { ...raw, input } : body;
53
55
  }
54
56
 
57
+ function stripUnsupportedReasoningSummaryDelivery(body: unknown, modelId: string): unknown {
58
+ if (catalogModelSupportsReasoningSummaries(modelId) !== false) return body;
59
+ if (!isPlainObject(body) || !isPlainObject(body.stream_options)) return body;
60
+ if (!("reasoning_summary_delivery" in body.stream_options)) return body;
61
+
62
+ const streamOptions = { ...body.stream_options };
63
+ delete streamOptions.reasoning_summary_delivery;
64
+ const next = { ...body };
65
+ if (Object.keys(streamOptions).length > 0) next.stream_options = streamOptions;
66
+ else delete next.stream_options;
67
+ return next;
68
+ }
69
+
55
70
  function stripInvalidItemIds(body: unknown): unknown {
56
71
  if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
57
72
 
@@ -158,6 +173,49 @@ function stripUnsupportedReasoningParams(body: unknown): unknown {
158
173
  return { ...body, reasoning: Object.keys(rest).length > 0 ? rest : undefined };
159
174
  }
160
175
 
176
+ /**
177
+ * A false model capability prevents Codex from emitting summary fields after the catalog refresh.
178
+ * Strip them here as well so an already-running client with a stale catalog cannot keep sending an
179
+ * upstream-rejected `reasoning_summary_delivery` value (issue #323).
180
+ */
181
+ function stripDisabledReasoningSummaries(
182
+ body: unknown,
183
+ provider: OcxProviderConfig,
184
+ modelId: string,
185
+ ): unknown {
186
+ if (modelRecordValue(provider.modelSupportsReasoningSummaries, modelId) !== false || !isPlainObject(body)) {
187
+ return body;
188
+ }
189
+
190
+ let changed = false;
191
+ let streamOptions = body.stream_options;
192
+ if (isPlainObject(streamOptions) && Object.hasOwn(streamOptions, "reasoning_summary_delivery")) {
193
+ const { reasoning_summary_delivery: _delivery, ...rest } = streamOptions;
194
+ streamOptions = rest;
195
+ changed = true;
196
+ }
197
+
198
+ let reasoning = body.reasoning;
199
+ if (isPlainObject(reasoning)) {
200
+ const { summary: _summary, generate_summary: _generateSummary, ...rest } = reasoning;
201
+ if (_summary !== undefined || _generateSummary !== undefined) {
202
+ reasoning = rest;
203
+ changed = true;
204
+ }
205
+ }
206
+
207
+ if (!changed) return body;
208
+ return {
209
+ ...body,
210
+ ...(isPlainObject(streamOptions) && Object.keys(streamOptions).length > 0
211
+ ? { stream_options: streamOptions }
212
+ : { stream_options: undefined }),
213
+ ...(isPlainObject(reasoning) && Object.keys(reasoning).length > 0
214
+ ? { reasoning }
215
+ : { reasoning: undefined }),
216
+ };
217
+ }
218
+
161
219
  /**
162
220
  * Comprehensive Spark compatibility layer. codex-rs emits five tool types (function,
163
221
  * namespace, tool_search, web_search, custom) plus extensions (defer_loading,
@@ -459,11 +517,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
459
517
  );
460
518
  if (forward) outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
461
519
  else outBody = stripConflictingHostedTools(outBody);
520
+ outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
462
521
  return {
463
522
  url,
464
523
  method: "POST",
465
524
  headers,
466
- body: JSON.stringify(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))))),
525
+ body: JSON.stringify(stripDisabledReasoningSummaries(
526
+ stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody))))))),
527
+ provider,
528
+ parsed.modelId,
529
+ )),
467
530
  };
468
531
  },
469
532
 
package/src/cli/doctor.ts CHANGED
@@ -16,6 +16,7 @@ import { resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWi
16
16
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
17
17
  import { countPendingOpencodexHistory } from "../codex/history-provider";
18
18
  import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings";
19
+ import { collectStartupHealth, startupHealthSummary } from "../codex/autostart-health";
19
20
  export { resolveCodexHomeDir } from "../codex/home";
20
21
 
21
22
  const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
@@ -331,6 +332,11 @@ export async function runDoctor(): Promise<void> {
331
332
  console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`);
332
333
  }
333
334
 
335
+ const startup = collectStartupHealth(readConfigDiagnostics().config);
336
+ console.log("\nCodex restart safety");
337
+ console.log(` ${startup.rebootSafe ? "ok " : "!! "} ${startupHealthSummary(startup)}`);
338
+ console.log(` routing=${startup.routingKind}, service=${startup.serviceViable ? "viable" : startup.serviceInstalled ? "installed-but-unhealthy" : "absent"}, shim=${startup.shimHealthy ? "healthy" : startup.shimInstalled ? "stale" : "absent"}`);
339
+
334
340
  const currentProxyEnv = collectProxyEnv();
335
341
  const configuredProxy = collectConfiguredProxy();
336
342
  const runningProxyEnv = collectRunningProxyEnv();
@@ -403,6 +409,10 @@ export async function runDoctor(): Promise<void> {
403
409
  const hints: string[] = [];
404
410
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
405
411
  const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
412
+ if (!startup.rebootSafe) {
413
+ const command = startup.recommendedCommand ?? startup.commands.restoreNative;
414
+ hints.push(`Codex is pinned to the local proxy without persistent startup protection. After restart, requests can reconnect indefinitely. Run '${command}'.`);
415
+ }
406
416
  if (anyDrvfs) {
407
417
  hints.push("State dir is on a Windows-mounted (/mnt) drive. Prefer the Linux home (~) under WSL for token/lock reliability.");
408
418
  }
package/src/cli/help.ts CHANGED
@@ -49,6 +49,15 @@ const helpEntries: Record<string, HelpEntry> = {
49
49
  summary: "Auto-start the proxy when `codex` launches.",
50
50
  details: ["Use `remove` as an alias for `uninstall`."],
51
51
  },
52
+ tray: {
53
+ usage: "ocx tray <install|start|stop|status|uninstall|remove> [--json] [--no-start]",
54
+ summary: "Install and control the Windows status tray icon.",
55
+ details: [
56
+ "The tray starts at Windows login and provides one-click proxy controls.",
57
+ "Tray start/stop controls the icon only; use its menu to start or stop the proxy.",
58
+ "--no-start (install only) installs the tray without launching it immediately.",
59
+ ],
60
+ },
52
61
  ensure: { usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." },
53
62
  sync: { usage: "ocx sync", summary: "Fetch provider models and inject them into Codex config." },
54
63
  "sync-cache": { usage: "ocx sync-cache", summary: "Refresh Codex's model cache from the active catalog." },
@@ -164,6 +173,7 @@ Usage:
164
173
  ocx uninstall Remove service/shim/config and restore native Codex (alias: remove)
165
174
  ocx service [sub] Run as a background service (default: install/update/start)
166
175
  ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall|remove)
176
+ ocx tray <sub> Windows status tray (install|start|stop|status|uninstall)
167
177
  ocx ensure Ensure the proxy is running and Codex config/cache are current
168
178
  ocx sync Fetch models from providers and inject into Codex config
169
179
  ocx sync-cache Refresh Codex's model cache from the active catalog