@bitkyc08/opencodex 2.33.0 → 2.34.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 (159) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-C4TMRloX.js +112 -0
  3. package/gui/dist/assets/index-DQ-Ie18T.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +40 -3
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +14 -0
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  17. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  18. package/src/adapters/cursor/native-exec-network.ts +1 -1
  19. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  20. package/src/adapters/cursor/native-exec.ts +20 -0
  21. package/src/adapters/cursor/protobuf-request.ts +68 -14
  22. package/src/adapters/cursor/request-builder.ts +29 -6
  23. package/src/adapters/cursor/tool-definitions.ts +11 -2
  24. package/src/adapters/cursor/tool-result-normalize.ts +32 -1
  25. package/src/adapters/cursor/types.ts +20 -0
  26. package/src/adapters/cursor.ts +189 -37
  27. package/src/adapters/google-antigravity-replay.ts +102 -23
  28. package/src/adapters/google-errors.ts +44 -12
  29. package/src/adapters/google.ts +115 -16
  30. package/src/adapters/image.ts +1 -1
  31. package/src/adapters/kiro-constants.ts +15 -0
  32. package/src/adapters/kiro-tools.ts +43 -15
  33. package/src/adapters/kiro.ts +54 -9
  34. package/src/adapters/openai-chat.ts +286 -242
  35. package/src/adapters/openai-responses.ts +334 -24
  36. package/src/adapters/tool-catalog-nudge.ts +2 -2
  37. package/src/adapters/xai-tool-schema.ts +436 -0
  38. package/src/bridge.ts +67 -24
  39. package/src/chat/inbound.ts +14 -1
  40. package/src/chat/outbound.ts +15 -7
  41. package/src/claude/agents-inject.ts +8 -1
  42. package/src/claude/outbound.ts +10 -8
  43. package/src/cli/account-api.ts +32 -4
  44. package/src/cli/account-extended.ts +1 -1
  45. package/src/cli/account.ts +29 -5
  46. package/src/cli/alias.ts +66 -0
  47. package/src/cli/claude.ts +26 -1
  48. package/src/cli/dispatch.ts +13 -1
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli/init.ts +1 -0
  51. package/src/cli/models-runtime.ts +95 -0
  52. package/src/cli/models.ts +13 -7
  53. package/src/cli/provider-runtime.ts +16 -2
  54. package/src/cli/registry.ts +6 -1
  55. package/src/cli/v2.ts +34 -10
  56. package/src/codex/account-pause.ts +2 -1
  57. package/src/codex/account-priority.ts +3 -2
  58. package/src/codex/app-server-processes.ts +80 -6
  59. package/src/codex/auth-api.ts +46 -8
  60. package/src/codex/auth-context.ts +21 -18
  61. package/src/codex/catalog/aggregation.ts +6 -0
  62. package/src/codex/catalog/native-models.ts +5 -2
  63. package/src/codex/catalog/parsing.ts +16 -0
  64. package/src/codex/catalog/provider-fetch.ts +20 -3
  65. package/src/codex/catalog/sync.ts +127 -2
  66. package/src/codex/catalog.ts +1 -1
  67. package/src/codex/codex-write-lock.ts +3 -1
  68. package/src/codex/convergence-types.ts +1 -1
  69. package/src/codex/convergence.ts +22 -2
  70. package/src/codex/desired-state.ts +2 -2
  71. package/src/codex/desktop-app-restart.ts +18 -5
  72. package/src/codex/inject-coordination.ts +83 -0
  73. package/src/codex/inject.ts +14 -1
  74. package/src/codex/log-guard/inspect.ts +22 -4
  75. package/src/codex/model-entitlements.ts +9 -2
  76. package/src/codex/prompt-layers.ts +371 -25
  77. package/src/codex/prompt-text-probe.ts +238 -0
  78. package/src/codex/quota.ts +91 -10
  79. package/src/codex/routing.ts +9 -0
  80. package/src/codex/subagent-model-fallback.ts +104 -25
  81. package/src/codex/transition-state.ts +107 -8
  82. package/src/combos/types.ts +10 -0
  83. package/src/compatibility/openai-responses.ts +33 -1
  84. package/src/config/rebase-provenance.ts +68 -0
  85. package/src/config.ts +107 -9
  86. package/src/generated/compatibility-version.json +191 -139
  87. package/src/images/loop.ts +5 -4
  88. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  89. package/src/lab/fabric/producer-child.ts +1 -1
  90. package/src/lib/config-ownership.ts +20 -0
  91. package/src/lib/errors.ts +11 -2
  92. package/src/lib/package-tree-integrity.ts +101 -0
  93. package/src/oauth/generic-account-failover.ts +231 -0
  94. package/src/oauth/index.ts +57 -5
  95. package/src/oauth/key-providers.ts +10 -1
  96. package/src/oauth/kiro.ts +45 -0
  97. package/src/oauth/types.ts +15 -0
  98. package/src/providers/command-code-efforts.ts +38 -6
  99. package/src/providers/context-cap.ts +4 -3
  100. package/src/providers/default-aliases.ts +65 -0
  101. package/src/providers/derive.ts +28 -0
  102. package/src/providers/fastwire.ts +7 -1
  103. package/src/providers/model-presets.ts +119 -0
  104. package/src/providers/new-model-policy.ts +146 -0
  105. package/src/providers/provider-id-rewrite.ts +2 -1
  106. package/src/providers/quota.ts +65 -7
  107. package/src/providers/registry.ts +160 -68
  108. package/src/providers/slug-codec.ts +52 -0
  109. package/src/responses/code-mode-helper-compat.ts +50 -0
  110. package/src/responses/custom-tool-compat.ts +34 -10
  111. package/src/responses/parser.ts +4 -0
  112. package/src/responses/schema.ts +5 -1
  113. package/src/responses/thought-signature-replay.ts +17 -0
  114. package/src/router.ts +40 -2
  115. package/src/routing/analytics.ts +1 -0
  116. package/src/routing/quota.ts +10 -0
  117. package/src/server/auth-cors.ts +4 -0
  118. package/src/server/chat-completions.ts +24 -16
  119. package/src/server/chat-native-sse.ts +3 -3
  120. package/src/server/chat-native.ts +30 -11
  121. package/src/server/claude-messages.ts +1 -1
  122. package/src/server/index.ts +77 -11
  123. package/src/server/lifecycle.ts +52 -1
  124. package/src/server/management/agent-settings-routes.ts +31 -15
  125. package/src/server/management/codex-prompt-routes.ts +570 -0
  126. package/src/server/management/combo-routes.ts +2 -1
  127. package/src/server/management/config-routes.ts +27 -9
  128. package/src/server/management/context.ts +9 -0
  129. package/src/server/management/model-routes.ts +266 -0
  130. package/src/server/management/provider-routes.ts +6 -0
  131. package/src/server/management/routing-profile-routes.ts +2 -2
  132. package/src/server/management-api.ts +2 -0
  133. package/src/server/relay-eager.ts +147 -20
  134. package/src/server/relay.ts +251 -19
  135. package/src/server/request-log-conversation.ts +21 -0
  136. package/src/server/request-log.ts +41 -15
  137. package/src/server/responses/collaboration.ts +37 -3
  138. package/src/server/responses/combo-stream-preflight.ts +10 -3
  139. package/src/server/responses/core.ts +494 -114
  140. package/src/server/responses/empty-completion-guard.ts +35 -0
  141. package/src/server/responses/fetch-helpers.ts +2 -1
  142. package/src/server/responses/input-admission.ts +3 -1
  143. package/src/server/responses/passthrough-error.ts +33 -9
  144. package/src/server/responses/responses-field-backfill.ts +105 -13
  145. package/src/server/responses-custom-tool-repair.ts +52 -7
  146. package/src/server/responses-terminal-repair.ts +25 -4
  147. package/src/server/sse-frame-buffer.ts +31 -4
  148. package/src/server/ws-bridge.ts +14 -2
  149. package/src/types/config.ts +53 -0
  150. package/src/types/provider.ts +64 -0
  151. package/src/types/request.ts +11 -3
  152. package/src/types/tools.ts +7 -5
  153. package/src/types.ts +1 -0
  154. package/src/usage/expected-prices.ts +43 -24
  155. package/src/usage/log.ts +3 -0
  156. package/src/web-search/exa-executor.ts +40 -9
  157. package/src/web-search/loop.ts +5 -4
  158. package/gui/dist/assets/index-23-Lf7jR.js +0 -102
  159. package/gui/dist/assets/index-DxJMDyOr.css +0 -1
@@ -41,7 +41,7 @@ import {
41
41
  } from "../model-entitlements";
42
42
 
43
43
 
44
- import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readNativeBaseline } from "./parsing";
44
+ import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
45
45
  import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
46
46
  import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
47
47
  import {
@@ -1403,6 +1403,130 @@ function catalogModelsForMergeWithNativeRecovery(
1403
1403
  ]);
1404
1404
  }
1405
1405
 
1406
+ const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/;
1407
+
1408
+ export function isValidAutoReviewModel(value: unknown): value is string {
1409
+ if (typeof value !== "string") return false;
1410
+ const trimmed = value.trim();
1411
+ return Boolean(trimmed)
1412
+ && trimmed.length <= 1024
1413
+ && !AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed);
1414
+ }
1415
+
1416
+ export type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved";
1417
+
1418
+ function isRoutedCatalogEntry(entry: RawEntry): boolean {
1419
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1420
+ return slug.includes("/")
1421
+ || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → "));
1422
+ }
1423
+
1424
+ function clearAutoReviewModelOverride(
1425
+ models: readonly RawEntry[],
1426
+ sourceModels: readonly RawEntry[] = [],
1427
+ ): void {
1428
+ const observedModels = [...models, ...sourceModels];
1429
+ const configuredValues = new Set(observedModels.flatMap(entry => {
1430
+ const value = entry?.auto_review_model_override;
1431
+ return typeof value === "string" && value.trim() ? [value] : [];
1432
+ }));
1433
+ const globalStamp = configuredValues.size === 1
1434
+ && observedModels.some(entry => {
1435
+ const value = entry.auto_review_model_override;
1436
+ return isRoutedCatalogEntry(entry)
1437
+ && typeof value === "string"
1438
+ && value.trim().length > 0
1439
+ && configuredValues.has(value);
1440
+ })
1441
+ && observedModels.every(entry => {
1442
+ const value = entry?.auto_review_model_override;
1443
+ return value === null
1444
+ || value === undefined
1445
+ || (typeof value === "string" && configuredValues.has(value));
1446
+ });
1447
+ for (const entry of models) {
1448
+ if (!entry || typeof entry !== "object") continue;
1449
+ const current = entry.auto_review_model_override;
1450
+ if (isRoutedCatalogEntry(entry)
1451
+ || (globalStamp && typeof current === "string" && configuredValues.has(current))) {
1452
+ entry.auto_review_model_override = null;
1453
+ }
1454
+ }
1455
+ }
1456
+
1457
+ function warnAutoReviewModelDiagnostic(
1458
+ reason: "invalid" | "unresolved",
1459
+ configured: string,
1460
+ ): void {
1461
+ const safeConfigured = JSON.stringify(redactSecretString(configured));
1462
+ const detail = reason === "unresolved"
1463
+ ? "the selector was not found in the final catalog"
1464
+ : "the selector format is invalid";
1465
+ console.warn(
1466
+ `[opencodex] auto_review_model ${detail} (${safeConfigured}); preserving normal upstream auto-review behavior.`,
1467
+ );
1468
+ }
1469
+
1470
+ function preserveNativeAutoReviewModelOverrides(
1471
+ models: readonly RawEntry[],
1472
+ sourceModels: readonly RawEntry[],
1473
+ ): void {
1474
+ const existing = new Map<string, string | null>();
1475
+ for (const entry of sourceModels) {
1476
+ const slug = typeof entry.slug === "string" ? entry.slug : undefined;
1477
+ const value = entry.auto_review_model_override;
1478
+ if (!slug || isRoutedCatalogEntry(entry)) continue;
1479
+ if (typeof value === "string" || value === null) existing.set(slug, value);
1480
+ }
1481
+ for (const entry of models) {
1482
+ const slug = typeof entry.slug === "string" ? entry.slug : undefined;
1483
+ if (!slug || isRoutedCatalogEntry(entry) || !existing.has(slug)) continue;
1484
+ entry.auto_review_model_override = existing.get(slug) ?? null;
1485
+ }
1486
+ }
1487
+
1488
+ export function applyAutoReviewModelOverride(
1489
+ models: RawEntry[] | undefined,
1490
+ autoReviewModel: string | null | undefined,
1491
+ sourceModels: readonly RawEntry[] = [],
1492
+ ): AutoReviewModelOverrideResult {
1493
+ if (!models || !Array.isArray(models)) return "absent";
1494
+ if (autoReviewModel === null || autoReviewModel === undefined) {
1495
+ clearAutoReviewModelOverride(models, sourceModels);
1496
+ return "absent";
1497
+ }
1498
+ const trimmed = autoReviewModel.trim();
1499
+ if (!trimmed) {
1500
+ clearAutoReviewModelOverride(models, sourceModels);
1501
+ return "absent";
1502
+ }
1503
+ if (!isValidAutoReviewModel(trimmed)) {
1504
+ clearAutoReviewModelOverride(models, sourceModels);
1505
+ warnAutoReviewModelDiagnostic("invalid", trimmed);
1506
+ return "invalid";
1507
+ }
1508
+ if (!configuredCatalogEntry(models, trimmed)) {
1509
+ clearAutoReviewModelOverride(models, sourceModels);
1510
+ warnAutoReviewModelDiagnostic("unresolved", trimmed);
1511
+ return "unresolved";
1512
+ }
1513
+ for (const entry of models) {
1514
+ if (entry && typeof entry === "object") {
1515
+ entry.auto_review_model_override = trimmed;
1516
+ }
1517
+ }
1518
+ return "applied";
1519
+ }
1520
+
1521
+ /** Apply the root Codex auto-review selector after the final catalog merge. */
1522
+ export function finalizeAutoReviewModelOverride(
1523
+ models: RawEntry[] | undefined,
1524
+ sourceModels: readonly RawEntry[] = [],
1525
+ ): AutoReviewModelOverrideResult {
1526
+ if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels);
1527
+ return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels);
1528
+ }
1529
+
1406
1530
  function writeRetainedCatalogSync({
1407
1531
  config,
1408
1532
  goModels,
@@ -1596,6 +1720,7 @@ function writeRetainedCatalogSync({
1596
1720
  },
1597
1721
  });
1598
1722
  clampCatalogModelsToCodexSupport(catalog.models);
1723
+ finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge);
1599
1724
 
1600
1725
  const added = goEntries.length + accountBoundEntries.length;
1601
1726
  const content = `${JSON.stringify(catalog, null, 2)}\n`;
@@ -1833,10 +1958,10 @@ export function invalidateCodexModelsCacheWithPermit(
1833
1958
  // keeps the cache consistent with the catalog it just wrote.
1834
1959
  if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
1835
1960
  const catalogPath = readCodexCatalogPathForHome(owningCodexHome);
1836
- const cachePath = join(owningCodexHome, "models_cache.json");
1837
1961
  if (!existsSync(catalogPath)) return false;
1838
1962
  const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
1839
1963
  const models = catalog.models ?? catalog;
1964
+ const cachePath = join(owningCodexHome, "models_cache.json");
1840
1965
  const currentCache = readCatalog(cachePath);
1841
1966
  const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
1842
1967
  typeof entry.slug === "string" ? [entry.slug] : []));
@@ -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 } from "./catalog/sync";
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";
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";
@@ -93,6 +93,8 @@ export interface CodexWriteLockOptions {
93
93
  admitted: CodexWriteWitness;
94
94
  /** Authoritative synchronous re-read while N and C are both held. */
95
95
  readAdmissionUnderLock(): CodexWriteWitness;
96
+ /** Positively authorized migration of an already-routed pre-substrate home. */
97
+ adoption?: { readonly direction: "apply" | "remove" };
96
98
  }
97
99
 
98
100
  /**
@@ -304,7 +306,7 @@ export async function withCodexWriteLock<T>(
304
306
 
305
307
  let transaction: ReturnType<typeof openCodexCoordinatorTransaction> | undefined;
306
308
  try {
307
- transaction = openCodexCoordinatorTransaction(databasePath);
309
+ transaction = openCodexCoordinatorTransaction(databasePath, options.adoption);
308
310
  } catch (error) {
309
311
  // Only contention retries. A malformed database, an unsafe path, or an
310
312
  // identity failure will fail identically forever; telling a caller to retry
@@ -34,7 +34,7 @@ export interface CodexIntegrationRecord {
34
34
  }
35
35
 
36
36
  export interface CodexHistoryState {
37
- status: "converged" | "pending" | "running" | "blocked" | "unknown" | "not-evaluated";
37
+ status: "adoption-pending" | "converged" | "pending" | "running" | "blocked" | "unknown" | "not-evaluated";
38
38
  /**
39
39
  * Why it is not converged, when it is not. These are terminal observations
40
40
  * for one attempt, not reasons to collapse the durable retry schedule.
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
 
3
- import { getConfigDir, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
3
+ import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
4
+ import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy";
4
5
  import { COMBO_NAMESPACE } from "../combos";
5
6
  import { getAuthStorePath } from "../oauth/store";
6
7
  import type { OcxConfig } from "../types";
@@ -41,6 +42,7 @@ import {
41
42
  import {
42
43
  buildCatalogEntriesFromObservedState,
43
44
  CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
45
+ finalizeAutoReviewModelOverride,
44
46
  mergeCatalogEntriesFromObservedState,
45
47
  mergeCatalogModelsWithNativeRecovery,
46
48
  orderForSubagents,
@@ -135,6 +137,7 @@ interface CandidateState {
135
137
  readonly changed: boolean;
136
138
  readonly notices: readonly CatalogNotice[];
137
139
  readonly modelEntitlements: CodexModelEntitlementSnapshot;
140
+ readonly discoveryConfig?: OcxConfig;
138
141
  }
139
142
 
140
143
  const candidateStates = new WeakMap<object, CandidateState>();
@@ -369,6 +372,7 @@ function prepareCatalog(
369
372
  ? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog)
370
373
  : null,
371
374
  );
375
+ finalizeAutoReviewModelOverride(mergedModels, catalogModels);
372
376
  catalog.models = mergedModels;
373
377
  return catalog;
374
378
  }
@@ -436,8 +440,17 @@ export async function gatherCodexCatalogCandidate(
436
440
  ? active
437
441
  : !hasRoutedEntries(source.catalog) ? source.catalog : null)
438
442
  : null);
443
+ const discoveryConfig = structuredClone(snapshot.config) as OcxConfig;
444
+ const discoveryChanged = reconcileSuccessfulModelDiscoveries({
445
+ config: discoveryConfig,
446
+ models: routedModels,
447
+ authoritativeProviders: providerModelOutcomes
448
+ .filter(outcome => outcome.state === "authoritative")
449
+ .map(outcome => outcome.provider),
450
+ now: new Date().toISOString(),
451
+ });
439
452
  const preparedCatalog = prepareCatalog(
440
- snapshot.config,
453
+ discoveryConfig,
441
454
  source,
442
455
  active,
443
456
  routedModels,
@@ -502,6 +515,7 @@ export async function gatherCodexCatalogCandidate(
502
515
  || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
503
516
  notices: Object.freeze([...notices]),
504
517
  modelEntitlements,
518
+ ...(discoveryChanged ? { discoveryConfig } : {}),
505
519
  });
506
520
  return { kind: "candidate", candidate };
507
521
  } catch (error) {
@@ -649,6 +663,12 @@ export async function convergeCodexCatalog(
649
663
  const state = candidateStates.get(gathered.candidate as object)!;
650
664
  lifecycle.onCommitBegin?.();
651
665
  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);
671
+ }
652
672
  return {
653
673
  changed: committed.kind === "committed" ? committed.changed : false,
654
674
  catalogRefresh: projectCommit(committed, state.notices),
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Design record: devlog/_fin/260803_codex_desktop_toggle/030_desired_state.md.
22
22
  */
23
- import { loadConfig, mutatePersistedConfig } from "../config";
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
26
 
@@ -114,7 +114,7 @@ export function setIntegrationEnabled(
114
114
  }
115
115
  // Drop the key entirely once nothing is left in it, so enabling twice does
116
116
  // not leave `"clientIntegrations": {}` behind in the user's file.
117
- if (Object.keys(integrations).length === 0) delete config.clientIntegrations;
117
+ if (Object.keys(integrations).length === 0) deleteConfigTopLevelKey(config, "clientIntegrations");
118
118
  else config.clientIntegrations = integrations;
119
119
  return { changed: true, value: enabled };
120
120
  });
@@ -43,6 +43,7 @@ export interface DesktopAppRestartIo {
43
43
  export type DesktopAppRestartReason =
44
44
  | "windows_only"
45
45
  | "package_discovery_failed"
46
+ | "process_probe_failed"
46
47
  | "no_targets"
47
48
  | "self_ancestry"
48
49
  | "targets_survived";
@@ -119,7 +120,7 @@ interface DesktopProcess {
119
120
  function listPackageProcesses(
120
121
  exec: NonNullable<DesktopAppRestartIo["execFile"]>,
121
122
  installLocation: string,
122
- ): DesktopProcess[] {
123
+ ): DesktopProcess[] | null {
123
124
  const literal = installLocation.replace(/'/g, "''");
124
125
  const script = [
125
126
  "$ErrorActionPreference='SilentlyContinue'",
@@ -136,7 +137,10 @@ function listPackageProcesses(
136
137
  " }",
137
138
  " }",
138
139
  " }",
139
- ].join(" ");
140
+ // Statements must be newline-separated. Joining with a space concatenates
141
+ // `$ErrorActionPreference='SilentlyContinue' $root = '...'` into one malformed statement,
142
+ // which PowerShell rejects — so the probe threw and every caller read "not running" (#2557).
143
+ ].join("\n");
140
144
  let stdout: string;
141
145
  try {
142
146
  stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], {
@@ -144,7 +148,10 @@ function listPackageProcesses(
144
148
  windowsHide: true,
145
149
  });
146
150
  } catch {
147
- return [];
151
+ // A probe that could not run is NOT proof the app is absent. Returning [] here made a
152
+ // failed enumeration indistinguishable from "no targets", so the CLI reported the app as
153
+ // not running and skipped a restart the user had explicitly asked for.
154
+ return null;
148
155
  }
149
156
  const processes: DesktopProcess[] = [];
150
157
  for (const line of stdout.split(/\r?\n/)) {
@@ -170,8 +177,11 @@ function stillSameProcess(
170
177
  installLocation: string,
171
178
  target: DesktopProcess,
172
179
  ): boolean {
173
- const current = listPackageProcesses(exec, installLocation)
174
- .find(p => p.pid === target.pid);
180
+ const processes = listPackageProcesses(exec, installLocation);
181
+ // Fail CLOSED on a failed re-probe: this guards a kill, and "we could not look" must not be
182
+ // read as "the pid was recycled and is now someone else's process".
183
+ if (processes === null) return false;
184
+ const current = processes.find(p => p.pid === target.pid);
175
185
  return current !== undefined && current.createdAt === target.createdAt;
176
186
  }
177
187
 
@@ -266,6 +276,9 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp
266
276
  if (!pkg) return skipped("package_discovery_failed");
267
277
 
268
278
  const processes = listPackageProcesses(exec, pkg.installLocation);
279
+ // A probe that could not run is not evidence of absence. Reporting it as `no_targets` told
280
+ // the user the app was not running and silently skipped the restart they asked for (#2557).
281
+ if (processes === null) return skipped("process_probe_failed");
269
282
  const roots = rootProcesses(processes);
270
283
  if (roots.length === 0) return skipped("no_targets");
271
284
 
@@ -11,7 +11,12 @@ import { atomicWriteFile } from "../config";
11
11
  import type { CodexWriteLockResult } from "./codex-write-lock";
12
12
  import { inspectCodexCoordinatorPath } from "./coordinator-doctor";
13
13
  import { JOURNAL_PATH } from "./journal";
14
+ import { updateIntegrationRecord } from "./integration-record";
14
15
  import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
16
+ import type {
17
+ CodexArtifactId,
18
+ CodexProvenanceEntry,
19
+ } from "./convergence-types";
15
20
  import {
16
21
  codexWriteCoordination,
17
22
  type CodexWriteCandidate,
@@ -41,6 +46,7 @@ export const DEFAULT_INJECT_LOCK_TIMEOUT_MS = 5_000;
41
46
  */
42
47
  export type CodexWriteCoordinationEligibility =
43
48
  | { kind: "coordinated" }
49
+ | { kind: "adopt" }
44
50
  | { kind: "legacy-uncoordinated"; reason: string }
45
51
  | { kind: "refused"; reason: string };
46
52
 
@@ -97,6 +103,7 @@ export function codexWriteCoordinationEligibility(deps: {
97
103
 
98
104
  const residue = deps.residue();
99
105
  if (residue.kind === "clean") return { kind: "coordinated" };
106
+ if (residue.kind === "residue" && !coordinatorIsStableZeroByte) return { kind: "adopt" };
100
107
  /*
101
108
  * Everything else keeps the path it has always had.
102
109
  *
@@ -176,6 +183,82 @@ export function captureCodexPreImages(): CodexPreImages {
176
183
  };
177
184
  }
178
185
 
186
+ /**
187
+ * How many transactions of evidence the ledger keeps.
188
+ *
189
+ * Each transaction appends three entries, and a `present` baseline embeds the artifact's exact
190
+ * bytes as base64 — a 25 KB `config.toml` is ~34 KB per entry, so roughly 100 KB per transaction.
191
+ * A machine that syncs on every start would grow this file without limit, and it is re-read and
192
+ * re-serialized on every append, so the cost is quadratic rather than merely large.
193
+ *
194
+ * A ledger is evidence, not an archive. The most recent transactions are the ones anyone
195
+ * diagnoses against, so the window keeps those and drops the oldest.
196
+ */
197
+ export const CODEX_PROVENANCE_MAX_TRANSACTIONS = 16;
198
+
199
+ function provenanceBaseline(bytes: string | null): CodexProvenanceEntry["baseline"] {
200
+ if (bytes === null) return { kind: "absent" };
201
+ return {
202
+ kind: "present",
203
+ sha256: createHash("sha256").update(bytes).digest("hex"),
204
+ bytesBase64: Buffer.from(bytes).toString("base64"),
205
+ };
206
+ }
207
+
208
+ function provenancePostImage(path: string): string | null {
209
+ try {
210
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Keep the newest `CODEX_PROVENANCE_MAX_TRANSACTIONS` transactions, whole.
218
+ *
219
+ * Trimming by ENTRY count would cut a transaction in half and leave evidence that says a
220
+ * transaction touched two artifacts when it touched three — worse than dropping it outright,
221
+ * because a partial record still reads as complete. Order is preserved; only whole leading
222
+ * transactions are removed.
223
+ */
224
+ export function boundProvenanceEntries(
225
+ entries: readonly CodexProvenanceEntry[],
226
+ maxTransactions = CODEX_PROVENANCE_MAX_TRANSACTIONS,
227
+ ): readonly CodexProvenanceEntry[] {
228
+ const order: string[] = [];
229
+ for (const entry of entries) if (!order.includes(entry.txId)) order.push(entry.txId);
230
+ if (order.length <= maxTransactions) return entries;
231
+ const keep = new Set(order.slice(order.length - maxTransactions));
232
+ return entries.filter(entry => keep.has(entry.txId));
233
+ }
234
+
235
+ /** Append evidence for an already-committed native transaction, best-effort. */
236
+ export function recordCodexNativeTransactionProvenance(
237
+ preImages: CodexPreImages,
238
+ txId: string,
239
+ ) {
240
+ const at = new Date().toISOString();
241
+ const surfaces: readonly [CodexArtifactId, string, string | null][] = [
242
+ [{ kind: "config" }, CODEX_CONFIG_PATH, preImages.config],
243
+ [{ kind: "generated-profile" }, CODEX_PROFILE_PATH, preImages.profile],
244
+ [{ kind: "injection-journal" }, JOURNAL_PATH, preImages.journal],
245
+ ];
246
+ const entries: readonly CodexProvenanceEntry[] = surfaces.map(([artifact, path, baseline]) => ({
247
+ artifact,
248
+ baseline: provenanceBaseline(baseline),
249
+ postImage: provenancePostImage(path),
250
+ txId,
251
+ at,
252
+ }));
253
+ return updateIntegrationRecord(record => ({
254
+ ...record,
255
+ provenance: {
256
+ ...record.provenance,
257
+ entries: boundProvenanceEntries([...(record.provenance?.entries ?? []), ...entries]),
258
+ },
259
+ }));
260
+ }
261
+
179
262
  /**
180
263
  * Put back exactly what was there, and report honestly when that fails.
181
264
  *
@@ -19,6 +19,7 @@ import {
19
19
  CodexWriteConflictError,
20
20
  DEFAULT_INJECT_LOCK_TIMEOUT_MS,
21
21
  recomputeInjectWitness,
22
+ recordCodexNativeTransactionProvenance,
22
23
  restoreCodexPreImages,
23
24
  } from "./inject-coordination";
24
25
  import { readIntegrationRecord } from "./integration-record";
@@ -953,6 +954,7 @@ export async function injectCodexConfig(
953
954
  const coordinated = await withCodexWriteLock(
954
955
  {
955
956
  timeoutMs: options.lockTimeoutMs ?? DEFAULT_INJECT_LOCK_TIMEOUT_MS,
957
+ ...(eligibility.kind === "adopt" ? { adoption: { direction: "apply" as const } } : {}),
956
958
  admitted: { authoritySnapshotId: witness.comparisonId },
957
959
  readAdmissionUnderLock: () => ({
958
960
  authoritySnapshotId: recomputeInjectWitness({
@@ -1019,6 +1021,7 @@ export async function injectCodexConfig(
1019
1021
  }
1020
1022
  return {
1021
1023
  kind: "applied" as const,
1024
+ preImages,
1022
1025
  /*
1023
1026
  * The receipt the terminal update matches on. The transition commits
1024
1027
  * when the callback returns, so this pair is what the post-job
@@ -1036,6 +1039,10 @@ export async function injectCodexConfig(
1036
1039
  if (coordinated.status !== "acquired") {
1037
1040
  return codexInjectLockOutcome(coordinated);
1038
1041
  }
1042
+ recordCodexNativeTransactionProvenance(
1043
+ coordinated.value.preImages,
1044
+ coordinated.value.receipt.currentTxId,
1045
+ );
1039
1046
  transitionReceipt = coordinated.value.receipt;
1040
1047
  }
1041
1048
  // Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs
@@ -1547,13 +1554,14 @@ export async function restoreNativeCodexAsync(
1547
1554
  let config: CodexRestoreConfigResult;
1548
1555
  let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
1549
1556
 
1550
- if (eligibility.kind === "coordinated") {
1557
+ if (eligibility.kind === "coordinated" || eligibility.kind === "adopt") {
1551
1558
  // The restore has no candidate bytes to witness; freshness comes from the
1552
1559
  // filesystem reads and the desired-state re-read performed under the lock.
1553
1560
  const witness = { authoritySnapshotId: "codex-native-restore" };
1554
1561
  const coordinated = await withCodexWriteLock(
1555
1562
  {
1556
1563
  timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS,
1564
+ ...(eligibility.kind === "adopt" ? { adoption: { direction: "remove" as const } } : {}),
1557
1565
  admitted: witness,
1558
1566
  readAdmissionUnderLock: () => witness,
1559
1567
  },
@@ -1589,6 +1597,7 @@ export async function restoreNativeCodexAsync(
1589
1597
  }
1590
1598
  return {
1591
1599
  config: restored,
1600
+ preImages,
1592
1601
  receipt: {
1593
1602
  nativeGeneration: ctx.expectation.nativeAfter,
1594
1603
  currentTxId: ctx.expectation.txId,
@@ -1607,6 +1616,10 @@ export async function restoreNativeCodexAsync(
1607
1616
  : `Codex configuration was not restored: ${coordinated.message}`,
1608
1617
  };
1609
1618
  } else {
1619
+ recordCodexNativeTransactionProvenance(
1620
+ coordinated.value.preImages,
1621
+ coordinated.value.receipt.currentTxId,
1622
+ );
1610
1623
  config = coordinated.value.config;
1611
1624
  transitionReceipt = coordinated.value.receipt;
1612
1625
  }
@@ -11,6 +11,9 @@ import {
11
11
 
12
12
  const IMMUTABLE_READONLY_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
13
13
  const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]);
14
+ // The issue reporter measured a ~1 GB database taking 17.3s for GROUP BY level
15
+ // alone; skipping all row aggregates above 64 MiB reduced /api/storage to 628ms.
16
+ const MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES = 64 * 1024 * 1024;
14
17
 
15
18
  interface CurrentLogColumn {
16
19
  name: string;
@@ -115,6 +118,10 @@ export interface CodexLogGuardInspection {
115
118
  reclaim: CodexLogGuardCapability;
116
119
  };
117
120
  metrics: CodexLogGuardMetrics | null;
121
+ metricsSkipped: null | {
122
+ reason: "database_too_large";
123
+ thresholdBytes: number;
124
+ };
118
125
  }
119
126
 
120
127
  interface ColumnRow {
@@ -167,9 +174,8 @@ function fileSize(path: string): number {
167
174
  * repeated stalls without ever serving stale numbers: any write changes the WAL
168
175
  * and invalidates the entry.
169
176
  *
170
- * This bounds the repeat cost, not the first one. A cold inspection of a huge
171
- * database still blocks; moving that work off-thread needs a Worker and is
172
- * tracked separately.
177
+ * Memoization bounds repeat cost. The database-size gate below separately bounds
178
+ * cold request-thread work by omitting these aggregates for large databases.
173
179
  */
174
180
  type InspectionCacheEntry = {
175
181
  key: string;
@@ -233,6 +239,7 @@ function unavailableInspection(): CodexLogGuardInspection {
233
239
  reclaim: unavailable,
234
240
  },
235
241
  metrics: null,
242
+ metricsSkipped: null,
236
243
  };
237
244
  }
238
245
 
@@ -425,6 +432,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard
425
432
  ...common,
426
433
  schema,
427
434
  metrics: null,
435
+ metricsSkipped: null,
428
436
  capabilities: {
429
437
  inspection: { state: "supported" },
430
438
  protection: mutation,
@@ -440,6 +448,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard
440
448
  ...common,
441
449
  schema,
442
450
  metrics: null,
451
+ metricsSkipped: null,
443
452
  capabilities: {
444
453
  inspection: { state: "supported" },
445
454
  protection: mutation,
@@ -458,6 +467,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard
458
467
  ...common,
459
468
  schema,
460
469
  metrics: null,
470
+ metricsSkipped: null,
461
471
  capabilities: {
462
472
  inspection: { state: "supported" },
463
473
  protection: mutation,
@@ -476,10 +486,17 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard
476
486
  ? { state: "compatible" }
477
487
  : { state: "unsupported", reason: "unknown_schema" };
478
488
  const mutation = capabilityFor(schema);
489
+ const metricsSkipped = files.databaseBytes > MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES
490
+ ? {
491
+ reason: "database_too_large" as const,
492
+ thresholdBytes: MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES,
493
+ }
494
+ : null;
479
495
  return {
480
496
  ...common,
481
497
  schema,
482
- metrics: readMetrics(db, columns),
498
+ metrics: metricsSkipped === null ? readMetrics(db, columns) : null,
499
+ metricsSkipped,
483
500
  capabilities: {
484
501
  inspection: { state: "supported" },
485
502
  protection: mutation,
@@ -496,6 +513,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard
496
513
  ...common,
497
514
  schema,
498
515
  metrics: null,
516
+ metricsSkipped: null,
499
517
  capabilities: {
500
518
  inspection: { state: "supported" },
501
519
  protection: mutation,
@@ -40,6 +40,10 @@ export interface CodexModelEntitlementResolveOptions {
40
40
  readonly now?: number;
41
41
  /** Test-only credential seam; production callers enumerate local main + Pool credentials. */
42
42
  readonly credentials?: readonly CodexModelEntitlementCredentialSnapshot[];
43
+ /** Test-only seam for proving lifecycle exclusions happen before credential reads. */
44
+ readonly credentialSnapshot?: typeof accountCredentialSnapshot;
45
+ /** Accounts whose credentials must not be read while another lifecycle owns them. */
46
+ readonly excludeAccountIds?: ReadonlySet<string>;
43
47
  }
44
48
 
45
49
  const accountModelsCache = new Map<string, CachedAccountModels>();
@@ -252,9 +256,12 @@ export async function resolveCodexModelEntitlements(
252
256
  ): Promise<CodexModelEntitlementSnapshot> {
253
257
  const now = options.now ?? Date.now();
254
258
  const fetcher = options.fetcher ?? fetch;
259
+ const allowedAccountIds = candidateAccountIds(config)
260
+ .filter(accountId => !options.excludeAccountIds?.has(accountId));
261
+ const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
255
262
  const credentials = options.credentials
256
- ? [...options.credentials]
257
- : (await Promise.all(candidateAccountIds(config).map(accountCredentialSnapshot)))
263
+ ? [...options.credentials].filter(credential => !options.excludeAccountIds?.has(credential.accountId))
264
+ : (await Promise.all(allowedAccountIds.map(credentialSnapshot)))
258
265
  .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
259
266
  const results = await Promise.all(credentials.map(async credential => ({
260
267
  credential,