@yansigit/opencodex 2.31.2 → 2.32.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 (118) hide show
  1. package/bin/ocx.mjs +99 -70
  2. package/gui/dist/assets/index-BG43zwVe.js +102 -0
  3. package/gui/dist/assets/index-CiSI-jrP.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +6 -8
  7. package/src/adapters/base.ts +2 -0
  8. package/src/adapters/command-code-project-context.ts +377 -0
  9. package/src/adapters/command-code.ts +7 -4
  10. package/src/adapters/cursor/live-models.ts +8 -0
  11. package/src/adapters/cursor/live-transport.ts +21 -0
  12. package/src/adapters/cursor/native-exec-bridge.ts +141 -0
  13. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  14. package/src/adapters/cursor/tool-definitions.ts +1 -1
  15. package/src/adapters/google-http.ts +12 -2
  16. package/src/adapters/google-wire-compiler.ts +83 -2
  17. package/src/adapters/google.ts +62 -21
  18. package/src/adapters/kiro.ts +0 -3
  19. package/src/adapters/openai-responses.ts +3 -0
  20. package/src/adapters/tool-catalog-nudge.ts +1 -1
  21. package/src/adapters/xai-web-search.ts +7 -2
  22. package/src/bridge.ts +21 -15
  23. package/src/cli/dispatch.ts +50 -2
  24. package/src/cli/doctor.ts +24 -11
  25. package/src/cli/help.ts +4 -3
  26. package/src/cli/index.ts +11 -4
  27. package/src/cli/models.ts +13 -3
  28. package/src/cli/observe.ts +20 -5
  29. package/src/cli/provider.ts +2 -1
  30. package/src/cli/registry.ts +7 -5
  31. package/src/cli/status.ts +2 -1
  32. package/src/cli/system-restart-client.ts +1 -1
  33. package/src/cli/usage-report.ts +134 -0
  34. package/src/codex/app-server-processes.ts +3 -1
  35. package/src/codex/catalog/aggregation.ts +13 -1
  36. package/src/codex/catalog/effort.ts +24 -9
  37. package/src/codex/catalog/model-metadata.ts +566 -0
  38. package/src/codex/catalog/parsing.ts +33 -0
  39. package/src/codex/catalog/provider-fetch.ts +123 -32
  40. package/src/codex/catalog/sync.ts +5 -4
  41. package/src/codex/desktop-app-restart.ts +342 -0
  42. package/src/codex/history-job.ts +32 -3
  43. package/src/codex/history-manifest.ts +112 -0
  44. package/src/codex/history-migration-guardian.ts +5 -5
  45. package/src/codex/history-provider.ts +825 -247
  46. package/src/codex/history-worker.ts +8 -5
  47. package/src/codex/inject.ts +49 -21
  48. package/src/codex/injected-marker.ts +1 -1
  49. package/src/codex/internal/history-writer.ts +4 -3
  50. package/src/codex/native-profile-startup.ts +157 -27
  51. package/src/codex/native-residue.ts +26 -33
  52. package/src/combos/failover.ts +27 -0
  53. package/src/compatibility/index.ts +26 -0
  54. package/src/compatibility/manifest.ts +253 -0
  55. package/src/compatibility/openai-responses.ts +81 -0
  56. package/src/config/atomic-write.ts +219 -0
  57. package/src/config/paths.ts +40 -0
  58. package/src/config/process-state.ts +308 -0
  59. package/src/config/provider-validation.ts +177 -0
  60. package/src/config.ts +77 -812
  61. package/src/generated/compatibility-version.json +162 -94
  62. package/src/images/plan.ts +5 -4
  63. package/src/integrations/ownership-policy.ts +141 -0
  64. package/src/integrations/ownership.ts +10 -0
  65. package/src/integrations/state.ts +44 -5
  66. package/src/integrations/writer.ts +6 -0
  67. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  68. package/src/lib/bounded-body.ts +14 -2
  69. package/src/lib/process-control.ts +2 -1
  70. package/src/lib/state-store-registrations.ts +2 -0
  71. package/src/lib/tool-argument-integers.ts +56 -5
  72. package/src/oauth/health.ts +1 -1
  73. package/src/oauth/index.ts +3 -0
  74. package/src/providers/registry.ts +1 -1
  75. package/src/reasoning-effort.ts +19 -2
  76. package/src/responses/apply-patch-envelope.ts +63 -0
  77. package/src/responses/custom-tool-compat.ts +132 -38
  78. package/src/responses/parser.ts +3 -2
  79. package/src/responses/reasoning-replay-cache.ts +81 -3
  80. package/src/routing/compatibility/behavior.ts +3 -0
  81. package/src/server/auth-cors.ts +9 -7
  82. package/src/server/index.ts +102 -21
  83. package/src/server/local-management-read-client.ts +1 -1
  84. package/src/server/local-provider-reload-client.ts +1 -1
  85. package/src/server/management/agent-settings-routes.ts +1 -1
  86. package/src/server/management/config-routes.ts +4 -1
  87. package/src/server/management/context.ts +1 -1
  88. package/src/server/management/logs-usage-routes.ts +27 -6
  89. package/src/server/management/model-routes.ts +8 -4
  90. package/src/server/management/native-integration-routes.ts +2 -1
  91. package/src/server/management/provider-capability-config.ts +1 -1
  92. package/src/server/management/system-restart.ts +1 -1
  93. package/src/server/port-reclaim.ts +1 -1
  94. package/src/server/proxy-liveness.ts +2 -1
  95. package/src/server/request-log-conversation.ts +30 -0
  96. package/src/server/responses/codex-auth-error.ts +55 -0
  97. package/src/server/responses/combo-stream-preflight.ts +171 -0
  98. package/src/server/responses/compact.ts +6 -21
  99. package/src/server/responses/core.ts +242 -101
  100. package/src/server/responses/fetch-helpers.ts +2 -97
  101. package/src/server/responses-custom-tool-repair.ts +41 -5
  102. package/src/server/responses-undeclared-tool-guard.ts +156 -15
  103. package/src/service.ts +8 -4
  104. package/src/types/provider.ts +7 -0
  105. package/src/types/request.ts +12 -1
  106. package/src/types/tools.ts +87 -11
  107. package/src/types.ts +1 -1
  108. package/src/update/index.ts +5 -4
  109. package/src/update/job.ts +3 -1
  110. package/src/update/transactional-install.mjs +8 -1
  111. package/src/usage/log.ts +16 -8
  112. package/src/usage/summary.ts +201 -8
  113. package/src/vision/describe.ts +18 -13
  114. package/src/web-search/executor.ts +10 -3
  115. package/src/web-search/gemini-executor.ts +35 -13
  116. package/src/web-search/index.ts +85 -1
  117. package/gui/dist/assets/index-BAMgarF9.js +0 -102
  118. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
@@ -75,6 +75,7 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr
75
75
 
76
76
  import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
77
77
  import type { CatalogModel } from "./parsing";
78
+ import { capsFromProvider, enrichCatalogModelMetadata, persistLiveModelMetadata, type LiveSnapshotRow } from "./model-metadata";
78
79
  import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
79
80
  import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
80
81
  import type { ComboCatalogOmission } from "./aggregation";
@@ -662,9 +663,16 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
662
663
  const hintedWindow = discoveredWindow !== undefined
663
664
  ? (configuredCap !== undefined ? Math.min(discoveredWindow, configuredCap) : discoveredWindow)
664
665
  : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined));
666
+ const capFilledMissingWindow = discoveredWindow === undefined && hintedWindow !== undefined;
665
667
  const hinted = {
666
668
  ...modelWithoutServiceTier,
667
669
  ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}),
670
+ ...(discoveredWindow !== undefined
671
+ ? { detectedContextWindow: model.detectedContextWindow ?? discoveredWindow }
672
+ : {}),
673
+ ...(capFilledMissingWindow && !model.metadataSource
674
+ ? { metadataSource: "config_fallback" as const }
675
+ : {}),
668
676
  ...(inputModalities ? { inputModalities } : {}),
669
677
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
670
678
  ...(configuredMaxInput !== undefined
@@ -689,10 +697,26 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
689
697
  ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}),
690
698
  };
691
699
  const capped = applyProviderContextCap(hinted.contextWindow, providerCap);
700
+ const detectedContextWindow = hinted.detectedContextWindow
701
+ ?? discoveredWindow;
702
+ const contextCapped = providerCap !== undefined
703
+ && typeof capped === "number"
704
+ && (
705
+ capped !== hinted.contextWindow
706
+ || (typeof detectedContextWindow === "number" && detectedContextWindow > capped)
707
+ || hinted.contextCapped === true
708
+ );
692
709
  if (providerCap !== undefined && capped !== hinted.contextWindow) {
693
- return { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true };
710
+ return {
711
+ ...hinted,
712
+ contextWindow: capped,
713
+ contextCap: providerCap,
714
+ contextCapped,
715
+ };
694
716
  }
695
- return providerCap !== undefined ? { ...hinted, contextCap: providerCap, contextCapped: false } : hinted;
717
+ return providerCap !== undefined
718
+ ? { ...hinted, contextCap: providerCap, contextCapped }
719
+ : hinted;
696
720
  }
697
721
 
698
722
  export function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string, contextCap?: number): Partial<CatalogModel> {
@@ -1043,15 +1067,35 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
1043
1067
  item.context_size,
1044
1068
  item.max_model_len,
1045
1069
  item.max_context_length,
1070
+ item.context_window,
1071
+ item.max_context_window,
1072
+ item.max_context_size,
1073
+ item.n_ctx,
1074
+ plainRecord(item.top_provider)?.max_context_length,
1075
+ plainRecord(metadata?.top_provider)?.max_context_length,
1046
1076
  // llama.cpp reports the served context under `meta`: `n_ctx` is what the
1047
1077
  // server was actually started with, `n_ctx_train` the model's trained
1048
1078
  // maximum. Prefer the served value — routing must not promise a window the
1049
1079
  // running server will refuse. Both come LAST so no provider already
1050
1080
  // supplying a recognized field changes behavior (#1797).
1051
1081
  plainRecord(item.meta)?.n_ctx,
1082
+ item.default_context_size,
1052
1083
  plainRecord(item.meta)?.n_ctx_train,
1053
1084
  );
1054
- const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
1085
+ const maxInputTokens = positiveSafeInteger(
1086
+ limits?.max_input_tokens,
1087
+ item.max_input_tokens,
1088
+ item.max_input_length,
1089
+ item.max_prompt_tokens,
1090
+ );
1091
+ // Output ceilings only. A top-level `max_tokens` is ambiguous (some catalogs
1092
+ // use it as context) and generated jawcode `maxTokens` is output-only — never
1093
+ // treat either as a context window.
1094
+ const maxOutputTokens = positiveSafeInteger(
1095
+ limits?.max_output_tokens,
1096
+ item.max_output_tokens,
1097
+ limits?.max_tokens,
1098
+ );
1055
1099
  // Some OpenAI-compatible catalogs expose the selectable ladder under
1056
1100
  // `reasoning_parameters.efforts` instead of the older `reasoning_efforts` key.
1057
1101
  // Treat both as model metadata: otherwise a valid upstream capability disappears
@@ -1079,6 +1123,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
1079
1123
  return {
1080
1124
  ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
1081
1125
  ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}),
1126
+ ...(maxOutputTokens && maxOutputTokens > 0 ? { maxOutputTokens } : {}),
1082
1127
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
1083
1128
  ...(inputModalities ? { inputModalities } : {}),
1084
1129
  ...(capabilities ? { capabilities } : {}),
@@ -1283,9 +1328,13 @@ async function fetchProviderModelsWithAuth(
1283
1328
  const stale = getStaleCached(name);
1284
1329
  return observed(
1285
1330
  withConfiguredRetention(
1286
- stale
1331
+ (stale
1287
1332
  ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
1288
- : failedDiscoveryConfigured,
1333
+ : failedDiscoveryConfigured
1334
+ ).map(model => enrichCatalogModelMetadata(model, {
1335
+ liveFresh: false,
1336
+ caps: capsFromProvider(prov, model.id, contextCap),
1337
+ })),
1289
1338
  ),
1290
1339
  "degraded",
1291
1340
  );
@@ -1312,12 +1361,14 @@ async function fetchProviderModelsWithAuth(
1312
1361
  markModelsFetchFailure(name);
1313
1362
  markProviderDiscoveryFailed(name, failure);
1314
1363
  const stale = getStaleCached(name);
1364
+ const fallbackModels = stale
1365
+ ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
1366
+ : failedDiscoveryConfigured;
1315
1367
  return {
1316
- models: withConfiguredRetention(
1317
- stale
1318
- ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
1319
- : failedDiscoveryConfigured,
1320
- ),
1368
+ models: withConfiguredRetention(fallbackModels.map(model => enrichCatalogModelMetadata(model, {
1369
+ liveFresh: false,
1370
+ caps: capsFromProvider(prov, model.id, contextCap),
1371
+ }))),
1321
1372
  fallback: stale ? "stale" : "configured",
1322
1373
  shouldLog,
1323
1374
  };
@@ -1384,19 +1435,40 @@ async function fetchProviderModelsWithAuth(
1384
1435
  return observed(models, "degraded");
1385
1436
  }
1386
1437
  if (antigravity) {
1387
- const live = antigravity.map(model => applyProviderConfigHints(name, prov, {
1388
- id: model.id,
1389
- provider: name,
1390
- // CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex
1391
- // effort-to-wire mapping for a newly discovered model, do not advertise a false ladder.
1392
- reasoningEfforts: [],
1393
- ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
1394
- ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}),
1395
- }, contextCap));
1438
+ const observedAt = new Date().toISOString();
1439
+ const snapshotRows: LiveSnapshotRow[] = [];
1440
+ const live = antigravity.map(model => {
1441
+ const hints = {
1442
+ // CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex
1443
+ // effort-to-wire mapping for a newly discovered model, do not advertise a false ladder.
1444
+ reasoningEfforts: [] as string[],
1445
+ ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
1446
+ ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}),
1447
+ };
1448
+ if (model.contextWindow || model.inputModalities) {
1449
+ snapshotRows.push({
1450
+ id: model.id,
1451
+ ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
1452
+ ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}),
1453
+ observedAt,
1454
+ });
1455
+ }
1456
+ const enriched = enrichCatalogModelMetadata({
1457
+ id: model.id,
1458
+ provider: name,
1459
+ ...hints,
1460
+ ...(model.contextWindow ? { metadataSource: "live" as const, metadataObservedAt: observedAt } : {}),
1461
+ }, {
1462
+ liveFresh: true,
1463
+ caps: capsFromProvider(prov, model.id, contextCap),
1464
+ });
1465
+ return applyProviderConfigHints(name, prov, enriched, contextCap);
1466
+ });
1396
1467
  const forCache = withConfiguredRetention(live, { retainComboTargets: false });
1397
1468
  if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
1398
1469
  return observed(withConfiguredRetention(configured), "degraded");
1399
1470
  }
1471
+ persistLiveModelMetadata(name, snapshotRows, { writerGeneration: cacheGeneration, observedAt });
1400
1472
  registerAntigravityDiscoveredWireModels(prov.baseUrl, antigravity, {
1401
1473
  provider: name,
1402
1474
  cacheGeneration,
@@ -1421,22 +1493,30 @@ async function fetchProviderModelsWithAuth(
1421
1493
  return observed(models, "degraded");
1422
1494
  }
1423
1495
  const items = extracted.items;
1496
+ const observedAt = new Date().toISOString();
1497
+ const snapshotRows: LiveSnapshotRow[] = [];
1424
1498
  const live = items.map(m => {
1425
1499
  const ownedBy = boundedOwnedBy(m.owned_by);
1426
- return applyProviderConfigHints(name, prov, {
1500
+ const hints = catalogHintsFromModelsApiItem(name, m);
1501
+ const discovered = Boolean(hints.contextWindow || hints.maxInputTokens || hints.maxOutputTokens
1502
+ || hints.inputModalities || hints.capabilities || hints.reasoningEfforts);
1503
+ if (discovered) {
1504
+ snapshotRows.push({ id: m.id, ...hints, observedAt });
1505
+ }
1506
+ const enriched = enrichCatalogModelMetadata({
1427
1507
  id: m.id,
1428
1508
  provider: name,
1429
1509
  ...(ownedBy ? { owned_by: ownedBy } : {}),
1430
- ...catalogHintsFromModelsApiItem(name, m),
1431
- }, contextCap);
1510
+ ...hints,
1511
+ ...(discovered ? { metadataSource: "live" as const, metadataObservedAt: observedAt } : {}),
1512
+ }, {
1513
+ liveFresh: true,
1514
+ caps: capsFromProvider(prov, m.id, contextCap),
1515
+ });
1516
+ return applyProviderConfigHints(name, prov, enriched, contextCap);
1432
1517
  })
1433
1518
  .filter(m => shouldExposeProviderModel(name, m.id));
1434
- // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into
1435
- // `live`; otherwise configured entries would be reported as discovered ones.
1436
1519
  const liveModelCount = live.length;
1437
- // Dated-release aliases + configured retention (compat allow-list, combo targets,
1438
- // Vertex default). Cache without combo retention so a later gather re-applies the
1439
- // current capture's retain set on read (warm-cache OCX-111 / #1308).
1440
1520
  const forCache = withConfiguredRetention(live, { retainComboTargets: false });
1441
1521
  const returned = withConfiguredRetention(forCache, { warnDrops: true });
1442
1522
  const droppedConfiguredIds = configured
@@ -1450,6 +1530,7 @@ async function fetchProviderModelsWithAuth(
1450
1530
  if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
1451
1531
  return observed(withConfiguredRetention(configured), "degraded");
1452
1532
  }
1533
+ persistLiveModelMetadata(name, snapshotRows, { writerGeneration: cacheGeneration, observedAt });
1453
1534
  markProviderDiscoveryOk(name, liveModelCount);
1454
1535
  return observed(returned, "authoritative");
1455
1536
  } catch (error) {
@@ -2035,7 +2116,14 @@ export function augmentRoutedModelsWithMetadata(
2035
2116
  providers?: Record<string, OcxProviderConfig>,
2036
2117
  caps?: Pick<OcxConfig, "providerContextCaps">,
2037
2118
  ): CatalogModel[] {
2038
- const out = [...models];
2119
+ const out = models.map((model) => {
2120
+ const provider = providers?.[model.provider];
2121
+ const contextCap = caps ? providerContextCap(caps, model.provider) : undefined;
2122
+ return enrichCatalogModelMetadata(model, {
2123
+ liveFresh: model.metadataSource === "live" && model.metadataStale !== true,
2124
+ caps: capsFromProvider(provider, model.id, contextCap),
2125
+ });
2126
+ });
2039
2127
  const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
2040
2128
  for (const provider of providerNames) {
2041
2129
  if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
@@ -2054,10 +2142,13 @@ export function augmentRoutedModelsWithMetadata(
2054
2142
  ...(typeof meta.contextWindow === "number" && meta.contextWindow > 0 ? { contextWindow: meta.contextWindow } : {}),
2055
2143
  ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}),
2056
2144
  };
2057
- out.push({
2058
- ...model,
2059
- ...(providers?.[provider] ? applyProviderConfigHints(provider, providers[provider], model, contextCap) : {}),
2060
- });
2145
+ const hinted = providers?.[provider]
2146
+ ? applyProviderConfigHints(provider, providers[provider], model, contextCap)
2147
+ : model;
2148
+ out.push(enrichCatalogModelMetadata(hinted, {
2149
+ liveFresh: false,
2150
+ caps: capsFromProvider(providers?.[provider], meta.id, contextCap),
2151
+ }));
2061
2152
  }
2062
2153
  }
2063
2154
  return out;
@@ -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, 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, 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 {
@@ -1832,11 +1832,12 @@ export function invalidateCodexModelsCacheWithPermit(
1832
1832
  // The catalog-only sync override applies here too so an explicit refresh
1833
1833
  // keeps the cache consistent with the catalog it just wrote.
1834
1834
  if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
1835
- const catalogPath = readCodexCatalogPath();
1835
+ const catalogPath = readCodexCatalogPathForHome(owningCodexHome);
1836
+ const cachePath = join(owningCodexHome, "models_cache.json");
1836
1837
  if (!existsSync(catalogPath)) return false;
1837
1838
  const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
1838
1839
  const models = catalog.models ?? catalog;
1839
- const currentCache = readCatalog(activeCodexModelsCachePath());
1840
+ const currentCache = readCatalog(cachePath);
1840
1841
  const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
1841
1842
  typeof entry.slug === "string" ? [entry.slug] : []));
1842
1843
  const currentConfig = loadConfig();
@@ -1864,7 +1865,7 @@ export function invalidateCodexModelsCacheWithPermit(
1864
1865
  models: [...models, ...observedAccountModels],
1865
1866
  };
1866
1867
  replaceCodexModelsCache(permit, owningCodexHome, {
1867
- path: activeCodexModelsCachePath(),
1868
+ path: cachePath,
1868
1869
  content: `${JSON.stringify(wrapper, null, 2)}\n`,
1869
1870
  });
1870
1871
  return true;
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Full restart of the Codex desktop app (the Electron shell), Windows only.
3
+ *
4
+ * `--restart-codex` deliberately signals only `codex app-server` /
5
+ * `codex-code-mode-host` processes: `isCodexAppServerCommandLine` requires a
6
+ * `codex` executable token, so the shell that owns the model picker is never a
7
+ * match. On macOS that is enough, because the respawned app-server re-emits
8
+ * `codex-app-server-initialized` and the renderer drops its cached
9
+ * `model/list`. On Windows MSIX it is not: externally terminating the child
10
+ * does not reliably re-emit that event in the surviving shell, so the picker
11
+ * keeps showing the old catalog until the app itself is restarted (#2292).
12
+ *
13
+ * This is therefore a SEPARATE opt-in flag rather than a widening of
14
+ * `--restart-codex`. Quitting the desktop app ends live conversations, which is
15
+ * a different consent from restarting a background helper, and the CLI contract
16
+ * for `--restart-codex` promises the narrow behavior.
17
+ *
18
+ * Everything here fails CLOSED: if the package cannot be identified, if a
19
+ * target is part of our own ancestry, or if any target survives termination,
20
+ * nothing is relaunched and the caller is told to restart manually. A stale
21
+ * picker is a much smaller problem than a wrongly killed process.
22
+ */
23
+ import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsTaskkillExe } from "../lib/windows-elevation";
24
+ import { execFileSync } from "node:child_process";
25
+
26
+ /** Bounded subprocess options. A hung Appx/CIM probe must never wedge `ocx sync`. */
27
+ export interface DesktopAppExecOptions {
28
+ timeout?: number;
29
+ windowsHide?: boolean;
30
+ }
31
+
32
+ export interface DesktopAppRestartIo {
33
+ platform?: NodeJS.Platform;
34
+ /** Returns stdout. Options are part of the seam so the timeout is testable. */
35
+ execFile?: (file: string, args: readonly string[], options?: DesktopAppExecOptions) => string;
36
+ /** Process ancestry of the current process, innermost first. Used for the self-kill guard. */
37
+ ancestryPids?: () => number[];
38
+ isAlive?: (pid: number) => boolean;
39
+ sleep?: (ms: number) => void;
40
+ now?: () => number;
41
+ }
42
+
43
+ export type DesktopAppRestartReason =
44
+ | "windows_only"
45
+ | "package_discovery_failed"
46
+ | "no_targets"
47
+ | "self_ancestry"
48
+ | "targets_survived";
49
+
50
+ export interface DesktopAppRestartResult {
51
+ attempted: boolean;
52
+ stopped: number[];
53
+ surviving: number[];
54
+ relaunch: "started" | "skipped";
55
+ reason?: DesktopAppRestartReason;
56
+ }
57
+
58
+ /** How long a graceful close is given before the forced pass. */
59
+ const GRACEFUL_EXIT_TIMEOUT_MS = 15_000;
60
+ /** How long a forced kill is given before the target counts as surviving. */
61
+ const FORCED_EXIT_TIMEOUT_MS = 5_000;
62
+ /** Every probe is bounded; PowerShell module loading is the slow part. */
63
+ const PROBE_TIMEOUT_MS = 10_000;
64
+
65
+ interface DesktopPackage {
66
+ family: string;
67
+ installLocation: string;
68
+ aumid: string;
69
+ }
70
+
71
+ /**
72
+ * Runtime discovery, never a hardcoded identifier. The beta MSIX package family
73
+ * changes between builds, so a literal AUMID would silently stop matching and
74
+ * then either do nothing or — worse — match a package we did not mean.
75
+ */
76
+ function discoverPackage(exec: NonNullable<DesktopAppRestartIo["execFile"]>): DesktopPackage | null {
77
+ const script = [
78
+ "$ErrorActionPreference='SilentlyContinue'",
79
+ "Import-Module Appx -ErrorAction SilentlyContinue",
80
+ "$p = Get-AppxPackage -Name OpenAI.Codex",
81
+ "if (-not $p) { $p = Get-AppxPackage -Name OpenAI.CodexBeta }",
82
+ "if (-not $p -or -not $p.InstallLocation) { 'MISS' } else {",
83
+ " $p.PackageFamilyName; $p.InstallLocation; \"$($p.PackageFamilyName)!App\"",
84
+ "}",
85
+ ].join("; ");
86
+ let stdout: string;
87
+ try {
88
+ stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], {
89
+ timeout: PROBE_TIMEOUT_MS,
90
+ windowsHide: true,
91
+ });
92
+ } catch {
93
+ return null;
94
+ }
95
+ const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0);
96
+ if (lines.length < 3 || lines[0] === "MISS") return null;
97
+ const [family, installLocation, aumid] = lines;
98
+ if (!family || !installLocation || !aumid) return null;
99
+ return { family, installLocation, aumid };
100
+ }
101
+
102
+ interface DesktopProcess {
103
+ pid: number;
104
+ parentPid: number;
105
+ /** Win32_Process CreationDate. Guards against PID reuse across the wait window. */
106
+ createdAt: string;
107
+ }
108
+
109
+ /**
110
+ * Only `ChatGPT.exe` processes whose image lives under the discovered install
111
+ * location AND owned by the current user. The install location alone is not
112
+ * enough: an MSIX package under `WindowsApps` is shared, so on a multi-user
113
+ * machine another account's Codex desktop matches the same path. The app-server
114
+ * collector already pays for `GetOwner` for exactly this reason.
115
+ *
116
+ * `CreationDate` is captured so a PID can be re-verified before it is signalled;
117
+ * a graceful-close window is long enough for Windows to recycle a PID.
118
+ */
119
+ function listPackageProcesses(
120
+ exec: NonNullable<DesktopAppRestartIo["execFile"]>,
121
+ installLocation: string,
122
+ ): DesktopProcess[] {
123
+ const literal = installLocation.replace(/'/g, "''");
124
+ const script = [
125
+ "$ErrorActionPreference='SilentlyContinue'",
126
+ `$root = '${literal}'`,
127
+ "$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name",
128
+ "Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |",
129
+ " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |",
130
+ " ForEach-Object {",
131
+ " $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner",
132
+ " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {",
133
+ " $owner = if ($o.Domain) { \"$($o.Domain)\\$($o.User)\" } else { $o.User }",
134
+ " if ($owner -ieq $me) {",
135
+ " \"$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o'))\"",
136
+ " }",
137
+ " }",
138
+ " }",
139
+ ].join(" ");
140
+ let stdout: string;
141
+ try {
142
+ stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], {
143
+ timeout: PROBE_TIMEOUT_MS,
144
+ windowsHide: true,
145
+ });
146
+ } catch {
147
+ return [];
148
+ }
149
+ const processes: DesktopProcess[] = [];
150
+ for (const line of stdout.split(/\r?\n/)) {
151
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
152
+ if (!match) continue;
153
+ const pid = Number(match[1]);
154
+ const parentPid = Number(match[2]);
155
+ const createdAt = match[3]!;
156
+ if (Number.isSafeInteger(pid) && Number.isSafeInteger(parentPid)) {
157
+ processes.push({ pid, parentPid, createdAt });
158
+ }
159
+ }
160
+ return processes;
161
+ }
162
+
163
+ /**
164
+ * True when the PID still names the same process we verified. Between listing
165
+ * and signalling there is a graceful-close window, and a `taskkill /T /F` on a
166
+ * recycled PID would tear down an unrelated process tree.
167
+ */
168
+ function stillSameProcess(
169
+ exec: NonNullable<DesktopAppRestartIo["execFile"]>,
170
+ installLocation: string,
171
+ target: DesktopProcess,
172
+ ): boolean {
173
+ const current = listPackageProcesses(exec, installLocation)
174
+ .find(p => p.pid === target.pid);
175
+ return current !== undefined && current.createdAt === target.createdAt;
176
+ }
177
+
178
+ /** Roots are the package processes whose parent is not itself in the package tree. */
179
+ function rootProcesses(processes: readonly DesktopProcess[]): DesktopProcess[] {
180
+ const inTree = new Set(processes.map(p => p.pid));
181
+ return processes.filter(p => !inTree.has(p.parentPid));
182
+ }
183
+
184
+ /**
185
+ * Full Windows parent chain for this process, innermost first.
186
+ *
187
+ * `process.ppid` is one level, which is not enough: a terminal hosted inside the
188
+ * desktop app sits several hops below `ChatGPT.exe`, so a one-level check would
189
+ * miss the exact case the guard exists for and we would terminate our own host.
190
+ * The chain therefore comes from CIM, with a bound so a corrupted parent cycle
191
+ * cannot spin.
192
+ */
193
+ function windowsAncestryPids(exec: NonNullable<DesktopAppRestartIo["execFile"]>): number[] {
194
+ const chain: number[] = [process.pid];
195
+ let current = process.pid;
196
+ for (let hop = 0; hop < 16; hop++) {
197
+ let stdout: string;
198
+ try {
199
+ stdout = exec(resolveTrustedWindowsPowerShellExe(), [
200
+ "-NoProfile", "-NonInteractive", "-Command",
201
+ `$ErrorActionPreference='SilentlyContinue'; (Get-CimInstance Win32_Process -Filter "ProcessId=${current}").ParentProcessId`,
202
+ ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
203
+ } catch {
204
+ // An unreadable chain must not be read as "not our ancestor".
205
+ return [];
206
+ }
207
+ const parent = Number(stdout.trim());
208
+ if (!Number.isSafeInteger(parent) || parent <= 0 || chain.includes(parent)) break;
209
+ chain.push(parent);
210
+ current = parent;
211
+ }
212
+ return chain;
213
+ }
214
+
215
+ function defaultExecFile(file: string, args: readonly string[], options?: DesktopAppExecOptions): string {
216
+ return execFileSync(file, [...args], {
217
+ encoding: "utf-8",
218
+ timeout: options?.timeout ?? PROBE_TIMEOUT_MS,
219
+ windowsHide: options?.windowsHide ?? true,
220
+ });
221
+ }
222
+
223
+ function defaultIsAlive(pid: number): boolean {
224
+ try {
225
+ process.kill(pid, 0);
226
+ return true;
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ function defaultSleep(ms: number): void {
233
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
234
+ }
235
+
236
+ function waitForExit(
237
+ pid: number,
238
+ timeoutMs: number,
239
+ isAlive: (pid: number) => boolean,
240
+ sleep: (ms: number) => void,
241
+ now: () => number,
242
+ ): boolean {
243
+ const deadline = now() + timeoutMs;
244
+ while (now() < deadline) {
245
+ if (!isAlive(pid)) return true;
246
+ sleep(250);
247
+ }
248
+ return !isAlive(pid);
249
+ }
250
+
251
+ /**
252
+ * Stop every package-tree root gracefully, force the stragglers, then relaunch
253
+ * through the discovered AUMID. Returns without relaunching if anything
254
+ * survived, because launching a second shell beside a stuck one is worse than
255
+ * leaving the user to restart it.
256
+ */
257
+ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopAppRestartResult {
258
+ const platform = io.platform ?? process.platform;
259
+ const skipped = (reason: DesktopAppRestartReason): DesktopAppRestartResult => ({
260
+ attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason,
261
+ });
262
+ if (platform !== "win32") return skipped("windows_only");
263
+
264
+ const exec = io.execFile ?? defaultExecFile;
265
+ const pkg = discoverPackage(exec);
266
+ if (!pkg) return skipped("package_discovery_failed");
267
+
268
+ const processes = listPackageProcesses(exec, pkg.installLocation);
269
+ const roots = rootProcesses(processes);
270
+ if (roots.length === 0) return skipped("no_targets");
271
+
272
+ const ancestryPids = io.ancestryPids ? io.ancestryPids() : windowsAncestryPids(exec);
273
+ if (ancestryPids.length === 0) {
274
+ // Fail closed: an unreadable ancestry chain cannot prove we are outside the
275
+ // tree we are about to terminate.
276
+ return skipped("self_ancestry");
277
+ }
278
+ const ancestry = new Set(ancestryPids);
279
+ if (processes.some(p => ancestry.has(p.pid))) {
280
+ // Terminating our own tree would kill this command mid-flight and leave the
281
+ // user with neither a restarted app nor an explanation.
282
+ return skipped("self_ancestry");
283
+ }
284
+
285
+ const isAlive = io.isAlive ?? defaultIsAlive;
286
+ const sleep = io.sleep ?? defaultSleep;
287
+ const now = io.now ?? (() => Date.now());
288
+ const stopped: number[] = [];
289
+ const surviving: number[] = [];
290
+
291
+ for (const root of roots) {
292
+ const pid = root.pid;
293
+ // Re-verify immediately before the graceful close: the listing is already
294
+ // one probe old.
295
+ if (!stillSameProcess(exec, pkg.installLocation, root)) {
296
+ stopped.push(pid);
297
+ continue;
298
+ }
299
+ try {
300
+ exec(resolveTrustedWindowsPowerShellExe(), [
301
+ "-NoProfile", "-NonInteractive", "-Command",
302
+ `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($p) { [void]$p.CloseMainWindow() }`,
303
+ ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
304
+ } catch {
305
+ /* a refused graceful close still gets the forced pass below */
306
+ }
307
+ if (waitForExit(pid, GRACEFUL_EXIT_TIMEOUT_MS, isAlive, sleep, now)) {
308
+ stopped.push(pid);
309
+ continue;
310
+ }
311
+ // The wait window is long enough for Windows to recycle a PID, and the next
312
+ // step is `/T /F` against a whole tree. Confirm the PID is still the process
313
+ // we verified, or leave it alone.
314
+ if (!stillSameProcess(exec, pkg.installLocation, root)) {
315
+ stopped.push(pid);
316
+ continue;
317
+ }
318
+ try {
319
+ exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(pid), "/T", "/F"], {
320
+ timeout: PROBE_TIMEOUT_MS, windowsHide: true,
321
+ });
322
+ } catch {
323
+ /* fall through to the liveness check: the process state decides, not the exit code */
324
+ }
325
+ if (waitForExit(pid, FORCED_EXIT_TIMEOUT_MS, isAlive, sleep, now)) stopped.push(pid);
326
+ else surviving.push(pid);
327
+ }
328
+
329
+ if (surviving.length > 0) {
330
+ return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" };
331
+ }
332
+
333
+ try {
334
+ exec(resolveTrustedWindowsPowerShellExe(), [
335
+ "-NoProfile", "-NonInteractive", "-Command",
336
+ `Start-Process 'shell:AppsFolder\\${pkg.aumid}'`,
337
+ ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
338
+ } catch {
339
+ return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" };
340
+ }
341
+ return { attempted: true, stopped, surviving, relaunch: "started" };
342
+ }