@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -28,6 +28,7 @@ import {
28
28
  import {
29
29
  preflightCodexHistoryInjection,
30
30
  syncCodexHistoryProvider,
31
+ HISTORY_RELABEL_STANDS_DOWN,
31
32
  type CodexHistoryFailureReason,
32
33
  } from "../history-provider";
33
34
  import {
@@ -44,7 +45,11 @@ import {
44
45
  } from "../paths";
45
46
  import { shouldInjectApiAuthHeader } from "../loopback-target";
46
47
  import { currentExternalCodexModelProvider } from "./config-toml";
47
- import { removeCodexConfig } from "./remove";
48
+ import {
49
+ readOcxProviderTableBlock,
50
+ removeCodexConfig,
51
+ retainOcxProviderTableOnDisk,
52
+ } from "./remove";
48
53
 
49
54
  class CodexRestoreRefusal extends Error {
50
55
  constructor(readonly config: CodexRestoreConfigResult) {
@@ -57,13 +62,49 @@ export function setBeforeRestoreConfigForTests(hook: typeof beforeRestoreConfigF
57
62
  beforeRestoreConfigForTests = hook;
58
63
  }
59
64
 
60
- export type CodexRestoreArtifactState = "ok" | "skipped" | "failed";
65
+ /**
66
+ * `partial` means the artifact was restored as far as it safely could be and named what
67
+ * it left behind. It is not a failure — the caller's obligation was discharged — but it
68
+ * is not a plain `ok` either, because something on disk still needs a decision (#4812).
69
+ */
70
+ export type CodexRestoreArtifactState = "ok" | "partial" | "skipped" | "failed";
71
+
72
+ /** What a degraded restore kept, why, and how to finish the job. */
73
+ export interface RetainedCodexProviderTable {
74
+ reason: typeof HISTORY_RELABEL_STANDS_DOWN;
75
+ /** The exact `config.toml` lines left on disk. */
76
+ lines: string[];
77
+ followUp: string;
78
+ }
79
+
80
+ const RETAINED_PROVIDER_TABLE_FOLLOW_UP =
81
+ "Run 'ocx restore --remove-codex-provider-table' to remove it; conversations already tagged "
82
+ + "opencodex will stop opening if you do.";
83
+
84
+ /**
85
+ * The one sentence every teardown surface prints about retained residue.
86
+ *
87
+ * Shared rather than rewritten per caller: `restore`, `stop`, `uninstall`, the service
88
+ * subcommands and the stop API all report this same outcome, and a user who runs two of
89
+ * them should not have to work out whether two different descriptions mean the same state.
90
+ */
91
+ export function describeRetainedCodexProviderTable(retained: RetainedCodexProviderTable): string {
92
+ return "Kept [model_providers.opencodex] in $CODEX_HOME/config.toml because Codex owns this home's"
93
+ + ` paginated history (${retained.reason}): conversations already tagged opencodex resolve only`
94
+ + ` through that table. Plain \`codex\` is native again. ${retained.followUp}`;
95
+ }
61
96
 
62
97
  export interface CodexRestoreConfigResult {
63
98
  state: CodexRestoreArtifactState;
64
99
  changed: boolean;
65
- action: "journal-restored" | "owned-fields-stripped" | "external-provider-preserved" | "failed";
100
+ action:
101
+ | "journal-restored"
102
+ | "owned-fields-stripped"
103
+ | "routing-restored-provider-retained"
104
+ | "external-provider-preserved"
105
+ | "failed";
66
106
  message: string;
107
+ retained?: RetainedCodexProviderTable;
67
108
  }
68
109
 
69
110
  export interface CodexRestoreCatalogResult {
@@ -101,6 +142,14 @@ export interface CodexNativeRestoreResult {
101
142
  * make a safety decision depend on prose.
102
143
  */
103
144
  historyPreflightRefusal?: string;
145
+ /**
146
+ * Set when routing came out but `[model_providers.opencodex]` stayed (#4812).
147
+ *
148
+ * Distinct from `historyPreflightRefusal`, which means nothing was attempted at all.
149
+ * This one means the config obligation WAS discharged, so a stop receipt must be
150
+ * released rather than preserved.
151
+ */
152
+ retainedCodexProviderTable?: RetainedCodexProviderTable;
104
153
  artifacts: {
105
154
  config: CodexRestoreConfigResult;
106
155
  catalog: CodexRestoreCatalogResult;
@@ -243,10 +292,42 @@ function historyPreflightRefusalEnvelope(historyError: string): CodexNativeResto
243
292
  return result;
244
293
  }
245
294
 
295
+ /**
296
+ * How a restore may proceed given what the history preflight says.
297
+ *
298
+ * The preflight answers one question — may conversation history be rewritten — and this
299
+ * translates it into the separate question the restore actually needs answered: may
300
+ * OpenCodex routing come out of `config.toml`, and what has to stay if it does.
301
+ */
302
+ export type RestoreHistoryDisposition =
303
+ | { kind: "proceed" }
304
+ | { kind: "stand-down"; retainProviderTable: boolean }
305
+ | { kind: "refuse"; reason: string };
306
+
307
+ export function resolveRestoreHistoryDisposition(
308
+ removeProviderTable: boolean | undefined,
309
+ reason: string | null = preflightCodexHistoryInjection(false, false),
310
+ ): RestoreHistoryDisposition {
311
+ if (!reason) return { kind: "proceed" };
312
+ // Every other reason still means the history state itself is wrong — a missing store, an
313
+ // unreadable rollout, an integrity failure. Those keep the hard refusal and the
314
+ // compensating rollback they have always had.
315
+ if (reason !== HISTORY_RELABEL_STANDS_DOWN) return { kind: "refuse", reason };
316
+ // The rows stay tagged `opencodex` either way, because the native writer owns them.
317
+ // Retaining the table is what keeps those conversations openable; the explicit flag is
318
+ // the user accepting that they will not be.
319
+ return { kind: "stand-down", retainProviderTable: removeProviderTable !== true };
320
+ }
321
+
322
+ export interface RestoreConfigOptions {
323
+ /** Strip `[model_providers.opencodex]` too, accepting that tagged threads stop opening. */
324
+ removeProviderTable?: boolean;
325
+ }
326
+
246
327
  /** The config/profile half of a native restore, reported as one artifact. */
247
- function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult {
328
+ function restoreCodexConfigInline(kind = "sync", options: RestoreConfigOptions = {}): CodexRestoreConfigResult {
248
329
  const preImages = captureCodexPreImages();
249
- const result = restoreCodexConfigInlineImpl(kind);
330
+ const result = restoreCodexConfigInlineImpl(kind, options);
250
331
  if (result.state === "failed") {
251
332
  const compensated = restoreCodexPreImages(preImages);
252
333
  if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
@@ -254,11 +335,25 @@ function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult {
254
335
  return result;
255
336
  }
256
337
 
257
- function restoreCodexConfigInlineImpl(kind: string): CodexRestoreConfigResult {
338
+ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOptions): CodexRestoreConfigResult {
258
339
  try {
259
340
  beforeRestoreConfigForTests?.(kind);
260
- const historyError = preflightCodexHistoryInjection(false, false);
261
- if (historyError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${historyError}.` };
341
+ const disposition = resolveRestoreHistoryDisposition(options.removeProviderTable);
342
+ if (disposition.kind === "refuse") {
343
+ return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${disposition.reason}.` };
344
+ }
345
+ // Captured unconditionally, not only when the stand-down is already known.
346
+ //
347
+ // Two different paths need bytes that only exist before the write. The journal restore
348
+ // replays the pre-injection config, which never contained our table, and then deletes
349
+ // the journal. And Codex can paginate DURING the write: the post-write re-check below
350
+ // then sees a stand-down that the pre-write check did not, at which point the table has
351
+ // already been stripped and there is nothing left to read. Both are cheap to prevent
352
+ // and impossible to repair afterwards, so the read happens once, here.
353
+ //
354
+ // The one caller that must not capture is the explicit removal flag: it is the user
355
+ // accepting that tagged conversations stop opening.
356
+ const capturedBlock = options.removeProviderTable === true ? null : readOcxProviderTableBlock();
262
357
  const journal = restoreJournalState();
263
358
  if (journal.unverified) {
264
359
  return {
@@ -267,13 +362,51 @@ function restoreCodexConfigInlineImpl(kind: string): CodexRestoreConfigResult {
267
362
  };
268
363
  }
269
364
  const restored = journal.configRestored
270
- ? { success: true, message: "Codex config restored from opencodex journal." }
271
- : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
365
+ ? { success: true, message: "Codex config restored from opencodex journal.", retainedProviderTable: undefined as string[] | undefined }
366
+ : removeCodexConfig({
367
+ preserveProfile: journal.profileRestored || journal.profileChanged,
368
+ // The history question was resolved above; hand the answer down rather than making
369
+ // the transform re-derive it, which refused the explicit-removal path outright.
370
+ historyDisposition: disposition.kind === "stand-down"
371
+ ? disposition.retainProviderTable ? "stand-down-retain" : "stand-down-remove"
372
+ : "refuse-on-any",
373
+ });
374
+ let retainedLines = restored.retainedProviderTable ?? null;
272
375
  if (restored.success) {
273
376
  // A successful journal/fallback write can race native history migration too.
274
377
  // Refuse here while preimage compensation and the remove transaction can roll back.
275
- const finalHistoryError = preflightCodexHistoryInjection(false, false);
276
- if (finalHistoryError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${finalHistoryError}.` };
378
+ // A stand-down observed now is the same stand-down that was already accounted for —
379
+ // it must not undo a routing removal that has already reached disk.
380
+ const settled = resolveRestoreHistoryDisposition(options.removeProviderTable);
381
+ if (settled.kind === "refuse") {
382
+ return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${settled.reason}.` };
383
+ }
384
+ // One re-attach covers three cases that all need the same bytes on disk: the journal
385
+ // path, which wrote a config without our table; the migration race, where the strip ran
386
+ // before anyone knew a table was needed; and the ordinary planned retention, where
387
+ // `removeCodexConfig` already put it back and this is a no-op. Re-attaching is
388
+ // idempotent — it checks for the table before appending — so the three do not have to
389
+ // be told apart here.
390
+ if (settled.kind === "stand-down" && settled.retainProviderTable && capturedBlock !== null) {
391
+ retainedLines = retainOcxProviderTableOnDisk(capturedBlock) ?? retainedLines;
392
+ }
393
+ }
394
+ if (restored.success && retainedLines !== null) {
395
+ return {
396
+ state: "partial",
397
+ changed: true,
398
+ action: "routing-restored-provider-retained",
399
+ message: journal.configRestored
400
+ ? "Codex config restored from opencodex journal. Kept [model_providers.opencodex] so conversations already"
401
+ + " tagged opencodex still open; remove it with 'ocx restore --remove-codex-provider-table'"
402
+ + " (those conversations stop opening)."
403
+ : restored.message,
404
+ retained: {
405
+ reason: HISTORY_RELABEL_STANDS_DOWN,
406
+ lines: retainedLines,
407
+ followUp: RETAINED_PROVIDER_TABLE_FOLLOW_UP,
408
+ },
409
+ };
277
410
  }
278
411
  return restored.success
279
412
  ? {
@@ -336,7 +469,7 @@ function restoreCodexCatalogArtifact(
336
469
  * that lost race into the discriminated `desired_enabled` skip.
337
470
  */
338
471
  export async function restoreNativeCodexAsync(
339
- options: { revalidateDesiredState?: boolean } = {},
472
+ options: { revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
340
473
  ): Promise<CodexNativeRestoreResult> {
341
474
  try {
342
475
  return await restoreNativeCodexAsyncImpl(options);
@@ -347,7 +480,7 @@ export async function restoreNativeCodexAsync(
347
480
  }
348
481
 
349
482
  async function restoreNativeCodexAsyncImpl(
350
- options: { revalidateDesiredState?: boolean },
483
+ options: { revalidateDesiredState?: boolean; removeProviderTable?: boolean },
351
484
  ): Promise<CodexNativeRestoreResult> {
352
485
  const activeProvider = currentExternalCodexModelProvider();
353
486
  if (activeProvider) {
@@ -368,8 +501,12 @@ async function restoreNativeCodexAsyncImpl(
368
501
  if (shouldSyncCodexOnStart(loadConfig())) return desiredEnabledRestoreSkip();
369
502
  }
370
503
 
371
- const historyError = preflightCodexHistoryInjection(false, false);
372
- if (historyError) return historyPreflightRefusalEnvelope(historyError);
504
+ const disposition = resolveRestoreHistoryDisposition(options.removeProviderTable);
505
+ if (disposition.kind === "refuse") return historyPreflightRefusalEnvelope(disposition.reason);
506
+ // A stand-down spawns no history Worker. The preflight the Worker would run first has
507
+ // already answered, and the rows stay tagged `opencodex` on purpose — which is exactly
508
+ // why the provider table has to survive the config half.
509
+ const historyStandsDown = disposition.kind === "stand-down";
373
510
 
374
511
  const eligibility = codexWriteCoordinationEligibility({
375
512
  coordinatorPath: () =>
@@ -420,7 +557,7 @@ async function restoreNativeCodexAsyncImpl(
420
557
  const preImages = captureCodexPreImages();
421
558
  let restored: CodexRestoreConfigResult;
422
559
  try {
423
- restored = restoreCodexConfigInline(eligibility.kind);
560
+ restored = restoreCodexConfigInline(eligibility.kind, options);
424
561
  // Throw inside N so the published remove transition rolls back too.
425
562
  if (restored.state === "failed") throw new CodexRestoreRefusal(restored);
426
563
  } catch (error) {
@@ -463,20 +600,34 @@ async function restoreNativeCodexAsyncImpl(
463
600
  if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
464
601
  return desiredEnabledRestoreSkip();
465
602
  }
466
- config = restoreCodexConfigInline(eligibility.kind);
603
+ config = restoreCodexConfigInline(eligibility.kind, options);
467
604
  }
468
605
 
469
606
  if (config.state === "failed") return failedConfigRestoreEnvelope(config);
470
607
  const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
471
- const outcome = await runCodexHistoryJob({
472
- ...resolveCodexHistoryJobTarget(),
473
- ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}),
474
- operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }),
475
- });
608
+ // Re-asked after the config half, because the store can paginate mid-transaction. Deciding
609
+ // the history job from the pre-write answer alone would spawn a Worker whose own preflight
610
+ // is now guaranteed to refuse, and report that refusal as a restore failure on a home that
611
+ // was in fact restored.
612
+ const historyStoodDown = historyStandsDown
613
+ || resolveRestoreHistoryDisposition(options.removeProviderTable).kind === "stand-down";
614
+ const outcome: CodexHistoryJobOutcome = historyStoodDown
615
+ ? { kind: "skipped" }
616
+ : await runCodexHistoryJob({
617
+ ...resolveCodexHistoryJobTarget(),
618
+ ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}),
619
+ operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }),
620
+ });
476
621
  if (transitionReceipt) {
477
622
  resolveCodexHistoryTransition(transitionReceipt, outcome);
478
623
  }
479
- const history: CodexRestoreHistoryResult = outcome.kind === "converged"
624
+ const history: CodexRestoreHistoryResult = historyStoodDown
625
+ ? {
626
+ state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0,
627
+ message: `Codex resume history was left to Codex's native writer (${HISTORY_RELABEL_STANDS_DOWN});`
628
+ + " existing threads keep the provider they are tagged with and no rollout byte was read or written.",
629
+ }
630
+ : outcome.kind === "converged"
480
631
  ? {
481
632
  state: "ok", changed: outcome.rows > 0 || outcome.files > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0,
482
633
  message: outcome.rows > 0
@@ -500,14 +651,24 @@ async function restoreNativeCodexAsyncImpl(
500
651
  : config.message;
501
652
  const success = catalog.state !== "failed"
502
653
  && history.state !== "failed";
654
+ // A stood-down relabel is not a failure, but it is something the operator has to be told:
655
+ // their existing conversations keep the provider they are tagged with, and nothing will
656
+ // ever change that from this side. Printing only the config half would be the same
657
+ // partial-success-reported-as-success problem this change exists to end.
658
+ const historyNote = history.state === "failed"
659
+ ? ` ⚠️ ${history.message}`
660
+ : historyStoodDown ? ` ${history.message}` : "";
503
661
  return {
504
662
  success,
505
- message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`,
663
+ message: `${base}${historyNote}`,
664
+ ...(config.retained ? { retainedCodexProviderTable: config.retained } : {}),
506
665
  artifacts: { config, catalog, history },
507
666
  };
508
667
  }
509
668
 
510
- export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult {
669
+ export function restoreNativeCodex(
670
+ options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
671
+ ): CodexNativeRestoreResult {
511
672
  const activeProvider = currentExternalCodexModelProvider();
512
673
  if (activeProvider) {
513
674
  removeJournal();
@@ -516,14 +677,18 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD
516
677
  if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
517
678
  return desiredEnabledRestoreSkip();
518
679
  }
519
- const historyError = preflightCodexHistoryInjection(false, false);
520
- if (historyError) return historyPreflightRefusalEnvelope(historyError);
680
+ const disposition = resolveRestoreHistoryDisposition(options.removeProviderTable);
681
+ if (disposition.kind === "refuse") return historyPreflightRefusalEnvelope(disposition.reason);
682
+ const historyStandsDown = disposition.kind === "stand-down";
521
683
  // Captured before the config half: a successful journal restore DELETES the journal, and
522
684
  // restoring the config can drop `model_catalog_json`. Either one would hide the routed
523
685
  // catalog we actually wrote (#1798).
524
686
  const journaledCatalogPath = journaledInjectedCatalogPath();
525
- const config = restoreCodexConfigInline();
687
+ const config = restoreCodexConfigInline("sync", options);
526
688
  if (config.state === "failed") return failedConfigRestoreEnvelope(config);
689
+ // Same mid-transaction pagination re-check as the async path.
690
+ const historyStoodDown = historyStandsDown
691
+ || resolveRestoreHistoryDisposition(options.removeProviderTable).kind === "stand-down";
527
692
  const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
528
693
  // Design B (loopback) steady state: threads are already tagged openai, so prove the
529
694
  // no-op with a readonly probe instead of write-opening a DB the Codex app may hold
@@ -537,12 +702,18 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD
537
702
  }
538
703
  // `skipHistory` is how the async wrapper takes this work for itself: the
539
704
  // native files come down here, and history runs in the Worker under H.
540
- const rawHistory = options.skipHistory
705
+ const rawHistory = options.skipHistory || historyStoodDown
541
706
  ? { rows: 0, files: 0 }
542
707
  : syncCodexHistoryProvider("openai", undefined, undefined, {
543
708
  skipWhenProvablyNoop,
544
709
  });
545
- const history: CodexRestoreHistoryResult = options.skipHistory
710
+ const history: CodexRestoreHistoryResult = historyStoodDown
711
+ ? {
712
+ state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0,
713
+ message: `Codex resume history was left to Codex's native writer (${HISTORY_RELABEL_STANDS_DOWN});`
714
+ + " existing threads keep the provider they are tagged with and no rollout byte was read or written.",
715
+ }
716
+ : options.skipHistory
546
717
  ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." }
547
718
  : rawHistory.failed
548
719
  ? failedHistoryRestore(rawHistory.failureReason, undefined, rawHistory)
@@ -561,7 +732,8 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD
561
732
  : config.message;
562
733
  return {
563
734
  success: catalog.state !== "failed" && history.state !== "failed",
564
- message,
735
+ message: historyStoodDown ? `${message} ${history.message}` : message,
736
+ ...(config.retained ? { retainedCodexProviderTable: config.retained } : {}),
565
737
  artifacts: { config, catalog, history },
566
738
  };
567
739
  }
@@ -40,7 +40,7 @@ import {
40
40
  removeJournal,
41
41
  writeJournal,
42
42
  } from "./journal";
43
- import { preflightCodexHistoryInjection } from "./history-provider";
43
+ import { HISTORY_RELABEL_STANDS_DOWN, preflightCodexHistoryInjection } from "./history-provider";
44
44
  import {
45
45
  describeHistoryJobFailure,
46
46
  deriveCodexHistoryOperation,
@@ -158,17 +158,13 @@ export interface CodexInjectResult {
158
158
  */
159
159
  historyPreflightFailureReason?: string;
160
160
  status?: "skipped";
161
+ /** Busy write lock, emitted by `codexInjectLockOutcome` and undeclared here until #4809. */
162
+ retryable?: boolean;
161
163
  /** `hub-gated` is the hub-role gate (#4236), distinct from the user's own OFF switch. */
162
164
  skippedReason?: "desired_disabled" | "desired_enabled" | "hub-gated";
163
165
  nativeSubagentDefaultsWarning?: string;
164
166
  }
165
167
 
166
- /**
167
- * The one history preflight reason that is permanent rather than operational: Codex owns
168
- * paginated rollout ordinals, so no retry makes the legacy relabel protocol available again.
169
- */
170
- const HISTORY_RELABEL_STANDS_DOWN = "history_paginated_requires_native_writer";
171
-
172
168
  class CodexHistoryPreflightRefusal extends Error {}
173
169
  let historyArtifactStageForTests: ((stage: string) => void) | undefined;
174
170
  export function setHistoryArtifactStageForTests(hook: typeof historyArtifactStageForTests): void {
@@ -373,7 +369,7 @@ async function injectCodexConfigImpl(
373
369
  content =
374
370
  content.trimEnd() +
375
371
  "\n" +
376
- buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}));
372
+ buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName);
377
373
  // 3) Keep existing `openai`-tagged threads reaching the proxy (see above). Ownership rules
378
374
  // are the Design B ones: a user's own root line is never replaced.
379
375
  if (keepRootOverrideAlongsideTable) {
@@ -434,6 +430,7 @@ async function injectCodexConfigImpl(
434
430
  catalogPath,
435
431
  websocketsEnabled(config ?? {}),
436
432
  config?.fastMode,
433
+ config?.codexProviderDisplayName,
437
434
  );
438
435
  content = applyEol(content, eol);
439
436
 
@@ -496,7 +493,7 @@ async function injectCodexConfigImpl(
496
493
  */
497
494
  if (hadOcxProviderTableOnDisk && !providerTableMode) {
498
495
  content = applyEol(
499
- content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {})),
496
+ content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName),
500
497
  eol,
501
498
  );
502
499
  }
@@ -14,9 +14,11 @@
14
14
  *
15
15
  * What it is for, and what it is not for:
16
16
  *
17
- * - FIRST PLACEMENT. A child with no binding of its own may start where its family is already
18
- * warm; see `pickLineageServingAccount` in ./routing. Once bound, the child is an ordinary
19
- * binding, so a later move of the parent does not drag it.
17
+ * - FIRST PLACEMENT. Largely subsumed by cohort keying (#4780): a member of a tree that any
18
+ * other member has already bound resolves to that same binding, so there is nothing to place.
19
+ * `pickLineageServingAccount` in ./routing remains for the one case cohort keying cannot
20
+ * unify -- a session-less chain whose parent is not recorded in this scope -- and is gated on
21
+ * the parent's key actually differing from this request's.
20
22
  * - COST ATTRIBUTION. {@link codexThreadLineageLookup} and {@link codexLineageRootForRequest}
21
23
  * answer which root workflow a conversation belongs to, so a grandchild's spend aggregates
22
24
  * onto the root. No budget is implemented here.
@@ -155,30 +157,83 @@ export function codexConversationKeyFor(familyId: string, threadId: string): str
155
157
  .digest("base64url")}`;
156
158
  }
157
159
 
160
+ /**
161
+ * The cohort anchor's key: the same string on both sides of the derivation.
162
+ *
163
+ * A cohort is identified by one value the whole tree shares, so the key is that value keyed
164
+ * against itself rather than against a member. Using {@link codexConversationKeyFor} keeps one
165
+ * derivation in the module, which is what guarantees a lineage record's `conversationKey` stays
166
+ * byte-identical to the key the thread actually binds under.
167
+ */
168
+ function cohortKeyFromAnchor(sessionId: string | undefined, fallbackAnchor: string): string {
169
+ const anchor = sessionId ?? fallbackAnchor;
170
+ return codexConversationKeyFor(anchor, anchor);
171
+ }
172
+
173
+ /**
174
+ * The cohort this request belongs to, or undefined when it names no cohort at all.
175
+ *
176
+ * The session IS the cohort, which is exactly how upstream keys the prompt cache:
177
+ * `prompt_cache_key()` returns `responses_metadata.session_id` (or `{source}:{parent_thread_id}`
178
+ * for an internal session), and `AgentControl.session_id` is the root thread's id, shared with
179
+ * every sub-agent spawned from that root. Two requests carrying the same `prompt_cache_key` must
180
+ * not be served by different accounts, and keying on the session is what makes that structural
181
+ * rather than a hint (#4780).
182
+ *
183
+ * Without a session there is nothing in the headers that names the tree, so the cohort is
184
+ * whatever the parent is already bound to. That lookup is what keeps a chain of parent-only
185
+ * turns converging on one key: anchoring each depth on its own parent would split the cohort
186
+ * again at every hop. When the parent has not been seen in this scope the parent id anchors it,
187
+ * which is the same key that parent derives for itself.
188
+ */
189
+ function cohortConversationKey(
190
+ headers: Headers,
191
+ sessionId: string | undefined,
192
+ parentThreadId: string | undefined,
193
+ now: number,
194
+ ): string | undefined {
195
+ if (sessionId !== undefined) return cohortKeyFromAnchor(sessionId, sessionId);
196
+ if (parentThreadId === undefined) return undefined;
197
+ const recorded = liveLineageRecord(
198
+ lineageByScope.get(codexLineageScopeKey(headers)),
199
+ parentThreadId,
200
+ now,
201
+ );
202
+ return recorded?.conversationKey ?? cohortKeyFromAnchor(undefined, parentThreadId);
203
+ }
204
+
158
205
  /**
159
206
  * Resolve a request's conversation identity, or undefined when it carries no bindable thread
160
207
  * identity at all.
161
208
  *
162
- * The set of requests that produce NO key is deliberately unchanged from the pre-#4546 rule: a
163
- * bare `thread-id` with neither a session nor a parent stays unbound, exactly as the Desktop
164
- * fallback required both halves of its pair. Only the VALUE moves, and only for requests that
165
- * name a parent:
209
+ * The set of requests that produce NO key is deliberately unchanged, through both #4546 and
210
+ * #4780: a bare `thread-id` with neither a session nor a parent stays unbound, exactly as the
211
+ * Desktop fallback required both halves of its pair. Only the VALUE moves.
212
+ *
213
+ * Every member of one tree resolves to the SAME key, because the cohort is the binding unit
214
+ * (#4780):
215
+ *
216
+ * - root (`session-id` + `thread-id`) -> the session's cohort key;
217
+ * - child and grandchild (parent + own `thread-id`) -> the same cohort key, since they carry the
218
+ * same session;
219
+ * - parent-only (no `thread-id`) -> the same cohort key, derived from the session or, without
220
+ * one, read from the parent's record.
166
221
  *
167
- * - root (`session-id` + `thread-id`) -> HMAC(session, thread), unchanged;
168
- * - child (parent + own `thread-id`) -> HMAC(session ?? parent, thread), previously the raw parent
169
- * id, which is what made siblings share one entry and made a child's first turn land on a key
170
- * the root had never bound;
171
- * - parent-only (no `thread-id`) -> the parent's OWN recorded key when this scope has one, and
172
- * otherwise HMAC(session ?? parent, parent).
222
+ * THIS IS NOT A REVERT OF #4546 wp8, and reading it as one would flip it straight back. wp8
223
+ * fixed a real incoherence: a child keyed under the RAW parent id, which is a different identity
224
+ * from the root's own `app:HMAC(session, thread)` binding, so siblings shared an entry unrelated
225
+ * to the root's and a grandchild keying on its own parent landed on a key nobody had ever bound.
226
+ * A cohort key cannot produce that, because the root's own binding IS the cohort key: there is
227
+ * one identity for the tree rather than two competing ones. What wp8 additionally gave each
228
+ * thread -- a binding of its own -- is what #4780 deliberately gives up, and the reason is that
229
+ * upstream never agreed to it: `prompt_cache_key` is keyed on the session the whole tree shares,
230
+ * so a proxy that splits the tree makes every split member assert a warm prefix that is cold on
231
+ * its account.
173
232
  *
174
- * That last case is the one with a trap in it. A parent-only turn belongs to the parent's
175
- * conversation, so it has to land on the binding the parent is already using -- but the parent's
176
- * key is HMAC(session, thread), and HMAC(parent, parent) reproduces it only when the session id
177
- * and the thread id are the same string. Codex's own root happens to satisfy that, which is
178
- * exactly why deriving the key looks correct until a caller whose session differs from its thread
179
- * starts a COLD conversation on every parent-only turn and overwrites the parent's record on the
180
- * way through. So the recorded key wins, the session-derived key is the fallback that reproduces
181
- * it when the parent has not been seen in this scope, and the raw parent id is never the answer.
233
+ * The parent-only case keeps the trap it always had. Such a turn belongs to the parent's
234
+ * conversation, so it must land on the binding the parent is already using. With a session in
235
+ * hand that is immediate, since both derive the same cohort key. Without one, the recorded key
236
+ * wins and the parent id anchors the fallback; the raw parent id is never the answer.
182
237
  */
183
238
  export function codexConversationIdentity(
184
239
  headers: Headers,
@@ -190,24 +245,20 @@ export function codexConversationIdentity(
190
245
 
191
246
  if (threadId === undefined) {
192
247
  if (parentThreadId === undefined) return undefined;
193
- const recorded = liveLineageRecord(
194
- lineageByScope.get(codexLineageScopeKey(headers)),
195
- parentThreadId,
196
- now,
197
- );
248
+ const parentOnlyKey = cohortConversationKey(headers, sessionId, parentThreadId, now);
249
+ if (parentOnlyKey === undefined) return undefined;
198
250
  return {
199
- conversationKey: recorded?.conversationKey
200
- ?? codexConversationKeyFor(sessionId ?? parentThreadId, parentThreadId),
251
+ conversationKey: parentOnlyKey,
201
252
  recordThreadId: parentThreadId,
202
253
  ...(sessionId !== undefined ? { sessionId } : {}),
203
254
  legacyConversationKey: parentThreadId,
204
255
  declaresParent: false,
205
256
  };
206
257
  }
207
- const familyId = sessionId ?? parentThreadId;
208
- if (familyId === undefined) return undefined;
258
+ const conversationKey = cohortConversationKey(headers, sessionId, parentThreadId, now);
259
+ if (conversationKey === undefined) return undefined;
209
260
  return {
210
- conversationKey: codexConversationKeyFor(familyId, threadId),
261
+ conversationKey,
211
262
  recordThreadId: threadId,
212
263
  ...(sessionId !== undefined ? { sessionId } : {}),
213
264
  ...(parentThreadId !== undefined ? { parentThreadId } : {}),
@@ -313,7 +364,7 @@ function lineageFor(
313
364
  const parentConversationKey = parentThreadId === undefined
314
365
  ? undefined
315
366
  : parentRecord?.conversationKey
316
- ?? codexConversationKeyFor(identity.sessionId ?? parentThreadId, parentThreadId);
367
+ ?? cohortKeyFromAnchor(identity.sessionId, parentThreadId);
317
368
  const rootSessionKey = parentConversationKey === undefined
318
369
  ? identity.conversationKey
319
370
  : parentRecord?.rootSessionKey ?? parentConversationKey;
@@ -5,6 +5,37 @@ import { NATIVE_RESERVE_MODEL } from "./catalog/native-models";
5
5
  export const CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE =
6
6
  "Luna Reserve compatibility is only available as a conversation model, not a vision helper. Choose another vision model.";
7
7
 
8
+ export const CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE =
9
+ "Luna Reserve (gpt-reserve) is not forwarded without the local Desktop authless opt-in."
10
+ + " OpenCodex holds no Reserve entitlement to send for this request, so the upstream would answer with a"
11
+ + " usage-limit error that names neither the cause nor the fix."
12
+ + " Enable the opt-in with 'ocx system settings --desktop-authless on' (or set codexDesktopAuthless to true"
13
+ + " in config.json), then retry. Choose another model to keep working without it.";
14
+
15
+ /**
16
+ * Strict complement of {@link isCodexReserveRequestEligible} for the FLAG reason ONLY (#4940).
17
+ *
18
+ * Read the two together. This answers a narrower question: Reserve was asked for, every ingress
19
+ * condition eligibility requires already holds, and the single missing piece is the operator
20
+ * opt-in. Flipping `codexDesktopAuthless` to true therefore always turns a true here into a true
21
+ * from {@link isCodexReserveRequestEligible}, which is what makes it honest for the refusal to
22
+ * name that one setting. The other two ineligibility reasons are deliberately not covered: a
23
+ * client role and a non-loopback admission source are different situations, and the correct
24
+ * answer for both is still to forward exactly as before.
25
+ *
26
+ * Callers classify the concrete destination as canonical forward before using this predicate, the
27
+ * same obligation {@link isCodexReserveHelperUnsupported} carries. An operator who has aliased or
28
+ * routed `gpt-reserve` onto some other provider owns a path that works, and it must keep working.
29
+ */
30
+ export function isCodexReserveOptInMissing(
31
+ config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
32
+ modelId: string,
33
+ admission: Pick<DataPlaneAdmission, "source"> | undefined,
34
+ ): boolean {
35
+ return modelId === NATIVE_RESERVE_MODEL && config.codexDesktopAuthless !== true
36
+ && config.runtimeRole !== "client" && admission?.source === "loopback";
37
+ }
38
+
8
39
  /** Callers classify the concrete destination as canonical forward before using this predicate. */
9
40
  export function isCodexReserveHelperUnsupported(
10
41
  config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
@@ -97,3 +128,12 @@ export function isEffectiveCodexDesktopAuthless(
97
128
  && config.runtimeRole !== "client"
98
129
  && !shouldInjectApiAuthHeader(config);
99
130
  }
131
+
132
+ /** Keep reporting aligned with the admission-token gate used by standalone injection. */
133
+ export function isEffectiveCodexClientCompaction(
134
+ config: Pick<OcxConfig, "runtimeRole" | "hostname" | "unauthenticatedLoopbackListener" | "codexClientCompaction"> | undefined,
135
+ ): boolean {
136
+ return config?.codexClientCompaction === true
137
+ && config.runtimeRole !== "client"
138
+ && !shouldInjectApiAuthHeader(config);
139
+ }
@@ -1,7 +1,8 @@
1
1
  import type { OcxConfig } from "../types";
2
2
  import { getMainPolicyQuota } from "./quota";
3
+ import { MAIN_ACCOUNT_HARD_LOCK_PERCENT } from "./quota-types";
3
4
 
4
- export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99;
5
+ export { MAIN_ACCOUNT_HARD_LOCK_PERCENT };
5
6
 
6
7
  export interface MainAccountHardLockStatus {
7
8
  enabled: boolean;