@bitkyc08/opencodex 2.88.0 → 2.90.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 (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
package/src/cli/combo.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeJevQuotaTiers } from "../combos/jev-quota-config";
1
2
  import { JEV_DECISION_TIMEOUT_MAX_MS, JEV_DECISION_TIMEOUT_MIN_MS } from "../combos/types";
2
3
  import {
3
4
  CliUsageError,
@@ -24,8 +25,10 @@ const USAGE = `Usage:
24
25
  [--effort <low|medium|high|xhigh|max|ultra|->] [--effort-mode <fallback|force|->]
25
26
  (force overrides valid client effort and can increase cost/latency) [--alias <name|->]
26
27
  [--native-alias [on|off]] [--allow-metered-targets <on|off>] [--display-name <label|->]
28
+ [--description <text|->]
27
29
  [--image-input <auto|disabled>] [--reasoning-effort-mode <strict|adaptive>]
28
30
  [--decision-provider <provider|-> | --decision-model <route|->] [--decision-timeout <ms|->]
31
+ [--decision-quota-signals <on|off|->] [--decision-quota-tiers <JSON|->]
29
32
  (jev only; the provider must be a configured jev-decision row)
30
33
  [--rename-from <id>] [--json]
31
34
  ocx combo stats <id> [--range <7d|30d|all>] [--json]
@@ -80,6 +83,7 @@ async function show(argv: string[], deps: RuntimeApiDeps): Promise<void> {
80
83
  printData(combo, wantsJson);
81
84
  }
82
85
 
86
+ /** Handle `ocx combo set`: parse the combo id and flags (including `--decision-quota-signals` and `--decision-quota-tiers`), then create or update the combo through the management API. */
83
87
  async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
84
88
  const args = [...argv];
85
89
  const id = args.shift()?.trim();
@@ -118,6 +122,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
118
122
  const alias = takeOption(args, "--alias");
119
123
 
120
124
  const displayName = takeOption(args, "--display-name");
125
+ const description = takeOption(args, "--description");
121
126
  const decisionProvider = takeOption(args, "--decision-provider");
122
127
  const decisionModel = takeOption(args, "--decision-model");
123
128
  if (decisionModel !== undefined && decisionModel !== "-" && decisionProvider !== undefined && decisionProvider !== "-") {
@@ -129,6 +134,13 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
129
134
  if (decisionProvider !== undefined && decisionProvider !== "-" && strategy !== "jev") {
130
135
  throw new CliUsageError("--decision-provider applies only to the jev strategy", USAGE);
131
136
  }
137
+ const quotaSignals = takeOption(args, "--decision-quota-signals");
138
+ const quotaTiers = takeOption(args, "--decision-quota-tiers");
139
+ if (quotaSignals !== undefined && !["on", "off", "-"].includes(quotaSignals)) throw new CliUsageError("--decision-quota-signals must be on, off or -", USAGE);
140
+ let parsedQuotaTiers: ReturnType<typeof normalizeJevQuotaTiers> | null;
141
+ try { parsedQuotaTiers = quotaTiers === undefined || quotaTiers === "-" ? null : normalizeJevQuotaTiers(JSON.parse(quotaTiers)); } catch { throw new CliUsageError("invalid --decision-quota-tiers JSON", USAGE); }
142
+ if (quotaTiers !== undefined && quotaTiers !== "-" && !parsedQuotaTiers) throw new CliUsageError("quota tiers must be finite 0..100 and strictly ascending", USAGE);
143
+ if (strategy !== "jev" && (quotaSignals !== undefined && quotaSignals !== "-" || quotaTiers !== undefined && quotaTiers !== "-")) throw new CliUsageError("quota settings apply only to jev", USAGE);
132
144
  const decisionTimeout = takeOption(args, "--decision-timeout");
133
145
  let decisionTimeoutMs: number | null | undefined;
134
146
  if (decisionTimeout !== undefined) {
@@ -164,15 +176,20 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
164
176
  if (additional.imageInput !== undefined) combo.imageInput = additional.imageInput;
165
177
  if (additional.reasoningEffortMode !== undefined) combo.reasoningEffortMode = additional.reasoningEffortMode;
166
178
  if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName;
179
+ if (description !== undefined) combo.description = description === "-" ? "" : description;
167
180
  if (decisionProvider !== undefined) combo.decisionProvider = decisionProvider === "-" ? null : decisionProvider;
168
181
  if (decisionModel !== undefined) combo.decisionModel = decisionModel === "-" ? null : decisionModel;
169
182
  if (decisionProvider !== undefined && decisionProvider !== "-") combo.decisionModel = null;
170
183
  if (decisionModel !== undefined && decisionModel !== "-") combo.decisionProvider = null;
171
184
  if (decisionTimeoutMs !== undefined) combo.decisionTimeoutMs = decisionTimeoutMs;
185
+ if (quotaSignals !== undefined) combo.decisionQuotaSignals = quotaSignals === "-" ? null : quotaSignals === "on";
186
+ if (quotaTiers !== undefined) combo.decisionQuotaTiers = parsedQuotaTiers;
172
187
  if (strategy !== "jev") {
173
188
  delete combo.decisionProvider;
174
189
  delete combo.decisionModel;
175
190
  delete combo.decisionTimeoutMs;
191
+ delete combo.decisionQuotaSignals;
192
+ delete combo.decisionQuotaTiers;
176
193
  }
177
194
  const current = partialCurrent ?? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps);
178
195
  const existing = (current.combos ?? []).find(row => row.id === (renameFrom ?? id));
@@ -241,12 +241,12 @@ function consentedDesktopBundle(home: string, installId: string, run: Execute):
241
241
  const plistPath = realpathSync(join(home, "Library", "LaunchAgents", `${DESKTOP_PLIST_LABEL}.plist`));
242
242
  const registration = JSON.parse(
243
243
  run("/usr/bin/plutil", ["-convert", "json", "-o", "-", plistPath], 3_000),
244
- ) as { Label?: unknown; ProgramArguments?: unknown; Program?: unknown };
244
+ ) as { Label?: unknown; RunAtLoad?: unknown; ProgramArguments?: unknown; Program?: unknown };
245
245
  const args = registration.ProgramArguments;
246
246
  // The shapes auto-launch writes: [program] or [program, "--autostart"]; see the migration
247
247
  // rules in desktop/src-tauri/src/login_registration.rs, which classify anything else Leave.
248
248
  // This is a macOS plist path, regardless of the host running an injected launch probe.
249
- if (registration.Label !== DESKTOP_PLIST_LABEL || !Array.isArray(args)
249
+ if (registration.Label !== DESKTOP_PLIST_LABEL || registration.RunAtLoad !== true || !Array.isArray(args)
250
250
  || (args.length !== 1 && args.length !== 2) || typeof args[0] !== "string" || !posix.isAbsolute(args[0])
251
251
  || (args.length === 2 && args[1] !== "--autostart")
252
252
  || (registration.Program !== undefined && registration.Program !== args[0])) return null;
package/src/cli/help.ts CHANGED
@@ -79,7 +79,7 @@ Usage:
79
79
  ocx alias <sub> Short names for providers and models (list, set, rm, defaults)
80
80
  ocx combo <sub> Combo routing strategies and failover
81
81
  ocx agent <sub> Subagents, injection, effort caps, and sidecars
82
- ocx message <sub> Loaded local Codex sessions and queued peer messages (sessions|send)
82
+ ocx message <sub> Codex queued messages, explicit SSH peers and foreground ownership
83
83
  ocx effort [sub] Inspect and configure reasoning effort caps and defaults
84
84
  ocx observe <sub> Logs, usage, storage, memory, and debug data
85
85
  ocx inspect <sub> Effective config, catalog, analytics, pacing, client-config
@@ -2,13 +2,16 @@ import { MessageBudget } from "../messaging/budget";
2
2
  import { readMessageInput } from "../messaging/input";
3
3
  import { localSessions, messageFailure, sendLocalMessage } from "../messaging/send";
4
4
  import { parseMessageArgs } from "./message-args";
5
+ import { parseRemoteMessageArgs } from "./message-remote-args";
5
6
  import { terminalSafeText } from "./runtime-api";
6
7
 
7
8
  /** Run one local messaging operation after pure syntax validation, then release owned resources. */
8
9
  export async function runMessageCommand(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
10
+ const remote = parseRemoteMessageArgs(argv);
11
+ if (remote) return (await import("./message-remote-command")).runRemoteMessageCommand(remote, env);
9
12
  const args = parseMessageArgs(argv);
10
13
  if (!args) {
11
- console.error("Usage: ocx message sessions [--json] | send (--thread <uuid> | --name <exact-name>) --stdin [--kind request|response|notification] [--in-reply-to <uuid>] [--json]");
14
+ console.error("Usage: ocx message sessions [--host <peer>] [--json] | send (--thread <uuid> | --name <exact-name>) --stdin [--host <peer>] [--kind request|response|notification] [--in-reply-to <uuid>] [--json] | enable [--port <port>] | disable | status | hosts <probe|add|list|remove|abandon|allow-inbound|deny-inbound> | serve [--host <peer>]...");
12
15
  return 64;
13
16
  }
14
17
  const controller = new AbortController();
@@ -0,0 +1,69 @@
1
+ import { validAlias, validPort, REMOTE_LIMITS } from "../messaging/remote-contract";
2
+ import { isSshAlias } from "../link/ssh-argv";
3
+ import { parseMessageArgs, type MessageArgs } from "./message-args";
4
+ import { isThreadId } from "../messaging/types";
5
+
6
+ export type RemoteMessageArgs = { action: "enable"; port?: number; json: boolean }
7
+ | { action: "disable" | "status" | "hosts-list" | "_control" | "_port"; json: boolean }
8
+ | { action: "serve"; hosts: string[]; json: boolean }
9
+ | { action: "hosts-probe"; ssh: string; json: boolean }
10
+ | { action: "hosts-add"; alias: string; ssh: string; fingerprint: string; json: boolean }
11
+ | { action: "hosts-remove"; host: string; transaction?: string; json: boolean }
12
+ | { action: "hosts-allow-inbound" | "hosts-deny-inbound"; host: string; json: boolean }
13
+ | { action: "hosts-abandon"; transaction: string; json: boolean }
14
+ | { action: "remote-operation"; host: string; local: MessageArgs; json: boolean };
15
+
16
+ /** Pure remote syntax validation precedes state, home selection, listeners or SSH allocation. */
17
+ export function parseRemoteMessageArgs(argv: readonly string[]): RemoteMessageArgs | null {
18
+ let action = argv[0], start = 1, positional: string | undefined;
19
+ if (action === "sessions" || action === "send") {
20
+ const hostIndex = argv.indexOf("--host");
21
+ if (hostIndex < 0 || argv.lastIndexOf("--host") !== hostIndex || !validAlias(argv[hostIndex + 1])) return null;
22
+ const local = parseMessageArgs([...argv.slice(0, hostIndex), ...argv.slice(hostIndex + 2)]);
23
+ return local ? { action: "remote-operation", host: argv[hostIndex + 1]!, local, json: local.json } : null;
24
+ }
25
+ if (action === "hosts") {
26
+ if (!["list", "probe", "add", "remove", "abandon", "allow-inbound", "deny-inbound"].includes(argv[1] ?? "")) return null;
27
+ action = `hosts-${argv[1]}`; start = 2;
28
+ if (["hosts-add", "hosts-remove", "hosts-allow-inbound", "hosts-deny-inbound"].includes(action)) {
29
+ positional = argv[start++]; if (!validAlias(positional)) return null;
30
+ }
31
+ }
32
+ const seen = new Set<string>(), flags: Record<string, string> = {}, hosts: string[] = [];
33
+ for (let index = start; index < argv.length; index++) {
34
+ const flag = argv[index]!;
35
+ if (seen.has(flag) && !(action === "serve" && flag === "--host")) return null;
36
+ seen.add(flag);
37
+ if (flag === "--json") continue;
38
+ const allowed = action === "enable" ? ["--port"] : action === "serve" ? ["--host"]
39
+ : action === "hosts-probe" ? ["--ssh"] : action === "hosts-add" ? ["--ssh", "--fingerprint"]
40
+ : action === "hosts-abandon" || action === "hosts-remove" ? ["--transaction"] : [];
41
+ if (!allowed.includes(flag)) return null;
42
+ const value = argv[++index]; if (!value || value.startsWith("--")) return null;
43
+ flags[flag] = value; if (flag === "--host") hosts.push(value);
44
+ }
45
+ const json = seen.has("--json");
46
+ if (action === "enable") {
47
+ if (flags["--port"] !== undefined && (!/^\d{4,5}$/.test(flags["--port"]) || !validPort(Number(flags["--port"])))) return null;
48
+ return { action, json, ...(flags["--port"] ? { port: Number(flags["--port"]) } : {}) };
49
+ }
50
+ if (action === "serve") return hosts.length <= REMOTE_LIMITS.peers && hosts.every(validAlias)
51
+ && new Set(hosts).size === hosts.length ? { action, hosts, json } : null;
52
+ if (["disable", "status", "hosts-list", "_control", "_port"].includes(action ?? "")) {
53
+ return { action: action as "disable", json };
54
+ }
55
+ if (action === "hosts-remove") {
56
+ const transaction = flags["--transaction"];
57
+ if (transaction !== undefined && !isThreadId(transaction)) return null;
58
+ return { action, host: positional!, json, ...(transaction !== undefined ? { transaction } : {}) };
59
+ }
60
+ if (action === "hosts-allow-inbound" || action === "hosts-deny-inbound") return { action, host: positional!, json };
61
+ if (action === "hosts-abandon" && isThreadId(flags["--transaction"])) return { action, transaction: flags["--transaction"]!, json };
62
+ if ((action === "hosts-probe" || action === "hosts-add") && flags["--ssh"] && isSshAlias(flags["--ssh"])) {
63
+ if (action === "hosts-probe") return { action, ssh: flags["--ssh"], json };
64
+ if (/^SHA256:[A-Za-z0-9+/=]{1,64}$/.test(flags["--fingerprint"] ?? "")) return {
65
+ action, alias: positional!, ssh: flags["--ssh"], fingerprint: flags["--fingerprint"], json,
66
+ };
67
+ }
68
+ return null;
69
+ }
@@ -0,0 +1,77 @@
1
+ import { getConfigDir } from "../config/paths";
2
+ import { MessageBudget } from "../messaging/budget";
3
+ import { readMessageInput } from "../messaging/input";
4
+ import { RemoteCapacity } from "../messaging/remote-contract";
5
+ import { enrollRemoteHost, handleEnrollmentControl, probeRemoteHost, removeRemoteHost } from "../messaging/remote-enrollment";
6
+ import { abandonRemoteEnrollment, pendingRemoteEnrollment } from "../messaging/remote-enrollment-recovery";
7
+ import { remoteControl } from "../messaging/remote-auth";
8
+ import { ownerEndpoint, startRemoteOwner } from "../messaging/remote-owner";
9
+ import { remotePortCandidate } from "../messaging/remote-ports";
10
+ import { remoteSessions, sendRemoteMessage } from "../messaging/remote-send";
11
+ import { RemoteMessageStore } from "../messaging/remote-store";
12
+ import { messageFailure } from "../messaging/send";
13
+ import { messageCodexHome } from "./message-runtime";
14
+ import { terminalSafeText } from "./runtime-api";
15
+ import type { RemoteMessageArgs } from "./message-remote-args";
16
+
17
+ /** Activate remote resources only after an explicit remote command's pure parser succeeds. */
18
+ export async function runRemoteMessageCommand(args: RemoteMessageArgs, env: NodeJS.ProcessEnv): Promise<number> {
19
+ const store = new RemoteMessageStore(getConfigDir()), controller = new AbortController(), capacity = new RemoteCapacity();
20
+ const cancel = () => controller.abort();
21
+ process.once("SIGINT", cancel); process.once("SIGTERM", cancel);
22
+ const emit = (value: unknown) => console.log(args.json || args.action.startsWith("_")
23
+ ? JSON.stringify(value) : terminalSafeText(JSON.stringify(value)));
24
+ try {
25
+ if (args.action === "serve") {
26
+ const owner = await startRemoteOwner(store, messageCodexHome(env), args.hosts, controller.signal);
27
+ try {
28
+ emit({ protocol: "ocx-message-remote/1", running: true, port: owner.port });
29
+ await Promise.race([owner.finished, new Promise<void>(resolve => {
30
+ controller.signal.addEventListener("abort", () => resolve(), { once: true });
31
+ if (controller.signal.aborted) resolve();
32
+ })]);
33
+ if (!controller.signal.aborted) { emit({ running: false, error: { code: "owner_retired", message: "Foreground messaging owner retired; restart explicitly after resolving its route/configuration failure." } }); return 1; }
34
+ } finally { await owner.close(); }
35
+ return 0;
36
+ }
37
+ const budget = new MessageBudget(30000, controller.signal);
38
+ try {
39
+ if (args.action === "enable") { store.enable(args.port ?? store.read()?.port ?? 39176); emit(store.publicState()); }
40
+ else if (args.action === "disable") { store.disable(); emit(store.publicState()); }
41
+ else if (args.action === "hosts-list") emit({ ...store.publicState(), pendingEnrollment: pendingRemoteEnrollment(store) });
42
+ else if (args.action === "hosts-abandon") { emit(abandonRemoteEnrollment(store, args.transaction)); return 3; }
43
+ else if (args.action === "status") {
44
+ const state = store.read(); let running = false, routes: unknown = [];
45
+ if (state?.enabled) {
46
+ try { const result = await remoteControl(state, ownerEndpoint(state), "message/routes", {}, budget);
47
+ running = true; routes = result; } catch { /* Disabled/offline is not activation or repair. */ }
48
+ }
49
+ emit({ ...store.publicState(), running, routes, pendingEnrollment: pendingRemoteEnrollment(store) });
50
+ } else if (args.action === "hosts-probe") emit({ ssh: args.ssh, fingerprint: (await probeRemoteHost(args.ssh, budget, capacity)).fingerprint });
51
+ else if (args.action === "hosts-add") emit(await enrollRemoteHost(store, args.alias, args.ssh, args.fingerprint, budget, capacity));
52
+ else if (args.action === "hosts-allow-inbound" || args.action === "hosts-deny-inbound") {
53
+ emit(store.setInbound(args.host, args.action === "hosts-allow-inbound"));
54
+ }
55
+ else if (args.action === "hosts-remove") {
56
+ const receipt = await removeRemoteHost(store, args.host, budget, capacity, undefined, undefined, args.transaction); emit(receipt);
57
+ return receipt.remote === "removed" ? 0 : 3;
58
+ } else if (args.action === "_control") {
59
+ emit(handleEnrollmentControl(store, JSON.parse(await readMessageInput(Bun.stdin.stream(), budget))));
60
+ } else if (args.action === "_port") { store.requireEnabled(); emit({ port: remotePortCandidate() }); }
61
+ else if (args.action === "remote-operation") {
62
+ if (args.local.action === "sessions") emit(await remoteSessions(store, args.host, budget));
63
+ else {
64
+ const body = await readMessageInput(Bun.stdin.stream(), budget);
65
+ const receipt = await sendRemoteMessage(store, { ...args.local, host: args.host, body },
66
+ { home: messageCodexHome(env), senderId: env.CODEX_THREAD_ID }, budget);
67
+ emit(receipt); return receipt.status === "queued" ? 0 : receipt.status === "unknown" ? 3 : 1;
68
+ }
69
+ }
70
+ return 0;
71
+ } finally { budget.dispose(); }
72
+ } catch (error) {
73
+ // Control errors never echo stdin, SSH output, keys or paths; a failed enrollment keeps its transaction.
74
+ const failure = messageFailure(error), unknown = ["enrollment_unknown", "remote_control_unknown", "remote_storage_unknown", "cleanup_incomplete"].includes(failure.code);
75
+ emit({ schema: "ocx-message-error/1", ...(unknown ? { status: "unknown" } : args.action === "remote-operation" && args.local.action === "send" ? { status: "not_sent" } : {}), error: failure }); return unknown ? 3 : 1;
76
+ } finally { process.removeListener("SIGINT", cancel); process.removeListener("SIGTERM", cancel); }
77
+ }
@@ -10,8 +10,10 @@ import {
10
10
  takeBooleanOption,
11
11
  takeFlag,
12
12
  takeOption,
13
+ terminalSafeText,
13
14
  type RuntimeApiDeps,
14
15
  } from "./runtime-api";
16
+ import { interactiveConfirm } from "./interactive-confirm";
15
17
  import { takeProviderEditSettings } from "./provider-settings";
16
18
  import { printProviderReceipt, runProviderAction } from "./provider-result";
17
19
  import { providerQuotaLine } from "./account-extended";
@@ -46,7 +48,7 @@ const USAGE = `Usage:
46
48
  [--retain-models <id,id|->] [--model <id> --text-only]
47
49
  [--xai-chat <on|off>] [--upstream-http-version <http1.1|->] [--fast <on|off>] [--context-window <tokens|->]
48
50
  [--allow-private-network <on|off>] [--model-context-tier <model=default|long_context>] [--json]
49
- ocx provider test <name> [--json]
51
+ ocx provider test <name> [--model <id> [--yes]] [--json]
50
52
  ocx provider quota [--refresh] [--json]
51
53
  ocx provider resets [--limit <n>] [--json]
52
54
  ocx provider presets [--json]
@@ -147,12 +149,78 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<number> {
147
149
  return printProviderReceipt(result, wantsJson, "Provider edit");
148
150
  }
149
151
 
152
+ interface CompletionTestDto {
153
+ applicable: boolean;
154
+ reason?: string;
155
+ detail?: string;
156
+ adapter?: string;
157
+ ok?: boolean;
158
+ wireModel?: string;
159
+ upstreamModel?: string | null;
160
+ modelComparison?: "match" | "mismatch" | "unknown";
161
+ comparisonNote?: "missing_metadata" | "conflicting_metadata";
162
+ ttftMs?: number | null;
163
+ totalLatencyMs?: number;
164
+ error?: { code: string; message: string; status?: number } | null;
165
+ }
166
+
167
+ async function testCompletion(name: string, model: string, yes: boolean, wantsJson: boolean, deps: RuntimeApiDeps): Promise<number> {
168
+ const input = deps.stdinImpl ?? process.stdin;
169
+ if (!yes && (wantsJson || !input.isTTY)) {
170
+ throw new CliUsageError("completion tests consume quota and may incur cost; --yes is required with --json or non-interactive input", USAGE);
171
+ }
172
+ const warning = terminalSafeText(`${name}: completion test for model ${model}. Warning: this sends an upstream request, consumes quota and may incur cost.`);
173
+ if (wantsJson) console.error(warning);
174
+ else console.log(warning);
175
+ if (!yes && !await interactiveConfirm({
176
+ question: "Run this completion test?", defaultYes: false, input: input as NodeJS.ReadStream,
177
+ })) {
178
+ console.log("cancelled");
179
+ return 1;
180
+ }
181
+ const result = await runtimeRequest<CompletionTestDto>(`/api/providers/test-completion?name=${encodeURIComponent(name)}`, {
182
+ method: "POST", redirect: "error", body: JSON.stringify({ model, confirm: true }),
183
+ }, deps);
184
+ if (result.applicable === false) {
185
+ printData(result, wantsJson, [
186
+ `${name}: not applicable — ${result.reason}`,
187
+ ...(result.detail ? [`Detail: ${result.detail}`] : []),
188
+ ...(result.adapter ? [`Adapter: ${result.adapter}`] : []),
189
+ ]);
190
+ return 0;
191
+ }
192
+ if (result.ok === true) {
193
+ printData(result, wantsJson, [
194
+ `${name}: connected`,
195
+ `TTFT: ${result.ttftMs ?? "unknown"} ms`,
196
+ `Total latency: ${result.totalLatencyMs ?? "unknown"} ms`,
197
+ `Upstream reported ${result.upstreamModel ?? "unknown"} for ${result.wireModel} — ${result.modelComparison}${result.comparisonNote ? ` (${result.comparisonNote})` : ""}`,
198
+ "metadata only — not proof of model identity",
199
+ ]);
200
+ return 0;
201
+ }
202
+ printData(result, wantsJson, [
203
+ `${name}: failed`,
204
+ `${result.error?.code ?? "unknown"}: ${result.error?.message ?? "Completion test failed"}${result.error?.status === undefined ? "" : ` (HTTP ${result.error.status})`}`,
205
+ ]);
206
+ return 1;
207
+ }
208
+
150
209
  async function testProvider(argv: string[], deps: RuntimeApiDeps): Promise<number> {
151
210
  const args = [...argv];
152
211
  const name = args.shift()?.trim();
153
212
  const wantsJson = takeFlag(args, "--json");
154
213
  if (!name) throw new CliUsageError("provider name is required", USAGE);
214
+ const model = takeOption(args, "--model")?.trim();
215
+ const yes = takeFlag(args, "--yes");
155
216
  rejectArgs(args, USAGE);
217
+ if (yes && model === undefined) throw new CliUsageError("--yes requires --model", USAGE);
218
+ if (model !== undefined) {
219
+ if (!model || model.length > 256 || /[\x00-\x1f\x7f-\x9f]/.test(model)) {
220
+ throw new CliUsageError("--model must contain 1..256 characters without control characters", USAGE);
221
+ }
222
+ return testCompletion(name, model, yes, wantsJson, deps);
223
+ }
156
224
  const result = await runtimeRequest<Record<string, unknown>>(`/api/providers/test?name=${encodeURIComponent(name)}`, {
157
225
  method: "POST",
158
226
  }, deps);
@@ -16,9 +16,9 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
16
16
  },
17
17
  {
18
18
  name: "message",
19
- usage: "ocx message <sessions|send> [options]",
20
- summary: "Discover loaded local Codex sessions and submit one queued peer message.",
21
- details: ["Local Unix transport only; starts no daemon. queued is submission, not processing; never replay an unknown send."],
19
+ usage: "ocx message <sessions|send|enable|disable|status|hosts|serve> [options]",
20
+ summary: "Discover loaded Codex sessions and queue messages locally or through explicit SSH peers.",
21
+ details: ["Remote listeners/tunnels require explicit foreground serve; no daemon start. queued is submission, not processing; never replay an unknown send."],
22
22
  },
23
23
  {
24
24
  name: "chatgpt",
@@ -1,4 +1,5 @@
1
1
  import { observeSignedInCodexIdentity } from "./signed-in-identity";
2
+ import { observeMainCodexCollisionIdentity } from "./collision-identity";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import {
4
5
  deleteConfigTopLevelKey,
@@ -9,11 +10,12 @@ import {
9
10
  import { captureConfigTopLevelRollback } from "../config/rebase-provenance";
10
11
  import { removeCodexAccountCredential } from "./account-store";
11
12
  import { clearAccountNeedsReauth } from "./account-runtime-state";
12
- import { readCodexTokensResult } from "./auth-collision";
13
+ import { readMainAuth } from "./auth-collision";
13
14
  import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
14
15
  import { clearAccountQuota } from "./quota";
15
16
  import { forgetManualPreference } from "./routing/active-account";
16
17
  import { deleteAllHealthForAccount } from "./routing/health-store";
18
+ import { forgetCodexFailureWindow } from "./routing/failure-window";
17
19
  import { clearThreadAccountMapForAccount } from "./routing/thread-affinity";
18
20
 
19
21
  import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff";
@@ -49,11 +51,15 @@ export function purgeCodexAccountRuntimeState(accountId: string): void {
49
51
  clearAccountNeedsReauth(accountId);
50
52
  clearAccountQuota(accountId);
51
53
  clearThreadAccountMapForAccount(accountId);
52
- // Deliberately not via `./routing`: the account-delete path only needs the health rows and
53
- // the manual preference gone, and importing the router here closed a cycle through
54
- // routing/health-store and routing/active-account.
54
+ // Deliberately not via `./routing`: the account-delete path only needs the health rows
55
+ // and their sibling routing state gone, and importing the router barrel here closed a
56
+ // cycle through routing/health-store and routing/active-account. The leaf imports below
57
+ // carry every per-account forget that barrel helper used to perform — including the
58
+ // failure window, whose degrade and pin-warning state must not leak into a replacement
59
+ // account or a new physical login behind __main__.
55
60
  deleteAllHealthForAccount(accountId);
56
61
  forgetManualPreference(accountId);
62
+ forgetCodexFailureWindow(accountId);
57
63
  clearCodexPoolRefreshFailure(accountId);
58
64
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
59
65
  clearMainAccountInfoCache();
@@ -74,7 +80,8 @@ function purgeMainCodexAccountRuntimeState(): void {
74
80
  * cooldown, quota, reauth, and thread affinity do not leak across accounts.
75
81
  */
76
82
  export function reconcileMainCodexAccountRuntimeState(): boolean {
77
- const read = readCodexTokensResult();
83
+ const read = readMainAuth();
84
+ if (read.status === "missing" || read.status === "api-key-only") observeMainCodexCollisionIdentity();
78
85
  const currentAccountId = read.status === "ok"
79
86
  ? extractAccountId(read.tokens.id_token, read.tokens.access_token) ?? (read.tokens.account_id || null)
80
87
  : null;
@@ -86,11 +93,13 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
86
93
  observedMainChatgptAccountId = currentAccountId;
87
94
  if (previousAccountId === undefined || previousAccountId === currentAccountId) {
88
95
  observeMainQuotaIdentity(currentAccountId);
96
+ if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
89
97
  return false;
90
98
  }
91
99
 
92
100
  purgeMainCodexAccountRuntimeState();
93
101
  observeMainQuotaIdentity(currentAccountId);
102
+ if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
94
103
  return true;
95
104
  }
96
105
 
@@ -103,7 +112,8 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
103
112
  export function initializeMainAccountPolicyBinding(authPath: string): boolean {
104
113
  // Startup observes the pinned owned path inside the exclusive claim: bound the read so a
105
114
  // replaced non-regular or oversized file cannot stall startup inside that claim.
106
- const result = readCodexTokensResult(authPath, { bounded: true });
115
+ const result = readMainAuth(authPath);
116
+ if (result.status === "missing" || result.status === "api-key-only") observeMainCodexCollisionIdentity();
107
117
  if (result.status !== "ok") return false;
108
118
  const { tokens } = result;
109
119
  if (typeof tokens.access_token !== "string" || !tokens.access_token
@@ -124,6 +134,7 @@ export function initializeMainAccountPolicyBinding(authPath: string): boolean {
124
134
  if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState();
125
135
  observeSignedInCodexIdentity(tokens);
126
136
  observeMainQuotaIdentity(accountId);
137
+ observeMainCodexCollisionIdentity(tokens);
127
138
  return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined;
128
139
  }
129
140
 
@@ -136,6 +147,7 @@ export function applyConfirmedMainCodexAccountTransition(
136
147
  toAccountId: string,
137
148
  ): boolean {
138
149
  observeSignedInCodexIdentity();
150
+ observeMainCodexCollisionIdentity();
139
151
  if (!fromAccountId || !toAccountId || fromAccountId === toAccountId) {
140
152
  if (toAccountId) {
141
153
  observedMainChatgptAccountId = toAccountId;
@@ -150,6 +162,7 @@ export function applyConfirmedMainCodexAccountTransition(
150
162
  }
151
163
 
152
164
  export function resetMainCodexAccountIdentityTrackingForTests(): void {
165
+ observeMainCodexCollisionIdentity();
153
166
  observedMainChatgptAccountId = undefined;
154
167
  observeSignedInCodexIdentity();
155
168
  clearMainAccountCredentialPresence();
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { AccountPauseDetails } from "../types/account-auto-pause";
1
3
  import type { OcxConfig } from "../types";
2
4
  import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
3
5
 
@@ -7,7 +9,9 @@ export function isCodexAccountPaused(config: OcxConfig, accountId: string): bool
7
9
  }
8
10
 
9
11
  /** Persist the account's pool eligibility without changing credentials or runtime health. */
10
- export function setCodexAccountPaused(config: OcxConfig, accountId: string, paused: boolean): void {
12
+ export function setCodexAccountPaused(config: OcxConfig, accountId: string, paused: boolean, details?: AccountPauseDetails): void {
13
+ config.codexAccountPauseState = { ...config.codexAccountPauseState,
14
+ [accountId]: { revision: randomUUID(), ...(paused && details ? { details } : {}) } };
11
15
  const pausedIds = new Set(config.pausedCodexAccountIds ?? []);
12
16
  if (paused) pausedIds.add(accountId);
13
17
  else pausedIds.delete(accountId);
@@ -18,4 +22,5 @@ export function setCodexAccountPaused(config: OcxConfig, accountId: string, paus
18
22
 
19
23
  export function forgetCodexAccountPause(config: OcxConfig, accountId: string): void {
20
24
  setCodexAccountPaused(config, accountId, false);
25
+ if (config.codexAccountPauseState) delete config.codexAccountPauseState[accountId];
21
26
  }
@@ -1,3 +1,4 @@
1
+ import { setDecisionAccountUsable } from "../lib/decision-quota-sink";
1
2
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
2
3
  import { isCodexAccountGenerationLive } from "./account-store";
3
4
 
@@ -39,12 +40,14 @@ export function clearMainRefreshGrantRejection(key: string): void {
39
40
  }
40
41
 
41
42
 
43
+ /** Quarantine a Codex account (or one credential generation) after an auth failure, and mark its advisory decision-quota row unusable so routing evidence cannot treat it as healthy. */
42
44
  export function markAccountNeedsReauth(
43
45
  id: string,
44
46
  writerGeneration = captureConfigGeneration(),
45
47
  credentialGeneration?: number,
46
48
  ): void {
47
49
  if (writerGeneration < lastReconciledGeneration && !liveAccountIds.has(id)) return;
50
+ setDecisionAccountUsable(id === "__main__" ? "codex-main" : "codex", id, false, credentialGeneration);
48
51
  // An account-wide mark supersedes a generation-scoped one: it is the stronger claim.
49
52
  if (credentialGeneration === undefined || !reauthAccounts.has(id)) {
50
53
  reauthAccounts.set(id, credentialGeneration);
@@ -81,6 +84,7 @@ export function isAccountNeedsReauth(id: string): boolean {
81
84
  return true;
82
85
  }
83
86
 
87
+ /** Lift a reauth quarantine only when the proof matches the quarantined credential generation, and restore the advisory decision-quota row's usability. */
84
88
  export function clearAccountNeedsReauth(id: string, credentialGeneration?: number): void {
85
89
  // A model response proves only the credential it used. Keep account-wide
86
90
  // quarantine and evidence from another generation intact.
@@ -88,4 +92,5 @@ export function clearAccountNeedsReauth(id: string, credentialGeneration?: numbe
88
92
  && (reauthAccounts.get(id) !== credentialGeneration
89
93
  || !isCodexAccountGenerationLive(id, credentialGeneration))) return;
90
94
  reauthAccounts.delete(id);
95
+ setDecisionAccountUsable(id === "__main__" ? "codex-main" : "codex", id, true, credentialGeneration);
91
96
  }
@@ -14,6 +14,7 @@ import {
14
14
  hardenExistingSecret,
15
15
  withConfigMutationLockSync,
16
16
  } from "../config";
17
+ import { notifyCodexStoreLoaded } from "../lib/decision-quota-sink";
17
18
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
18
19
  import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
19
20
  import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
@@ -113,30 +114,68 @@ function normalizeRecord(value: CodexAccountCredentials | CodexAccountCredential
113
114
  return undefined;
114
115
  }
115
116
 
117
+ /**
118
+ * Observe-only read for the JEV quota activation owner: the same normalization as the loader,
119
+ * without hardening, invalid-file backup or slot notification. Absent or invalid stores read as empty.
120
+ */
121
+ export function peekCodexAccountRecordStore(): Record<string, CodexAccountCredentialRecord> {
122
+ const path = codexAccountsPath();
123
+ if (!existsSync(path)) return {};
124
+ const normalized: Record<string, CodexAccountCredentialRecord> = {};
125
+ try {
126
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore;
127
+ for (const [id, value] of Object.entries(raw)) {
128
+ const record = normalizeRecord(value);
129
+ if (record) normalized[id] = record;
130
+ }
131
+ } catch {
132
+ return {};
133
+ }
134
+ return normalized;
135
+ }
136
+
137
+ /** Read the Codex account record store from disk with hardened permissions, and hand it to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
116
138
  function loadCodexAccountRecordStore(): CodexAccountStore {
139
+ const result = loadCodexAccountRecordStoreResult();
140
+ return result.status === "ok" ? result.records : {};
141
+ }
142
+
143
+ type CodexAccountRecordStoreResult = { status: "ok"; records: CodexAccountStore } | { status: "failed" };
144
+
145
+ /** Retain read/parse failure separately for consumers that must preserve existing evidence. */
146
+ function loadCodexAccountRecordStoreResult(): CodexAccountRecordStoreResult {
117
147
  const path = codexAccountsPath();
118
148
  hardenConfigDir();
119
149
  hardenExistingSecret(path);
120
- if (!existsSync(path)) return {};
150
+ const normalized: CodexAccountStore = {};
121
151
  try {
122
152
  const raw = JSON.parse(readFileSync(path, "utf-8")) as RawCodexAccountStore;
123
- const normalized: CodexAccountStore = {};
153
+ if (!isObject(raw)) throw new Error("invalid Codex account store shape");
124
154
  for (const [id, value] of Object.entries(raw)) {
125
155
  const record = normalizeRecord(value);
126
156
  if (record) normalized[id] = record;
127
157
  }
128
- return normalized;
129
- } catch {
158
+ } catch (error) {
159
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
160
+ notifyCodexStoreLoaded({});
161
+ return { status: "ok", records: {} };
162
+ }
130
163
  backupInvalidConfig(path);
131
- return {};
164
+ notifyCodexStoreLoaded({});
165
+ return { status: "failed" };
132
166
  }
167
+ // Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path.
168
+ notifyCodexStoreLoaded(normalized);
169
+ return { status: "ok", records: normalized };
133
170
  }
134
171
 
172
+ /** Atomically write the Codex account store, then hand it to the opt-in decision-quota slot so active advisory evidence tracks the persisted credentials. */
135
173
  function persist(store: CodexAccountStore): void {
136
174
  const dir = getConfigDir();
137
175
  assertNotRealHomeUnderTest(dir);
138
176
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
139
177
  atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
178
+ notifyCodexStoreLoaded(store);
140
179
  }
141
180
 
142
181
  function persistCredentialMutation(store: CodexAccountStore): void {
@@ -373,6 +412,10 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
373
412
  return !!record?.credential && record.deletedAt == null && record.generation === generation;
374
413
  }
375
414
 
415
+ /** Callable for existing liveness consumers; stateful consumers can distinguish unknown snapshots. */
416
+ export type CodexAccountGenerationLiveCheck = ((id: string, generation: number) => boolean)
417
+ & { readonly snapshotStatus: "ok" | "failed" };
418
+
376
419
  /**
377
420
  * The same verdict as {@link isCodexAccountGenerationLive}, over ONE store load.
378
421
  *
@@ -382,12 +425,15 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
382
425
  * several accounts in one synchronous pass on the request path, so it opens one checker and
383
426
  * closes over the snapshot instead.
384
427
  */
385
- export function beginCodexAccountGenerationLiveCheck(): (id: string, generation: number) => boolean {
386
- const snapshot = loadCodexAccountRecordSnapshot();
387
- return (id, generation) => {
428
+ export function beginCodexAccountGenerationLiveCheck(): CodexAccountGenerationLiveCheck {
429
+ let result: CodexAccountRecordStoreResult;
430
+ try { result = loadCodexAccountRecordStoreResult(); }
431
+ catch { result = { status: "failed" }; }
432
+ const snapshot = result.status === "ok" ? result.records : {};
433
+ return Object.assign((id: string, generation: number) => {
388
434
  const record = snapshot[id];
389
435
  return !!record?.credential && record.deletedAt == null && record.generation === generation;
390
- };
436
+ }, { snapshotStatus: result.status });
391
437
  }
392
438
 
393
439
  export function saveCodexAccountCredentialIfGeneration(