@bitkyc08/opencodex 2.82.0 → 2.84.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 (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -1,4 +1,4 @@
1
- import { lstatSync, readFileSync, type Stats } from "node:fs";
1
+ import { lstatSync, readFileSync, type BigIntStats, type Stats } from "node:fs";
2
2
  import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsRegExe } from "../lib/windows-elevation";
3
3
  import { hostname } from "node:os";
4
4
 
@@ -6,11 +6,12 @@ import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
6
6
 
7
7
  export interface HostIdentity { hostname: string; machine: string }
8
8
  export interface OwnerEvidence { pid: number; host?: HostIdentity; processStart?: string }
9
+ export type LockStat = Stats | BigIntStats;
9
10
  export interface OwnerDeps {
10
11
  isProcessAlive: (pid: number) => boolean | undefined;
11
12
  hostIdentity: () => HostIdentity | undefined;
12
13
  processStart: (pid: number) => string | undefined;
13
- lstat: (path: string) => Stats;
14
+ lstat: (path: string) => LockStat;
14
15
  uid: () => number | undefined;
15
16
  platform: NodeJS.Platform;
16
17
  }
@@ -94,7 +95,9 @@ export const ownerDefaults: OwnerDeps = {
94
95
  return code === "ESRCH" ? false : code === "EPERM" ? true : undefined;
95
96
  }
96
97
  },
97
- ...identity, lstat: lstatSync,
98
+ // File IDs can exceed Number's exact range on Windows. Keep every bit for
99
+ // the entry/parent witnesses used before destructive operations and retries.
100
+ ...identity, lstat: path => lstatSync(path, { bigint: true }),
98
101
  uid: () => process.getuid?.(), platform: process.platform,
99
102
  };
100
103
  export function ownEvidence(deps: OwnerDeps): OwnerEvidence {
@@ -118,11 +121,16 @@ export function ownerState(record: OwnerEvidence | null, deps: OwnerDeps): "dead
118
121
  return deps.isProcessAlive(record.pid) === false ? "dead" : "live";
119
122
  } catch { return "live"; }
120
123
  }
124
+ /** Type, no symlink, and current-user ownership on POSIX, judged on one captured observation. */
125
+ export function safeNamespaceStat(stat: LockStat, kind: "file" | "directory", deps: OwnerDeps): boolean {
126
+ const uid = deps.uid();
127
+ return !stat.isSymbolicLink() && (kind === "file" ? stat.isFile() : stat.isDirectory())
128
+ && (deps.platform === "win32" || (uid !== undefined
129
+ && stat.uid === (typeof stat.uid === "bigint" ? BigInt(uid) : uid)));
130
+ }
121
131
  export function safeNamespace(path: string, kind: "file" | "directory", deps: OwnerDeps, missing = false): boolean {
122
132
  try {
123
- const stat = lockFileOperation(() => deps.lstat(path), deps.platform), uid = deps.uid();
124
- return !stat.isSymbolicLink() && (kind === "file" ? stat.isFile() : stat.isDirectory())
125
- && (deps.platform === "win32" || (uid !== undefined && stat.uid === uid));
133
+ return safeNamespaceStat(lockFileOperation(() => deps.lstat(path), deps.platform), kind, deps);
126
134
  } catch (error) {
127
135
  if (error instanceof LockFileBusy) throw error;
128
136
  return missing && (error as NodeJS.ErrnoException).code === "ENOENT";
@@ -776,18 +776,23 @@ function extractSections(raw: string): Map<string, string> {
776
776
  }
777
777
  if (!Array.isArray(parsed)) return sections;
778
778
  for (const item of parsed) {
779
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
779
780
  const content = (item as { content?: unknown }).content;
780
781
  if (!Array.isArray(content)) continue;
781
- const text = content.map(part => String((part as { text?: unknown }).text ?? "")).join("");
782
+ const text = content.map(part => {
783
+ if (!part || typeof part !== "object" || Array.isArray(part)) return "";
784
+ const value = (part as { text?: unknown }).text;
785
+ return typeof value === "string" ? value : "";
786
+ }).join("");
782
787
  // Tag names are NOT all snake_case: Codex renders `<permissions instructions>`
783
788
  // with a space. A `[a-z_]+` pattern silently skipped it, and the layer was
784
789
  // reported as "sent nothing" while its text sat right there.
785
790
  for (const match of text.matchAll(/<([a-zA-Z_][a-zA-Z0-9_ -]*)>([\s\S]*?)<\/\1>/g)) {
786
791
  sections.set(match[1]!, match[2]!.trim());
787
792
  }
788
- // AGENTS.md is NOT tagged: it arrives as a plain `# AGENTS.md instructions
789
- // for <path>` block among the tagged sections. Matching only on tags would
790
- // report the layer as unrendered while its text sits in the same message.
793
+ // AGENTS.md is NOT tagged: it arrives as a `# AGENTS.md instructions`
794
+ // block among the tagged sections. Matching only on tags would report the
795
+ // layer as unrendered while its text sits in the same message.
791
796
  //
792
797
  // Bounded at both ends. Capturing to end-of-message swept up any unrelated
793
798
  // untagged prose that happened to follow, and stripping tag-shaped blocks
@@ -795,8 +800,25 @@ function extractSections(raw: string): Map<string, string> {
795
800
  // AGENTS.md. Codex wraps the body in <INSTRUCTIONS>, so that is the boundary.
796
801
  // No line anchor: the block is concatenated directly onto the previous
797
802
  // section's closing tag, so requiring a newline before it never matched.
798
- const projectDoc = /# AGENTS\.md instructions for [^\n]*\n+<INSTRUCTIONS>\n?([\s\S]*?)\n?<\/INSTRUCTIONS>/.exec(text);
803
+ //
804
+ // The `for <path>` suffix is OPTIONAL. Upstream emits three shapes from
805
+ // `contextual_user_fragment().render()` (core/src/context.rs) and this has
806
+ // to read all three rather than the one that existed when the probe was
807
+ // written: the legacy single-root form `# AGENTS.md instructions for
808
+ // <dir>`, the no-directory form `# AGENTS.md instructions`, and the
809
+ // multi-environment form where each project's block is labeled `for \`<env>\`
810
+ // with root <cwd>` INSIDE the INSTRUCTIONS wrapper. The labels are part of
811
+ // what the model sees, so they stay in the captured body.
812
+ const projectDoc = /# AGENTS\.md instructions(?: for [^\n]*)?\n+<INSTRUCTIONS>\n?([\s\S]*?)\n?<\/INSTRUCTIONS>/.exec(text);
799
813
  if (projectDoc) sections.set("__agents_md", projectDoc[1]!.trim());
814
+ else if (/# AGENTS\.md instructions/.test(text)) {
815
+ // The header rendered but in a shape this extractor does not recognize -
816
+ // a future upstream format, or prose truncated by the output cap. Claiming
817
+ // the layer "sent nothing" would be a lie about a block that is plainly
818
+ // present; the sentinel maps it to `unmapped`, the existing honest answer
819
+ // for "rendered in a form we cannot show".
820
+ sections.set("__agents_md_unmatched", "");
821
+ }
800
822
  }
801
823
  return sections;
802
824
  }
@@ -814,6 +836,13 @@ function mapSectionsToLayers(sections: Map<string, string>): Record<string, Laye
814
836
  ? { text: null, reason: "not-rendered", bytes: 0 }
815
837
  : { text, reason: "ok", bytes: Buffer.byteLength(text, "utf8") };
816
838
  }
839
+ // `__agents_md_unmatched` is the sentinel for "the AGENTS.md header rendered in
840
+ // a shape the bounded extractor does not recognize". `unmapped` is the honest
841
+ // answer for it: the text exists but cannot be shown, which `not-rendered`
842
+ // would deny outright.
843
+ if (layers["agents-md"]?.reason === "not-rendered" && sections.has("__agents_md_unmatched")) {
844
+ layers["agents-md"] = { text: null, reason: "unmapped", bytes: 0 };
845
+ }
817
846
  return layers;
818
847
  }
819
848
 
@@ -319,9 +319,12 @@ export function setAccountQuotaFromParsed(
319
319
  credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw),
320
320
  }, updatedAt);
321
321
  }
322
- // Legacy rotation keeps its existing carry behavior, but never inherits policy-only
322
+ // Rotation shares validated main-window retirement, but never inherits policy-only
323
323
  // evidence that outlived its disk TTL. Policy has a separate, identity-checked base.
324
- const next = mergeAccountQuota(quota, legacyExisting, updatedAt);
324
+ const displayQuota: MainPolicyQuotaObservation = isMain && mainWriter && policyQuota?.shortWindowAbsent === true
325
+ ? { ...quota, shortWindowAbsent: true }
326
+ : quota;
327
+ const next = mergeAccountQuota(displayQuota, legacyExisting, updatedAt);
325
328
  accountQuota.set(accountId, next);
326
329
  if (isMain) {
327
330
  if (mainWriter && policyQuota && snapshotHasUsage(policyQuota)) {
@@ -440,7 +443,7 @@ function mergeAccountQuota(
440
443
  }
441
444
  if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt;
442
445
  if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds;
443
- } else if (!policyEvidence || quota.shortWindowAbsent !== true) {
446
+ } else if (quota.shortWindowAbsent !== true) {
444
447
  // Unknown usage is not a lower reading. Retain the entire known tuple: pairing
445
448
  // its percentage with new metadata would silently extend or shorten its reset.
446
449
  // An elapsed reset is the exception. It describes a window that has already rolled over,
@@ -879,14 +882,17 @@ function filterMainPolicyMonthlyQuota(
879
882
  /**
880
883
  * Parse ordinary main-policy usage, rejecting messages with invalid numeric window percentages.
881
884
  * A measured long primary, or explicitly absent primary with measured weekly secondary,
882
- * proves replacement only with complete long/null topology. Null supplies no usable evidence.
885
+ * proves replacement with long/null topology; an allowed two-window response may omit tertiary.
886
+ * Null supplies no usable evidence.
883
887
  */
884
888
  export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQuotaObservation | null {
885
889
  const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
886
890
  if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null;
887
891
  const quota = filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type));
888
892
  const [primary, secondary, tertiary] = windows;
889
- // WHAM explicitly reports absent windows as null; omissions cannot prove replacement.
893
+ // Primary and secondary must explicitly report their topology. WHAM also sends
894
+ // two-window responses without tertiary; an explicit allowed verdict disambiguates those
895
+ // from partial observations. A present tertiary still requires measured long/null evidence.
890
896
  // Policy trusts one complete snapshot only when every non-null window is >=24h AND
891
897
  // carries a valid usage reading: a long window without used_percent leaves that
892
898
  // window's usage unknown, and unknown usage must never release a block.
@@ -894,7 +900,8 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu
894
900
  if (quota && (isMeasuredLongWindow(primary)
895
901
  || (primary === null && isMeasuredLongWindow(secondary) && quota.weeklyPercent !== undefined))
896
902
  && (secondary === null || isMeasuredLongWindow(secondary))
897
- && (tertiary === null || isMeasuredLongWindow(tertiary))) {
903
+ && (tertiary === null || isMeasuredLongWindow(tertiary)
904
+ || (tertiary === undefined && data.rate_limit?.allowed === true))) {
898
905
  return { ...quota, shortWindowAbsent: true };
899
906
  }
900
907
  return quota;
@@ -123,7 +123,7 @@ export type RoleProposal = RoleProposalInput & (
123
123
  readonly moveUpIf: string;
124
124
  readonly moveDownIf: string;
125
125
  readonly proposedModel: string | null;
126
- /** Only when the role already carries an effort, unless the caller asks for one always. */
126
+ /** The sized effort on the picked model's ladder; null only when that model has no ladder. */
127
127
  readonly proposedEffort: string | null;
128
128
  readonly reason: string | null;
129
129
  }
@@ -134,7 +134,6 @@ export function buildRoleProposals(
134
134
  roles: readonly RoleProposalInput[],
135
135
  sizing: ReadonlyMap<string, RoleSizingOutcome>,
136
136
  classified: readonly ClassifiedCandidate[],
137
- options: { readonly alwaysProposeEffort?: boolean } = {},
138
137
  ): RoleProposal[] {
139
138
  return roles.map(role => {
140
139
  const outcome = sizing.get(role.role) ?? { unsized: "the role was not sized" };
@@ -151,9 +150,7 @@ export function buildRoleProposals(
151
150
  reason: `no available model is classified ${tier} or above`,
152
151
  };
153
152
  }
154
- const proposedEffort = role.effort === null && !options.alwaysProposeEffort
155
- ? null
156
- : mapEffortToLevel(effort, pick.efforts, pick.defaultEffort);
153
+ const proposedEffort = mapEffortToLevel(effort, pick.efforts, pick.defaultEffort);
157
154
  return { ...sized, status: "proposed", proposedModel: pick.model, proposedEffort, reason: null };
158
155
  });
159
156
  }
@@ -2,6 +2,7 @@ import { saveConfigPreservingClaudeCode } from "../../config";
2
2
  import { clearCodexAccountPin, pinnedCodexAccountId } from "../account-priority";
3
3
  import {
4
4
  POOL_KEY_CODEX,
5
+ clearPoolRotationState,
5
6
  normalizeCodexAccountPoolStrategy,
6
7
  seedPoolRotationAccount,
7
8
  } from "../pool-rotation";
@@ -74,6 +75,26 @@ export function resetCodexRoutingForManualSelection(accountId: string): void {
74
75
  }
75
76
  }
76
77
 
78
+ /**
79
+ * Operator released the selection instead of naming a new account: return routing to
80
+ * automatic without steering. A release names no replacement, so bound threads keep
81
+ * their captured account and quota-avoidance health stands — nothing new overrules it.
82
+ * What still has to go is the steering a selection planted: the one-shot manual
83
+ * preference (releasing account A must not keep blocking every other pick), the
84
+ * runtime cursor, and the rotation seeds on the shared ring and each independent
85
+ * quota-scope ring, which would otherwise force the next pick onto that same account.
86
+ */
87
+ export function resetCodexRoutingForClearedSelection(): void {
88
+ runtimeActiveCodexAccountId = undefined;
89
+ manualPreference.delete(POOL_KEY_CODEX);
90
+ clearPoolRotationState(POOL_KEY_CODEX);
91
+ for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) {
92
+ if (isIndependentCodexQuotaScope(scope)) {
93
+ clearPoolRotationState(codexPoolKeyForScope(scope));
94
+ }
95
+ }
96
+ }
97
+
77
98
  /** Effective active: automatic runtime cursor, else operator/persisted selection. */
78
99
  /**
79
100
  * Unspent operator selections, keyed by pool scope.
@@ -215,7 +215,7 @@ export {
215
215
  pickAlternateCodexAccount,
216
216
  } from "./routing/selection";
217
217
  export {
218
- resetCodexRoutingForManualSelection,
218
+ resetCodexRoutingForManualSelection, resetCodexRoutingForClearedSelection,
219
219
  getEffectiveActiveCodexAccountId,
220
220
  isEffectiveCodexAccountPinned,
221
221
  } from "./routing/active-account";
@@ -1,13 +1,14 @@
1
1
  import { execFile, execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
2
+ import { closeSync, constants, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { delimiter, join } from "node:path";
4
+ import { delimiter, join, posix } from "node:path";
5
5
  import { atomicWriteFile, getConfigDir } from "../config";
6
6
  import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation";
7
7
  import { resolveCodexHomeDir } from "./home";
8
8
  import { decodeOverlayState } from "./shim-state-file";
9
9
  import { redactSecretString, redactUserPath } from "../lib/redact";
10
10
  import { SUBPROCESS_KILL_GRACE_MS, waitForSubprocessExit } from "../lib/bounded-subprocess";
11
+ import { isPassiveRead } from "../lib/passive-read";
11
12
 
12
13
  export type CodexRuntimeSource =
13
14
  | "environment"
@@ -89,6 +90,8 @@ export interface ResolveCodexRuntimeDeps {
89
90
  execFile?: RuntimeExecFileAsync;
90
91
  existsSync?: (path: string) => boolean;
91
92
  readFileSync?: (path: string, encoding: "utf8") => string;
93
+ /** Bounded in-process macOS bundle identity reader; injected readers bypass the memo. */
94
+ readBundleIdentifier?: (infoPlistPath: string) => string | null;
92
95
  now?: () => number;
93
96
  /**
94
97
  * When false, stop after the first valid priority candidate (skip PATH-wide
@@ -225,10 +228,11 @@ export function loadLastEffortClamp(
225
228
  }
226
229
  }
227
230
 
231
+ /** Return whether a nonempty observation changed, so catalog notices share persistence's identity. */
228
232
  export function persistEffortClamp(
229
233
  diagnostic: EffortClampDiagnostic | null,
230
234
  deps: ResolveCodexRuntimeDeps = {},
231
- ): void {
235
+ ): boolean {
232
236
  const configDir = deps.configDir ?? getConfigDir();
233
237
  const path = codexRuntimeClampStatePath(configDir);
234
238
  if (!diagnostic || diagnostic.removedEfforts.length === 0) {
@@ -237,8 +241,16 @@ export function persistEffortClamp(
237
241
  } catch {
238
242
  /* absent is fine */
239
243
  }
240
- return;
244
+ return false;
241
245
  }
246
+ // This is an observation, not a heartbeat: rewriting updatedAt on every sync both churns disk
247
+ // and makes identical clamps look new after restart. Compare sets, not catalog enumeration order.
248
+ const previous = loadLastEffortClamp(deps);
249
+ const setKey = (values: readonly string[]) => JSON.stringify([...new Set(values)].sort());
250
+ if (previous && previous.runtimePath === diagnostic.runtimePath
251
+ && previous.runtimeVersion === diagnostic.runtimeVersion
252
+ && setKey(previous.removedEfforts) === setKey(diagnostic.removedEfforts)
253
+ && setKey(previous.affectedModels) === setKey(diagnostic.affectedModels)) return false;
242
254
  const payload: PersistedClampState = {
243
255
  version: 1,
244
256
  updatedAt: new Date((deps.now ?? Date.now)()).toISOString(),
@@ -249,6 +261,7 @@ export function persistEffortClamp(
249
261
  };
250
262
  mkdirSync(configDir, { recursive: true, mode: 0o700 });
251
263
  atomicWriteFile(path, `${JSON.stringify(payload, null, 2)}\n`);
264
+ return true;
252
265
  }
253
266
 
254
267
  export function displayCodexRuntimePath(command: string): string {
@@ -425,6 +438,9 @@ function versionProbePrecheck(command: string, deps: ResolveCodexRuntimeDeps): V
425
438
  // `codex --version` here is ~1s of blocking exec per candidate; on the
426
439
  // dashboard probe that cost made Windows report Codex as missing even when
427
440
  // the App install was sitting under LOCALAPPDATA/OpenAI/Codex/bin (issue 4458).
441
+ if (!macCodexAppIdentityMatches(command, deps)) {
442
+ return { ok: false, reason: "Codex app bundle identity mismatch or unavailable" };
443
+ }
428
444
  if (deps.probeVersion === false) return { ok: true, version: null };
429
445
  return null;
430
446
  }
@@ -599,6 +615,161 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] {
599
615
  return [...new Set(out)];
600
616
  }
601
617
 
618
+ /** Conventional Codex desktop bundles, ordered system before user. */
619
+ function macCodexAppBundles(deps: ResolveCodexRuntimeDeps): string[] {
620
+ const env = deps.env ?? process.env;
621
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
622
+ return ["/Applications/ChatGPT.app", posix.join(home, "Applications/ChatGPT.app")];
623
+ }
624
+
625
+ function macCodexAppCommands(deps: ResolveCodexRuntimeDeps): string[] {
626
+ return macCodexAppBundles(deps).flatMap(bundle => [
627
+ `${bundle}/Contents/Resources/codex-cli/bin/codex`, `${bundle}/Contents/Resources/codex`,
628
+ ]);
629
+ }
630
+
631
+ function realpathOrNull(path: string): string | null {
632
+ try { return realpathSync(path); } catch { return null; }
633
+ }
634
+
635
+ /**
636
+ * Classify any path inside either bundle, including backing executables, by normalized and
637
+ * canonical forms with a segment boundary: a symlinked HOME (or /tmp -> /private/tmp) must not
638
+ * let a canonical bundle path skip the identity check or the handover exclusion.
639
+ */
640
+ function macCodexAppBundleFor(command: string, deps: ResolveCodexRuntimeDeps): string | undefined {
641
+ if ((deps.platform ?? process.platform) !== "darwin" || !posix.isAbsolute(command)) return undefined;
642
+ const forms = [posix.normalize(command), realpathOrNull(command)].filter((form): form is string => Boolean(form));
643
+ return macCodexAppBundles(deps).find(bundle => {
644
+ const roots = [posix.normalize(bundle), realpathOrNull(bundle)].filter((root): root is string => Boolean(root));
645
+ return forms.some(form => roots.some(root => form.startsWith(`${root}/`)));
646
+ });
647
+ }
648
+
649
+ function macCodexAppIdentityMatches(command: string, deps: ResolveCodexRuntimeDeps): boolean {
650
+ const bundle = macCodexAppBundleFor(command, deps);
651
+ if (!bundle) return true;
652
+ try {
653
+ return (deps.readBundleIdentifier ?? readBoundedBundleIdentifier)(`${bundle}/Contents/Info.plist`) === "com.openai.codex";
654
+ } catch { return false; }
655
+ }
656
+
657
+ /** XML plist subset only: no external entity expansion, subprocess, or unbounded read. */
658
+ function readBoundedBundleIdentifier(path: string): string | null {
659
+ let fd: number | undefined;
660
+ try {
661
+ fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
662
+ const st = fstatSync(fd);
663
+ const limit = 64 * 1024;
664
+ if (!st.isFile() || st.size > limit) return null;
665
+ const bytes = Buffer.alloc(limit + 1);
666
+ let size = 0;
667
+ while (size < bytes.length) {
668
+ const count = readSync(fd, bytes, size, bytes.length - size, null);
669
+ if (!count) break;
670
+ size += count;
671
+ }
672
+ if (size > limit) return null;
673
+ // XML 1.0 Char: tab, LF, CR, U+0020-D7FF, U+E000-FFFD, U+10000-10FFFF (no surrogates after fatal decoding).
674
+ const xmlChar = (code: number): boolean => code === 9 || code === 10 || code === 13
675
+ || (code >= 0x20 && code <= 0xd7ff) || (code >= 0xe000 && code <= 0xfffd) || (code >= 0x10000 && code <= 0x10ffff);
676
+ const allXmlChars = (text: string): boolean => [...text].every(char => xmlChar(char.codePointAt(0)!));
677
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, size));
678
+ // Every character is checked before anything is stripped, the prolog included.
679
+ if (!allXmlChars(decoded)) return null;
680
+ // Only a well-formed UTF-8 declaration and Apple's DOCTYPE are accepted, anchored in the prolog, in that order.
681
+ const declaration = /^<\?xml\s+version=(["'])1\.[0-9]\1(?:\s+encoding=(["'])utf-8\2)?(?:\s+standalone=(["'])(?:yes|no)\3)?\s*\?>/i;
682
+ if (decoded.startsWith("<?") && !declaration.test(decoded)) return null;
683
+ const xml = decoded
684
+ .replace(declaration, "")
685
+ .replace(/^\s*<!DOCTYPE plist PUBLIC "-\/\/Apple\/\/DTD PLIST 1\.0\/\/EN" "http:\/\/www\.apple\.com\/DTDs\/PropertyList-1\.0\.dtd">/, "");
686
+ type Node = { name: string; text: string; children: Node[] };
687
+ const root: Node = { name: "root", text: "", children: [] };
688
+ const stack = [root];
689
+ const tokens = xml.match(/<!--[\s\S]*?-->|<[^>]*>|[^<]+/g) ?? [];
690
+ if (tokens.join("") !== xml) return null;
691
+ for (const token of tokens) {
692
+ if (token.startsWith("<!--")) {
693
+ // A comment may not contain "--" or end in "-" (XML 1.0 §2.5).
694
+ if (/--|-$/.test(token.slice(4, -3))) return null;
695
+ continue;
696
+ }
697
+ const parent = stack[stack.length - 1]!;
698
+ if (!token.startsWith("<")) {
699
+ if (token.includes("]]>")) return null;
700
+ parent.text += token;
701
+ continue;
702
+ }
703
+ const close = /^<\/(\w+)\s*>$/.exec(token);
704
+ if (close) {
705
+ if (stack.length === 1 || parent.name !== close[1]) return null;
706
+ stack.pop();
707
+ continue;
708
+ }
709
+ const open = /^<(plist|dict|array|key|string|integer|real|date|data|true|false)(?:\s+version="1\.0")?\s*(\/?)>$/.exec(token);
710
+ if (!open || (token.includes("version=") && open[1] !== "plist")) return null;
711
+ const node: Node = { name: open[1]!, text: "", children: [] };
712
+ parent.children.push(node);
713
+ if (!open[2]) stack.push(node);
714
+ }
715
+ if (stack.length !== 1 || root.text.trim() || root.children.length !== 1) return null;
716
+ const plist = root.children[0]!;
717
+ if (plist.name !== "plist" || plist.text.trim() || plist.children.length !== 1) return null;
718
+ const dict = plist.children[0]!;
719
+ if (dict.name !== "dict" || dict.text.trim() || dict.children.length % 2) return null;
720
+ const decode = (text: string): string => text.replace(/&([^;]*);|&/g, (_match, entity: string | undefined) => {
721
+ const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
722
+ if (entity && Object.hasOwn(named, entity)) return named[entity]!;
723
+ if (!entity || !/^#(?:[0-9]+|x[0-9a-fA-F]+)$/.test(entity)) throw new Error("invalid XML entity");
724
+ const value = entity.startsWith("#x") ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
725
+ if (!xmlChar(value)) throw new Error("invalid XML character");
726
+ return String.fromCodePoint(value);
727
+ });
728
+ // The identity only counts inside a document that is valid as a whole: every node, entity and
729
+ // literal character is checked, not just the CFBundleIdentifier pair.
730
+ const leafFormats: Record<string, RegExp> = {
731
+ integer: /^\s*[+-]?(?:\d+|0x[0-9a-fA-F]+)\s*$/,
732
+ real: /^\s*(?:[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|[+-]?(?:nan|inf(?:inity)?))\s*$/i,
733
+ date: /^\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\s*$/,
734
+ data: /^[A-Za-z0-9+/=\s]*$/,
735
+ };
736
+ const validNode = (node: Node, inDict: "key" | "value" | null): boolean => {
737
+ if (!allXmlChars(node.text)) return false;
738
+ if (inDict === "key" && node.name !== "key") return false;
739
+ if (inDict !== "key" && node.name === "key") return false;
740
+ switch (node.name) {
741
+ case "dict":
742
+ return !node.text.trim() && node.children.length % 2 === 0
743
+ && node.children.every((child, index) => validNode(child, index % 2 === 0 ? "key" : "value"));
744
+ case "array":
745
+ return !node.text.trim() && node.children.every(child => validNode(child, null));
746
+ case "key": case "string":
747
+ decode(node.text);
748
+ return node.children.length === 0;
749
+ case "true": case "false":
750
+ return node.children.length === 0 && !node.text.trim();
751
+ case "integer": case "real": case "date": case "data":
752
+ return node.children.length === 0 && leafFormats[node.name]!.test(decode(node.text));
753
+ default:
754
+ return false;
755
+ }
756
+ };
757
+ if (!validNode(dict, null)) return null;
758
+ let identifier: string | null = null;
759
+ let keys = 0;
760
+ for (let i = 0; i < dict.children.length; i += 2) {
761
+ const key = dict.children[i]!, value = dict.children[i + 1]!;
762
+ if (key.name !== "key" || key.children.length) return null;
763
+ if (decode(key.text) !== "CFBundleIdentifier") continue;
764
+ keys++;
765
+ if (value.name !== "string" || value.children.length) return null;
766
+ identifier = decode(value.text);
767
+ }
768
+ return keys === 1 ? identifier : null;
769
+ } catch { return null; }
770
+ finally { if (fd !== undefined) closeSync(fd); }
771
+ }
772
+
602
773
  /**
603
774
  * Codex installs that PATH does not necessarily expose.
604
775
  *
@@ -647,6 +818,10 @@ function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] {
647
818
  "/usr/local/bin/codex",
648
819
  "/opt/homebrew/bin/codex",
649
820
  ];
821
+ if (platform === "darwin") {
822
+ const exists = deps.existsSync ?? existsSync;
823
+ return [...posix, ...macCodexAppCommands(deps).filter(command => exists(command) && macCodexAppIdentityMatches(command, deps))];
824
+ }
650
825
  if (platform !== "linux") return posix;
651
826
  // Windows Codex Desktop in WSL app-server mode ships its Linux binary under the
652
827
  // effective Codex home as bin/wsl/<version-hash>/codex, and a Desktop update replaces
@@ -839,6 +1014,7 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
839
1014
  || deps.execFile
840
1015
  || deps.existsSync
841
1016
  || deps.readFileSync
1017
+ || deps.readBundleIdentifier
842
1018
  || deps.readdirSync
843
1019
  || deps.statSync
844
1020
  || deps.configDir
@@ -974,7 +1150,8 @@ export function getCodexRuntimeSnapshot(): ResolveCodexRuntimeResult {
974
1150
  const cacheKey = resolveCacheKey({});
975
1151
  const memo = resolveCache?.key === cacheKey ? resolveCache : null;
976
1152
  if (memo && Date.now() - memo.at < RESOLVE_CACHE_MS) return cloneAndDeepFreeze(memo.value);
977
- resolveCodexRuntimeAsync().catch(() => { /* the next read retries */ });
1153
+ // A passive read (a Remote Link Child's, #6959) starts no version probes on this machine.
1154
+ if (!isPassiveRead()) resolveCodexRuntimeAsync().catch(() => { /* the next read retries */ });
978
1155
  if (memo) return cloneAndDeepFreeze(memo.value);
979
1156
  return resolveCodexRuntime({ discoverAlternatives: false, probeVersion: false });
980
1157
  }
@@ -1020,10 +1197,18 @@ function* resolveCodexRuntimeSteps(
1020
1197
  for (const command of pathCandidates(deps)) {
1021
1198
  ordered.push({ command, source: "path" });
1022
1199
  }
1023
- for (const command of installedCodexCandidates(deps)) {
1200
+ const installed = installedCodexCandidates(deps);
1201
+ // Order by the generated app commands only: an established install path that links into the
1202
+ // app keeps its precedence, while identity and handover use the canonical classification.
1203
+ const appCommands = new Set(macCodexAppCommands(deps));
1204
+ const appCandidates = installed.filter(command => appCommands.has(command));
1205
+ for (const command of installed.filter(command => !appCommands.has(command))) {
1024
1206
  ordered.push({ command, source: "installed" });
1025
1207
  }
1026
1208
  ordered.push({ command: "codex", source: "fallback" });
1209
+ for (const command of appCandidates) {
1210
+ ordered.push({ command, source: "installed" });
1211
+ }
1027
1212
 
1028
1213
  const seen = new Set<string>();
1029
1214
  const valid: ResolvedCodexRuntime[] = [];
@@ -1045,13 +1230,16 @@ function* resolveCodexRuntimeSteps(
1045
1230
  // there is then nothing to compare it against.
1046
1231
  const persistedIsUnpinned = Boolean(persisted?.command) && !persistedCodexRuntimeIsPinned(persisted);
1047
1232
  for (const candidate of ordered) {
1233
+ // Deferred resolution accepts the bare fallback without proving it exists; when a verified
1234
+ // app CLI is present it must not preempt that app. Other platforms keep the old behaviour.
1235
+ if (candidate.source === "fallback" && deps.probeVersion === false && appCandidates.length > 0) continue;
1048
1236
  const key = candidate.command.toLowerCase();
1049
1237
  if (seen.has(key)) continue;
1050
1238
  seen.add(key);
1051
1239
  if (
1052
1240
  deps.discoverAlternatives === false
1053
1241
  && valid.length > 0
1054
- && !(persistedIsUnpinned && candidate.source === "installed")
1242
+ && !(persistedIsUnpinned && candidate.source === "installed" && !macCodexAppBundleFor(candidate.command, deps))
1055
1243
  ) {
1056
1244
  continue;
1057
1245
  }
@@ -1111,6 +1299,7 @@ function* resolveCodexRuntimeSteps(
1111
1299
  const newerDiscovered = valid
1112
1300
  .filter(item =>
1113
1301
  !sameRuntimeCommand(item.command, selected.command)
1302
+ && !macCodexAppBundleFor(item.command, deps)
1114
1303
  && typeof item.version === "string"
1115
1304
  && typeof selected.version === "string"
1116
1305
  && compareCodexVersions(item.version, selected.version) > 0)
@@ -1232,6 +1421,5 @@ export function formatClampLogLines(diagnostic: EffortClampDiagnostic): string[]
1232
1421
  const efforts = diagnostic.removedEfforts.join(", ");
1233
1422
  return [
1234
1423
  `[opencodex] Removed unsupported reasoning efforts: ${efforts}`,
1235
- "[opencodex] Run ocx doctor for diagnosis and recovery.",
1236
1424
  ];
1237
1425
  }
@@ -404,7 +404,9 @@ function failure(error: unknown): InstallResult {
404
404
  const message = error instanceof Error ? error.message : "Codex overlay operation failed";
405
405
  return { installed: false, refused: true, message: truncateRetainedUtf8(message.replace(/[\r\n]/g, " "), MAX_DIAGNOSTIC_VALUE_BYTES) };
406
406
  }
407
- function installLocked(discover: () => string | null, automatic: boolean, check: () => void): InstallResult {
407
+ function installLocked(
408
+ discover: () => string | null, automatic: boolean, check: () => void, describeMiss?: () => string | null,
409
+ ): InstallResult {
408
410
  recoverJournal(check);
409
411
  const pinnedState = snapshot(statePath());
410
412
  const result = readStateResult();
@@ -428,18 +430,20 @@ function installLocked(discover: () => string | null, automatic: boolean, check:
428
430
  const discovered = automatic ? null : discover();
429
431
  launcher = discovered ? resolve(discovered) : null;
430
432
  }
431
- if (!launcher) throw new Error("Native Codex launcher not found; install or repair Codex with its package manager");
433
+ // A discovery that refused a candidate (fnm's temporary multishell path) says why; reporting it
434
+ // as plain absence sent users to reinstall a Codex that was already there (#6963).
435
+ if (!launcher) throw new Error(describeMiss?.() || "Native Codex launcher not found; install or repair Codex with its package manager");
432
436
  check();
433
437
  return publishOverlay(launcher, overlay, check, pinnedState);
434
438
  }
435
- export function installUnixOverlay(discover: () => string | null): InstallResult {
439
+ export function installUnixOverlay(discover: () => string | null, describeMiss?: () => string | null): InstallResult {
436
440
  let lock: ReturnType<typeof tryAcquireShimRestoreLock> = null;
437
441
  try {
438
442
  const check = namespace(true);
439
443
  lock = tryAcquireShimRestoreLock();
440
444
  if (!lock) throw new Error("Codex shim operation is already in progress; retry later");
441
445
  check();
442
- return installLocked(discover, false, check);
446
+ return installLocked(discover, false, check, describeMiss);
443
447
  } catch (error) { return failure(error); }
444
448
  finally { lock?.release(); }
445
449
  }
package/src/codex/shim.ts CHANGED
@@ -1079,7 +1079,7 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
1079
1079
  }
1080
1080
 
1081
1081
  export function installCodexShim(deps: RuntimePreflightDeps = {}): { installed: boolean; message: string; refused?: boolean; runnable?: boolean } {
1082
- if (process.platform !== "win32") return installUnixOverlay(findCodexOnPath);
1082
+ if (process.platform !== "win32") return installUnixOverlay(findCodexOnPath, lastCodexDiscoveryError);
1083
1083
  return installCodexShimInternal({ ...deps, allowFreshInstall: true });
1084
1084
  }
1085
1085
 
@@ -1,6 +1,7 @@
1
1
  import { parseResetCooldownMs } from "../codex/routing";
2
2
  import { classifyError, isCyberPolicyCode } from "../lib/errors";
3
3
  import { isNonReplayableUpstreamCode } from "../lib/upstream-retry";
4
+ import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal";
4
5
  import type { OcxComboTarget } from "../types";
5
6
  import { targetKey } from "./types";
6
7
  import {
@@ -563,6 +564,7 @@ export function comboFailureCooldownScope(
563
564
  options?: { code?: string | null },
564
565
  ): ComboFailureCooldownScope {
565
566
  const code = normalizedFailureCode(options?.code);
567
+ if (isAnthropicBillingFailure(status, message, options?.code)) return "provider";
566
568
  // Request-shape refusals first: an oversized request must not cool a healthy target.
567
569
  // A native transport can surface a zero-output model overflow as a generic
568
570
  // upstream_server_error carrying precise context-window prose, so consult the bounded
@@ -847,6 +849,7 @@ export function comboFailureDecision(
847
849
  // reaches that list and returns terminal before anything can ask whether the next target
848
850
  // could have served the request (#4903).
849
851
  if (isResponseFormatCapabilityRefusal(status, message, options?.code)) return "hop";
852
+ if (isAnthropicBillingFailure(status, message, options?.code)) return "hop";
850
853
  if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
851
854
  return "stop";
852
855
  }