@bitkyc08/opencodex 2.77.0 → 2.78.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 (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
package/src/cli/v2.ts CHANGED
@@ -10,6 +10,10 @@
10
10
  * config keys, with byte-for-byte rollback when the feature command fails.
11
11
  * - nothing in the catalog build path calls this module; no auto-flip exists.
12
12
  */
13
+ import { CliUsageError, terminalSafeText, type RuntimeApiDeps } from "./runtime-api";
14
+ import { parseV2Command, V2_USAGE, type V2ParsedCommand } from "./v2-input";
15
+ import { handleV2RuntimeCommand } from "./v2-runtime";
16
+ import { localV2State, v2LocalSyncResult, type V2LocalSync } from "./v2-local-output";
13
17
  import { execFileSync } from "node:child_process";
14
18
  import { dirname } from "node:path";
15
19
  import { activeCodexConfigPath, getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getMultiAgentModeHintText, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMultiAgentModeHintText, transitionMultiAgentV2 } from "../codex/features";
@@ -23,6 +27,9 @@ export interface V2CliDeps {
23
27
  isEnabled?: typeof isMultiAgentV2Enabled;
24
28
  hasMaxThreads?: typeof hasAgentsMaxThreads;
25
29
  sync?: (port?: number) => Promise<unknown>;
30
+ runtimeApi?: RuntimeApiDeps;
31
+ /** Internal seam: bypass executable discovery only when an exec implementation is injected. */
32
+ featuresInvocation?: (action: "enable" | "disable") => SpawnInvocation;
26
33
  log?: Pick<Console, "log" | "error">;
27
34
  }
28
35
 
@@ -79,7 +86,7 @@ export function runCodexFeaturesCommand(
79
86
 
80
87
  function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps): void {
81
88
  if (deps.execFile) {
82
- const inv = codexFeaturesInvocation(action);
89
+ const inv = (deps.featuresInvocation ?? codexFeaturesInvocation)(action);
83
90
  deps.execFile(inv.file, inv.args, inv.options);
84
91
  return;
85
92
  }
@@ -106,13 +113,10 @@ function requiresGlobalV2Disabled(multiAgentMode: string | undefined, keepNative
106
113
  return multiAgentMode === "v2" && keepNativeChatGptOnV1;
107
114
  }
108
115
 
109
- export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () => Promise<number | undefined>): Promise<number> {
116
+ function printLocalStatus(deps: V2CliDeps): number {
110
117
  const log = deps.log ?? console;
111
118
  const isEnabled = deps.isEnabled ?? isMultiAgentV2Enabled;
112
119
  const hasMaxThreads = deps.hasMaxThreads ?? hasAgentsMaxThreads;
113
- const verb = (args[0] ?? "status").trim().toLowerCase();
114
-
115
- if (verb === "status") {
116
120
  log.log(v2StatusLine(isEnabled()));
117
121
  const cfg = loadConfig();
118
122
  const mode = cfg.multiAgentMode ?? "default";
@@ -140,153 +144,107 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
140
144
  log.log("WARNING: [agents] max_threads is set — codex refuses to start while multi_agent_v2 is enabled. Remove it from config.toml (concurrency lives in features.multi_agent_v2.max_concurrent_threads_per_session).");
141
145
  }
142
146
  return 0;
147
+ }
148
+
149
+ /** Parse before selecting either the native writer or the runtime management target. */
150
+ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () => Promise<number | undefined>): Promise<number> {
151
+ const log = deps.log ?? console;
152
+ let parsed: V2ParsedCommand;
153
+ try { parsed = parseV2Command(args); }
154
+ catch (error) {
155
+ log.error(error instanceof CliUsageError ? error.message : "v2: invalid command");
156
+ for (const line of V2_USAGE.split("\n")) log.error(line);
157
+ const boundary = args.indexOf("--");
158
+ return (boundary < 0 ? args : args.slice(0, boundary)).includes("--live") ? 2 : 1;
143
159
  }
144
- if (verb === "mode-hint") {
145
- const value = args[1];
146
- if (value === undefined) {
147
- log.error("v2 mode-hint: pass the hint text, or --clear to unset it.");
148
- return 1;
149
- }
150
- if (value === "--clear") {
151
- const result = setMultiAgentModeHintText(null);
152
- if (!result.ok) {
153
- log.error(`v2 mode-hint: ${result.error}`);
154
- return 1;
155
- }
156
- log.log(result.changed
157
- ? "multi_agent_mode_hint_text cleared — effort-derived policy resumes (new sessions)."
158
- : "multi_agent_mode_hint_text already unset — nothing to do.");
159
- return 0;
160
- }
161
- // `--clear` is the only reserved token; hints are otherwise arbitrary
162
- // nonblank text and may legitimately begin with a hyphen.
163
- if (value.trim().length === 0) {
164
- log.error("v2 mode-hint: pass the hint text, or --clear to unset it.");
165
- return 1;
166
- }
167
- const result = setMultiAgentModeHintText(value);
168
- if (!result.ok) {
169
- log.error(`v2 mode-hint: ${result.error}`);
170
- return 1;
160
+ if (parsed.live) return handleV2RuntimeCommand(parsed, deps.runtimeApi);
161
+ let changed: boolean | null = false;
162
+ let sync: V2LocalSync = { status: "not-attempted", ok: false };
163
+ const emit = (ok: boolean, lines: string[]): number => {
164
+ // A read-back failure cannot claim rollback or overwrite already observed mutation evidence.
165
+ let state: ReturnType<typeof localV2State> | null = null;
166
+ try { state = localV2State(deps.isEnabled, deps.hasMaxThreads); }
167
+ catch { ok = false; lines = ["Local V2 outcome is unverified. Read ocx v2 status before retrying; no rollback is implied."]; }
168
+ if (parsed.json) log.log(JSON.stringify({ ok, target: "local", action: parsed.verb, changed, state, sync }));
169
+ else for (const line of lines) (ok ? log.log : log.error).call(log, terminalSafeText(line));
170
+ return ok ? 0 : 1;
171
+ };
172
+ try {
173
+ if (parsed.verb === "status") {
174
+ if (!parsed.json) return printLocalStatus(deps);
175
+ return emit(true, []);
171
176
  }
172
- log.log(result.changed
173
- ? `multi_agent_mode_hint_text set (new sessions).`
174
- : "multi_agent_mode_hint_text already set — nothing to do.");
175
- return 0;
176
- }
177
- if (verb === "threads") {
178
- const value = Number((args[1] ?? "").trim());
179
- if (!Number.isInteger(value) || value < 1) {
180
- log.error("v2 threads: pass an integer >= 1 (features.multi_agent_v2.max_concurrent_threads_per_session)");
181
- return 1;
177
+ if (parsed.verb === "mode-hint") {
178
+ const result = setMultiAgentModeHintText(parsed.value);
179
+ if (!result.ok) return emit(false, ["Unable to write the native mode hint. Check Codex support and ownership, then read ocx v2 status."]);
180
+ changed = result.changed;
181
+ return emit(true, [result.changed ? "multi_agent_mode_hint_text saved — applies to new sessions." : "multi_agent_mode_hint_text already set — nothing to do."]);
182
182
  }
183
- const enabled = isEnabled();
184
- const result = transitionMultiAgentV2(enabled, next => runCodexFeatures(next ? "enable" : "disable", deps), { threadLimit: value });
185
- if (!result.ok) { log.error(`v2 threads: ${result.error}`); return 1; }
186
- log.log(result.changed
187
- ? `max_threads = ${value} (${enabled ? "v2" : "v1"}) — applies to new sessions.`
188
- : `max_threads already ${value} — nothing to do.`);
189
- return 0;
190
- }
191
- if (verb === "mode") {
192
- const modeArg = (args[1] ?? "").trim().toLowerCase();
193
- if (modeArg !== "v1" && modeArg !== "default" && modeArg !== "v2") {
194
- log.error("v2 mode: expected v1|default|v2");
195
- return 1;
183
+ if (parsed.verb === "threads") {
184
+ const enabled = (deps.isEnabled ?? isMultiAgentV2Enabled)();
185
+ const result = transitionMultiAgentV2(enabled, next => runCodexFeatures(next ? "enable" : "disable", deps), { threadLimit: parsed.value });
186
+ if (!result.ok) return emit(false, ["Unable to update the native thread limit. Read ocx v2 status before retrying."]);
187
+ changed = result.changed;
188
+ return emit(true, [changed ? `max_threads = ${parsed.value} (${enabled ? "v2" : "v1"}) — applies to new sessions.` : `max_threads already ${parsed.value} — nothing to do.`]);
196
189
  }
197
- const cfg = loadConfig();
198
- if (modeArg !== "default") {
199
- const target = modeArg === "v2" && cfg.keepNativeChatGptOnV1 !== true;
200
- const transition = transitionMultiAgentV2(target, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps));
201
- if (!transition.ok) {
202
- log.error(`multi-agent mode transition failed: ${transition.error}`);
203
- return 1;
190
+ const config = loadConfig();
191
+ let successLines: string[];
192
+ let unchangedKeep: boolean | undefined;
193
+ if (parsed.verb === "mode") {
194
+ changed = (config.multiAgentMode ?? "default") !== parsed.value;
195
+ if (parsed.value !== "default") {
196
+ const target = parsed.value === "v2" && config.keepNativeChatGptOnV1 !== true;
197
+ const transition = transitionMultiAgentV2(target, next => runCodexFeatures(next ? "enable" : "disable", deps));
198
+ if (!transition.ok) { changed = false; return emit(false, ["Native mode transition failed. Read ocx v2 status before retrying."]); }
199
+ changed ||= transition.changed;
204
200
  }
205
- }
206
- if (modeArg === "default") deleteConfigTopLevelKey(cfg, "multiAgentMode");
207
- else cfg.multiAgentMode = modeArg as "v1" | "v2";
208
- saveConfig(cfg);
209
- try {
210
- const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
211
- await sync(findPort ? await findPort() : undefined);
212
- } catch (err) {
213
- log.error(`catalog resync failed: ${err instanceof Error ? err.message : String(err)} — run 'ocx sync' manually.`);
214
- return 1;
215
- }
216
- log.log(multiAgentModeLine(modeArg));
217
- log.log("Applies to NEW sessions; running sessions keep their pinned multi-agent version.");
218
- return 0;
219
- }
220
- if (verb === "keep-native-v1") {
221
- const flag = (args[1] ?? "").trim().toLowerCase();
222
- if (flag !== "on" && flag !== "off") {
223
- log.error("v2 keep-native-v1: expected on|off");
224
- return 1;
225
- }
226
- const cfg = loadConfig();
227
- const next = flag === "on";
228
- const already = cfg.keepNativeChatGptOnV1 === true === next;
229
- if (next && requiresGlobalV2Disabled(cfg.multiAgentMode, true)) {
230
- const transition = transitionMultiAgentV2(false, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps));
231
- if (!transition.ok) {
232
- log.error(`keep-native-v1 transition failed: ${transition.error}`);
233
- return 1;
201
+ if (parsed.value === "default") deleteConfigTopLevelKey(config, "multiAgentMode");
202
+ else config.multiAgentMode = parsed.value;
203
+ saveConfig(config);
204
+ successLines = [multiAgentModeLine(parsed.value, config.keepNativeChatGptOnV1 === true),
205
+ "Applies to NEW sessions; running sessions keep their pinned multi-agent version."];
206
+ } else if (parsed.verb === "keep-native-v1") {
207
+ unchangedKeep = (config.keepNativeChatGptOnV1 === true) === parsed.value;
208
+ changed = !unchangedKeep;
209
+ if (parsed.value && requiresGlobalV2Disabled(config.multiAgentMode, true)) {
210
+ const transition = transitionMultiAgentV2(false, next => runCodexFeatures(next ? "enable" : "disable", deps));
211
+ if (!transition.ok) { changed = false; return emit(false, ["Native hybrid-mode transition failed. Read ocx v2 status before retrying."]); }
212
+ changed ||= transition.changed;
213
+ }
214
+ if (parsed.value) config.keepNativeChatGptOnV1 = true;
215
+ else deleteConfigTopLevelKey(config, "keepNativeChatGptOnV1");
216
+ saveConfig(config);
217
+ successLines = [parsed.value
218
+ ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2 (new sessions)."
219
+ : "keep_native_chatgpt_on_v1: OFF — ChatGPT-native rows follow v1/base/v2 (new sessions)."];
220
+ } else {
221
+ const want = parsed.verb === "on";
222
+ if (want && requiresGlobalV2Disabled(config.multiAgentMode, config.keepNativeChatGptOnV1 === true)) {
223
+ return emit(false, ["v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."]);
234
224
  }
225
+ const transition = transitionMultiAgentV2(want, next => runCodexFeatures(next ? "enable" : "disable", deps));
226
+ if (!transition.ok) return emit(false, ["Native feature transition failed. Read ocx v2 status before retrying."]);
227
+ changed = transition.changed;
228
+ if (!changed) return emit(true, [`multi_agent_v2 already ${want ? "ON" : "OFF"} — nothing to do.`]);
229
+ successLines = [v2StatusLine(want),
230
+ "Applies to NEW sessions; running sessions keep their pinned multi-agent version. Restart the Codex app (or wait out its picker cache) to see the ladder change."];
235
231
  }
236
- if (next) cfg.keepNativeChatGptOnV1 = true;
237
- else deleteConfigTopLevelKey(cfg, "keepNativeChatGptOnV1");
238
- saveConfig(cfg);
232
+ // Preserve the legacy graph: mode/keep and changed toggles sync even with no port.
239
233
  try {
240
- const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
241
- await sync(findPort ? await findPort() : undefined);
242
- } catch (err) {
243
- log.error(`catalog resync failed: ${err instanceof Error ? err.message : String(err)} — run 'ocx sync' manually.`);
244
- return 1;
245
- }
246
- if (already) {
247
- log.log(next
248
- ? "keep_native_chatgpt_on_v1 already ON — catalog re-synced."
249
- : "keep_native_chatgpt_on_v1 already OFF — catalog re-synced.");
250
- return 0;
251
- }
252
- log.log(next
253
- ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2 (new sessions)."
254
- : "keep_native_chatgpt_on_v1: OFF — ChatGPT-native rows follow v1/base/v2 (new sessions).");
255
- return 0;
256
- }
257
- if (verb !== "on" && verb !== "off") {
258
- log.error(`v2: unknown verb '${verb}' (expected status|on|off|mode <v1|default|v2>|keep-native-v1 <on|off>|threads <n>|mode-hint <text|--clear>)`);
259
- return 1;
260
- }
261
-
262
- const want = verb === "on";
263
- if (want) {
264
- const cfg = loadConfig();
265
- if (requiresGlobalV2Disabled(cfg.multiAgentMode, cfg.keepNativeChatGptOnV1 === true)) {
266
- log.error("v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first.");
267
- return 1;
234
+ const port = findPort ? await findPort() : undefined;
235
+ const result = deps.sync ? await deps.sync(port)
236
+ : await (await import("../codex/sync")).syncModelsToCodex(port, undefined, parsed.json ? null : log);
237
+ sync = v2LocalSyncResult(result);
238
+ } catch { sync = { status: "failed", ok: false }; }
239
+ if (sync.ok && parsed.verb === "keep-native-v1" && unchangedKeep) {
240
+ const outcome = sync.status !== "skipped" && "catalog" in sync && sync.catalog?.converged
241
+ ? "catalog re-synced." : "catalog sync skipped by policy.";
242
+ successLines = [`keep_native_chatgpt_on_v1 already ${parsed.value ? "ON" : "OFF"} — ${outcome}`];
268
243
  }
244
+ return emit(sync.ok, sync.ok ? successLines
245
+ : ["catalog resync failed or is unverified; local V2 settings landed. Read ocx v2 status, then run ocx sync."]);
246
+ } catch {
247
+ changed = null;
248
+ return emit(false, ["Local V2 operation failed; native or saved settings may have changed. Read ocx v2 status before retrying; no rollback is implied."]);
269
249
  }
270
- const transition = transitionMultiAgentV2(want, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps));
271
- if (!transition.ok) {
272
- log.error(`codex features ${want ? "enable" : "disable"} multi_agent_v2 failed: ${transition.error}`);
273
- return 1;
274
- }
275
- if (!transition.changed) {
276
- log.log(`multi_agent_v2 already ${want ? "ON" : "OFF"} — nothing to do.`);
277
- return 0;
278
- }
279
-
280
- // Resync catalog so multi-agent surface metadata stays fresh in both the
281
- // on-disk catalog and models_cache.json after the toggle flip.
282
- try {
283
- const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
284
- await sync(findPort ? await findPort() : undefined);
285
- } catch (err) {
286
- log.error(`catalog resync failed (flag IS flipped): ${err instanceof Error ? err.message : String(err)} — run 'ocx sync' manually.`);
287
- return 1;
288
- }
289
- log.log(v2StatusLine(want));
290
- log.log("Applies to NEW sessions; running sessions keep their pinned multi-agent version. Restart the Codex app (or wait out its picker cache) to see the ladder change.");
291
- return 0;
292
250
  }
@@ -130,3 +130,24 @@ export function buildDshContribution(ctx: ExportContext): ManagedContribution {
130
130
  const doc = buildDshClientConfig(ctx);
131
131
  return singleFragment("dsh", ["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]);
132
132
  }
133
+
134
+ /**
135
+ * Where DSH 0.1.7+ reads provider routes: the `llm-pi-ai` row of a profile
136
+ * patch, a top-level YAML list of loader rows. `$DSH_HOME/settings.yaml` is
137
+ * imported into the first profile that boots and renamed, so a write there
138
+ * reaches DSH once at most. The Desktop app runs the `desktop` profile and
139
+ * owns it alone, and DSH hot reloads that file when it changes.
140
+ */
141
+ export const DSH_DESKTOP_PROFILE = "desktop";
142
+ export const DSH_PROFILE_PROVIDER_PATH = ["[id=llm-pi-ai]", "config", "providers", OPENCODE_PROVIDER_ID] as const;
143
+
144
+ /** A profile patch DSH itself would load: a list whose every row is a mapping. */
145
+ export function dshProfilePatchEstablished(parsed: unknown): boolean {
146
+ return Array.isArray(parsed)
147
+ && parsed.every(row => typeof row === "object" && row !== null && !Array.isArray(row));
148
+ }
149
+
150
+ export function buildDshProfilePatchContribution(ctx: ExportContext): ManagedContribution {
151
+ const doc = buildDshClientConfig(ctx);
152
+ return singleFragment("dsh", DSH_PROFILE_PROVIDER_PATH, doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]);
153
+ }
@@ -48,6 +48,7 @@ export {
48
48
  zcodeStoreSchemaEstablished,
49
49
  } from "./config-export/zcode-store";
50
50
  export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh";
51
+ export { DSH_PROFILE_PROVIDER_PATH, dshProfilePatchEstablished, buildDshProfilePatchContribution } from "./config-export/dsh";
51
52
  export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode";
52
53
  export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProviderEntry, RaycastGeneratedConfig } from "./config-export/raycast";
53
54
  export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
@@ -59,7 +60,7 @@ import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENC
59
60
  import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, inputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata";
60
61
  import { exportReasoningEfforts, exportDefaultReasoningEffort, legacyReasoningMetadata, type LegacyEffortVariant } from "./config-export/reasoning-metadata";
61
62
  import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp";
62
- import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh";
63
+ import { buildDshClientConfig, summarizeDsh, buildDshContribution, DSH_DESKTOP_PROFILE } from "./config-export/dsh";
63
64
  import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode";
64
65
  import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode";
65
66
  import { buildClineClientConfig, summarizeCline, buildClineContribution } from "./config-export/cline";
@@ -435,6 +436,11 @@ export function dshConfigPath(env: OpencodeLaunchEnv = process.env, home: string
435
436
  return join(dshHomeDir(env, home), "settings.yaml");
436
437
  }
437
438
 
439
+ /** The Desktop profile's patch, where DSH 0.1.7+ reads provider routes (see `DSH_PROFILE_PROVIDER_PATH`). */
440
+ export function dshProfilePatchPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
441
+ return join(dshHomeDir(env, home), "profiles", DSH_DESKTOP_PROFILE, "cordis.patch.yml");
442
+ }
443
+
438
444
  /**
439
445
  * MiniMax Code stores runtime state under `MINIMAX_DATA_DIR`, then the legacy
440
446
  * `MAVIS_DATA_DIR`, and finally `~/.minimax`. Relative overrides are refused
@@ -372,7 +372,7 @@ export function orderForModelPicker(
372
372
  * user tooling do not. `owned_by` describes upstream ownership, and `comp_hash`
373
373
  * describes history compatibility; neither is an authorship signal.
374
374
  */
375
- function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean {
375
+ export function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean {
376
376
  if (isNativeAliasCatalogEntry(entry)) return true;
377
377
  const desc = typeof entry.description === "string" ? entry.description : "";
378
378
  const slug = typeof entry.slug === "string" ? entry.slug : "";
@@ -29,6 +29,7 @@ import type { NormalizedComboConfig } from "../../combos/types";
29
29
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
30
30
  import { redactSecretString } from "../../lib/redact";
31
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
+ import { decodeOverlayState } from "../shim-state-file";
32
33
 
33
34
 
34
35
  import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing";
@@ -196,14 +197,20 @@ export function codexCommandCandidates(): string[] {
196
197
 
197
198
  export function codexShimCommandCandidates(): string[] {
198
199
  try {
199
- const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
200
+ const configDir = getConfigDir();
201
+ const state = JSON.parse(readFileSync(join(configDir, "codex-shim.json"), "utf8")) as {
202
+ schema?: unknown;
203
+ mode?: unknown;
200
204
  wrapperPath?: unknown;
201
205
  originalPath?: unknown;
202
206
  backupPath?: unknown;
203
207
  wrappers?: Array<{ wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown }>;
204
208
  };
209
+ const overlay = decodeOverlayState(state, configDir);
210
+ if ((state.schema !== undefined || state.mode !== undefined) && !overlay) return [];
205
211
  const files = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state];
206
- const out: string[] = [];
212
+ const out: string[] = overlay && isSpawnableCodexCandidate(overlay.launcherPath)
213
+ ? [overlay.launcherPath] : [];
207
214
  for (const file of files) {
208
215
  for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) {
209
216
  if (typeof value !== "string" || value.length === 0) continue;
@@ -0,0 +1,66 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, type Stats } from "node:fs";
2
+ import { readCodexHomeJournal } from "../codex-home-owner";
3
+ import { samePath, type RawCatalog } from "./parsing";
4
+
5
+ export const CATALOG_HEAL_MAX_BYTES = 64 * 1024 * 1024;
6
+
7
+ export interface CatalogObservation {
8
+ readonly signature: string;
9
+ readonly catalog: RawCatalog | null;
10
+ }
11
+
12
+ /** Canonical path equality when available; unavailable paths retain lexical equality. */
13
+ export function sameCatalogHealPath(left: string, right: string): boolean {
14
+ try { return samePath(realpathSync.native(left), realpathSync.native(right)); }
15
+ catch { return samePath(left, right); }
16
+ }
17
+
18
+ /** Resolve authority without reading catalog bytes or cleaning journal evidence. */
19
+ export function selectCatalogHealPath(
20
+ journalPath: string,
21
+ defaultPath: string,
22
+ resolvePath: (path: string) => string,
23
+ ): string | null {
24
+ const inspected = readCodexHomeJournal(journalPath);
25
+ if (inspected.kind === "unknown") return null;
26
+ if (inspected.kind === "missing") return defaultPath;
27
+ const selected = inspected.journal.injectedCatalogPath;
28
+ if (selected === undefined || selected === null) return defaultPath;
29
+ // A selected custom catalog remains selected even when missing or corrupt.
30
+ if (typeof selected !== "string" || !selected.trim() || selected.includes("\0")) return null;
31
+ try { return resolvePath(selected); }
32
+ catch { return null; }
33
+ }
34
+
35
+ function signature(stat: Stats): string {
36
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
37
+ }
38
+
39
+ /** Bounded regular descriptor reads; never open a FIFO/device or follow a replaced symlink. */
40
+ export function observeCatalogHealFile(path: string, readContent: boolean): CatalogObservation | null {
41
+ let fd: number | undefined;
42
+ try {
43
+ const before = lstatSync(path);
44
+ if (!before.isFile() || before.size > CATALOG_HEAL_MAX_BYTES) return null;
45
+ if (!readContent) return { signature: signature(before), catalog: null };
46
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
47
+ const opened = fstatSync(fd);
48
+ if (!opened.isFile() || opened.size > CATALOG_HEAL_MAX_BYTES || signature(before) !== signature(opened)) return null;
49
+ const bytes = Buffer.alloc(opened.size + 1);
50
+ let length = 0;
51
+ while (length < bytes.length) {
52
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
53
+ if (count === 0) break;
54
+ length += count;
55
+ }
56
+ if (length !== opened.size || signature(fstatSync(fd)) !== signature(opened)
57
+ || signature(lstatSync(path)) !== signature(opened)) return null;
58
+ let catalog: RawCatalog | null = null;
59
+ try {
60
+ const value: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8"));
61
+ if (value && typeof value === "object" && Array.isArray((value as RawCatalog).models)) catalog = value as RawCatalog;
62
+ } catch { /* Invalid catalog bytes remain unavailable until their stat changes. */ }
63
+ return { signature: signature(opened), catalog };
64
+ } catch { return null; }
65
+ finally { if (fd !== undefined) closeSync(fd); }
66
+ }
@@ -0,0 +1,19 @@
1
+ import type { CatalogWriteIntent } from "./write-audit-contract";
2
+
3
+ export type CatalogPublicationEvent =
4
+ | { readonly kind: "published"; readonly path: string; readonly intent: CatalogWriteIntent }
5
+ | { readonly kind: "native-released"; readonly path: string | null };
6
+
7
+ const listeners = new Set<(event: CatalogPublicationEvent) => void>();
8
+
9
+ /** Process-local notifications; subscribers never own publication success. */
10
+ export function subscribeCatalogPublication(listener: (event: CatalogPublicationEvent) => void): () => void {
11
+ listeners.add(listener);
12
+ return () => { listeners.delete(listener); };
13
+ }
14
+
15
+ export function notifyCatalogPublication(event: CatalogPublicationEvent): void {
16
+ for (const listener of [...listeners]) {
17
+ try { listener(event); } catch { /* A subscriber cannot fail or undo publication. */ }
18
+ }
19
+ }
@@ -7,7 +7,7 @@ import { replaceActiveCodexCatalog } from "../internal/catalog-writer";
7
7
  import { resetCodexAppServerCatalogStateCache } from "../app-server-processes";
8
8
  import { getCodexHome } from "../paths";
9
9
  import { readCodexCatalogPathForHome } from "./parsing";
10
- import { invalidateCodexModelsCacheWithPermit } from "./sync";
10
+ import { invalidateCodexModelsCacheWithPermitOutcome } from "./sync";
11
11
 
12
12
  const DEFAULT_TIMEOUT_MS = 15_000;
13
13
  const MAX_MODELS = 2_000;
@@ -17,7 +17,7 @@ const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]);
17
17
  export type RemoteCatalogFailureCode =
18
18
  | "url_invalid" | "insecure_http_refused" | "credential_invalid" | "request_failed"
19
19
  | "redirect_refused" | "http_error" | "body_too_large" | "body_invalid"
20
- | "catalog_invalid" | "write_failed" | "lock_busy" | "lock_database" | "unsafe_path";
20
+ | "catalog_invalid" | "write_failed" | "lock_busy" | "lock_database" | "unsafe_path" | "foreign_owner" | "owner_unknown";
21
21
 
22
22
  export class RemoteCatalogError extends Error {
23
23
  constructor(readonly code: RemoteCatalogFailureCode, message: string, readonly status?: number) {
@@ -202,7 +202,10 @@ export async function fetchRemoteCatalog(
202
202
 
203
203
  function mapSerializationFailure<T>(outcome: CatalogSerializationOutcome<T>): never {
204
204
  if (outcome.kind === "completed") throw new RemoteCatalogError("write_failed", "Remote catalog installation failed");
205
- const code = outcome.reason === "busy" ? "lock_busy" : outcome.reason === "database" ? "lock_database" : "unsafe_path";
205
+ const code = outcome.reason === "busy" ? "lock_busy"
206
+ : outcome.reason === "database" ? "lock_database"
207
+ : outcome.reason === "foreign-owner" ? "foreign_owner"
208
+ : outcome.reason === "owner-unknown" ? "owner_unknown" : "unsafe_path";
206
209
  throw new RemoteCatalogError(code, `Remote catalog installation unavailable (${outcome.reason})`);
207
210
  }
208
211
 
@@ -235,23 +238,18 @@ export async function pullRemoteCatalog(input: string, options: PullRemoteCatalo
235
238
  const fetched = await fetchRemoteCatalog(input, options);
236
239
  const codexHome = options.codexHome ?? getCodexHome();
237
240
  const catalogPath = readCodexCatalogPathForHome(codexHome);
238
- const current = existsSync(catalogPath) ? readFileSync(catalogPath) : null;
239
- const candidate = Buffer.from(fetched.content, "utf8");
240
- if (current?.equals(candidate)) {
241
- return { status: "unchanged", catalogWritten: false, cacheSynced: false, codexHome, catalogPath, modelCount: fetched.document.models.length };
242
- }
243
241
  const outcome = withCatalogWriteSerialization(codexHome, permit => {
244
242
  // Re-check under K: another writer may have installed these bytes while the request was in flight.
245
243
  const lockedCurrent = existsSync(catalogPath) ? readFileSync(catalogPath) : null;
246
- if (lockedCurrent?.equals(candidate)) return { catalogWritten: false, cacheSynced: false };
247
- replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content: fetched.content });
248
- const cacheSynced = invalidateCodexModelsCacheWithPermit(permit, codexHome, { allowWhenDesiredDisabled: true });
249
- if (!cacheSynced) {
250
- restorePreviousCatalog(permit, codexHome, catalogPath, lockedCurrent);
244
+ const catalog = replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content: fetched.content });
245
+ if (catalog.kind === "refused") throw new RemoteCatalogError("write_failed", "Remote catalog publication refused");
246
+ const cache = invalidateCodexModelsCacheWithPermitOutcome(permit, codexHome, { allowWhenDesiredDisabled: true });
247
+ if (cache !== "written" && cache !== "unchanged") {
248
+ if (catalog.kind === "written") restorePreviousCatalog(permit, codexHome, catalogPath, lockedCurrent);
251
249
  throw new RemoteCatalogError("write_failed", "Remote catalog cache synchronization failed");
252
250
  }
253
- return { catalogWritten: true, cacheSynced: true };
254
- });
251
+ return { catalogWritten: catalog.kind === "written", cacheSynced: cache === "written" };
252
+ }, { intent: "pull", writer: "catalog-pull" });
255
253
  if (outcome.kind !== "completed") return mapSerializationFailure(outcome);
256
254
  return {
257
255
  status: outcome.value.catalogWritten ? "updated" : "unchanged",
@@ -6,9 +6,12 @@ import { RETIRED_NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./m
6
6
  import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
7
7
  import {
8
8
  withCatalogWriteSerialization,
9
+ CatalogWritePermitRefusal,
9
10
  type CatalogWritePermit,
10
11
  } from "../catalog-write-serialization";
11
12
  import { replaceActiveCodexCatalog } from "../internal/catalog-writer";
13
+ import { FOREIGN_CODEX_HOME_OWNER_MESSAGE, UNKNOWN_CODEX_HOME_OWNER_MESSAGE } from "./routed-removal";
14
+ import { notifyCatalogPublication } from "./publication-observer";
12
15
 
13
16
  function visibleAccountReplacementNatives(
14
17
  models: readonly RawEntry[],
@@ -72,7 +75,10 @@ export function restoreCodexCatalogWithPermit(
72
75
  ): { removed: number; kept: number; path: string } {
73
76
  const catalogPath = injectedCatalogPath ?? readCodexCatalogPath();
74
77
  const catalog = readCatalog(catalogPath);
75
- if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
78
+ if (!catalog || !Array.isArray(catalog.models)) {
79
+ notifyCatalogPublication({ kind: "published", path: catalogPath, intent: "restore" });
80
+ return { removed: 0, kept: 0, path: catalogPath };
81
+ }
76
82
  const disabledModels = currentDisabledModelsForRestore();
77
83
  const replacementVisibility = visibleAccountReplacementNatives(catalog.models, disabledModels);
78
84
  const backup = readCatalogBackup(catalogPath);
@@ -94,10 +100,11 @@ export function restoreCodexCatalogWithPermit(
94
100
  models: [...backup.models.filter(m => typeof m.slug !== "string"
95
101
  || !RETIRED_NATIVE_OPENAI_MODELS.has(trustedAccountBoundNativeCatalogSlug(m) ?? m.slug)), ...userNativeAdditions],
96
102
  };
97
- replaceActiveCodexCatalog(permit, owningCodexHome, {
103
+ const replacement = replaceActiveCodexCatalog(permit, owningCodexHome, {
98
104
  path: catalogPath,
99
105
  content: `${JSON.stringify(restored, null, 2)}\n`,
100
106
  });
107
+ if (replacement.kind === "refused") throw new CatalogWritePermitRefusal("Catalog restoration was refused.");
101
108
  return { removed, kept: restored.models.length, path: catalogPath };
102
109
  }
103
110
  const before = catalog.models.length;
@@ -110,11 +117,13 @@ export function restoreCodexCatalogWithPermit(
110
117
  const removed = before - native.length;
111
118
  if (removed > 0) {
112
119
  catalog.models = native;
113
- replaceActiveCodexCatalog(permit, owningCodexHome, {
120
+ const replacement = replaceActiveCodexCatalog(permit, owningCodexHome, {
114
121
  path: catalogPath,
115
122
  content: `${JSON.stringify(catalog, null, 2)}\n`,
116
123
  });
124
+ if (replacement.kind === "refused") throw new CatalogWritePermitRefusal("Catalog restoration was refused.");
117
125
  }
126
+ if (removed === 0) notifyCatalogPublication({ kind: "published", path: catalogPath, intent: "restore" });
118
127
  return { removed, kept: native.length, path: catalogPath };
119
128
  }
120
129
 
@@ -123,10 +132,14 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
123
132
  const outcome = withCatalogWriteSerialization(
124
133
  owningCodexHome,
125
134
  permit => restoreCodexCatalogWithPermit(permit, owningCodexHome),
135
+ { intent: "restore", writer: "catalog-restore" },
126
136
  );
127
- return outcome.kind === "completed"
128
- ? outcome.value
129
- : { removed: 0, kept: 0, path: readCodexCatalogPath() };
137
+ if (outcome.kind === "unavailable") {
138
+ throw new CatalogWritePermitRefusal(outcome.reason === "foreign-owner"
139
+ ? FOREIGN_CODEX_HOME_OWNER_MESSAGE : outcome.reason === "owner-unknown"
140
+ ? UNKNOWN_CODEX_HOME_OWNER_MESSAGE : `Catalog restoration unavailable (${outcome.reason}).`);
141
+ }
142
+ return outcome.value;
130
143
  }
131
144
 
132
145
  /** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */