@bitkyc08/opencodex 2.54.0-preview.20260914 → 2.55.0-preview.20260914

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 (86) hide show
  1. package/gui/dist/assets/{index-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic-image-codec.ts +57 -0
  5. package/src/adapters/anthropic-image-normalize.ts +28 -1
  6. package/src/adapters/anthropic.ts +68 -6
  7. package/src/adapters/base.ts +8 -0
  8. package/src/adapters/coding-agent/protocol.ts +41 -16
  9. package/src/adapters/cursor/cursor-errors.ts +1 -1
  10. package/src/adapters/cursor/live-transport.ts +5 -1
  11. package/src/adapters/cursor/native-exec-fs.ts +10 -10
  12. package/src/adapters/cursor/native-exec-network.ts +2 -2
  13. package/src/adapters/cursor/native-exec-shell.ts +13 -12
  14. package/src/adapters/cursor/native-exec.ts +51 -10
  15. package/src/adapters/cursor/policy-error.ts +75 -0
  16. package/src/adapters/cursor/protobuf-request.ts +105 -1
  17. package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
  18. package/src/adapters/devin/live-models.ts +33 -2
  19. package/src/adapters/google-wire-compiler.ts +8 -0
  20. package/src/adapters/google.ts +46 -0
  21. package/src/adapters/input-media-guard.ts +45 -0
  22. package/src/adapters/kiro/adapter.ts +8 -0
  23. package/src/adapters/kiro/payload.ts +28 -6
  24. package/src/adapters/kiro-events.ts +25 -6
  25. package/src/adapters/kiro-images.ts +30 -0
  26. package/src/adapters/kiro-retry.ts +8 -0
  27. package/src/adapters/openai-chat.ts +33 -4
  28. package/src/adapters/openai-responses.ts +26 -0
  29. package/src/adapters/registry.ts +4 -0
  30. package/src/bridge.ts +163 -116
  31. package/src/chat/image-parts.ts +151 -0
  32. package/src/chat/inbound.ts +70 -33
  33. package/src/cli/connect.ts +30 -9
  34. package/src/cli/dispatch.ts +7 -3
  35. package/src/cli/index.ts +3 -0
  36. package/src/cli/runtime-api.ts +25 -0
  37. package/src/cli/status.ts +21 -19
  38. package/src/cli/system-restart-client.ts +25 -0
  39. package/src/clients/config-export.ts +14 -4
  40. package/src/codex/app-server-processes.ts +25 -0
  41. package/src/codex/auth-context.ts +8 -0
  42. package/src/codex/autostart-health.ts +36 -2
  43. package/src/codex/catalog/provider-fetch.ts +41 -0
  44. package/src/codex/catalog-auto-refresh.ts +182 -0
  45. package/src/codex/catalog-refresh-status.ts +93 -0
  46. package/src/codex/history-provider.ts +55 -0
  47. package/src/codex/model-entitlements.ts +78 -0
  48. package/src/codex/native-profile-processes.ts +114 -15
  49. package/src/codex/prompt-text-probe.ts +274 -41
  50. package/src/codex/routing-adoption.ts +189 -0
  51. package/src/codex/routing.ts +520 -48
  52. package/src/codex/runtime.ts +249 -7
  53. package/src/combos/failover.ts +45 -0
  54. package/src/config.ts +124 -4
  55. package/src/generated/compatibility-version.json +110 -74
  56. package/src/generated/model-metadata.ts +1 -0
  57. package/src/lib/request-execution-budget.ts +202 -0
  58. package/src/lib/upstream-retry.ts +95 -8
  59. package/src/lib/workflow-budget.ts +172 -0
  60. package/src/oauth/devin.ts +57 -12
  61. package/src/providers/quota.ts +37 -6
  62. package/src/providers/registry.ts +53 -6
  63. package/src/responses/input-media.ts +65 -0
  64. package/src/responses/parser-content.ts +42 -0
  65. package/src/responses/schema.ts +12 -2
  66. package/src/server/audio-live.ts +1 -2
  67. package/src/server/audio-transcriptions.ts +1 -2
  68. package/src/server/auth-cors.ts +1 -1
  69. package/src/server/background-lifecycle.ts +18 -0
  70. package/src/server/chat-completions.ts +23 -8
  71. package/src/server/chat-native.ts +17 -17
  72. package/src/server/index.ts +24 -0
  73. package/src/server/management/request-history-routes.ts +5 -0
  74. package/src/server/request-log.ts +8 -2
  75. package/src/server/responses/compact.ts +51 -3
  76. package/src/server/responses/core.ts +238 -28
  77. package/src/server/search.ts +7 -9
  78. package/src/types/config.ts +51 -6
  79. package/src/usage/log.ts +37 -0
  80. package/src/vision/eligibility.ts +37 -4
  81. package/src/vision/index.ts +1 -0
  82. package/src/vision/plan.ts +45 -10
  83. package/src/web-search/alpha-search.ts +324 -0
  84. package/src/web-search/index.ts +13 -22
  85. package/src/web-search/passthrough-bridge.ts +195 -22
  86. package/src/web-search/sidecar-providers.ts +22 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Inbound Chat Completions image parts, recognized once for every consumer.
3
+ *
4
+ * Two call sites used to answer "does this body carry an image?" independently and
5
+ * gave different answers: the translated path understood Pi/MCP and Anthropic-shaped
6
+ * parts, while the native fast path's route-eligibility predicate matched only
7
+ * `image_url`. A text-only routed model therefore kept an image-bearing body and
8
+ * forwarded a non-OpenAI part verbatim to an OpenAI-compatible upstream.
9
+ *
10
+ * Normalization runs before route selection so the diversion decision and the
11
+ * forwarded wire see the same parts. This module deliberately imports nothing: it is
12
+ * shared by `src/chat/` and `src/server/` and must not create an edge between them.
13
+ */
14
+
15
+ type Rec = Record<string, unknown>;
16
+
17
+ function isRec(v: unknown): v is Rec {
18
+ return !!v && typeof v === "object" && !Array.isArray(v);
19
+ }
20
+
21
+ /**
22
+ * The image reference a Chat content part carries, as a URL or data URI.
23
+ *
24
+ * Accepts OpenAI `image_url` (string or `{url}`), Pi/MCP-style
25
+ * `{type:"image", data, mimeType}` (Aside read_file tool results), and
26
+ * Anthropic-shaped `{type:"image", source:{...}}` in both base64 and url form.
27
+ * Returns null for anything else — including a part with no usable reference, which
28
+ * must be left alone rather than turned into a claim of an attachment.
29
+ */
30
+ export function chatImageUrlFromPart(part: Rec): string | null {
31
+ if (part.type === "image_url") {
32
+ const imageUrl = part.image_url;
33
+ if (typeof imageUrl === "string" && imageUrl.length > 0) return imageUrl;
34
+ if (isRec(imageUrl) && typeof imageUrl.url === "string" && imageUrl.url.length > 0) return imageUrl.url;
35
+ return null;
36
+ }
37
+ if (part.type === "image") {
38
+ const data = part.data;
39
+ if (typeof data === "string" && data.length > 0) {
40
+ if (data.startsWith("data:")) return data;
41
+ const media = typeof part.mimeType === "string" && part.mimeType.length > 0 ? part.mimeType
42
+ : typeof part.mediaType === "string" && part.mediaType.length > 0 ? part.mediaType
43
+ : "image/png";
44
+ return "data:" + media + ";base64," + data;
45
+ }
46
+ const source = part.source;
47
+ if (isRec(source)) {
48
+ if (source.type === "base64" && typeof source.data === "string" && source.data.length > 0) {
49
+ const media = typeof source.media_type === "string" && source.media_type.length > 0 ? source.media_type : "image/png";
50
+ return "data:" + media + ";base64," + source.data;
51
+ }
52
+ if (source.type === "url" && typeof source.url === "string" && source.url.length > 0) return source.url;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** The fidelity hint a recognized part carries, when it is one the wire accepts. */
59
+ export function chatImageDetailFromPart(part: Rec): "auto" | "low" | "high" | undefined {
60
+ const raw = isRec(part.image_url) ? part.image_url.detail : part.detail;
61
+ return raw === "auto" || raw === "low" || raw === "high" ? raw : undefined;
62
+ }
63
+
64
+ /**
65
+ * True when any `messages[].content[]` part carries a recognized image, in any of
66
+ * the accepted shapes. This is the predicate native-route eligibility depends on, so
67
+ * widening `chatImageUrlFromPart` widens the text-only diversion with it.
68
+ */
69
+ export function chatBodyCarriesImage(rawBody: Rec): boolean {
70
+ const messages = rawBody.messages;
71
+ if (!Array.isArray(messages)) return false;
72
+ for (const message of messages) {
73
+ if (!isRec(message) || !Array.isArray(message.content)) continue;
74
+ for (const part of message.content) {
75
+ if (isRec(part) && chatImageUrlFromPart(part) !== null) return true;
76
+ }
77
+ }
78
+ return false;
79
+ }
80
+
81
+ /**
82
+ * Rewrite every recognized non-OpenAI image part into `image_url` form.
83
+ *
84
+ * Copy-on-write, and genuinely lazy: replacement arrays are allocated only after a
85
+ * part actually needs rewriting. An ordinary text or native-Chat request walks the
86
+ * messages and allocates nothing, and the original object reference is returned.
87
+ * An earlier revision mapped every message and content array eagerly and only then
88
+ * compared — identity was preserved, but the transient arrays were not, so the
89
+ * "only rewritten paths are rebuilt" claim was false for the common path.
90
+ *
91
+ * Every sibling part, every other message field and every top-level body field keep
92
+ * their exact value: the native path is a whitelist passthrough, so an incidental
93
+ * deep clone would itself be a behavior change.
94
+ *
95
+ * Each rewritten Pi/Anthropic base64 part costs one copy of its payload string,
96
+ * bounded by the inbound body limit `readChatBody` already enforces.
97
+ */
98
+ export function normalizeChatImageParts(rawBody: Rec): Rec {
99
+ const messages = rawBody.messages;
100
+ if (!Array.isArray(messages)) return rawBody;
101
+ let nextMessages: unknown[] | undefined;
102
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
103
+ const message = messages[messageIndex];
104
+ if (!isRec(message) || !Array.isArray(message.content)) continue;
105
+ const content = message.content;
106
+ let nextContent: unknown[] | undefined;
107
+ for (let partIndex = 0; partIndex < content.length; partIndex++) {
108
+ const part = content[partIndex];
109
+ // Already-OpenAI parts are left byte-identical; only foreign shapes are rewritten.
110
+ if (!isRec(part) || part.type === "image_url") continue;
111
+ const url = chatImageUrlFromPart(part);
112
+ if (url === null) continue;
113
+ const detail = chatImageDetailFromPart(part);
114
+ nextContent ??= content.slice();
115
+ nextContent[partIndex] = { type: "image_url", image_url: { url, ...(detail ? { detail } : {}) } };
116
+ }
117
+ if (!nextContent) continue;
118
+ nextMessages ??= messages.slice();
119
+ nextMessages[messageIndex] = { ...message, content: nextContent };
120
+ }
121
+ return nextMessages ? { ...rawBody, messages: nextMessages } : rawBody;
122
+ }
123
+
124
+ /**
125
+ * True when a `role: "tool"` message carries a recognized image, in any accepted shape.
126
+ *
127
+ * Shape normalization alone does NOT make such a request safe on the native fast path.
128
+ * A standard Chat tool message accepts a string or text parts only — not `image_url` —
129
+ * so rewriting a Pi/Anthropic tool image into `image_url` still leaves an image part
130
+ * inside a tool message, which a standard-enforcing endpoint rejects.
131
+ *
132
+ * The translated openai-chat adapter already solves placement: it collects tool-result
133
+ * images and flushes them into a following `user` carrier after the complete paired
134
+ * tool-result batch. Diverting these requests there is narrower than reimplementing
135
+ * that carrier on the native path, and it leaves ordinary user images and text-only
136
+ * tool results on the native fast path untouched.
137
+ */
138
+ export function chatBodyCarriesToolResultImage(rawBody: Rec): boolean {
139
+ const messages = rawBody.messages;
140
+ if (!Array.isArray(messages)) return false;
141
+ for (const message of messages) {
142
+ // The legacy `function` role carries a tool result under the same schema constraint,
143
+ // so it needs the same diversion.
144
+ if (!isRec(message) || (message.role !== "tool" && message.role !== "function")) continue;
145
+ if (!Array.isArray(message.content)) continue;
146
+ for (const part of message.content) {
147
+ if (isRec(part) && chatImageUrlFromPart(part) !== null) return true;
148
+ }
149
+ }
150
+ return false;
151
+ }
@@ -5,6 +5,9 @@
5
5
  * Same translate-and-replay pattern as Claude Messages: the produced body must pass
6
6
  * responsesRequestSchema so routing/OAuth/pool/sidecars are inherited unchanged.
7
7
  */
8
+ import { chatImageUrlFromPart } from "./image-parts";
9
+ import { untranslatedChatInputMedia, untranslatedInputMediaMessage } from "../responses/input-media";
10
+
8
11
  export class ChatCompletionsRequestError extends Error {}
9
12
 
10
13
  type Rec = Record<string, unknown>;
@@ -25,7 +28,12 @@ export function assertChatCompletionsRoutingBody(raw: unknown): asserts raw is C
25
28
  }
26
29
  }
27
30
 
28
- const OUTPUT_CONFIG_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
31
+ // "none" is the runtime's disable sentinel, not an unknown value: src/reasoning-effort.ts
32
+ // accepts it and maps it to "omit the reasoning parameter", and the Pi client export maps
33
+ // Pi's "off" thinking level onto it (src/clients/config-export.ts). Dropping it here let a
34
+ // provider default re-enable thinking the caller had explicitly turned off — and for the
35
+ // Anthropic families that think by default, omission is not the same as disabled.
36
+ const OUTPUT_CONFIG_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
29
37
  const OUTPUT_CONFIG_SUMMARIES = new Set(["auto", "concise", "detailed", "none"]);
30
38
 
31
39
  function contentToText(content: unknown): string {
@@ -45,38 +53,9 @@ function contentToText(content: unknown): string {
45
53
  return parts.join("\n");
46
54
  }
47
55
 
48
- function imageUrlFromPart(part: Rec): string | null {
49
- if (part.type === "image_url") {
50
- const imageUrl = part.image_url;
51
- if (typeof imageUrl === "string" && imageUrl.length > 0) return imageUrl;
52
- if (isRec(imageUrl) && typeof imageUrl.url === "string" && imageUrl.url.length > 0) return imageUrl.url;
53
- return null;
54
- }
55
- // Agent clients whose native wire shape is not OpenAI's still send images over
56
- // Chat Completions: Pi/MCP-style parts carry {type:"image", data, mimeType}
57
- // (Aside read_file tool results), Anthropic-shaped clients carry a source
58
- // object. Dropping either silently blinds a vision model, so normalize both
59
- // to the URL/data-URI form the Responses pipeline already understands.
60
- if (part.type === "image") {
61
- const data = part.data;
62
- if (typeof data === "string" && data.length > 0) {
63
- if (data.startsWith("data:")) return data;
64
- const media = typeof part.mimeType === "string" && part.mimeType.length > 0 ? part.mimeType
65
- : typeof part.mediaType === "string" && part.mediaType.length > 0 ? part.mediaType
66
- : "image/png";
67
- return "data:" + media + ";base64," + data;
68
- }
69
- const source = part.source;
70
- if (isRec(source)) {
71
- if (source.type === "base64" && typeof source.data === "string" && source.data.length > 0) {
72
- const media = typeof source.media_type === "string" && source.media_type.length > 0 ? source.media_type : "image/png";
73
- return "data:" + media + ";base64," + source.data;
74
- }
75
- if (source.type === "url" && typeof source.url === "string" && source.url.length > 0) return source.url;
76
- }
77
- }
78
- return null;
79
- }
56
+ // Recognition moved to src/chat/image-parts.ts so the native fast path's
57
+ // route-eligibility predicate and this translator cannot drift apart again.
58
+ const imageUrlFromPart = chatImageUrlFromPart;
80
59
 
81
60
  function videoUrlFromPart(part: Rec): string | null {
82
61
  if (part.type !== "video_url") return null;
@@ -118,6 +97,31 @@ function userContentToBlocks(content: unknown): Rec[] {
118
97
  return blocks;
119
98
  }
120
99
 
100
+ /**
101
+ * The assistant's prior thinking, as plaintext, from either Chat spelling.
102
+ *
103
+ * The outbound direction already reconstructs these for providers listed in
104
+ * `preserveReasoningContentModels` (src/adapters/openai-chat.ts), so a client
105
+ * replaying a turn sends them back. Dropping them here made the round trip lossy and
106
+ * left interleaved-thinking providers seeing a bare continuation.
107
+ *
108
+ * Only representable plaintext is read. No signature, encrypted payload or
109
+ * provider-issued item id is reconstructed — see the reasoning item built below.
110
+ */
111
+ function assistantReasoningText(msg: Rec): string | undefined {
112
+ if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
113
+ return msg.reasoning_content;
114
+ }
115
+ if (Array.isArray(msg.reasoning_details)) {
116
+ const segments: string[] = [];
117
+ for (const raw of msg.reasoning_details) {
118
+ if (isRec(raw) && typeof raw.text === "string" && raw.text.length > 0) segments.push(raw.text);
119
+ }
120
+ if (segments.length > 0) return segments.join("");
121
+ }
122
+ return undefined;
123
+ }
124
+
121
125
  function assistantContentToBlocks(content: unknown): Rec[] {
122
126
  if (typeof content === "string") {
123
127
  return content.length > 0 ? [{ type: "output_text", text: content }] : [];
@@ -274,6 +278,13 @@ function resolveReasoningSummary(raw: Rec): string | undefined {
274
278
  */
275
279
  export function chatCompletionsToResponsesBody(raw: unknown): Rec {
276
280
  assertChatCompletionsRoutingBody(raw);
281
+ // Only the translated path reaches this function. Native Chat can retain its
282
+ // provider-specific file/audio blocks; projecting them here would discard them.
283
+ const unsupportedMedia = untranslatedChatInputMedia(raw);
284
+ if (unsupportedMedia) {
285
+ throw new ChatCompletionsRequestError(untranslatedInputMediaMessage(unsupportedMedia));
286
+ }
287
+
277
288
 
278
289
  const systemParts: string[] = [];
279
290
  const input: Rec[] = [];
@@ -295,11 +306,30 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
295
306
  break;
296
307
  }
297
308
  case "assistant": {
309
+ // A reasoning item precedes the assistant message it belongs to: the
310
+ // Responses assistant item schema admits only output content blocks, so there
311
+ // is no attachment point on the message itself, and the parser buffers a
312
+ // reasoning item and prepends it to the NEXT assistant message. Emitting it
313
+ // here keeps that adjacency intact.
314
+ const reasoningText = assistantReasoningText(msg);
315
+ if (reasoningText !== undefined) {
316
+ input.push({ type: "reasoning", content: [{ type: "reasoning_text", text: reasoningText }] });
317
+ }
298
318
  const blocks = assistantContentToBlocks(msg.content);
299
319
  if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks });
300
320
  if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input, knownNameByCallId);
301
321
  break;
302
322
  }
323
+ case "function": {
324
+ // Native eligibility diverts legacy image results too, but this translator
325
+ // has no legacy function_call/name pairing. Never silently discard them.
326
+ if (Array.isArray(msg.content) && msg.content.some(part => isRec(part) && imageUrlFromPart(part))) {
327
+ throw new ChatCompletionsRequestError(
328
+ "Legacy function-result image translation is not implemented. Use tool_calls and role:tool with tool_call_id.",
329
+ );
330
+ }
331
+ break;
332
+ }
303
333
  case "tool": {
304
334
  const callId = typeof msg.tool_call_id === "string" ? msg.tool_call_id
305
335
  : typeof msg.tool_use_id === "string" ? msg.tool_use_id
@@ -342,6 +372,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
342
372
  if (typeof maxTokens === "number") body.max_output_tokens = maxTokens;
343
373
  if (typeof raw.temperature === "number") body.temperature = raw.temperature;
344
374
  if (typeof raw.top_p === "number") body.top_p = raw.top_p;
375
+ // responsesRequestSchema accepts both, parser.ts reads them into
376
+ // options.presencePenalty/frequencyPenalty, and the openai-chat adapter writes them
377
+ // back to the wire. Only this first link was missing, so a Chat caller's penalties
378
+ // never reached a provider that supports them. Per-model noPenaltyModels opt-outs
379
+ // still apply at the adapter.
380
+ if (typeof raw.presence_penalty === "number") body.presence_penalty = raw.presence_penalty;
381
+ if (typeof raw.frequency_penalty === "number") body.frequency_penalty = raw.frequency_penalty;
345
382
  if (raw.stop !== undefined) body.stop = raw.stop;
346
383
  if (typeof raw.user === "string") body.user = raw.user;
347
384
  if (typeof raw.parallel_tool_calls === "boolean") body.parallel_tool_calls = raw.parallel_tool_calls;
@@ -29,6 +29,8 @@ import {
29
29
  takeFlag,
30
30
  takeIntegerOption,
31
31
  takeOption,
32
+ terminalSafeError,
33
+ terminalSafeText,
32
34
  type RuntimeApiDeps,
33
35
  } from "./runtime-api";
34
36
 
@@ -40,6 +42,15 @@ export interface ClientCommandDeps extends RuntimeApiDeps {
40
42
  export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps {
41
43
  /** Injected in tests; defaults to reading the materialized client catalog off disk. */
42
44
  readCatalogBody?: () => string | null;
45
+ /**
46
+ * A Codex command the caller already resolved, handed over so readiness skips resolving it
47
+ * again. General `ocx status` resolves the full runtime for its diagnostics block; the resolver
48
+ * memo is keyed by discovery scope and holds one entry, so a priority-only readiness resolve
49
+ * and the full one miss each other and re-probe the same command with `--version` — up to eight
50
+ * seconds apiece. Passing the command across adds no cache state, and it cannot disagree with
51
+ * what status prints because it is the selection status is printing.
52
+ */
53
+ selectedCodexCommand?: string;
43
54
  }
44
55
 
45
56
  export const CONNECT_USAGE = `Usage:
@@ -101,11 +112,15 @@ function readInstalledCatalogBody(): string | null {
101
112
  * `codexSupportedReasoningEfforts()` with no deps reaches `resolveAndPersistCodexRuntime`, which
102
113
  * writes codex-runtime.json. `ocx status` deliberately resolves without persisting, and a
103
114
  * read-only diagnostics command should not start writing runtime selection state because a
104
- * readiness check was added to it. Handing the already-resolved command in as the only candidate
105
- * skips that path and reuses the resolve cache `ocx status` has usually already filled.
115
+ * readiness check was added to it. Stop at the first valid runtime, then hand only that command
116
+ * to the catalog probe: readiness does not consume alternative-runtime diagnostics. The resolver
117
+ * keeps this priority-only cache separate from the full discovery used by `ocx status`.
118
+ *
119
+ * A caller that has already resolved passes its selection in through `selectedCodexCommand` rather
120
+ * than paying for a second `--version` probe of the command it just resolved.
106
121
  */
107
- function observeLocalCodexEffortLadder(): ReadonlySet<string> | null {
108
- const command = resolveCodexRuntime().runtime.command;
122
+ function observeLocalCodexEffortLadder(selected?: string): ReadonlySet<string> | null {
123
+ const command = selected ?? resolveCodexRuntime({ discoverAlternatives: false }).runtime.command;
109
124
  return codexSupportedReasoningEfforts({ commandCandidates: () => [command] });
110
125
  }
111
126
 
@@ -117,7 +132,8 @@ function observeLocalCodexEffortLadder(): ReadonlySet<string> | null {
117
132
  * a single `ocx connect`.
118
133
  */
119
134
  function catalogObserver(deps: ClientCatalogProbeDeps | undefined): CatalogCompatibilityDeps {
120
- return { supportedEfforts: deps?.supportedEfforts ?? observeLocalCodexEffortLadder };
135
+ const selected = deps?.selectedCodexCommand;
136
+ return { supportedEfforts: deps?.supportedEfforts ?? (() => observeLocalCodexEffortLadder(selected)) };
121
137
  }
122
138
 
123
139
  /** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */
@@ -212,7 +228,7 @@ function readinessLine(status: ClientConnectionStatus): string {
212
228
  : status.readiness === "incompatible"
213
229
  ? "not ready"
214
230
  : "unverified";
215
- return `Local Codex CLI: ${label}${status.readinessReason ? ` (${status.readinessReason})` : ""}`;
231
+ return `Local Codex CLI: ${label}${status.readinessReason ? ` (${terminalSafeText(status.readinessReason)})` : ""}`;
216
232
  }
217
233
 
218
234
  export type ConnectCompletionReport = {
@@ -249,9 +265,10 @@ export function connectCompletionReport(
249
265
  if (readiness.kind === "unverified") {
250
266
  // Not a failure. A client with no observable Codex CLI is a working configuration, and the
251
267
  // write-time gate deliberately lets it through; saying so is the honest middle report.
252
- return { lines: [connected, `Local Codex CLI: unverified (${readiness.reason}).`], failure: null };
268
+ return { lines: [connected, `Local Codex CLI: unverified (${terminalSafeText(readiness.reason)}).`], failure: null };
253
269
  }
254
- const verdict = `Local Codex CLI: not ready (${readiness.reason})`;
270
+ const safeReason = terminalSafeText(readiness.reason);
271
+ const verdict = `Local Codex CLI: not ready (${safeReason})`;
255
272
  if (!selectedClients.includes("codex")) {
256
273
  return {
257
274
  lines: [connected, `${verdict} This connection selected ${selectedClients.join(", ")}, so nothing here launches Codex.`],
@@ -260,7 +277,7 @@ export function connectCompletionReport(
260
277
  }
261
278
  return {
262
279
  lines: [verdict, `The connection to ${connection.serverUrl} as key ${connection.apiKeyId} was saved; run 'ocx connect status' to see it.`],
263
- failure: `client_not_ready: ${readiness.reason}`,
280
+ failure: `client_not_ready: ${safeReason}`,
264
281
  };
265
282
  }
266
283
 
@@ -344,6 +361,10 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise<void
344
361
  // production would let the gate fall back to its own probing, persisting default, so one
345
362
  // command could run two probes and act on two different ladders.
346
363
  catalogCompatibility: catalogObserver(deps.catalogProbeDeps),
364
+ }).catch((error: unknown) => {
365
+ // Compatibility refusals happen before the completion report and reach stderr.
366
+ // Keep the domain error untouched; render its message only at the CLI boundary.
367
+ throw terminalSafeError(error);
347
368
  });
348
369
  // The hub and the credential are proven at this point; the local runtime is not. Reporting
349
370
  // only the first half is what #4207 was filed for, so the catalog now on disk is checked
@@ -28,7 +28,7 @@ import { restoreNativeCodexAsync } from "../codex/inject";
28
28
  import { stripGrokConfig } from "../grok/inject";
29
29
  import { handleRestartScopeAfterWrite, readRestartScope, type RestartScope } from "./restart-scope";
30
30
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
31
- import { isJsonOption, takeFlag } from "./runtime-api";
31
+ import { isJsonOption, takeFlag, terminalSafeError } from "./runtime-api";
32
32
  import type { ClientConnectionState } from "../client/state";
33
33
  import { OCX_NATIVE_REPLAY_RECOVERY_NOTE } from "../responses/compaction";
34
34
 
@@ -70,7 +70,7 @@ export function selectDefaultGuiUrl(
70
70
  probeHostname: (hostname: string | undefined) => string,
71
71
  ): string {
72
72
  const ingress = config.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
73
- if (ingress?.enabled) return `http://localhost:${ingress.port}`;
73
+ if (ingress?.enabled) return `http://127.0.0.1:${ingress.port}`;
74
74
 
75
75
  const guiHost = probeHostname(live?.hostname ?? config.hostname);
76
76
  const hostname = guiHost === "127.0.0.1" ? "localhost" : guiHost;
@@ -405,7 +405,11 @@ const commandRunners: Record<string, CommandRunner> = {
405
405
  // types it as `number | string`; only a numeric code means anything here.
406
406
  return typeof process.exitCode === "number" ? process.exitCode : 0;
407
407
  } catch (error) {
408
- console.error(`Connected sync failed without local fallback: ${error instanceof Error ? error.message : String(error)}`);
408
+ // The refresh path reaches the same hub catalog `ocx connect` validates, so a rejected
409
+ // reasoning level arrives here as hub-supplied text. Rendering it through the shared
410
+ // terminal boundary is what keeps the routine refresh from forging output; the domain
411
+ // error itself is left alone for callers that inspect it.
412
+ console.error(`Connected sync failed without local fallback: ${terminalSafeError(error).message}`);
409
413
  return 1;
410
414
  }
411
415
  }
package/src/cli/index.ts CHANGED
@@ -732,6 +732,9 @@ function reportRestartFailure(result: Extract<ProxyRestartResult, { ok: false }>
732
732
  if (code === "restart_capability_unsupported") {
733
733
  console.error("❌ The running proxy predates process-bound restart support; no unsafe fallback was attempted.");
734
734
  console.error(" After confirming this home owns the proxy, run `ocx stop` and then `ocx start` once.");
735
+ } else if (code === "restart_version_skew") {
736
+ console.error("❌ The running proxy reports a different OpenCodex version than this CLI; restarting in place would respawn the old installation.");
737
+ console.error(" Run `ocx stop` and then `ocx start` from this installation instead.");
735
738
  } else {
736
739
  console.error("❌ Proxy restart request could not be confirmed; no fallback stop/start was attempted.");
737
740
  }
@@ -350,6 +350,31 @@ export function printData(value: unknown, wantsJson: boolean, lines?: string[]):
350
350
  else for (const line of lines) console.log(line);
351
351
  }
352
352
 
353
+ /**
354
+ * Render untrusted diagnostic text without letting it control the operator's terminal. Catalog
355
+ * values are hub-supplied and surface on more than one CLI path -- first-time `ocx connect` and the
356
+ * connected `ocx sync` refresh both print them -- so the escaping sits beside `printData`, at the
357
+ * one boundary that already separates human output from structured output. Structured output keeps
358
+ * the exact value: escaping is a rendering decision for a tty, not a change to the data.
359
+ */
360
+ export function terminalSafeText(value: string): string {
361
+ return value.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, character => {
362
+ const code = character.charCodeAt(0);
363
+ return code <= 0x7f
364
+ ? `\\x${code.toString(16).padStart(2, "0")}`
365
+ : `\\u${code.toString(16).padStart(4, "0")}`;
366
+ });
367
+ }
368
+
369
+ /**
370
+ * The same rendering for a failure about to be printed or rethrown. The original is kept as
371
+ * `cause` rather than discarded, so a caller that inspects the domain error still reads the exact
372
+ * message and fields it threw.
373
+ */
374
+ export function terminalSafeError(error: unknown): Error {
375
+ return new Error(terminalSafeText(error instanceof Error ? error.message : String(error)), { cause: error });
376
+ }
377
+
353
378
  /** Compact human view for safe management DTOs; JSON remains available for complete fidelity. */
354
379
  export function summaryLines(value: unknown, prefix = "", depth = 0): string[] {
355
380
  if (!value || typeof value !== "object" || depth > 1) return [`${prefix || "value"}: ${String(value)}`];
package/src/cli/status.ts CHANGED
@@ -509,7 +509,27 @@ export async function collectStatus(): Promise<CliStatusView> {
509
509
  desiredEnabled: claudeDesktopIntegrationEnabled(config),
510
510
  policy: claudeDesktopPolicyHealth(probeClaudeDesktopPolicy()),
511
511
  };
512
- const clientConnection = collectClientConnectionStatus();
512
+ const resolvedRuntime = (() => {
513
+ try {
514
+ return resolveCodexRuntime();
515
+ } catch (error) {
516
+ const message = error instanceof Error ? error.message : String(error);
517
+ const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
518
+ return {
519
+ runtime: { command: "codex", version: null, source: "fallback" as const },
520
+ failures: [{
521
+ command: "codex",
522
+ source: "fallback" as const,
523
+ reason: `resolve threw: ${redacted}`,
524
+ }],
525
+ replacedConfigured: undefined,
526
+ newerAvailable: undefined,
527
+ };
528
+ }
529
+ })();
530
+ const clientConnection = collectClientConnectionStatus(Date.now(), undefined, {
531
+ selectedCodexCommand: resolvedRuntime.runtime.command,
532
+ });
513
533
  // Asked before the local probes below so a connected client's report is hub-sourced from its
514
534
  // first line. Bounded and failure-tolerant: an offline hub degrades the remoteHub block, it
515
535
  // does not fail `ocx status`.
@@ -567,24 +587,6 @@ export async function collectStatus(): Promise<CliStatusView> {
567
587
  routingKind: getCodexRoutingKind(),
568
588
  });
569
589
  const codexPlugins = diagnoseCodexBundledPlugins();
570
- const resolvedRuntime = (() => {
571
- try {
572
- return resolveCodexRuntime();
573
- } catch (error) {
574
- const message = error instanceof Error ? error.message : String(error);
575
- const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
576
- return {
577
- runtime: { command: "codex", version: null, source: "fallback" as const },
578
- failures: [{
579
- command: "codex",
580
- source: "fallback" as const,
581
- reason: `resolve threw: ${redacted}`,
582
- }],
583
- replacedConfigured: undefined,
584
- newerAvailable: undefined,
585
- };
586
- }
587
- })();
588
590
  const lastClamp = loadLastEffortClamp();
589
591
  const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime);
590
592
  const codexHome = collectOrcaCodexHomeDiagnostic();
@@ -22,6 +22,8 @@ import {
22
22
  type LiveProxy,
23
23
  } from "../server/proxy-liveness";
24
24
  import type { ProxyRestartRequestOutcome } from "./tray-proxy";
25
+ import { packageVersion } from "./help";
26
+ import { computeVersionSkew } from "./version-skew";
25
27
 
26
28
  export const SYSTEM_RESTART_REQUEST_TIMEOUT_MS = 5_000;
27
29
  export const SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS = 4_000;
@@ -32,12 +34,23 @@ export interface BoundSystemRestartDeps {
32
34
  findLive?: typeof findLiveProxy;
33
35
  createChallenge?: () => string;
34
36
  now?: () => number;
37
+ /** Invoking CLI version for the skew guard; defaults to this bundle's package version. */
38
+ cliVersion?: string;
35
39
  }
36
40
 
37
41
  function rejected(code: string): ProxyRestartRequestOutcome {
38
42
  return { accepted: false, uncertain: false, error: new Error(code) };
39
43
  }
40
44
 
45
+ /** Own-bundle version for the skew comparison; an unreadable bundle is "cannot compare", not a crash. */
46
+ function ownCliVersion(): string {
47
+ try {
48
+ return packageVersion();
49
+ } catch {
50
+ return "unknown";
51
+ }
52
+ }
53
+
41
54
  function uncertain(code: string): ProxyRestartRequestOutcome {
42
55
  return { accepted: false, uncertain: true, error: new Error(code) };
43
56
  }
@@ -107,6 +120,18 @@ export async function requestBoundSystemRestart(
107
120
  return rejected("restart_capability_unsupported");
108
121
  }
109
122
 
123
+ // An in-place restart respawns the live process from its own installation
124
+ // (selfLaunchArgv in server/management/system-restart.ts), so a restart accepted
125
+ // from a different-version CLI would keep the OLD build serving while reporting
126
+ // success (#4522). Both sides already publish exactly the data doctor's skew
127
+ // diagnosis compares (packageVersion vs the /healthz version), so reuse that
128
+ // comparison and refuse before POST. Placeholder versions (unknown/0.0.0) are
129
+ // "cannot compare", not mismatch, and keep the existing behavior.
130
+ const proxyVersion = typeof body.version === "string" ? body.version : undefined;
131
+ if (computeVersionSkew(deps.cliVersion ?? ownCliVersion(), proxyVersion).skewed) {
132
+ return rejected("restart_version_skew");
133
+ }
134
+
110
135
  let observed: LiveProxy | null;
111
136
  try {
112
137
  observed = await (deps.findLive ?? findLiveProxy)({ deadlineAt, nowFn: now });
@@ -627,10 +627,20 @@ function opencodeProviderConnection(baseURL: string, config: OcxConfig): Opencod
627
627
  * override a default the user controls in opencodex. Variants are opt-in per selection,
628
628
  * which is the same reason we never emit `defaultModel` for MCode.
629
629
  *
630
- * `none` is dropped even when a ladder declares it. It is a valid *declared* effort, but the
631
- * chat ingress filters wire efforts against `OUTPUT_CONFIG_EFFORTS`, which has no `none`, so
632
- * selecting it would send no effort at all and silently fall back to the proxy default — a
633
- * selectable value that cannot do what its label says. Same call MCode makes for its picker.
630
+ * `none` is dropped even when a ladder declares it.
631
+ *
632
+ * The original reason no longer holds and is recorded here so it is not repeated: the chat
633
+ * ingress `OUTPUT_CONFIG_EFFORTS` allowlist DID omit `none`, so selecting it sent no effort
634
+ * at all and fell back to the proxy default. That allowlist now accepts `none` (audit F7),
635
+ * because it is the runtime's disable sentinel and dropping it let a provider default
636
+ * re-enable thinking a caller had turned off.
637
+ *
638
+ * The variant stays filtered anyway, deliberately and narrowly: emitting it would change
639
+ * what this exporter writes into a user's opencode config, and whether opencode's own
640
+ * picker round-trips `reasoningEffort: "none"` to the wire this proxy reads has not been
641
+ * verified here. Re-enabling it is a scoped follow-up that needs that check first, not a
642
+ * side effect of an ingress fix. MCode and ZCode filter `none` for their own separate
643
+ * reasons, documented at their call sites.
634
644
  */
635
645
  function opencodeEffortVariants(model: OpencodeCatalogModel): OpencodeModelVariant[] | undefined {
636
646
  if (model.reasoningEfforts === undefined) return undefined;
@@ -524,6 +524,31 @@ function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number |
524
524
  return listUnixProcSnapshots(getuid());
525
525
  }
526
526
 
527
+ export interface ListProcessSnapshotsOptions {
528
+ platform?: NodeJS.Platform;
529
+ getuid?: () => number | undefined;
530
+ }
531
+
532
+ /**
533
+ * Raw process snapshots for callers that need to match their own predicate.
534
+ *
535
+ * Throws on enumeration failure. That is the contract routing-adoption needs:
536
+ * a thrown read is "could not enumerate" and must never collapse to an empty
537
+ * list. listCodexAppServerProcesses maps the same failure to [] for the #476
538
+ * kill path, which would otherwise print a false adopted for #4550.
539
+ */
540
+ export function listProcessSnapshots(options: ListProcessSnapshotsOptions = {}): ProcessSnapshot[] {
541
+ const platform = options.platform ?? process.platform;
542
+ const getuid = options.getuid ?? (() => {
543
+ try {
544
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
545
+ } catch {
546
+ return undefined;
547
+ }
548
+ });
549
+ return defaultListSnapshots(platform, getuid);
550
+ }
551
+
527
552
  export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcess[] {
528
553
  const platform = io.platform ?? process.platform;
529
554
  const getuid = io.getuid ?? (() => {