@yansigit/opencodex 2.36.1-dev.20260829.42 → 2.36.1-dev.20260829.48

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 (51) hide show
  1. package/gui/dist/assets/{ApiKeys-C_cz6Rww.js → ApiKeys-LOOFiZfv.js} +1 -1
  2. package/gui/dist/assets/{Claude-ofGguLfF.js → Claude-BwKzpXe3.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-fhGiVwxe.js → CodexSet-B5qi9KeE.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-B-dI69Kn.js → FileIntegrationPage-B0sUef6W.js} +1 -1
  5. package/gui/dist/assets/{Grok-CgkBu_Sm.js → Grok-bsb4n-f4.js} +1 -1
  6. package/gui/dist/assets/{Integrations-CTWMNzkD.js → Integrations-CSMwFDvM.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-BKZ0dZnj.js → IntegrationsOverview-CTORdKJA.js} +1 -1
  8. package/gui/dist/assets/{Logs-6r90C0x4.js → Logs-BWYbfELf.js} +1 -1
  9. package/gui/dist/assets/{Models-CpcS1zZr.js → Models-CexEtdT1.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-C70ttiJY.js → NumberStepper-BapSFQnW.js} +1 -1
  11. package/gui/dist/assets/{Providers-BROTzuSF.js → Providers-BwUFeAgA.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-DR5vgFXB.js → RestoreDialog-CD8piq90.js} +1 -1
  13. package/gui/dist/assets/{Startup-BSoZDYbS.js → Startup-BgX731C2.js} +1 -1
  14. package/gui/dist/assets/{Storage-CUvBwbeB.js → Storage-Bgq7HphP.js} +1 -1
  15. package/gui/dist/assets/{Subagents-CYu3_DBt.js → Subagents-ChxItbeF.js} +1 -1
  16. package/gui/dist/assets/{Usage-BV36sd7V.js → Usage-BkylzP50.js} +1 -1
  17. package/gui/dist/assets/{data-surface-DhcMe0J_.js → data-surface-pCXChiBf.js} +1 -1
  18. package/gui/dist/assets/{index-DnuNKc0D.js → index-CNopOid3.js} +2 -2
  19. package/gui/dist/assets/{model-display-BPrQ7F3P.js → model-display-CiUpg8T9.js} +1 -1
  20. package/gui/dist/assets/{provider-payload-CMb37zWF.js → provider-payload-b2jlS-On.js} +1 -1
  21. package/gui/dist/index.html +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/agent.ts +19 -2
  24. package/src/cli/config-command.ts +7 -3
  25. package/src/cli/init.ts +2 -2
  26. package/src/cli/provider.ts +58 -74
  27. package/src/cli/registry.ts +1 -1
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence.ts +110 -15
  30. package/src/codex/inject.ts +2 -0
  31. package/src/codex/subagent-defaults.ts +1 -8
  32. package/src/codex/subagent-model-authority.ts +189 -0
  33. package/src/config.ts +209 -11
  34. package/src/generated/compatibility-version.json +35 -27
  35. package/src/oauth/index.ts +143 -82
  36. package/src/oauth/login-cli.ts +53 -11
  37. package/src/providers/alibaba-region-startup.ts +31 -11
  38. package/src/providers/api-keys.ts +58 -34
  39. package/src/providers/key-failover.ts +59 -46
  40. package/src/providers/model-rename-startup.ts +25 -7
  41. package/src/providers/openai-tier-startup.ts +38 -14
  42. package/src/server/index.ts +10 -0
  43. package/src/server/management/agent-settings-routes.ts +12 -0
  44. package/src/server/management/context.ts +10 -3
  45. package/src/server/management/logs-usage-routes.ts +19 -16
  46. package/src/server/management/model-routes.ts +284 -165
  47. package/src/server/management/oauth-account-routes.ts +93 -15
  48. package/src/server/management/provider-routes.ts +200 -87
  49. package/src/server/responses/collaboration.ts +1 -1
  50. package/src/storage/policy-input.ts +166 -0
  51. package/src/storage/policy.ts +59 -211
@@ -8,7 +8,7 @@
8
8
  * show <name> Show provider config details (secrets masked)
9
9
  * set-default <name> Change the default provider
10
10
  */
11
- import { hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config";
11
+ import { hasOwnProvider, isValidProviderName, loadConfig, mutatePersistedConfig, sanitizeModelCostsForDisplay } from "../config";
12
12
  import { apiKeyTransportConfigError } from "../config/provider-validation";
13
13
  import { hasHelpFlag } from "./help";
14
14
  import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry";
@@ -57,19 +57,26 @@ function maskSecret(value: string): string {
57
57
  }
58
58
 
59
59
  // ---------------------------------------------------------------------------
60
- // Validation helper (F1 fix: validate before saveConfig)
60
+ // Validation helper
61
61
  // ---------------------------------------------------------------------------
62
62
 
63
- function validateAndSave(config: ReturnType<typeof loadConfig>): void {
63
+ function validateProviderConfig(config: ReturnType<typeof loadConfig>): void {
64
64
  if (!config.providers || Object.keys(config.providers).length === 0) {
65
- console.error("Error: config would have no providers. Aborting.");
66
- process.exit(1);
65
+ throw new Error("config would have no providers");
67
66
  }
68
67
  if (!hasOwnProvider(config.providers, config.defaultProvider)) {
69
- console.error(`Error: defaultProvider "${config.defaultProvider}" does not exist in providers. Aborting.`);
70
- process.exit(1);
68
+ throw new Error(`defaultProvider "${config.defaultProvider}" does not exist in providers`);
69
+ }
70
+ }
71
+
72
+ function mutateProviderConfig<T>(mutate: (config: ReturnType<typeof loadConfig>) => { changed: boolean; value: T }): T {
73
+ const outcome = mutatePersistedConfig(mutate);
74
+ if (outcome.status === "unavailable") {
75
+ throw new Error(outcome.reason === "conflict"
76
+ ? "config changed while applying this provider update; retry"
77
+ : `config is ${outcome.reason}`);
71
78
  }
72
- saveConfig(config);
79
+ return outcome.value;
73
80
  }
74
81
 
75
82
  // ---------------------------------------------------------------------------
@@ -154,19 +161,6 @@ async function handleAdd(args: string[]): Promise<void> {
154
161
  const defaultModel = consumeFlagValue(restArgs, "--default-model");
155
162
  rejectUnknownArgs(restArgs, ADD_USAGE);
156
163
 
157
- const config = loadConfig();
158
-
159
- const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
160
- if (namespaceCollision) {
161
- console.error(`Error: ${namespaceCollision}.`);
162
- process.exit(1);
163
- }
164
-
165
- if (hasOwnProvider(config.providers, name) && !force) {
166
- console.error(`Provider "${name}" already exists. Use --force to overwrite.`);
167
- process.exit(1);
168
- }
169
-
170
164
  let provConfig: OcxProviderConfig;
171
165
  const registryEntry = getProviderRegistryEntry(name);
172
166
 
@@ -211,18 +205,21 @@ async function handleAdd(args: string[]): Promise<void> {
211
205
  provConfig.apiKeyTransport = apiKeyTransport;
212
206
  }
213
207
 
214
- const existingProvider = config.providers[name];
215
- config.providers[name] = provConfig;
216
- // A --force overwrite rotates the key/endpoint but must not drop a
217
- // user-configured price overlay (same rule as the /api/providers path and
218
- // the login paths); there is no explicit clear/replace flag yet.
219
- if (existingProvider?.modelCosts !== undefined && provConfig.modelCosts === undefined) {
220
- provConfig.modelCosts = existingProvider.modelCosts;
221
- }
222
208
  if (allowPrivateNetwork) provConfig.allowPrivateNetwork = true;
223
- if (setDefault) config.defaultProvider = name;
224
-
225
- validateAndSave(config);
209
+ const saved = mutateProviderConfig(config => {
210
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
211
+ if (namespaceCollision) throw new Error(`${namespaceCollision}.`);
212
+ const existingProvider = config.providers[name];
213
+ if (existingProvider && !force) throw new Error(`Provider "${name}" already exists. Use --force to overwrite.`);
214
+ const next = structuredClone(provConfig);
215
+ // A --force overwrite rotates the key/endpoint but must not drop a user-configured price overlay.
216
+ if (existingProvider?.modelCosts !== undefined && next.modelCosts === undefined) next.modelCosts = existingProvider.modelCosts;
217
+ config.providers[name] = next;
218
+ if (setDefault) config.defaultProvider = name;
219
+ validateProviderConfig(config);
220
+ return { changed: JSON.stringify(existingProvider) !== JSON.stringify(next) || (setDefault && config.defaultProvider === name), value: { provider: next, defaultProvider: config.defaultProvider } };
221
+ });
222
+ provConfig = saved.provider;
226
223
 
227
224
  if (wantsJson) {
228
225
  console.log(JSON.stringify({
@@ -231,7 +228,7 @@ async function handleAdd(args: string[]): Promise<void> {
231
228
  adapter: provConfig.adapter,
232
229
  baseUrl: provConfig.baseUrl,
233
230
  defaultModel: provConfig.defaultModel ?? null,
234
- isDefault: config.defaultProvider === name,
231
+ isDefault: saved.defaultProvider === name,
235
232
  source: registryEntry ? "registry" : "custom",
236
233
  needsSync: true,
237
234
  }, null, 2));
@@ -285,42 +282,32 @@ function handleRemove(args: string[]): void {
285
282
  }
286
283
  rejectUnknownArgs(restArgs.slice(1), "Usage: ocx provider remove <name> [--json]");
287
284
 
288
- const config = loadConfig();
289
- if (!hasOwnProvider(config.providers, name)) {
290
- console.error(`Provider "${name}" is not configured.`);
291
- process.exit(1);
292
- }
293
-
294
- if (name === config.defaultProvider) {
295
- console.error(`Cannot remove "${name}" — it is the default provider. Change the default first: ocx provider set-default <other>`);
296
- process.exit(1);
297
- }
298
-
299
- if (Object.keys(config.providers).length <= 1) {
300
- console.error("Cannot remove the last provider.");
301
- process.exit(1);
302
- }
303
-
304
- const dependentCombos = Object.entries(config.combos ?? {})
305
- .filter(([, combo]) => combo.targets.some(target => target.provider === name))
306
- .map(([id]) => id)
307
- .sort();
308
- if (dependentCombos.length > 0) {
309
- console.error(`Cannot remove "${name}" — combo(s) depend on it: ${dependentCombos.join(", ")}`);
310
- process.exit(1);
311
- }
312
-
313
- delete config.providers[name];
314
- const droppedCustomModels = dropProviderCustomModels(config, name);
315
- validateAndSave(config);
285
+ const saved = mutateProviderConfig(config => {
286
+ if (!hasOwnProvider(config.providers, name)) throw new Error(`Provider "${name}" is not configured.`);
287
+ if (name === config.defaultProvider) throw new Error(`Cannot remove "${name}" — it is the default provider. Change the default first: ocx provider set-default <other>`);
288
+ if (Object.keys(config.providers).length <= 1) throw new Error("Cannot remove the last provider.");
289
+ const dependentCombos = Object.entries(config.combos ?? {})
290
+ .filter(([, combo]) => combo.targets.some(target => target.provider === name))
291
+ .map(([id]) => id).sort();
292
+ if (dependentCombos.length > 0) throw new Error(`Cannot remove "${name}" — combo(s) depend on it: ${dependentCombos.join(", ")}`);
293
+ const dependentProfiles = Object.entries(config.routingProfiles ?? {})
294
+ .filter(([, profile]) => profile.candidates.some(candidate => candidate.provider === name))
295
+ .map(([id]) => id).sort();
296
+ if (dependentProfiles.length > 0) throw new Error(`Cannot remove "${name}" — routing profile(s) depend on it: ${dependentProfiles.join(", ")}`);
297
+ delete config.providers[name];
298
+ const droppedCustomModels = dropProviderCustomModels(config, name);
299
+ validateProviderConfig(config);
300
+ return { changed: true, value: { droppedCustomModels, providers: Object.keys(config.providers), defaultProvider: config.defaultProvider } };
301
+ });
302
+ const { droppedCustomModels } = saved;
316
303
 
317
304
 
318
305
  if (wantsJson) {
319
306
  console.log(JSON.stringify({
320
307
  action: "removed",
321
308
  provider: name,
322
- remainingProviders: Object.keys(config.providers),
323
- defaultProvider: config.defaultProvider,
309
+ remainingProviders: saved.providers,
310
+ defaultProvider: saved.defaultProvider,
324
311
  needsSync: true,
325
312
  ...(droppedCustomModels > 0 ? { droppedCustomModels } : {}),
326
313
  }, null, 2));
@@ -396,13 +383,14 @@ function handleSetDefault(args: string[]): void {
396
383
  }
397
384
  rejectUnknownArgs(restArgs.slice(1), "Usage: ocx provider set-default <name> [--json]");
398
385
 
399
- const config = loadConfig();
400
- if (!hasOwnProvider(config.providers, name)) {
401
- console.error(`Provider "${name}" is not configured. Add it first: ocx provider add ${name}`);
402
- process.exit(1);
403
- }
404
-
405
- if (config.defaultProvider === name) {
386
+ const changed = mutateProviderConfig(config => {
387
+ if (!hasOwnProvider(config.providers, name)) throw new Error(`Provider "${name}" is not configured. Add it first: ocx provider add ${name}`);
388
+ if (config.defaultProvider === name) return { changed: false, value: false };
389
+ config.defaultProvider = name;
390
+ validateProviderConfig(config);
391
+ return { changed: true, value: true };
392
+ });
393
+ if (!changed) {
406
394
  if (wantsJson) {
407
395
  console.log(JSON.stringify({ action: "noop", provider: name, defaultProvider: name, needsSync: false }, null, 2));
408
396
  } else {
@@ -411,10 +399,6 @@ function handleSetDefault(args: string[]): void {
411
399
  return;
412
400
  }
413
401
 
414
- config.defaultProvider = name;
415
- validateAndSave(config);
416
-
417
-
418
402
  if (wantsJson) {
419
403
  console.log(JSON.stringify({ action: "set-default", provider: name, defaultProvider: name, needsSync: true }, null, 2));
420
404
  return;
@@ -205,7 +205,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
205
205
  },
206
206
  {
207
207
  name: "agent",
208
- usage: "ocx agent <status|injection|effort|subagents|roles|fallback|sidecar> ...",
208
+ usage: "ocx agent <status|injection|effort|subagents|authority|roles|fallback|sidecar> ...",
209
209
  summary: "Manage headless multi-agent, roster, roles, effort, injection, and sidecar settings.",
210
210
  },
211
211
  {
@@ -8,7 +8,7 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
9
9
  export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
10
10
  export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
11
- export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync";
11
+ export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, isEligibleV2SubagentEntry, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync";
12
12
  export type { ObservedCatalogMergeInput } from "./catalog/sync";
13
13
  export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync";
14
14
  export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models";
@@ -1,10 +1,13 @@
1
1
  import { join } from "node:path";
2
2
 
3
- import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
4
- import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy";
3
+ import { getConfigDir, mutatePersistedConfig, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
4
+ import {
5
+ reconcileSuccessfulModelDiscoveries,
6
+ type KnownModelBaseline,
7
+ } from "../providers/new-model-policy";
5
8
  import { COMBO_NAMESPACE } from "../combos";
6
9
  import { getAuthStorePath } from "../oauth/store";
7
- import type { OcxConfig } from "../types";
10
+ import type { OcxConfig, OcxProviderConfig } from "../types";
8
11
  import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
9
12
  import { legacyCustomModelCatalogSlugs } from "./custom-model-catalog-migration";
10
13
  import {
@@ -137,7 +140,22 @@ interface CandidateState {
137
140
  readonly changed: boolean;
138
141
  readonly notices: readonly CatalogNotice[];
139
142
  readonly modelEntitlements: CodexModelEntitlementSnapshot;
140
- readonly discoveryConfig?: OcxConfig;
143
+ readonly discovery: DiscoveryEvidence;
144
+ }
145
+
146
+ interface DiscoveryEvidence {
147
+ readonly models: readonly { provider: string; id: string; custom?: boolean }[];
148
+ readonly authoritativeProviders: readonly string[];
149
+ readonly providerIdentities: Readonly<Record<string, string>>;
150
+ readonly now: string;
151
+ }
152
+
153
+ interface DiscoveryProjection {
154
+ readonly providers: Readonly<Record<string, Readonly<{
155
+ knownModel?: KnownModelBaseline;
156
+ recentArrivals?: readonly { id: string; at: string }[];
157
+ }>>>;
158
+ readonly disabledModelsToAdd: readonly string[];
141
159
  }
142
160
 
143
161
  const candidateStates = new WeakMap<object, CandidateState>();
@@ -145,6 +163,16 @@ function same(left: unknown, right: unknown): boolean {
145
163
  return JSON.stringify(left) === JSON.stringify(right);
146
164
  }
147
165
 
166
+ function discoveryProviderIdentity(provider: OcxProviderConfig | undefined): string {
167
+ if (!provider) return "missing";
168
+ const row: Record<string, unknown> = { ...provider };
169
+ delete row.note;
170
+ delete row.newModelPolicy;
171
+ return JSON.stringify(row, (_key, value) => value && typeof value === "object" && !Array.isArray(value)
172
+ ? Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left.localeCompare(right)))
173
+ : value);
174
+ }
175
+
148
176
  function targetPath(identity: string): string {
149
177
  const parsed = JSON.parse(identity) as { path?: unknown };
150
178
  if (typeof parsed.path !== "string") throw new TypeError("Catalog target identity has no path.");
@@ -441,13 +469,15 @@ export async function gatherCodexCatalogCandidate(
441
469
  : !hasRoutedEntries(source.catalog) ? source.catalog : null)
442
470
  : null);
443
471
  const discoveryConfig = structuredClone(snapshot.config) as OcxConfig;
444
- const discoveryChanged = reconcileSuccessfulModelDiscoveries({
472
+ const authoritativeProviders = providerModelOutcomes
473
+ .filter(outcome => outcome.state === "authoritative")
474
+ .map(outcome => outcome.provider);
475
+ const discoveryNow = new Date().toISOString();
476
+ reconcileSuccessfulModelDiscoveries({
445
477
  config: discoveryConfig,
446
478
  models: routedModels,
447
- authoritativeProviders: providerModelOutcomes
448
- .filter(outcome => outcome.state === "authoritative")
449
- .map(outcome => outcome.provider),
450
- now: new Date().toISOString(),
479
+ authoritativeProviders,
480
+ now: discoveryNow,
451
481
  });
452
482
  const preparedCatalog = prepareCatalog(
453
483
  discoveryConfig,
@@ -515,7 +545,15 @@ export async function gatherCodexCatalogCandidate(
515
545
  || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
516
546
  notices: Object.freeze([...notices]),
517
547
  modelEntitlements,
518
- ...(discoveryChanged ? { discoveryConfig } : {}),
548
+ discovery: {
549
+ models: routedModels,
550
+ authoritativeProviders,
551
+ providerIdentities: Object.fromEntries(authoritativeProviders.map(provider => [
552
+ provider,
553
+ discoveryProviderIdentity(snapshot.config.providers[provider]),
554
+ ])),
555
+ now: discoveryNow,
556
+ },
519
557
  });
520
558
  return { kind: "candidate", candidate };
521
559
  } catch (error) {
@@ -532,6 +570,63 @@ export async function gatherCodexCatalogCandidate(
532
570
  }
533
571
  }
534
572
 
573
+ function reconcilePersistedDiscovery(state: CandidateState) {
574
+ return mutatePersistedConfig(config => {
575
+ if (state.discovery.authoritativeProviders.some(provider => (
576
+ discoveryProviderIdentity(config.providers[provider]) !== state.discovery.providerIdentities[provider]
577
+ ))) {
578
+ return { changed: false, value: null };
579
+ }
580
+ const disabledBefore = new Set(config.disabledModels ?? []);
581
+ const changed = reconcileSuccessfulModelDiscoveries({
582
+ config,
583
+ ...state.discovery,
584
+ });
585
+ const providers = Object.fromEntries(state.discovery.authoritativeProviders.flatMap(provider => {
586
+ const configured = config.providers[provider];
587
+ if (!configured || configured.liveModels === false) return [];
588
+ const knownModel = config.modelDiscovery?.knownModels?.[provider];
589
+ const recentArrivals = config.modelDiscovery?.recentArrivals?.[provider];
590
+ return [[provider, {
591
+ ...(knownModel ? { knownModel: structuredClone(knownModel) } : {}),
592
+ ...(recentArrivals ? { recentArrivals: structuredClone(recentArrivals) } : {}),
593
+ }]];
594
+ }));
595
+ return {
596
+ changed,
597
+ value: {
598
+ providers,
599
+ disabledModelsToAdd: (config.disabledModels ?? []).filter(slug => !disabledBefore.has(slug)),
600
+ } satisfies DiscoveryProjection,
601
+ };
602
+ });
603
+ }
604
+
605
+ function adoptDiscoveryProjection(config: OcxConfig, projection: DiscoveryProjection): void {
606
+ for (const [provider, discovered] of Object.entries(projection.providers)) {
607
+ if (discovered.knownModel) {
608
+ const discovery = config.modelDiscovery ??= {};
609
+ const knownModels = discovery.knownModels ??= {};
610
+ knownModels[provider] = structuredClone(discovered.knownModel);
611
+ } else if (config.modelDiscovery?.knownModels) {
612
+ delete config.modelDiscovery.knownModels[provider];
613
+ }
614
+ if (discovered.recentArrivals) {
615
+ const discovery = config.modelDiscovery ??= {};
616
+ const recentArrivals = discovery.recentArrivals ??= {};
617
+ recentArrivals[provider] = discovered.recentArrivals.map(arrival => ({ ...arrival }));
618
+ } else if (config.modelDiscovery?.recentArrivals) {
619
+ delete config.modelDiscovery.recentArrivals[provider];
620
+ }
621
+ }
622
+ if (projection.disabledModelsToAdd.length > 0) {
623
+ const disabledModels = config.disabledModels ??= [];
624
+ for (const slug of projection.disabledModelsToAdd) {
625
+ if (!disabledModels.includes(slug)) disabledModels.push(slug);
626
+ }
627
+ }
628
+ }
629
+
535
630
  function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null {
536
631
  if (!isCodexModelEntitlementSnapshotCurrent(state.modelEntitlements)) {
537
632
  return { kind: "stale", reason: "account-entitlement" };
@@ -663,11 +758,11 @@ export async function convergeCodexCatalog(
663
758
  const state = candidateStates.get(gathered.candidate as object)!;
664
759
  lifecycle.onCommitBegin?.();
665
760
  const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs);
666
- if (committed.kind === "committed" && state.discoveryConfig) {
667
- const mutable = snapshot.config as OcxConfig;
668
- mutable.modelDiscovery = state.discoveryConfig.modelDiscovery;
669
- mutable.disabledModels = state.discoveryConfig.disabledModels;
670
- saveConfigPreservingClaudeCode(mutable);
761
+ if (committed.kind === "committed") {
762
+ const persisted = reconcilePersistedDiscovery(state);
763
+ if ((persisted.status === "committed" || persisted.status === "unchanged") && persisted.value) {
764
+ adoptDiscoveryProjection(snapshot.config as OcxConfig, persisted.value);
765
+ }
671
766
  }
672
767
  return {
673
768
  changed: committed.kind === "committed" ? committed.changed : false,
@@ -38,6 +38,7 @@ import {
38
38
  writeJournal,
39
39
  } from "./journal";
40
40
  import { withCatalogWriteSerialization } from "./catalog-write-serialization";
41
+ import { resetCodexAppServerCatalogStateCache } from "./app-server-processes";
41
42
  import { restoreCodexCatalogWithPermit } from "./catalog/sync";
42
43
  import { syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider";
43
44
  import {
@@ -931,6 +932,7 @@ export async function injectCodexConfig(
931
932
  configContent: baselineContent,
932
933
  });
933
934
  atomicWriteFile(CODEX_CONFIG_PATH, content);
935
+ resetCodexAppServerCatalogStateCache();
934
936
  atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
935
937
  markJournalInjectedState(content, profileContent, {
936
938
  // A root override is ours only in loopback Design B when no user-owned value won.
@@ -442,16 +442,9 @@ export async function resolveNativeDefaultState(
442
442
  const inspected = inspectManagedSubagentDefaults(content);
443
443
  if (!inspected.ok) return "blocked";
444
444
 
445
- let parsed: Record<string, unknown>;
446
- try {
447
- parsed = Bun.TOML.parse(content) as Record<string, unknown>;
448
- } catch {
449
- return "blocked";
450
- }
451
445
  const provider = resolveEffectiveProjectModelProvider(content).provider;
452
446
  if (provider && provider !== "openai" && provider !== "opencodex") return "blocked";
453
- const baseUrl = parsed.openai_base_url;
454
- if (typeof baseUrl === "string" && baseUrl.trim() && !hasInjectedCodexRouting(content)) return "blocked";
447
+ if (!hasInjectedCodexRouting(content)) return "blocked";
455
448
 
456
449
  const { values, owned } = inspected.inspection;
457
450
  for (const key of TARGET_KEYS) {
@@ -0,0 +1,189 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { isEligibleV2SubagentEntry, readCatalog, readCodexCatalogPath } from "./catalog";
3
+ import { catalogEntryEfforts } from "./catalog/effort";
4
+ import type { RawEntry } from "./catalog/parsing";
5
+ import { resolveNativeDefaultState, type NativeDefaultState } from "./subagent-defaults";
6
+
7
+ export const SUBAGENT_MODEL_AUTHORITY_VERSION = 1 as const;
8
+
9
+ export interface SubagentModelAuthorityInput {
10
+ schemaVersion: typeof SUBAGENT_MODEL_AUTHORITY_VERSION;
11
+ role: string;
12
+ agentType: string;
13
+ typedDispatchRequired?: boolean;
14
+ requestedModel?: string;
15
+ requestedEffort?: string;
16
+ spawnAgent: {
17
+ surface: "v1" | "v2";
18
+ supportsAgentType: boolean;
19
+ supportsModel: boolean;
20
+ supportsEffort: boolean;
21
+ supportsForkTurns: boolean;
22
+ };
23
+ confirmation?: { decision: "approve" | "decline" };
24
+ interactive?: boolean;
25
+ }
26
+
27
+ export interface SubagentModelAuthorityHost {
28
+ catalogState: "fresh" | "stale" | "not_running" | "unknown";
29
+ nativeDefaultState: NativeDefaultState;
30
+ preferredModel: string | null;
31
+ preferredEffort: string | null;
32
+ executableModels: Array<{ model: string; efforts: string[] }>;
33
+ }
34
+
35
+ export type SubagentModelAuthorityResult = {
36
+ schemaVersion: typeof SUBAGENT_MODEL_AUTHORITY_VERSION;
37
+ decision: "forward" | "omit" | "confirm" | "blocked";
38
+ requestClassification: "inherit" | "preferred" | "exception";
39
+ reason: string;
40
+ spawn?: {
41
+ agent_type?: string;
42
+ model?: string;
43
+ reasoning_effort?: string;
44
+ fork_turns?: "none";
45
+ };
46
+ confirmation?: {
47
+ role: string;
48
+ preferredModel: string | null;
49
+ requestedModel: string;
50
+ scope: "single spawn";
51
+ };
52
+ };
53
+
54
+ export function parseSubagentModelAuthorityInput(value: unknown): SubagentModelAuthorityInput | null {
55
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
56
+ const input = value as Record<string, unknown>;
57
+ const spawn = input.spawnAgent;
58
+ if (!spawn || typeof spawn !== "object" || Array.isArray(spawn)) return null;
59
+ const tool = spawn as Record<string, unknown>;
60
+ if (input.schemaVersion !== SUBAGENT_MODEL_AUTHORITY_VERSION
61
+ || typeof input.role !== "string"
62
+ || typeof input.agentType !== "string"
63
+ || (input.typedDispatchRequired !== undefined && typeof input.typedDispatchRequired !== "boolean")
64
+ || (input.requestedModel !== undefined && typeof input.requestedModel !== "string")
65
+ || (input.requestedEffort !== undefined && typeof input.requestedEffort !== "string")
66
+ || (input.interactive !== undefined && typeof input.interactive !== "boolean")
67
+ || (tool.surface !== "v1" && tool.surface !== "v2")
68
+ || ["supportsAgentType", "supportsModel", "supportsEffort", "supportsForkTurns"]
69
+ .some(key => typeof tool[key] !== "boolean")) return null;
70
+ if (input.confirmation !== undefined) {
71
+ if (!input.confirmation || typeof input.confirmation !== "object" || Array.isArray(input.confirmation)) return null;
72
+ const decision = (input.confirmation as Record<string, unknown>).decision;
73
+ if (decision !== "approve" && decision !== "decline") return null;
74
+ }
75
+ return value as SubagentModelAuthorityInput;
76
+ }
77
+
78
+ function result(
79
+ decision: SubagentModelAuthorityResult["decision"],
80
+ requestClassification: SubagentModelAuthorityResult["requestClassification"],
81
+ reason: string,
82
+ extra: Pick<SubagentModelAuthorityResult, "spawn" | "confirmation"> = {},
83
+ ): SubagentModelAuthorityResult {
84
+ return { schemaVersion: SUBAGENT_MODEL_AUTHORITY_VERSION, decision, requestClassification, reason, ...extra };
85
+ }
86
+
87
+ export function resolveSubagentModelAuthority(
88
+ input: SubagentModelAuthorityInput,
89
+ host: SubagentModelAuthorityHost,
90
+ ): SubagentModelAuthorityResult {
91
+ const requestedModel = input.requestedModel?.trim() || undefined;
92
+ const preferredModel = host.preferredModel?.trim() || undefined;
93
+ const classification = requestedModel === undefined
94
+ ? "inherit"
95
+ : requestedModel === preferredModel ? "preferred" : "exception";
96
+ if (input.schemaVersion !== SUBAGENT_MODEL_AUTHORITY_VERSION) {
97
+ return result("blocked", classification, "unsupported authority schema version");
98
+ }
99
+ if (!input.role.trim() || !input.agentType.trim()) {
100
+ return result("blocked", classification, "role and agentType must be non-empty");
101
+ }
102
+ if (input.typedDispatchRequired && !input.spawnAgent.supportsAgentType) {
103
+ return result("blocked", classification, "spawn_agent does not support required agent_type dispatch");
104
+ }
105
+ if (host.catalogState === "stale" || host.catalogState === "unknown") {
106
+ return result("blocked", classification, "OpenCodex model authority is not current; sync or restart Codex and retry");
107
+ }
108
+
109
+ const chosenModel = requestedModel ?? preferredModel;
110
+ const executable = chosenModel
111
+ ? host.executableModels.find(candidate => candidate.model === chosenModel)
112
+ : undefined;
113
+ if (chosenModel && !executable) {
114
+ return result("blocked", classification, `model ${JSON.stringify(chosenModel)} is not executable on this collaboration surface`);
115
+ }
116
+ if (chosenModel && !input.spawnAgent.supportsModel) {
117
+ return result("blocked", classification, "spawn_agent does not support model overrides");
118
+ }
119
+ const effort = input.requestedEffort?.trim() || (requestedModel ? undefined : host.preferredEffort?.trim()) || undefined;
120
+ if (effort && !input.spawnAgent.supportsEffort) {
121
+ return result("blocked", classification, "spawn_agent does not support reasoning_effort overrides");
122
+ }
123
+ if (effort && executable && !executable.efforts.includes(effort)) {
124
+ return result("blocked", classification, `reasoning effort ${JSON.stringify(effort)} is unavailable for ${JSON.stringify(chosenModel)}`);
125
+ }
126
+
127
+ if (classification === "exception") {
128
+ if (input.confirmation?.decision === "decline") {
129
+ return result("blocked", classification, "single-spawn model exception was declined");
130
+ }
131
+ if (input.confirmation?.decision !== "approve") {
132
+ if (input.interactive === false) {
133
+ return result("blocked", classification, "single-spawn model exception requires interactive confirmation");
134
+ }
135
+ return result("confirm", classification, "confirm this model exception for one spawn", {
136
+ confirmation: {
137
+ role: input.role,
138
+ preferredModel: preferredModel ?? null,
139
+ requestedModel: requestedModel!,
140
+ scope: "single spawn",
141
+ },
142
+ });
143
+ }
144
+ }
145
+
146
+ if (!chosenModel) {
147
+ return host.nativeDefaultState === "active"
148
+ ? result("omit", classification, "native OpenCodex defaults are active")
149
+ : result("blocked", classification, `native OpenCodex defaults are ${host.nativeDefaultState}`);
150
+ }
151
+ const spawn: NonNullable<SubagentModelAuthorityResult["spawn"]> = {
152
+ ...(input.spawnAgent.supportsAgentType ? { agent_type: input.agentType } : {}),
153
+ model: chosenModel,
154
+ ...(effort ? { reasoning_effort: effort } : {}),
155
+ ...((input.spawnAgent.supportsForkTurns && (chosenModel || effort)) ? { fork_turns: "none" as const } : {}),
156
+ };
157
+ return result("forward", classification, classification === "exception"
158
+ ? "single-spawn model exception approved"
159
+ : "OpenCodex preferred model is executable", { spawn });
160
+ }
161
+
162
+ function executableModels(entries: readonly RawEntry[], surface: "v1" | "v2"): SubagentModelAuthorityHost["executableModels"] {
163
+ const seen = new Set<string>();
164
+ return entries.flatMap(entry => {
165
+ const model = typeof entry.slug === "string" ? entry.slug.trim() : "";
166
+ if (!model || seen.has(model) || entry.visibility !== "list" || (surface === "v2" && !isEligibleV2SubagentEntry(entry))) return [];
167
+ seen.add(model);
168
+ return [{ model, efforts: catalogEntryEfforts(entry) }];
169
+ });
170
+ }
171
+
172
+ export async function resolveOpenCodexSubagentModelAuthority(
173
+ input: SubagentModelAuthorityInput,
174
+ config: OcxConfig,
175
+ ): Promise<SubagentModelAuthorityResult> {
176
+ const { collectCodexAppServerCatalogStateForRequest } = await import("./app-server-processes");
177
+ const catalogState = await collectCodexAppServerCatalogStateForRequest();
178
+ const models = executableModels(readCatalog(readCodexCatalogPath())?.models ?? [], input.spawnAgent.surface);
179
+ const configured = config.injectionModel?.trim() || null;
180
+ const exact = configured ? models.find(candidate => candidate.model === configured)?.model ?? null : null;
181
+ const host: SubagentModelAuthorityHost = {
182
+ catalogState: catalogState.state,
183
+ nativeDefaultState: await resolveNativeDefaultState(config),
184
+ preferredModel: exact,
185
+ preferredEffort: config.injectionEffort?.trim() || null,
186
+ executableModels: models,
187
+ };
188
+ return resolveSubagentModelAuthority(input, host);
189
+ }