@bitkyc08/opencodex 2.67.0 → 2.68.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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-DCBismRi.css +1 -0
  3. package/gui/dist/assets/App-mpsibsFj.js +51 -0
  4. package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-kUE8tuAK.js +86 -0
  7. package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -1095,6 +1095,38 @@ export async function collectCodexAppServerCatalogStateForRequest(
1095
1095
  return servableStale ?? flight.promise;
1096
1096
  }
1097
1097
 
1098
+ /**
1099
+ * Dashboard read of the request-path catalog state, bounded by `deadlineMs`.
1100
+ *
1101
+ * The request collector keeps the event loop free, but a cold Windows probe still
1102
+ * takes 4-7s (CIM walk plus GetOwner per candidate), and the Models and Subagents
1103
+ * pages cannot render their rosters until these routes answer. The reading only
1104
+ * drives an advisory banner, so a slow probe answers `unknown` (which renders no
1105
+ * banner) and keeps running; its result is cached for the next poll.
1106
+ *
1107
+ * The deadline bounds the Windows path, where the request collector is asynchronous. On
1108
+ * other platforms the request collector keeps its existing synchronous read (a /proc walk on
1109
+ * Linux, `ps` on macOS, typically tens of milliseconds), which completes before the deadline
1110
+ * timer can fire; making those reads asynchronous is outside this change.
1111
+ */
1112
+ export async function collectCodexAppServerCatalogStateWithin(
1113
+ deadlineMs: number,
1114
+ io: CodexAppServerProcessIo = {},
1115
+ ): Promise<CodexAppServerCatalogStatus> {
1116
+ let timer: ReturnType<typeof setTimeout> | undefined;
1117
+ const deadline = new Promise<CodexAppServerCatalogStatus>(resolve => {
1118
+ timer = setTimeout(() => resolve({ state: "unknown", processes: [], catalogMtimeMs: null }), deadlineMs);
1119
+ });
1120
+ try {
1121
+ return await Promise.race([collectCodexAppServerCatalogStateForRequest(io), deadline]);
1122
+ } finally {
1123
+ clearTimeout(timer);
1124
+ }
1125
+ }
1126
+
1127
+ /** Deadline for dashboard catalog-state reads; a cached reading answers well within it. */
1128
+ export const DASHBOARD_CATALOG_STATE_DEADLINE_MS = 250;
1129
+
1098
1130
  /**
1099
1131
  * Drop memoized catalog state after a relevant catalog/cache write and before
1100
1132
  * the post-write state read. Advancing the generation prevents an older
@@ -20,6 +20,8 @@
20
20
  */
21
21
  import {
22
22
  collectCodexAppServerCatalogState,
23
+ collectCodexAppServerCatalogStateWithin,
24
+ DASHBOARD_CATALOG_STATE_DEADLINE_MS,
23
25
  listCodexAppServerProcesses,
24
26
  readProcessStartMsBatch,
25
27
  resetCodexAppServerCatalogStateCache,
@@ -59,6 +61,8 @@ export interface CodexRestartServiceIo {
59
61
  */
60
62
  listenPort?: () => number | undefined;
61
63
  collectState?: typeof collectCodexAppServerCatalogState;
64
+ /** Deadline for the default dashboard state read (ms). */
65
+ stateDeadlineMs?: number;
62
66
  listProcesses?: typeof listCodexAppServerProcesses;
63
67
  restart?: typeof restartCodexAppServers;
64
68
  resetStateCache?: () => void;
@@ -88,10 +92,23 @@ class CodexAppServerIdentityChanged extends Error {
88
92
  */
89
93
  let inFlight: Promise<CodexRestartResponse> | null = null;
90
94
 
91
- export function readCodexAppServerState(
95
+ /**
96
+ * Read-only state for the dashboard route. The default classifier is the bounded
97
+ * request-path read: on Windows the synchronous one runs PowerShell CIM enumeration
98
+ * inline, which measured 4-7s per call and stalled every listener, proxy traffic
99
+ * included, each time the Models page opened. A probe slower than the deadline answers
100
+ * `unknown` and keeps refreshing the cache. The restart path below keeps the
101
+ * synchronous reading because it acts on it.
102
+ */
103
+ export async function readCodexAppServerState(
92
104
  io: CodexRestartServiceIo = {},
93
- ): CodexAppServerStateResponse {
94
- const status = (io.collectState ?? collectCodexAppServerCatalogState)(io.processIo ?? {});
105
+ ): Promise<CodexAppServerStateResponse> {
106
+ const status = io.collectState
107
+ ? io.collectState(io.processIo ?? {})
108
+ : await collectCodexAppServerCatalogStateWithin(
109
+ io.stateDeadlineMs ?? DASHBOARD_CATALOG_STATE_DEADLINE_MS,
110
+ io.processIo ?? {},
111
+ );
95
112
  return { state: status.state, runningCount: status.processes.length };
96
113
  }
97
114
 
@@ -0,0 +1,50 @@
1
+ import { isMainCodexAccountTarget } from "../account-namespaces";
2
+ import { COMBO_NAMESPACE } from "../../combos";
3
+ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
4
+ import type { CodexModelEntitlementSnapshot } from "../model-entitlements";
5
+ import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
6
+ import { isNativeAliasCatalogEntry, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./metadata";
7
+ import type { RawEntry } from "./parsing";
8
+
9
+ /** Project authenticated account metadata onto native rows only. */
10
+ export function applyNativeAccessPrograms(
11
+ entries: RawEntry[],
12
+ snapshot: CodexModelEntitlementSnapshot,
13
+ accountTargets: ReadonlyMap<string, string>,
14
+ ): void {
15
+ for (const entry of entries) {
16
+ // A combo may deliberately claim a bare native slug. It is still a routed combo row,
17
+ // so the matching native Codex roster must not project its access programs onto it.
18
+ if (isNativeAliasCatalogEntry(entry)) continue;
19
+ if (entry.owned_by === COMBO_NAMESPACE) {
20
+ delete entry.available_access_programs;
21
+ continue;
22
+ }
23
+ const accountBoundSlug = trustedAccountBoundNativeCatalogSlug(entry);
24
+ const bareSlug = typeof entry.slug === "string" && !entry.slug.includes("/")
25
+ && SUPPORTED_NATIVE_OPENAI_SLUGS.has(entry.slug) ? entry.slug : undefined;
26
+ const slug = accountBoundSlug ?? bareSlug;
27
+ if (!slug) continue;
28
+ const selector = accountBoundSlug && typeof entry.slug === "string"
29
+ ? entry.slug.slice(0, entry.slug.indexOf("/")) : undefined;
30
+ const target = selector === undefined ? MAIN_CODEX_ACCOUNT_ID : accountTargets.get(selector);
31
+ const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
32
+ if (!accountId) {
33
+ delete entry.available_access_programs;
34
+ continue;
35
+ }
36
+ // An old on-disk catalog can contain metadata from a previous credential. Until this
37
+ // account has a confirmed roster, that value is no longer evidence of a grant.
38
+ if (!snapshot.confirmedAccountIds.has(accountId)
39
+ || !snapshot.modelsByAccount.get(accountId)?.has(slug)) {
40
+ delete entry.available_access_programs;
41
+ continue;
42
+ }
43
+ const accountPrograms = snapshot.accessProgramsByAccount?.get(accountId);
44
+ if (accountPrograms?.has(slug)) {
45
+ entry.available_access_programs = accountPrograms.get(slug) ?? null;
46
+ } else {
47
+ delete entry.available_access_programs;
48
+ }
49
+ }
50
+ }
@@ -865,9 +865,22 @@ export function mergeCatalogEntriesFromObservedState({
865
865
  const normalized = reserveProjection ? m : normalizeServiceTiers(m);
866
866
  if (!reserveProjection && !isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap);
867
867
  const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs);
868
+ // The builder copied this metadata from the pinned first-party row only for an exact
869
+ // ChatGPT/Codex forward custom alias. Freshness plus source equality prevents an old or
870
+ // unrelated routed row from claiming the native account's access programs at this second
871
+ // normalization boundary.
872
+ const nativeSourceSlug = typeof m.slug === "string" && m.slug.startsWith("openai/")
873
+ ? m.slug.slice("openai/".length) : undefined;
874
+ const nativeSource = nativeSourceSlug ? upstreamNativeEntry(nativeSourceSlug) : undefined;
875
+ const preserveNativeAccessPrograms = freshCustomEntries.has(m)
876
+ && m.owned_by !== COMBO_NAMESPACE
877
+ && nativeSource != null
878
+ && Object.hasOwn(nativeSource, "available_access_programs")
879
+ && JSON.stringify(m.available_access_programs) === JSON.stringify(nativeSource.available_access_programs);
868
880
  const e = reserveProjection ? normalized : ensureStrictCatalogFields(normalized, {
869
881
  preserveExactInputModalities: exactCombo,
870
882
  isRouted: finalRoutedEntrySet.has(m),
883
+ preserveNativeAccessPrograms,
871
884
  });
872
885
  // Mock-max universality (260709): preserved routed entries from disk may predate
873
886
  // the max rung — ensure it here so subagent max spawns validate on every
@@ -195,6 +195,7 @@ export function deriveEntry(
195
195
  return ensureStrictCatalogFields(normalizeServiceTiers(e), {
196
196
  preserveExactInputModalities: preserveExact,
197
197
  isRouted,
198
+ preserveNativeAccessPrograms: codexForwardNativeCapabilityAlias !== null,
198
199
  });
199
200
  }
200
201
  // Fallback when no template is available (best-effort; strict parser may need more).
@@ -426,6 +426,17 @@ export function suppressedSyntheticMaxCatalogSlugs(
426
426
  export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
427
427
 
428
428
  export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly<Record<string, ReadonlySet<string>>> = {
429
+ // CodeBuddy's vendor defaults are real callable selectors — both the bundled manifests
430
+ // (`product.json` / `product.internal.json`) and `--model` accept them — but the key-scoped
431
+ // configuration roster omits them. Without this entry, a successful live roster
432
+ // would drop the configured default ("default" for CN, "default-model" for Global) from
433
+ // the catalog even though the client can still call it (maintainer review, #5147).
434
+ codebuddy: new Set([
435
+ "default-model",
436
+ ]),
437
+ "codebuddy-cn": new Set([
438
+ "default",
439
+ ]),
429
440
  kimi: new Set([
430
441
  "k3[1m]",
431
442
  "kimi-k2.7-code",
@@ -582,9 +582,10 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ
582
582
  }
583
583
  }
584
584
 
585
+ /** Normalize a row for Codex's catalog parser, stripping native eligibility from routed rows unless explicitly preserved. */
585
586
  export function ensureStrictCatalogFields(
586
587
  entry: RawEntry,
587
- options: { preserveExactInputModalities?: boolean; isRouted?: boolean } = {},
588
+ options: { preserveExactInputModalities?: boolean; isRouted?: boolean; preserveNativeAccessPrograms?: boolean } = {},
588
589
  ): RawEntry {
589
590
  if (entry.shell_type === "default" || entry.shell_type === "local" || entry.shell_type === "shell_command") {
590
591
  entry.shell_type = "unified_exec";
@@ -634,7 +635,7 @@ export function ensureStrictCatalogFields(
634
635
  if (typeof entry.effective_context_window_percent !== "number") entry.effective_context_window_percent = 95;
635
636
  if (typeof entry.comp_hash !== "string") entry.comp_hash = "opencodex";
636
637
  // Routed rows must not carry NATIVE eligibility metadata. `deriveEntry` deep-clones a
637
- // native template and deletes a fixed denylist, so these five survive onto rows backed
638
+ // native template and deletes a fixed denylist, so these eligibility fields survive onto rows backed
638
639
  // by unrelated provider credentials — advertising ChatGPT plan eligibility for a model
639
640
  // that never touches a ChatGPT account (#2813).
640
641
  //
@@ -644,6 +645,9 @@ export function ensureStrictCatalogFields(
644
645
  // leave already-contaminated rows contaminated forever.
645
646
  if (options.isRouted === true) {
646
647
  entry.supported_in_api = true;
648
+ // Exact Codex-forward aliases still use a ChatGPT credential and may retain their native
649
+ // source metadata. Other routed rows cannot claim that account's access programs.
650
+ if (!options.preserveNativeAccessPrograms) delete entry.available_access_programs;
647
651
  delete entry.available_in_plans;
648
652
  delete entry.minimal_client_version;
649
653
  delete entry.availability_nux;
@@ -6,6 +6,8 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from
6
6
  import { delimiter, dirname, join, resolve } from "node:path";
7
7
  import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
8
8
  import { resolveProviderApiKey } from "../../providers/key-store";
9
+ import { getAccountSet } from "../../oauth/store";
10
+ import { readKiroAccountModels, kiroObservedContextWindow } from "../../providers/kiro-model-catalog";
9
11
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
10
12
  import {
11
13
  clearModelCache,
@@ -55,6 +57,9 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
55
57
  import { cursorLiveRosterScope, recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog";
56
58
  import { fetchQoderModels } from "../../adapters/qoder/live-models";
57
59
  import { resolveQoderProfile } from "../../adapters/qoder/profiles";
60
+ import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "../../adapters/codebuddy/profiles";
61
+ import { fetchCodeBuddyModels } from "../../adapters/codebuddy/live-models";
62
+ import { resolveProfileByBaseUrl } from "../../adapters/coding-agent/profile";
58
63
  import { fetchDevinUsableModels } from "../../adapters/devin/live-models";
59
64
  import { resolveDevinApiBaseUrl } from "../../oauth/devin/api-base";
60
65
  import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
@@ -107,6 +112,11 @@ import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels,
107
112
  import { mergeConfiguredModelsIntoLiveCatalog, shouldExposeProviderModel, warnDroppedConfiguredIdsOnce } from "./model-visibility";
108
113
  import { captureModelsRequest, captureProviderGather, materializeCapturedHeaders } from "./gather-capture";
109
114
 
115
+
116
+ /** Observed Kiro ids advertised in the public catalog: plain ids that need no router decoding. */
117
+ const KIRO_PUBLISHABLE_MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
118
+ /** Upper bound on observed Kiro ids added to the catalog across the whole roster. */
119
+ const KIRO_OBSERVED_CATALOG_MAX = 64;
110
120
  export interface ProviderModelsResult {
111
121
  readonly models: CatalogModel[];
112
122
  readonly outcome: CatalogGatherProviderModelOutcome;
@@ -214,6 +224,30 @@ export async function fetchProviderModelsWithAuth(
214
224
  // discovery failure left by an older live configuration even when the account is logged out.
215
225
  if (prov.liveModels === false) {
216
226
  clearProviderDiscoveryStatus(name);
227
+ if (name === "kiro") {
228
+ const ids = [...configuredIds];
229
+ // Observed ids are advertised only when they round-trip through catalog encoding (no "/"
230
+ // or other separators the router would have to decode), and the roster adds at most
231
+ // KIRO_OBSERVED_CATALOG_MAX of them. Every observed id still informs routing preference.
232
+ let observedAdded = 0;
233
+ for (const account of getAccountSet("kiro")?.accounts ?? []) {
234
+ if (account.needsReauth === true) continue;
235
+ for (const row of readKiroAccountModels(account) ?? []) {
236
+ if (observedAdded >= KIRO_OBSERVED_CATALOG_MAX) break;
237
+ if (!KIRO_PUBLISHABLE_MODEL_ID.test(row.modelId) || ids.includes(row.modelId)) continue;
238
+ ids.push(row.modelId);
239
+ observedAdded += 1;
240
+ }
241
+ }
242
+ return observed(ids.map(id => {
243
+ const hints = catalogHintsFromProviderConfig(name, prov, id, contextCap,
244
+ metadataModelIdCaseFold, captured.effectiveAlias);
245
+ const observedWindow = kiroObservedContextWindow(id);
246
+ return { id, provider: name, ...hints,
247
+ ...(observedWindow !== undefined
248
+ ? { contextWindow: applyProviderContextCap(observedWindow, contextCap) } : {}) };
249
+ }), "authoritative");
250
+ }
217
251
  return observed(configured, "authoritative");
218
252
  }
219
253
  const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing"
@@ -245,6 +279,53 @@ export async function fetchProviderModelsWithAuth(
245
279
  ? [...models, vertexDefaultSeed]
246
280
  : models
247
281
  );
282
+ if (prov.adapter === "codebuddy") {
283
+ if (!apiKey) return observed(configured, "degraded");
284
+ const resolvedProfile = resolveProfileByBaseUrl(CODEBUDDY_PROFILES, prov.baseUrl);
285
+ if (!resolvedProfile) return observed(configured, "degraded");
286
+ const profile = resolvedProfile as CodeBuddyProfile;
287
+ // Cache reads/writes are provider/key-fingerprint-scoped: an irreversible fingerprint of
288
+ // the configured key means a key switch never reuses the roster cached for the previous
289
+ // key. The roster comes from the product configuration endpoint authenticated with that
290
+ // same key, so the fingerprint scope and the roster's authority are the same identity: the
291
+ // roster is the key's own account answer, never the CLI login's.
292
+ const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
293
+ const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
294
+ if (fresh) {
295
+ return observed(withConfiguredRetention(
296
+ applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
297
+ ), "authoritative");
298
+ }
299
+ const scopedStale = getStaleCached(name, authorityIdentity);
300
+ if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity) && scopedStale) {
301
+ return observed(withConfiguredRetention(
302
+ applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
303
+ ), "degraded");
304
+ }
305
+ const live = await fetchCodeBuddyModels(profile, apiKey);
306
+ if (live.ok) {
307
+ const discovered = live.models.map(id => ({
308
+ id,
309
+ provider: name,
310
+ ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
311
+ }));
312
+ const forCache = withConfiguredRetention(discovered, { retainComboTargets: false });
313
+ if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
314
+ return observed(withConfiguredRetention(configured), "degraded");
315
+ }
316
+ markProviderDiscoveryOk(name, live.models.length);
317
+ return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
318
+ }
319
+ if (isCurrentCacheGeneration()) {
320
+ markModelsFetchFailure(name, undefined, authorityIdentity);
321
+ markProviderDiscoveryFailed(name, { reason: "provider" });
322
+ console.warn(`[opencodex] CodeBuddy model discovery failed [${live.error}]${live.status === undefined ? "" : ` status=${live.status}`}; using stale/static catalog degradation.`);
323
+ }
324
+ const stale = getStaleCached(name, authorityIdentity);
325
+ return observed(withConfiguredRetention(
326
+ stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
327
+ ), "degraded");
328
+ }
248
329
  if (prov.adapter === "qoder") {
249
330
  if (!apiKey) return observed(configured, "degraded");
250
331
  const profile = resolveQoderProfile(prov.baseUrl);
@@ -12,6 +12,7 @@ import { providerCodexAccountMode } from "../../providers/registry";
12
12
  import { COMBO_NAMESPACE } from "../../combos";
13
13
  import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces";
14
14
  import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
15
+ import { applyNativeAccessPrograms } from "./access-programs";
15
16
  import {
16
17
  availableAccountGatedNativeModels,
17
18
  codexModelEntitlementStateForAccount,
@@ -266,6 +267,7 @@ function catalogModelsForMergeWithNativeRecovery(
266
267
  ]);
267
268
  }
268
269
 
270
+ /** Merge retained rows, project confirmed account metadata, and publish only changed catalog bytes. */
269
271
  function writeRetainedCatalogSync({
270
272
  config,
271
273
  goModels,
@@ -526,6 +528,7 @@ function writeRetainedCatalogSync({
526
528
  warningPolicy: "emit",
527
529
  },
528
530
  });
531
+ applyNativeAccessPrograms(catalog.models, modelEntitlements, accountTargets);
529
532
  clampCatalogModelsToCodexSupport(catalog.models);
530
533
  finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config);
531
534
  // Last mutation before serialization; see `enforceCatalogSlugUniqueness` for why the ordering
@@ -144,9 +144,9 @@ export interface CodexWriteCommitContext {
144
144
  *
145
145
  * `hub-gated` is not the user's switch: a hub declines to rewrite its own local clients, and
146
146
  * reporting that as "integration is OFF" sent operators hunting for a toggle they never set
147
- * (#4236).
147
+ * (#4236). `sibling` is a second instance beside a live proxy that owns the routing.
148
148
  */
149
- export type CodexWriteLockSkipReason = "desired_disabled" | "desired_enabled" | "hub-gated";
149
+ export type CodexWriteLockSkipReason = "desired_disabled" | "desired_enabled" | "hub-gated" | "sibling";
150
150
 
151
151
  /** A synchronous under-lock policy re-read proved the requested apply stale. */
152
152
  export class CodexWriteLockSkipped extends Error {
@@ -74,6 +74,7 @@ import { suppressedSyntheticMaxCatalogSlugs } from "./catalog/model-hints";
74
74
  import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime";
75
75
  import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./account-namespaces";
76
76
  import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
77
+ import { applyNativeAccessPrograms } from "./catalog/access-programs";
77
78
  import {
78
79
  availableAccountGatedNativeModels,
79
80
  codexModelEntitlementStateForAccount,
@@ -384,6 +385,7 @@ function prepareCatalog(
384
385
  warningPolicy: "suppress",
385
386
  },
386
387
  });
388
+ applyNativeAccessPrograms(mergedModels, modelEntitlements, accountTargets);
387
389
  clampCatalogModelsToObservedCodexSupport(
388
390
  mergedModels,
389
391
  source.runtimeSupport.kind === "available"
@@ -23,6 +23,7 @@
23
23
  import { deleteConfigTopLevelKey, loadConfig, mutatePersistedConfig } from "../config";
24
24
  import type { OcxClientIntegrationsConfig, OcxConfig } from "../types";
25
25
  import { runStartupReadinessSync, type ReadinessGate, type SyncOutcomeLike } from "../server/readiness";
26
+ import { siblingOfLivePort, siblingSkipMessage } from "./sibling-start";
26
27
 
27
28
  /** Clients whose durable intent this module owns. */
28
29
  export type DurableIntentClientId = keyof OcxClientIntegrationsConfig;
@@ -77,6 +78,8 @@ type LocalClientSyncConfig = Pick<
77
78
  >;
78
79
 
79
80
  export function localClientSyncAllowed(config: LocalClientSyncConfig): boolean {
81
+ // A sibling instance never writes the live owner's client routing (`sibling-start.ts`).
82
+ if (siblingOfLivePort() !== null) return false;
80
83
  return config.runtimeRole !== "hub"
81
84
  || config.unauthenticatedLoopbackListener?.enabled === true;
82
85
  }
@@ -93,8 +96,11 @@ export const HUB_GATED_SKIP_MESSAGE =
93
96
  "This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unless "
94
97
  + "unauthenticatedLoopbackListener is enabled.";
95
98
 
96
- /** Why a local-client write was skipped. The gate outranks the toggle: it is the surprising one. */
97
- export type LocalClientSkipReason = "desired_disabled" | "hub-gated";
99
+ /**
100
+ * Why a local-client write was skipped. The gate outranks the toggle: it is the surprising one.
101
+ * `sibling` outranks both: this process is a second instance beside a live owner.
102
+ */
103
+ export type LocalClientSkipReason = "desired_disabled" | "hub-gated" | "sibling";
98
104
 
99
105
  /**
100
106
  * "hub-gated" is claimed only when the toggle is ON and the gate is what stopped the write.
@@ -106,6 +112,7 @@ export function localClientSkipReason(
106
112
  config: LocalClientSyncConfig,
107
113
  client: DurableIntentClientId = "codex",
108
114
  ): LocalClientSkipReason {
115
+ if (siblingOfLivePort() !== null) return "sibling";
109
116
  return integrationEnabled(config, client) && !localClientSyncAllowed(config)
110
117
  ? "hub-gated"
111
118
  : "desired_disabled";
@@ -123,7 +130,9 @@ export function localClientSkipMessage(
123
130
  hubSuffix?: string,
124
131
  client: DurableIntentClientId = "codex",
125
132
  ): string {
126
- if (localClientSkipReason(config, client) !== "hub-gated") return integrationOffMessage;
133
+ const reason = localClientSkipReason(config, client);
134
+ if (reason === "sibling") return siblingSkipMessage();
135
+ if (reason !== "hub-gated") return integrationOffMessage;
127
136
  return hubSuffix ? `${HUB_GATED_SKIP_MESSAGE} ${hubSuffix}` : HUB_GATED_SKIP_MESSAGE;
128
137
  }
129
138
 
@@ -134,7 +143,8 @@ export function shouldSyncCodexOnStart(config: LocalClientSyncConfig): boolean {
134
143
  // /readyz failed because it tried to run the full local client sync).
135
144
  // A hub can be a local client only through its explicitly enabled loopback
136
145
  // listener. The public hub bind remains outside this gate and still requires
137
- // admission; an explicit client OFF continues to win.
146
+ // admission; an explicit client OFF continues to win. A sibling instance is closed by the
147
+ // same gate: the live owner keeps the routing it maintains.
138
148
  return localClientSyncAllowed(config) && codexIntegrationEnabled(config);
139
149
  }
140
150
 
package/src/codex/home.ts CHANGED
@@ -132,17 +132,35 @@ export function findWslWindowsCodexHome(deps: CodexHomeDeps = {}): string | null
132
132
  return candidates.length === 1 ? candidates[0]! : null;
133
133
  }
134
134
 
135
+ function canonicalExistingCodexHome(path: string, deps: CodexHomeDeps): string {
136
+ const stat = deps.statSync ?? statSync;
137
+ try {
138
+ if (!stat(path).isDirectory()) return path;
139
+ // Use the portable resolver here rather than realpathSync.native. The
140
+ // Windows Bun standalone runtime can reject a valid junction through the
141
+ // native resolver, while the effective home still needs to be physical
142
+ // before callers open auth.json or native-profile state.
143
+ return (deps.realpathSync ?? realpathSync)(path);
144
+ } catch {
145
+ // Preserve the existing lexical-path behavior for missing or unreadable
146
+ // homes; callers that require the directory still fail at their boundary.
147
+ return path;
148
+ }
149
+ }
150
+
135
151
  export function defaultCodexHome(deps: CodexHomeDeps = {}): string {
136
152
  const home = (deps.homedir ?? homedir)();
137
153
  const defaultHome = join(home, ".codex");
154
+ const canonicalDefaultHome = canonicalExistingCodexHome(defaultHome, deps);
138
155
  // A local ~/.codex that Codex is already using is the user's Codex home even before
139
156
  // config.toml exists (a fresh install: login writes auth.json, first use writes
140
157
  // sessions/ and history.jsonl). A local directory with none of that state is not
141
158
  // evidence of a local Codex: before #5441 such a home let WSL discovery pick the
142
159
  // Windows home, and existing WSL users who run against that Windows home must not
143
- // be moved to an empty local one on upgrade.
144
- if (localCodexHomeIsDirectory(defaultHome, deps) && localCodexHomeInUse(defaultHome, deps)) return defaultHome;
145
- return findWslWindowsCodexHome(deps) ?? defaultHome;
160
+ // be moved to an empty local one on upgrade. Return the canonical path so a
161
+ // Windows junction cannot leak into later credential and profile writes.
162
+ if (localCodexHomeIsDirectory(defaultHome, deps) && localCodexHomeInUse(defaultHome, deps)) return canonicalDefaultHome;
163
+ return findWslWindowsCodexHome(deps) ?? canonicalDefaultHome;
146
164
  }
147
165
 
148
166
  function localCodexHomeInUse(home: string, deps: CodexHomeDeps): boolean {
@@ -1,5 +1,6 @@
1
1
  import { loadConfig } from "../../config";
2
2
  import { shouldSyncCodexOnStart } from "../desired-state";
3
+ import { siblingOfLivePort, siblingSkipMessage } from "../sibling-start";
3
4
  import { withCatalogWriteSerialization } from "../catalog-write-serialization";
4
5
  import { restoreCodexCatalogWithPermit } from "../catalog/sync";
5
6
  import { withCodexWriteLock, CodexWriteLockSkipped } from "../codex-write-lock";
@@ -253,6 +254,18 @@ function desiredEnabledRestoreSkip(): CodexNativeRestoreResult {
253
254
  return skippedRestoreEnvelope(true, message);
254
255
  }
255
256
 
257
+ /**
258
+ * A sibling instance restores nothing: the journal, config and catalog are the live owner's.
259
+ *
260
+ * It has to be checked before anything else, not left to the desired-state re-reads below. Those
261
+ * skip a restore when `shouldSyncCodexOnStart` says ON, and the sibling mark makes that gate
262
+ * answer OFF, so without this the gate would read as "the user turned Codex off" and the restore
263
+ * would replay the owner's journal.
264
+ */
265
+ function siblingRestoreSkip(): CodexNativeRestoreResult | null {
266
+ return siblingOfLivePort() === null ? null : skippedRestoreEnvelope(true, siblingSkipMessage());
267
+ }
268
+
256
269
  /**
257
270
  * A schema-complete all-skipped envelope for outcomes decided before any
258
271
  * restore machinery runs. Every `restore --json` path must stay shape-stable
@@ -471,6 +484,8 @@ function restoreCodexCatalogArtifact(
471
484
  export async function restoreNativeCodexAsync(
472
485
  options: { revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
473
486
  ): Promise<CodexNativeRestoreResult> {
487
+ const sibling = siblingRestoreSkip();
488
+ if (sibling) return sibling;
474
489
  try {
475
490
  return await restoreNativeCodexAsyncImpl(options);
476
491
  } catch (error) {
@@ -669,6 +684,8 @@ async function restoreNativeCodexAsyncImpl(
669
684
  export function restoreNativeCodex(
670
685
  options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
671
686
  ): CodexNativeRestoreResult {
687
+ const sibling = siblingRestoreSkip();
688
+ if (sibling) return sibling;
672
689
  const activeProvider = currentExternalCodexModelProvider();
673
690
  if (activeProvider) {
674
691
  removeJournal();
@@ -11,7 +11,7 @@ import { CODEX_CONFIG_PATH } from "../paths";
11
11
  export type CodexRoutingKind =
12
12
  "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
13
13
 
14
- type RoutingEndpointKind = "local" | "remote" | "unknown";
14
+ export type RoutingEndpointKind = "local" | "remote" | "unknown";
15
15
 
16
16
  function ipv4Octets(hostname: string): number[] | null {
17
17
  const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
@@ -26,7 +26,8 @@ function ipv4Octets(hostname: string): number[] | null {
26
26
  return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
27
27
  }
28
28
 
29
- function classifyRoutingEndpoint(value: string): RoutingEndpointKind {
29
+ /** The one loopback rule for routing URLs; the drift detector (`routing-drift.ts`) shares it. */
30
+ export function classifyRoutingEndpoint(value: string): RoutingEndpointKind {
30
31
  try {
31
32
  const url = new URL(value);
32
33
  if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown";
@@ -10,6 +10,7 @@ import { existsSync, lstatSync, readFileSync } from "node:fs";
10
10
  import { atomicWriteFile } from "../config";
11
11
  import type { CodexWriteLockResult, CodexWriteLockSkipReason } from "./codex-write-lock";
12
12
  import { HUB_GATED_SKIP_MESSAGE } from "./desired-state";
13
+ import { siblingSkipMessage } from "./sibling-start";
13
14
  import { inspectCodexCoordinatorPath } from "./coordinator-doctor";
14
15
  import { JOURNAL_PATH } from "./journal";
15
16
  import { updateIntegrationRecord } from "./integration-record";
@@ -452,10 +453,12 @@ export function codexInjectLockOutcome(
452
453
  success: true,
453
454
  status: "skipped",
454
455
  skippedReason: result.reason,
455
- // Three distinct facts, three sentences. The hub gate in particular must not borrow the
456
+ // Four distinct facts, four sentences. The hub gate in particular must not borrow the
456
457
  // toggle's wording — that is the phantom "integration is OFF" report from #4236.
457
458
  message: result.reason === "hub-gated"
458
459
  ? `${HUB_GATED_SKIP_MESSAGE} No Codex config, catalog, cache, or history was changed.`
460
+ : result.reason === "sibling"
461
+ ? `${siblingSkipMessage()} No Codex config, catalog, cache, or history was changed.`
459
462
  : result.reason === "desired_disabled"
460
463
  ? "Codex integration is OFF; no Codex config, catalog, cache, or history was changed."
461
464
  : "Codex integration was re-enabled; native restore was skipped.",
@@ -12,7 +12,9 @@ import {
12
12
  localClientSkipMessage,
13
13
  localClientSkipReason,
14
14
  shouldSyncCodexOnStart,
15
+ type LocalClientSkipReason,
15
16
  } from "./desired-state";
17
+ import { siblingOfLivePort, siblingSkipMessage } from "./sibling-start";
16
18
  import { resolveCodexHistoryTransition } from "./history-transition";
17
19
  import {
18
20
  buildInjectWitness,
@@ -147,7 +149,7 @@ export interface CodexInjectResult {
147
149
  /** Busy write lock, emitted by `codexInjectLockOutcome` and undeclared here until #4809. */
148
150
  retryable?: boolean;
149
151
  /** `hub-gated` is the hub-role gate (#4236), distinct from the user's own OFF switch. */
150
- skippedReason?: "desired_disabled" | "desired_enabled" | "hub-gated";
152
+ skippedReason?: LocalClientSkipReason | "desired_enabled";
151
153
  nativeSubagentDefaultsWarning?: string;
152
154
  }
153
155
 
@@ -184,6 +186,11 @@ export async function injectCodexConfig(
184
186
  config?: OcxConfig,
185
187
  options: InjectCodexOptions = {},
186
188
  ): Promise<CodexInjectResult> {
189
+ // First, before the external-provider branch below removes the SHARED journal: a sibling owns
190
+ // none of this home's routing, not even the courtesy cleanup.
191
+ if (siblingOfLivePort() !== null) {
192
+ return { success: true, status: "skipped", skippedReason: "sibling", message: siblingSkipMessage() };
193
+ }
187
194
  try { return await injectCodexConfigImpl(port, config, options); }
188
195
  catch (error) {
189
196
  if (error instanceof CodexHistoryPreflightRefusal) return { success: false, historyPreflightFailureReason: error.message, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` };
@@ -255,8 +255,12 @@ function readJournal(cleanInvalid = true): Journal | null {
255
255
  }
256
256
  }
257
257
 
258
- export function journalOwner(): JournalOwner | null {
259
- const journal = readJournal();
258
+ /**
259
+ * Who owns the journal. `readOnly` never deletes an unreadable journal: a background reader (the
260
+ * routing healer, `routing-healer.ts`) must not destroy recovery evidence it only looked at.
261
+ */
262
+ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner | null {
263
+ const journal = readJournal(options.readOnly !== true);
260
264
  if (!journal) return null;
261
265
  if (journal.owner?.kind === "client" && typeof journal.owner.apiKeyId === "string" && journal.owner.apiKeyId) {
262
266
  return { kind: "client", apiKeyId: journal.owner.apiKeyId };
@@ -2,6 +2,7 @@ import type { OcxConfig } from "../types";
2
2
  import { resolvePendingInitialModelSelection } from "../providers/initial-model-selection-runtime";
3
3
  import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
4
4
  import { convergeCodexCatalog } from "./convergence";
5
+ import { siblingOfLivePort } from "./sibling-start";
5
6
  import type {
6
7
  CatalogDisposition,
7
8
  CatalogFailureCause,
@@ -155,6 +156,14 @@ export function createManagementConvergeCodex(
155
156
  }
156
157
  // Registration choices are committed independently, before sealing catalog authority.
157
158
  await resolvePendingInitialModelSelection(retainedConfig as OcxConfig);
159
+ // The catalog and models cache live in the shared `CODEX_HOME`; a sibling instance leaves
160
+ // them to the live owner. This funnel serves every management caller and the auto-refresh tick.
161
+ if (siblingOfLivePort() !== null) {
162
+ return projectCatalogOnlyOutcome({
163
+ changed: false,
164
+ catalogRefresh: { status: "skipped", reason: "refused", retryable: false },
165
+ });
166
+ }
158
167
  const snapshot = captureCatalogAdmissionSnapshot(retainedConfig);
159
168
  const result = await convergeCodexCatalog(snapshot, request, {
160
169
  onCommitBegin: () => { commitBegan = true; },