@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -19,15 +19,32 @@ interface ProviderQuotasDto {
19
19
  reports?: ProviderQuotaReportDto[];
20
20
  }
21
21
 
22
+ interface QuotaResetEventDto {
23
+ kind?: string;
24
+ scope?: string;
25
+ window?: string;
26
+ percentBefore?: number;
27
+ percentAfter?: number;
28
+ resetAt?: number;
29
+ detectedAt?: number;
30
+ }
31
+
32
+ interface QuotaResetsDto {
33
+ enabled?: boolean;
34
+ events?: QuotaResetEventDto[];
35
+ }
36
+
22
37
  const USAGE = `Usage:
23
38
  ocx provider edit <name> [--adapter <id>] [--base-url <url>] [--default-model <id|->]
24
39
  [--auth-mode <key|forward|oauth|local|->] [--note <text|->]
25
40
  [--api-key-transport <x-api-key|bearer|->]
26
41
  [--headers <json>] [--enabled <on|off>] [--live-models <on|off>]
27
42
  [--retain-models <id,id|->]
43
+ [--xai-chat <on|off>]
28
44
  [--allow-private-network <on|off>] [--json]
29
45
  ocx provider test <name> [--json]
30
46
  ocx provider quota [--refresh] [--json]
47
+ ocx provider resets [--limit <n>] [--json]
31
48
  ocx provider presets [--json]
32
49
  ocx provider account-mode <pool|direct> [--json]
33
50
  ocx provider selected <name> [--set <model,model...>] [--clear] [--json]
@@ -54,7 +71,12 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
54
71
  const enabled = takeBooleanOption(args, "--enabled");
55
72
  const liveModels = takeBooleanOption(args, "--live-models");
56
73
  const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network");
74
+ const xaiChat = takeBooleanOption(args, "--xai-chat");
57
75
  rejectArgs(args, USAGE);
76
+ if (xaiChat !== undefined) {
77
+ if (name !== "xai") throw new CliUsageError("--xai-chat is valid only for provider xai", USAGE);
78
+ patch.xaiResponsesOptIn = !xaiChat;
79
+ }
58
80
  if (adapter !== undefined) patch.adapter = adapter;
59
81
  if (baseUrl !== undefined) patch.baseUrl = baseUrl;
60
82
  if (defaultModel !== undefined) patch.defaultModel = defaultModel;
@@ -132,6 +154,48 @@ async function quota(argv: string[], deps: RuntimeApiDeps): Promise<void> {
132
154
  printData(result, wantsJson, lines);
133
155
  }
134
156
 
157
+
158
+ /**
159
+ * Recently detected quota resets, newest first.
160
+ *
161
+ * `accountTag` is deliberately NOT rendered: it is a salted hash that means nothing to a human
162
+ * reading a terminal, and printing it invites treating an opaque tag as an account identifier.
163
+ */
164
+ function quotaResetLine(event: QuotaResetEventDto): string {
165
+ const when = typeof event.detectedAt === "number"
166
+ ? new Date(event.detectedAt).toISOString()
167
+ : "unknown time";
168
+ const movement = typeof event.percentBefore === "number" && typeof event.percentAfter === "number"
169
+ ? `${event.percentBefore}% -> ${event.percentAfter}%`
170
+ : "usage unknown";
171
+ const next = typeof event.resetAt === "number"
172
+ ? `, next ${new Date(event.resetAt).toISOString()}`
173
+ : "";
174
+ return `${when} ${event.scope ?? "?"} ${event.window ?? "?"} ${event.kind ?? "?"}: ${movement}${next}`;
175
+ }
176
+
177
+ async function resets(argv: string[], deps: RuntimeApiDeps): Promise<void> {
178
+ const args = [...argv];
179
+ const wantsJson = takeFlag(args, "--json");
180
+ const limitRaw = takeOption(args, "--limit");
181
+ rejectArgs(args, USAGE);
182
+ if (limitRaw !== undefined && !/^\d+$/.test(limitRaw)) {
183
+ throw new CliUsageError("--limit must be a non-negative integer", USAGE);
184
+ }
185
+ const query = limitRaw === undefined ? "" : `?limit=${limitRaw}`;
186
+ const result = await runtimeRequest<QuotaResetsDto>(`/api/quota-resets${query}`, {}, deps);
187
+ const events = Array.isArray(result?.events) ? result.events : [];
188
+ // One line per event, NOT summaryLines: that helper is a depth-1 flattener and renders a
189
+ // non-scalar array as "N item(s)", which is what made `ocx provider quota` useless in #2565.
190
+ const lines = events.length > 0
191
+ ? events.map(quotaResetLine)
192
+ // An empty list is ambiguous, so say which kind of empty it is. Without this an operator
193
+ // cannot tell "nothing has reset yet" from "I never turned this on".
194
+ : [result?.enabled === true
195
+ ? "no resets detected yet"
196
+ : "quota-reset notifications are disabled (set quotaResetNotify.enabled)"];
197
+ printData(result, wantsJson, lines);
198
+ }
135
199
  async function presets(argv: string[], deps: RuntimeApiDeps): Promise<void> {
136
200
  const args = [...argv];
137
201
  const wantsJson = takeFlag(args, "--json");
@@ -210,6 +274,7 @@ export async function handleProviderRuntimeCommand(sub: string, argv: string[],
210
274
  update: edit,
211
275
  test: testProvider,
212
276
  quota,
277
+ resets,
213
278
  presets,
214
279
  "account-mode": accountMode,
215
280
  selected,
@@ -18,6 +18,7 @@ import type { OcxProviderConfig } from "../types";
18
18
  import { findLiveProxy } from "../server/proxy-liveness";
19
19
  import { syncModelsToCodex } from "../codex/sync";
20
20
  import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
21
+ import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance";
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // Arg helpers
@@ -212,6 +213,8 @@ async function handleAdd(args: string[]): Promise<void> {
212
213
  }
213
214
 
214
215
  const existingProvider = config.providers[name];
216
+ const { initializeProviderModelSelection } = await import("../providers/initial-model-selection");
217
+ initializeProviderModelSelection(name, provConfig, existingProvider, config);
215
218
  config.providers[name] = provConfig;
216
219
  // A --force overwrite rotates the key/endpoint but must not drop a
217
220
  // user-configured price overlay (same rule as the /api/providers path and
@@ -227,6 +230,7 @@ async function handleAdd(args: string[]): Promise<void> {
227
230
  if (wantsJson) {
228
231
  console.log(JSON.stringify({
229
232
  action: "added",
233
+ modelSelection: modelSelectionNextSteps(name),
230
234
  provider: name,
231
235
  adapter: provConfig.adapter,
232
236
  baseUrl: provConfig.baseUrl,
@@ -255,6 +259,7 @@ async function handleAdd(args: string[]): Promise<void> {
255
259
 
256
260
  const registryLabel = registryEntry ? ` (${registryEntry.label})` : "";
257
261
  console.log(`✅ Provider "${name}"${registryLabel} added.`);
262
+ for (const line of modelSelectionGuidance(name)) console.log(line);
258
263
  if (setDefault) console.log(` Set as default provider.`);
259
264
  if (registryEntry?.authKind === "oauth") {
260
265
  console.log(` Authenticate with: ocx login ${name}`);
@@ -433,6 +438,7 @@ Subcommands:
433
438
  set-default <name> Change the default provider
434
439
  selected <name> Show or set the provider model allowlist
435
440
  quota Show provider quota reports
441
+ resets Show recently detected quota resets
436
442
  presets List GUI provider presets
437
443
  account-mode <mode> Set OpenAI Codex pool/direct mode
438
444
 
@@ -442,6 +448,8 @@ Examples:
442
448
  ocx provider add my-ollama --adapter openai-chat --base-url http://localhost:11434/v1
443
449
  ocx provider show anthropic --json
444
450
  ocx provider set-default anthropic
451
+ ocx provider edit xai --xai-chat on # opt Grok 4.5/4.6 into Chat Completions
452
+ ocx provider edit xai --xai-chat off # use Responses again
445
453
  ocx provider remove my-ollama`;
446
454
 
447
455
  export async function handleProviderCommand(args: string[]): Promise<void> {
@@ -228,6 +228,19 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
228
228
  usage: "ocx route combo <list|show|set|remove> ...",
229
229
  summary: "Manage routing features; combo is currently the supported routing resource.",
230
230
  },
231
+ {
232
+ name: "effort",
233
+ usage: "ocx effort [status|<level>|set|clear|model] [--main <level|->] [--subagent <level|->] [--injection <level|->] [--json]",
234
+ summary: "Inspect and configure reasoning effort caps and defaults.",
235
+ details: [
236
+ "With no arguments or `status`, displays effective effort caps, injection effort, and supported rungs.",
237
+ "`ocx effort <level>` (or `set --main <level>`) sets the global/main-agent reasoning ceiling.",
238
+ "`--subagent <level>` sets the hard ceiling for delegated sub-agent turns.",
239
+ "`ocx effort clear` (or `set --main - --subagent -`) removes main and subagent caps but preserves injection effort; use `ocx effort set --injection -` to clear it.",
240
+ "`ocx effort model <provider/model|model>` inspects a model's configured ladder, disabled status, and wire mappings.",
241
+ "Works both online (via live proxy API) and offline (modifies persisted config safely with atomic writes).",
242
+ ],
243
+ },
231
244
  {
232
245
  name: "agent",
233
246
  usage: "ocx agent <status|injection|effort|subagents|fallback|sidecar> ...",
@@ -315,13 +328,14 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
315
328
  {
316
329
  name: "claude",
317
330
  usage: "ocx claude [claude args...]",
318
- summary: "Launch Claude Code wired to the proxy (env injection + gateway model discovery).",
331
+ summary: "Launch Claude Code through the proxy, with native fallback when Claude routing is disabled.",
319
332
  details: [
320
333
  "Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,",
321
334
  "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.",
335
+ "When Claude routing is explicitly disabled, it launches natively after removing proven OpenCodex-owned proxy state.",
322
336
  "Routed models appear in the native /model picker with stable claude-opus-4-8-2026MMDD slot aliases (Claude Code >= 2.1.129).",
323
337
  "Older versions: pick models via ANTHROPIC_MODEL or /model <id> directly (any string passes through).",
324
- "User-exported ANTHROPIC_* variables always take precedence.",
338
+ "User-exported ANTHROPIC_* variables take precedence for routed launches; native fallback removes only proven OpenCodex-owned proxy values.",
325
339
  "",
326
340
  "Claude Desktop profile:",
327
341
  " ocx claude desktop [apply] Save and apply the four-family profile",
@@ -9,7 +9,7 @@
9
9
  * - 다른 대안 대신 이 방식을 선택한 이유: GUI/CLI의 검증 규칙이 갈라지지 않고 fallback port도 안전하게 찾는다.
10
10
  * - 장점, 단점 및 영향: 동작 일관성이 높아지는 대신 live 관리 명령은 실행 중인 proxy가 필요하다.
11
11
  */
12
- import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
12
+ import { findLiveProxy, probeHostname, type LivenessIo, type LiveProxy } from "../server/proxy-liveness";
13
13
  import { runningProxyUpdateHeaders } from "../oauth/login-cli";
14
14
 
15
15
  export type CliStdin = NodeJS.ReadableStream & { isTTY?: boolean; readableEnded?: boolean };
@@ -20,6 +20,8 @@ export interface RuntimeApiDeps {
20
20
  /** Test injection for commands that read a secret from stdin instead of argv. */
21
21
  stdinImpl?: CliStdin;
22
22
  stdinTimeoutMs?: number;
23
+ /** Optional proxy liveness probe injection for commands that check or fall back around live runtime state. */
24
+ findLiveProxy?: (io?: LivenessIo) => Promise<LiveProxy | null>;
23
25
  }
24
26
 
25
27
  export class CliUsageError extends Error {
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { isatty } from "node:tty";
3
4
  import { spawnSync } from "node:child_process";
4
5
  import { getConfigDir } from "../config";
5
6
  import { recordOwnedConfigPath } from "../lib/config-ownership";
@@ -86,11 +87,20 @@ function ghAvailable(): boolean {
86
87
  }
87
88
 
88
89
  /** Test seam: replace gh/interactiveConfirm so the full prompt flow is
89
- * drivable without a real gh login or a TTY conversation. */
90
- let depsForTests: { ghAvailable?: () => boolean; interactiveConfirm?: typeof interactiveConfirm } | null = null;
91
- export function setStarPromptDepsForTests(
92
- deps: { ghAvailable?: () => boolean; interactiveConfirm?: typeof interactiveConfirm } | null,
93
- ): void {
90
+ * drivable without a real gh login or a TTY conversation.
91
+ *
92
+ * `isTty` is part of the seam because the guard reads the file descriptors directly through
93
+ * `isatty` rather than `process.stdin.isTTY`: touching the stream properties would make Bun
94
+ * construct the stream, which dereferences the working directory and throws when that directory
95
+ * has been unlinked (#3400). A test therefore cannot fake a TTY by redefining those properties,
96
+ * so it overrides the decision here instead. */
97
+ type StarPromptTestDeps = {
98
+ ghAvailable?: () => boolean;
99
+ interactiveConfirm?: typeof interactiveConfirm;
100
+ isTty?: () => boolean;
101
+ };
102
+ let depsForTests: StarPromptTestDeps | null = null;
103
+ export function setStarPromptDepsForTests(deps: StarPromptTestDeps | null): void {
94
104
  depsForTests = deps;
95
105
  }
96
106
 
@@ -167,7 +177,13 @@ function printAgentDeferral(): void {
167
177
  */
168
178
  export async function maybeShowStarPrompt(): Promise<void> {
169
179
  try {
170
- if (process.env.OCX_SERVICE || !process.stdin.isTTY || !process.stdout.isTTY) return;
180
+ let isTty = false;
181
+ try {
182
+ isTty = depsForTests?.isTty ? depsForTests.isTty() : isatty(0) && isatty(1);
183
+ } catch {
184
+ /* best-effort */
185
+ }
186
+ if (process.env.OCX_SERVICE || !isTty) return;
171
187
  const dir = getConfigDir();
172
188
  const marker = join(dir, MARKER);
173
189
  if (existsSync(marker)) return;
@@ -0,0 +1,168 @@
1
+ import { readPidFileValue, readRuntimePort } from "../config/process-state";
2
+ import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
3
+ import { directLocalHttpFetch } from "../server/direct-local-http";
4
+ import { isProcessAlive } from "../lib/process-control";
5
+
6
+ type HealthCheck = {
7
+ ok: boolean;
8
+ url: string;
9
+ message: string;
10
+ label: string;
11
+ /** True only for a connect-phase refusal: proof that nothing holds the port. */
12
+ refused?: boolean;
13
+ };
14
+
15
+ export type ListenTarget = {
16
+ port: number;
17
+ hostname?: string;
18
+ source: "runtime" | "config";
19
+ healthUrl: string;
20
+ dashboardUrl: string;
21
+ };
22
+
23
+ export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): "timed out" | "unreachable" {
24
+ return signal.aborted || (error instanceof Error && error.name === "AbortError")
25
+ ? "timed out"
26
+ : "unreachable";
27
+ }
28
+
29
+ /**
30
+ * "Nothing is listening" is narrower than "the probe failed". `unreachable` covers every
31
+ * non-abort failure, including a socket that was ACCEPTED and then reset — which is what
32
+ * an in-flight start looks like mid-bind. Only a connect-phase refusal proves the port is
33
+ * free, so this reads the underlying errno instead of the display string.
34
+ */
35
+ export function isConnectionRefused(error: unknown): boolean {
36
+ for (let current: unknown = error, depth = 0; current instanceof Error && depth < 4; depth++) {
37
+ const code = (current as { code?: unknown }).code;
38
+ if (code === "ECONNREFUSED" || code === "ConnectionRefused") return true;
39
+ // Bun surfaces the refusal as a plain message on some platforms; the errno name is
40
+ // still the discriminator, not a substring of arbitrary prose.
41
+ if (typeof code === "string" && code.endsWith("ECONNREFUSED")) return true;
42
+ current = (current as { cause?: unknown }).cause;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /**
48
+ * A proxy killed by a native trap or SIGKILL never runs the exit cleanup that removes
49
+ * `ocx.pid` and `runtime-port.json` (only SIGINT/SIGTERM/SIGHUP and normal exit are
50
+ * wired to it), so both records outlive it. That makes "crashed" and "never started"
51
+ * distinguishable — and #1419 is what it costs when we discard the distinction: the
52
+ * reporter's unsupervised `ocx gui` proxy died and every later command said only
53
+ * "not running", never that a previous process had exited or that a service would
54
+ * have restarted it.
55
+ *
56
+ * Two races have to stay closed, because a false "it crashed" is worse than a missing
57
+ * hint. `handleStart` binds the port BEFORE it publishes either record, so:
58
+ *
59
+ * - a start that publishes between two reads is caught by comparing the raw records
60
+ * observed before and after the probes (the same snapshot discipline
61
+ * `removePidIfValueIs` uses for deletion);
62
+ * - a start that has bound but not yet published leaves both snapshots identical, so
63
+ * records alone cannot see it. That one is excluded on the port instead: the probe
64
+ * must have been REFUSED at connect, which is the only outcome proving nothing holds
65
+ * the port. A socket that is accepted and then reset — an in-flight bind — is not a
66
+ * refusal, so review caught `unreachable` being too broad for this job.
67
+ *
68
+ * What this can and cannot prove: the records outliving their process establish that the
69
+ * previous run did not complete its cleanup. It does not establish a cause, and it cannot
70
+ * fully exclude a clean exit whose `unlinkSync` failed, because cleanup ignores that
71
+ * error (`src/cli/index.ts:324-325`) and the records carry no session provenance. The
72
+ * wording therefore says the records remain and the run MAY have exited unexpectedly.
73
+ */
74
+ export function isUncleanExitEvidence(input: {
75
+ live: boolean;
76
+ healthOk: boolean;
77
+ healthRefused: boolean;
78
+ ownerPidAlive: boolean;
79
+ pidRecordBefore: number | null;
80
+ pidRecordAfter: number | null;
81
+ runtimePidBefore: number | null;
82
+ runtimePidAfter: number | null;
83
+ }): boolean {
84
+ if (input.live || input.healthOk) return false;
85
+ if (!input.healthRefused) return false;
86
+ if (input.ownerPidAlive) return false;
87
+ if (input.pidRecordBefore !== input.pidRecordAfter) return false;
88
+ if (input.runtimePidBefore !== input.runtimePidAfter) return false;
89
+ return input.pidRecordAfter !== null || input.runtimePidAfter !== null;
90
+ }
91
+
92
+ export async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
93
+ const url = target.healthUrl;
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => controller.abort(), 800);
96
+ try {
97
+ const response = await directLocalHttpFetch(url, { signal: controller.signal });
98
+ if (!response.ok) {
99
+ const message = `returned HTTP ${response.status}`;
100
+ return { ok: false, url, message, label: `${url} ${message}` };
101
+ }
102
+ const body = await response.json().catch(() => null) as { service?: unknown; status?: unknown; version?: unknown; uptime?: unknown } | null;
103
+ if (!isOpencodexHealthz(body)) {
104
+ const message = "responded, but not an opencodex proxy";
105
+ return { ok: false, url, message, label: `${url} ${message}` };
106
+ }
107
+ const version = typeof body?.version === "string" ? ` v${body.version}` : "";
108
+ const uptime = typeof body?.uptime === "number" ? `, uptime ${Math.round(body.uptime)}s` : "";
109
+ const message = `ok${version}${uptime}`;
110
+ return { ok: true, url, message, label: `${url} ${message}` };
111
+ } catch (error) {
112
+ const reason = proxyHealthFailureReason(error, controller.signal);
113
+ return { ok: false, url, message: reason, label: `${url} ${reason}`, refused: isConnectionRefused(error) };
114
+ } finally {
115
+ clearTimeout(timer);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * The ONE evidence gatherer for stale-process state, shared by `ocx status` and
121
+ * `ocx doctor`.
122
+ *
123
+ * It deliberately probes the port named by the STALE RECORD, not the configured display
124
+ * port. Review found the two commands disagreeing precisely here: a proxy that hopped to
125
+ * a fallback port, or a config whose port changed after the crash, left status probing
126
+ * the configured port while doctor probed the recorded one, so one reported a crash and
127
+ * the other did not. The question being asked is "is the process that wrote this record
128
+ * gone?", and only that record's own port can answer it.
129
+ *
130
+ * `live` short-circuits before the probe so a healthy install pays nothing.
131
+ */
132
+ export async function probeUncleanExitState(input: {
133
+ live: boolean;
134
+ port?: number;
135
+ hostname?: string | null;
136
+ }): Promise<boolean> {
137
+ if (input.live) return false;
138
+ const pidRecordBefore = readPidFileValue();
139
+ const runtimeBefore = readRuntimePort();
140
+ const runtimePidBefore = runtimeBefore?.pid ?? null;
141
+ if (pidRecordBefore === null && runtimePidBefore === null) return false;
142
+ const ownerPid = pidRecordBefore ?? runtimePidBefore;
143
+ if (ownerPid !== null && isProcessAlive(ownerPid)) return false;
144
+ // The recorded port is the evidence target. Fall back to the configured port only when
145
+ // no runtime record exists, which is the pid-file-only case.
146
+ const port = runtimeBefore?.port ?? input.port ?? 10100;
147
+ const hostname = runtimeBefore?.hostname ?? input.hostname ?? undefined;
148
+ const health = await checkProxyHealth({
149
+ port,
150
+ hostname,
151
+ source: runtimeBefore ? "runtime" : "config",
152
+ healthUrl: `http://${probeHostname(hostname)}:${port}/healthz`,
153
+ dashboardUrl: `http://localhost:${port}/`,
154
+ });
155
+ const pidRecordAfter = readPidFileValue();
156
+ const runtimePidAfter = readRuntimePort()?.pid ?? null;
157
+ const ownerPidAfter = pidRecordAfter ?? runtimePidAfter;
158
+ return isUncleanExitEvidence({
159
+ live: false,
160
+ healthOk: health.ok,
161
+ healthRefused: health.refused === true,
162
+ ownerPidAlive: ownerPidAfter !== null && isProcessAlive(ownerPidAfter),
163
+ pidRecordBefore,
164
+ pidRecordAfter,
165
+ runtimePidBefore,
166
+ runtimePidAfter,
167
+ });
168
+ }
package/src/cli/status.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  import { durableBunRuntime } from "../lib/bun-runtime";
2
2
  import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config";
3
- import { getPidPath, readPid, readPidFileValue, readRuntimePort, type RuntimePortState } from "../config/process-state";
3
+ import { getPidPath, readPid, readRuntimePort, type RuntimePortState } from "../config/process-state";
4
4
  import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor";
5
- import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
6
- import { directLocalHttpFetch } from "../server/direct-local-http";
5
+ import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
7
6
  import type { OcxConfig } from "../types";
8
7
  import { diagnoseService, serviceLogPath } from "../service";
9
8
  import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
10
- import { isProcessAlive } from "../lib/process-control";
11
9
  import { getCodexRoutingKind } from "../codex/inject";
12
10
  import { diagnoseCodexShim } from "../codex/shim";
13
11
  import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";
@@ -19,15 +17,9 @@ import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status";
19
17
  import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
20
18
  import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy";
21
19
  import { collectClientConnectionStatus } from "./connect";
22
-
23
- type HealthCheck = {
24
- ok: boolean;
25
- url: string;
26
- message: string;
27
- label: string;
28
- /** True only for a connect-phase refusal: proof that nothing holds the port. */
29
- refused?: boolean;
30
- };
20
+ export { proxyHealthFailureReason, isConnectionRefused, isUncleanExitEvidence, probeUncleanExitState } from "./status-probes";
21
+ export type { ListenTarget } from "./status-probes";
22
+ import { checkProxyHealth, probeUncleanExitState, type ListenTarget } from "./status-probes";
31
23
 
32
24
  export type CliStatusJson = {
33
25
  schemaVersion: 1;
@@ -112,14 +104,6 @@ export type CliStatusView = {
112
104
  };
113
105
 
114
106
 
115
- export type ListenTarget = {
116
- port: number;
117
- hostname?: string;
118
- source: "runtime" | "config";
119
- healthUrl: string;
120
- dashboardUrl: string;
121
- };
122
-
123
107
  type StatusListenConfig = Pick<OcxConfig, "port" | "hostname" | "runtimeRole" | "hub">;
124
108
 
125
109
  function statusDashboardUrl(config: StatusListenConfig, hostname: string | undefined, port: number): string {
@@ -160,75 +144,6 @@ export function resolveStatusPid(
160
144
  return live ? live.pid : pidFile;
161
145
  }
162
146
 
163
- export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): "timed out" | "unreachable" {
164
- return signal.aborted || (error instanceof Error && error.name === "AbortError")
165
- ? "timed out"
166
- : "unreachable";
167
- }
168
-
169
- /**
170
- * "Nothing is listening" is narrower than "the probe failed". `unreachable` covers every
171
- * non-abort failure, including a socket that was ACCEPTED and then reset — which is what
172
- * an in-flight start looks like mid-bind. Only a connect-phase refusal proves the port is
173
- * free, so this reads the underlying errno instead of the display string.
174
- */
175
- export function isConnectionRefused(error: unknown): boolean {
176
- for (let current: unknown = error, depth = 0; current instanceof Error && depth < 4; depth++) {
177
- const code = (current as { code?: unknown }).code;
178
- if (code === "ECONNREFUSED" || code === "ConnectionRefused") return true;
179
- // Bun surfaces the refusal as a plain message on some platforms; the errno name is
180
- // still the discriminator, not a substring of arbitrary prose.
181
- if (typeof code === "string" && code.endsWith("ECONNREFUSED")) return true;
182
- current = (current as { cause?: unknown }).cause;
183
- }
184
- return false;
185
- }
186
-
187
- /**
188
- * A proxy killed by a native trap or SIGKILL never runs the exit cleanup that removes
189
- * `ocx.pid` and `runtime-port.json` (only SIGINT/SIGTERM/SIGHUP and normal exit are
190
- * wired to it), so both records outlive it. That makes "crashed" and "never started"
191
- * distinguishable — and #1419 is what it costs when we discard the distinction: the
192
- * reporter's unsupervised `ocx gui` proxy died and every later command said only
193
- * "not running", never that a previous process had exited or that a service would
194
- * have restarted it.
195
- *
196
- * Two races have to stay closed, because a false "it crashed" is worse than a missing
197
- * hint. `handleStart` binds the port BEFORE it publishes either record, so:
198
- *
199
- * - a start that publishes between two reads is caught by comparing the raw records
200
- * observed before and after the probes (the same snapshot discipline
201
- * `removePidIfValueIs` uses for deletion);
202
- * - a start that has bound but not yet published leaves both snapshots identical, so
203
- * records alone cannot see it. That one is excluded on the port instead: the probe
204
- * must have been REFUSED at connect, which is the only outcome proving nothing holds
205
- * the port. A socket that is accepted and then reset — an in-flight bind — is not a
206
- * refusal, so review caught `unreachable` being too broad for this job.
207
- *
208
- * What this can and cannot prove: the records outliving their process establish that the
209
- * previous run did not complete its cleanup. It does not establish a cause, and it cannot
210
- * fully exclude a clean exit whose `unlinkSync` failed, because cleanup ignores that
211
- * error (`src/cli/index.ts:324-325`) and the records carry no session provenance. The
212
- * wording therefore says the records remain and the run MAY have exited unexpectedly.
213
- */
214
- export function isUncleanExitEvidence(input: {
215
- live: boolean;
216
- healthOk: boolean;
217
- healthRefused: boolean;
218
- ownerPidAlive: boolean;
219
- pidRecordBefore: number | null;
220
- pidRecordAfter: number | null;
221
- runtimePidBefore: number | null;
222
- runtimePidAfter: number | null;
223
- }): boolean {
224
- if (input.live || input.healthOk) return false;
225
- if (!input.healthRefused) return false;
226
- if (input.ownerPidAlive) return false;
227
- if (input.pidRecordBefore !== input.pidRecordAfter) return false;
228
- if (input.runtimePidBefore !== input.runtimePidAfter) return false;
229
- return input.pidRecordAfter !== null || input.runtimePidAfter !== null;
230
- }
231
-
232
147
  /**
233
148
  * `ocx status` greens on process liveness alone, so a proxy that answers
234
149
  * /healthz reads healthy even when Codex is not pointed at it and every routed
@@ -252,84 +167,6 @@ export function unusedProxyWarningLines(input: {
252
167
  ];
253
168
  }
254
169
 
255
- async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
256
- const url = target.healthUrl;
257
- const controller = new AbortController();
258
- const timer = setTimeout(() => controller.abort(), 800);
259
- try {
260
- const response = await directLocalHttpFetch(url, { signal: controller.signal });
261
- if (!response.ok) {
262
- const message = `returned HTTP ${response.status}`;
263
- return { ok: false, url, message, label: `${url} ${message}` };
264
- }
265
- const body = await response.json().catch(() => null) as { service?: unknown; status?: unknown; version?: unknown; uptime?: unknown } | null;
266
- if (!isOpencodexHealthz(body)) {
267
- const message = "responded, but not an opencodex proxy";
268
- return { ok: false, url, message, label: `${url} ${message}` };
269
- }
270
- const version = typeof body?.version === "string" ? ` v${body.version}` : "";
271
- const uptime = typeof body?.uptime === "number" ? `, uptime ${Math.round(body.uptime)}s` : "";
272
- const message = `ok${version}${uptime}`;
273
- return { ok: true, url, message, label: `${url} ${message}` };
274
- } catch (error) {
275
- const reason = proxyHealthFailureReason(error, controller.signal);
276
- return { ok: false, url, message: reason, label: `${url} ${reason}`, refused: isConnectionRefused(error) };
277
- } finally {
278
- clearTimeout(timer);
279
- }
280
- }
281
-
282
- /**
283
- * The ONE evidence gatherer for stale-process state, shared by `ocx status` and
284
- * `ocx doctor`.
285
- *
286
- * It deliberately probes the port named by the STALE RECORD, not the configured display
287
- * port. Review found the two commands disagreeing precisely here: a proxy that hopped to
288
- * a fallback port, or a config whose port changed after the crash, left status probing
289
- * the configured port while doctor probed the recorded one, so one reported a crash and
290
- * the other did not. The question being asked is "is the process that wrote this record
291
- * gone?", and only that record's own port can answer it.
292
- *
293
- * `live` short-circuits before the probe so a healthy install pays nothing.
294
- */
295
- export async function probeUncleanExitState(input: {
296
- live: boolean;
297
- port?: number;
298
- hostname?: string | null;
299
- }): Promise<boolean> {
300
- if (input.live) return false;
301
- const pidRecordBefore = readPidFileValue();
302
- const runtimeBefore = readRuntimePort();
303
- const runtimePidBefore = runtimeBefore?.pid ?? null;
304
- if (pidRecordBefore === null && runtimePidBefore === null) return false;
305
- const ownerPid = pidRecordBefore ?? runtimePidBefore;
306
- if (ownerPid !== null && isProcessAlive(ownerPid)) return false;
307
- // The recorded port is the evidence target. Fall back to the configured port only when
308
- // no runtime record exists, which is the pid-file-only case.
309
- const port = runtimeBefore?.port ?? input.port ?? 10100;
310
- const hostname = runtimeBefore?.hostname ?? input.hostname ?? undefined;
311
- const health = await checkProxyHealth({
312
- port,
313
- hostname,
314
- source: runtimeBefore ? "runtime" : "config",
315
- healthUrl: `http://${probeHostname(hostname)}:${port}/healthz`,
316
- dashboardUrl: `http://localhost:${port}/`,
317
- });
318
- const pidRecordAfter = readPidFileValue();
319
- const runtimePidAfter = readRuntimePort()?.pid ?? null;
320
- const ownerPidAfter = pidRecordAfter ?? runtimePidAfter;
321
- return isUncleanExitEvidence({
322
- live: false,
323
- healthOk: health.ok,
324
- healthRefused: health.refused === true,
325
- ownerPidAlive: ownerPidAfter !== null && isProcessAlive(ownerPidAfter),
326
- pidRecordBefore,
327
- pidRecordAfter,
328
- runtimePidBefore,
329
- runtimePidAfter,
330
- });
331
- }
332
-
333
170
  export async function collectStatus(): Promise<CliStatusView> {
334
171
  const configDiagnostics = readConfigDiagnostics();
335
172
  const config = configDiagnostics.config;