@yansigit/opencodex 2.36.1-dev.20260829.35 → 2.36.1-dev.20260829.39

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 (42) hide show
  1. package/gui/dist/assets/{ApiKeys-Dmc3vk4f.js → ApiKeys-BDLkAqHs.js} +1 -1
  2. package/gui/dist/assets/{Claude-B8nBn_TE.js → Claude-CbTHl2B0.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-RKnAl3Rr.js → CodexSet-BWMY8up4.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-LBYKB5gf.js → FileIntegrationPage-BFywFnu_.js} +1 -1
  5. package/gui/dist/assets/{Grok-B6nDl-pq.js → Grok-D7_rD_UK.js} +1 -1
  6. package/gui/dist/assets/{Integrations-DPcI3mwy.js → Integrations-BM5cNRt8.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-BLgOYwUO.js → IntegrationsOverview-DUL8qhww.js} +1 -1
  8. package/gui/dist/assets/{Logs-BkM5ic5m.js → Logs-BG9kIs3A.js} +1 -1
  9. package/gui/dist/assets/Models-BJWzO0Kh.js +1 -0
  10. package/gui/dist/assets/{NumberStepper-Dsiahf_V.js → NumberStepper-K9tocDBx.js} +1 -1
  11. package/gui/dist/assets/{Providers-DFBGM2BG.js → Providers-BLweq_Hw.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-0YZ3cmhw.js → RestoreDialog-D9aPE4O0.js} +1 -1
  13. package/gui/dist/assets/{Startup-BRXwp250.js → Startup-CkcpO3Jv.js} +1 -1
  14. package/gui/dist/assets/{Storage-D0Ve2fj0.js → Storage-YIqUviJJ.js} +1 -1
  15. package/gui/dist/assets/Subagents-C1AfLEl9.js +1 -0
  16. package/gui/dist/assets/{Usage-C0T5SUrf.js → Usage-BerHlEN0.js} +1 -1
  17. package/gui/dist/assets/codex-stale-banner-D4gvSWnO.js +1 -0
  18. package/gui/dist/assets/{data-surface-D8RXShV2.js → data-surface-BtO3lwam.js} +1 -1
  19. package/gui/dist/assets/{data-surface-Bfu-W0pf.js → data-surface-DCct07RT.js} +1 -1
  20. package/gui/dist/assets/{index-WNYAjf83.js → index-Bs27ITj_.js} +3 -3
  21. package/gui/dist/assets/{model-display-hWp9VTdv.js → model-display-KCeq505s.js} +1 -1
  22. package/gui/dist/assets/{provider-payload-F7zzYMeI.js → provider-payload-DHs_ctdK.js} +1 -1
  23. package/gui/dist/assets/{section-tabs-DEASco68.js → section-tabs-BP7gEPK6.js} +1 -1
  24. package/gui/dist/assets/shared-BdYGV-yJ.js +69 -0
  25. package/gui/dist/index.html +2 -2
  26. package/package.json +1 -1
  27. package/src/codex/app-server-processes.ts +160 -54
  28. package/src/codex/internal/catalog-writer.ts +3 -0
  29. package/src/codex/subagent-defaults.ts +114 -0
  30. package/src/codex/sync.ts +5 -0
  31. package/src/generated/compatibility-version.json +13 -13
  32. package/src/server/effort-policy.ts +43 -12
  33. package/src/server/index.ts +6 -0
  34. package/src/server/management/agent-settings-routes.ts +2 -0
  35. package/src/server/request-log.ts +7 -0
  36. package/src/server/responses/collaboration.ts +20 -4
  37. package/src/server/responses/core.ts +6 -4
  38. package/src/server/ws-bridge.ts +5 -0
  39. package/src/usage/log.ts +9 -0
  40. package/gui/dist/assets/Models-uh6UTNK5.js +0 -1
  41. package/gui/dist/assets/Subagents-TaTl5eEL.js +0 -1
  42. package/gui/dist/assets/shared-p0R307IX.js +0 -69
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-WNYAjf83.js"></script>
20
- <link rel="modulepreload" crossorigin href="/assets/shared-p0R307IX.js">
19
+ <script type="module" crossorigin src="/assets/index-Bs27ITj_.js"></script>
20
+ <link rel="modulepreload" crossorigin href="/assets/shared-BdYGV-yJ.js">
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-pLho_LBF.css">
22
22
  </head>
23
23
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yansigit/opencodex",
3
- "version": "2.36.1-dev.20260829.35",
3
+ "version": "2.36.1-dev.20260829.39",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -15,6 +15,7 @@ import {
15
15
  resolveTrustedWindowsTaskkillExe,
16
16
  } from "../lib/windows-elevation";
17
17
  import { readCodexCatalogPath } from "./catalog/parsing";
18
+ import { CODEX_CONFIG_PATH } from "./paths";
18
19
 
19
20
  export const STALE_CODEX_APP_SERVER_HINT =
20
21
  "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex). "
@@ -104,6 +105,10 @@ export interface CodexAppServerProcessIo {
104
105
  /** Async batch start-time seam used by the request-path Windows collector. */
105
106
  readStartMsBatchAsync?: (pids: readonly number[]) => Promise<Map<number, number | null>>;
106
107
  catalogMtimeMs?: () => number | null;
108
+ /** Which on-disk write should be compared with app-server start times. */
109
+ freshnessTarget?: "catalog" | "config";
110
+ /** Custom path for the default freshness stat; process-observation identity excludes it. */
111
+ freshnessPath?: string;
107
112
  }
108
113
 
109
114
  function execFileTextAsync(
@@ -680,9 +685,12 @@ export interface CodexAppServerCatalogStatus {
680
685
  }
681
686
 
682
687
  /** Resolve the catalog file Codex app-servers loaded at startup, for staleness checks. */
683
- function defaultCatalogMtimeMs(): number | null {
688
+ function defaultCatalogMtimeMs(
689
+ target: "catalog" | "config" = "catalog",
690
+ freshnessPath?: string,
691
+ ): number | null {
684
692
  try {
685
- return statSync(readCodexCatalogPath()).mtimeMs;
693
+ return statSync(freshnessPath ?? (target === "config" ? CODEX_CONFIG_PATH : readCodexCatalogPath())).mtimeMs;
686
694
  } catch {
687
695
  return null;
688
696
  }
@@ -720,23 +728,58 @@ function catalogStatusFromProcesses(
720
728
  return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs };
721
729
  }
722
730
 
731
+ function startsHasUnreadable(starts: ReadonlyMap<number, number | null>): boolean {
732
+ for (const startedAtMs of starts.values()) {
733
+ if (startedAtMs === null) return true;
734
+ }
735
+ return false;
736
+ }
737
+
723
738
  // Short TTL: process listing + stat run once per window even under per-turn
724
- // guidance calls (#857).
725
- let catalogStateCache: { atMs: number; status: CodexAppServerCatalogStatus } | null = null;
739
+ // guidance calls (#857). The process evidence is independent of which on-disk
740
+ // write callers compare it with; only the mtime lookup and final status are
741
+ // target-specific.
742
+ interface ProcessEvidence {
743
+ processes: CodexAppServerProcess[];
744
+ starts: Map<number, number | null>;
745
+ enumerationFailed: boolean;
746
+ }
747
+
748
+ interface ProcessEvidenceIdentity {
749
+ platform: NodeJS.Platform;
750
+ getuid?: CodexAppServerProcessIo["getuid"];
751
+ listSnapshots?: CodexAppServerProcessIo["listSnapshots"];
752
+ readStartMs?: CodexAppServerProcessIo["readStartMs"];
753
+ }
754
+
755
+ let processEvidenceCache: {
756
+ atMs: number;
757
+ identity: ProcessEvidenceIdentity;
758
+ evidence: ProcessEvidence;
759
+ ttlState: CodexAppServerCatalogState;
760
+ } | null = null;
761
+ const catalogStatusCache = new Map<"catalog" | "config", {
762
+ evidence: ProcessEvidence;
763
+ status: CodexAppServerCatalogStatus;
764
+ }>();
726
765
  interface RequestCatalogStateIdentity {
727
766
  platform: NodeJS.Platform;
728
767
  listSnapshots?: CodexAppServerProcessIo["listSnapshots"];
729
768
  listSnapshotsAsync?: CodexAppServerProcessIo["listSnapshotsAsync"];
730
769
  readStartMs?: CodexAppServerProcessIo["readStartMs"];
731
770
  readStartMsBatchAsync?: CodexAppServerProcessIo["readStartMsBatchAsync"];
732
- catalogMtimeMs?: CodexAppServerProcessIo["catalogMtimeMs"];
733
771
  now?: CodexAppServerProcessIo["now"];
734
772
  }
735
773
 
736
774
  interface RequestCatalogStateFlight {
737
775
  generation: number;
738
776
  identity: RequestCatalogStateIdentity;
739
- promise: Promise<CodexAppServerCatalogStatus>;
777
+ promise: Promise<RequestCatalogStateReading>;
778
+ }
779
+
780
+ interface RequestCatalogStateReading {
781
+ status: CodexAppServerCatalogStatus;
782
+ observationState: CodexAppServerCatalogState;
740
783
  }
741
784
 
742
785
  let requestCatalogStateGeneration = 0;
@@ -745,6 +788,7 @@ let requestCatalogStateCache: {
745
788
  identity: RequestCatalogStateIdentity;
746
789
  atMs: number;
747
790
  status: CodexAppServerCatalogStatus;
791
+ observationState: CodexAppServerCatalogState;
748
792
  } | null = null;
749
793
  let requestCatalogStateFlight: RequestCatalogStateFlight | null = null;
750
794
  const CATALOG_STATE_TTL_MS = 5_000;
@@ -792,10 +836,34 @@ function sameRequestCatalogStateIdentity(
792
836
  && left.listSnapshotsAsync === right.listSnapshotsAsync
793
837
  && left.readStartMs === right.readStartMs
794
838
  && left.readStartMsBatchAsync === right.readStartMsBatchAsync
795
- && left.catalogMtimeMs === right.catalogMtimeMs
796
839
  && left.now === right.now;
797
840
  }
798
841
 
842
+ function statusForFreshnessTarget(
843
+ status: CodexAppServerCatalogStatus,
844
+ io: CodexAppServerProcessIo,
845
+ ): CodexAppServerCatalogStatus {
846
+ if (status.processes.length === 0) return status;
847
+ let catalogMtimeMs: number | null;
848
+ try {
849
+ const target = io.freshnessTarget ?? "catalog";
850
+ catalogMtimeMs = (io.catalogMtimeMs ?? (() => defaultCatalogMtimeMs(target, io.freshnessPath)))();
851
+ } catch {
852
+ catalogMtimeMs = null;
853
+ }
854
+ if (status.catalogMtimeMs === catalogMtimeMs) return status;
855
+ return catalogStatusFromProcesses(
856
+ status.processes.map(proc => ({ pid: proc.pid, commandLine: "" })),
857
+ catalogMtimeMs,
858
+ new Map(status.processes.map(proc => [proc.pid, proc.startedAtMs] as const)),
859
+ );
860
+ }
861
+
862
+ function processObservationState(status: CodexAppServerCatalogStatus): CodexAppServerCatalogState {
863
+ if (status.processes.length === 0) return status.state === "unknown" ? "unknown" : "not_running";
864
+ return status.processes.some(process => process.startedAtMs === null) ? "unknown" : "fresh";
865
+ }
866
+
799
867
  /**
800
868
  * Compare the on-disk catalog mtime against the start time of running Codex
801
869
  * app-servers (#857): a server that started before the catalog changed keeps
@@ -816,15 +884,26 @@ export function collectCodexAppServerCatalogState(
816
884
  io: CodexAppServerProcessIo = {},
817
885
  ): CodexAppServerCatalogStatus {
818
886
  const now = (io.now ?? Date.now)();
819
- const fullyDefault = !io.listSnapshots && !io.readStartMs && !io.catalogMtimeMs
820
- && !io.platform && !io.getuid && !io.now;
821
- if (fullyDefault
822
- && catalogStateCache
823
- && now - catalogStateCache.atMs < catalogStateTtlMs(catalogStateCache.status.state)) {
824
- return catalogStateCache.status;
825
- }
826
- const compute = (): CodexAppServerCatalogStatus => {
827
- const platform = io.platform ?? process.platform;
887
+ const target = io.freshnessTarget ?? "catalog";
888
+ const platform = io.platform ?? process.platform;
889
+ const identity: ProcessEvidenceIdentity = {
890
+ platform,
891
+ getuid: io.getuid,
892
+ listSnapshots: io.listSnapshots,
893
+ readStartMs: io.readStartMs,
894
+ };
895
+ const cachedEvidence = processEvidenceCache
896
+ && processEvidenceCache.identity.platform === identity.platform
897
+ && processEvidenceCache.identity.getuid === identity.getuid
898
+ && processEvidenceCache.identity.listSnapshots === identity.listSnapshots
899
+ && processEvidenceCache.identity.readStartMs === identity.readStartMs
900
+ && now - processEvidenceCache.atMs < catalogStateTtlMs(processEvidenceCache.ttlState)
901
+ ? processEvidenceCache.evidence
902
+ : null;
903
+ let evidence: ProcessEvidence;
904
+ if (cachedEvidence) {
905
+ evidence = cachedEvidence;
906
+ } else {
828
907
  const getuid = io.getuid ?? (() => {
829
908
  try {
830
909
  return typeof process.getuid === "function" ? process.getuid() : undefined;
@@ -841,26 +920,38 @@ export function collectCodexAppServerCatalogState(
841
920
  // Enumeration failure must never read as "nothing running" — that would let
842
921
  // positive model guidance through on guesswork (#857). The injected seam gets
843
922
  // the same contract as the default path: whoever enumerates, a failure to read
844
- // the process list is unknown, not an empty machine.
923
+ // the process list, unknown, not an empty machine.
845
924
  snapshots = [];
846
925
  enumerationFailed = true;
847
926
  }
848
927
  const processes = codexAppServerProcessesFromSnapshots(snapshots);
849
- if (processes.length === 0) {
850
- return enumerationFailed
851
- ? { state: "unknown", processes: [], catalogMtimeMs: null }
852
- : { state: "not_running", processes: [], catalogMtimeMs: null };
853
- }
854
- const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)();
855
- const starts = io.readStartMs
856
- ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const))
857
- : readProcessStartMsBatch(processes.map(proc => proc.pid), platform);
858
- return catalogStatusFromProcesses(processes, catalogMtimeMs, starts);
859
- };
860
- const status = compute();
861
- if (fullyDefault) {
862
- catalogStateCache = { atMs: now, status };
928
+ const starts = processes.length === 0
929
+ ? new Map<number, number | null>()
930
+ : io.readStartMs
931
+ ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const))
932
+ : readProcessStartMsBatch(processes.map(proc => proc.pid), platform);
933
+ const ttlState: CodexAppServerCatalogState = enumerationFailed || startsHasUnreadable(starts)
934
+ ? "unknown"
935
+ : processes.length === 0 ? "not_running" : "fresh";
936
+ evidence = { processes, starts, enumerationFailed };
937
+ processEvidenceCache = { atMs: now, identity, evidence, ttlState };
938
+ }
939
+ if (evidence.processes.length === 0) {
940
+ const status: CodexAppServerCatalogStatus = evidence.enumerationFailed
941
+ ? { state: "unknown", processes: [], catalogMtimeMs: null }
942
+ : { state: "not_running", processes: [], catalogMtimeMs: null };
943
+ const prior = catalogStatusCache.get(target);
944
+ if (prior?.evidence === evidence) return prior.status;
945
+ catalogStatusCache.set(target, { evidence, status });
946
+ return status;
863
947
  }
948
+ const catalogMtimeMs = (io.catalogMtimeMs ?? (() => defaultCatalogMtimeMs(target, io.freshnessPath)))();
949
+ const status = catalogStatusFromProcesses(evidence.processes, catalogMtimeMs, evidence.starts);
950
+ const prior = catalogStatusCache.get(target);
951
+ if (prior?.evidence === evidence && prior.status.catalogMtimeMs === status.catalogMtimeMs) {
952
+ return prior.status;
953
+ }
954
+ catalogStatusCache.set(target, { evidence, status });
864
955
  return status;
865
956
  }
866
957
 
@@ -897,7 +988,6 @@ export async function collectCodexAppServerCatalogStateForRequest(
897
988
  listSnapshotsAsync: io.listSnapshotsAsync,
898
989
  readStartMs: io.readStartMs,
899
990
  readStartMsBatchAsync: io.readStartMsBatchAsync,
900
- catalogMtimeMs: io.catalogMtimeMs,
901
991
  now: io.now,
902
992
  };
903
993
  const cached = requestCatalogStateCache
@@ -905,8 +995,8 @@ export async function collectCodexAppServerCatalogStateForRequest(
905
995
  && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity)
906
996
  ? requestCatalogStateCache
907
997
  : null;
908
- if (cached && now - cached.atMs < catalogStateTtlMs(cached.status.state)) {
909
- return cached.status;
998
+ if (cached && now - cached.atMs < catalogStateTtlMs(cached.observationState)) {
999
+ return statusForFreshnessTarget(cached.status, io);
910
1000
  }
911
1001
  // An expired real reading is still worth handing back while the refresh runs. It
912
1002
  // cannot have been invalidated by an ocx catalog write: every such write calls
@@ -915,18 +1005,20 @@ export async function collectCodexAppServerCatalogStateForRequest(
915
1005
  // What it can miss is an app-server that started or stopped meanwhile -- and a
916
1006
  // server started after the reading is newer than the catalog, which is the `fresh`
917
1007
  // this entry already says.
1008
+ const targetStatus = cached ? statusForFreshnessTarget(cached.status, io) : null;
918
1009
  const servableStale = cached
919
- && cached.status.state !== "unknown"
920
- && now - cached.atMs < catalogStateTtlMs(cached.status.state) + CATALOG_STATE_MAX_STALE_MS
921
- ? cached.status
1010
+ && cached.observationState !== "unknown"
1011
+ && targetStatus?.state !== "unknown"
1012
+ && now - cached.atMs < catalogStateTtlMs(cached.observationState) + CATALOG_STATE_MAX_STALE_MS
1013
+ ? targetStatus
922
1014
  : null;
923
1015
  if (requestCatalogStateFlight
924
1016
  && requestCatalogStateFlight.generation === generation
925
1017
  && sameRequestCatalogStateIdentity(requestCatalogStateFlight.identity, identity)) {
926
- return servableStale ?? requestCatalogStateFlight.promise;
1018
+ return servableStale ?? requestCatalogStateFlight.promise.then(reading => statusForFreshnessTarget(reading.status, io));
927
1019
  }
928
1020
 
929
- const refresh = async (): Promise<CodexAppServerCatalogStatus> => {
1021
+ const refresh = async (): Promise<RequestCatalogStateReading> => {
930
1022
  let snapshots: ProcessSnapshot[];
931
1023
  try {
932
1024
  snapshots = io.listSnapshotsAsync
@@ -935,15 +1027,21 @@ export async function collectCodexAppServerCatalogStateForRequest(
935
1027
  ? io.listSnapshots()
936
1028
  : await listWindowsSnapshotsAsync();
937
1029
  } catch {
938
- return { state: "unknown", processes: [], catalogMtimeMs: null };
1030
+ return {
1031
+ status: { state: "unknown", processes: [], catalogMtimeMs: null },
1032
+ observationState: "unknown",
1033
+ };
939
1034
  }
940
1035
  const processes = codexAppServerProcessesFromSnapshots(snapshots);
941
1036
  if (processes.length === 0) {
942
- return { state: "not_running", processes: [], catalogMtimeMs: null };
1037
+ return {
1038
+ status: { state: "not_running", processes: [], catalogMtimeMs: null },
1039
+ observationState: "not_running",
1040
+ };
943
1041
  }
944
1042
  let catalogMtimeMs: number | null;
945
1043
  try {
946
- catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)();
1044
+ catalogMtimeMs = (io.catalogMtimeMs ?? (() => defaultCatalogMtimeMs(io.freshnessTarget, io.freshnessPath)))();
947
1045
  } catch {
948
1046
  catalogMtimeMs = null;
949
1047
  }
@@ -953,16 +1051,19 @@ export async function collectCodexAppServerCatalogStateForRequest(
953
1051
  : io.readStartMs
954
1052
  ? new Map(pids.map(pid => [pid, io.readStartMs!(pid)] as const))
955
1053
  : await readWindowsProcessStartMsBatchAsync(pids);
956
- return catalogStatusFromProcesses(processes, catalogMtimeMs, starts);
1054
+ const status = catalogStatusFromProcesses(processes, catalogMtimeMs, starts);
1055
+ return {
1056
+ status,
1057
+ observationState: processObservationState(status),
1058
+ };
957
1059
  };
958
1060
 
959
1061
  const pending = refresh().catch(() => ({
960
- state: "unknown" as const,
961
- processes: [],
962
- catalogMtimeMs: null,
1062
+ status: { state: "unknown" as const, processes: [], catalogMtimeMs: null },
1063
+ observationState: "unknown" as const,
963
1064
  }));
964
1065
  let flight: RequestCatalogStateFlight;
965
- const promise = pending.then(status => {
1066
+ const promise = pending.then(reading => {
966
1067
  // A catalog write can invalidate while slow CIM is still running. The result
967
1068
  // describes the PRE-write world, so it must neither repopulate the post-write
968
1069
  // cache nor reach the caller.
@@ -979,7 +1080,10 @@ export async function collectCodexAppServerCatalogStateForRequest(
979
1080
  // invalidated observation knows, and the guidance path already treats it as
980
1081
  // "say nothing positive".
981
1082
  if (requestCatalogStateGeneration !== generation) {
982
- return { state: "unknown" as const, processes: [], catalogMtimeMs: null };
1083
+ return {
1084
+ status: { state: "unknown" as const, processes: [], catalogMtimeMs: null },
1085
+ observationState: "unknown" as const,
1086
+ };
983
1087
  }
984
1088
  // A refresh that failed must not evict a real reading. Before this function
985
1089
  // served stale entries, caching `unknown` cost at most the 250ms that state
@@ -988,19 +1092,20 @@ export async function collectCodexAppServerCatalogStateForRequest(
988
1092
  // them on a transient failure -- `unknown` is not servable, so the next
989
1093
  // caller waits for a probe instead of getting the reading it would have had.
990
1094
  // Keep the observation and let its own age retire it.
991
- const wouldEvictAnObservation = status.state === "unknown"
1095
+ const wouldEvictAnObservation = reading.observationState === "unknown"
992
1096
  && requestCatalogStateCache?.generation === generation
993
- && requestCatalogStateCache.status.state !== "unknown"
1097
+ && requestCatalogStateCache.observationState !== "unknown"
994
1098
  && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity);
995
1099
  if (requestCatalogStateFlight === flight && !wouldEvictAnObservation) {
996
1100
  requestCatalogStateCache = {
997
1101
  generation,
998
1102
  identity,
999
1103
  atMs: (io.now ?? Date.now)(),
1000
- status,
1104
+ status: reading.status,
1105
+ observationState: reading.observationState,
1001
1106
  };
1002
1107
  }
1003
- return status;
1108
+ return reading;
1004
1109
  }).finally(() => {
1005
1110
  if (requestCatalogStateFlight === flight) requestCatalogStateFlight = null;
1006
1111
  });
@@ -1008,7 +1113,7 @@ export async function collectCodexAppServerCatalogStateForRequest(
1008
1113
  requestCatalogStateFlight = flight;
1009
1114
  // `promise` already absorbs its own failures, so leaving it unawaited here cannot
1010
1115
  // surface as an unhandled rejection; the next caller picks up whatever it stored.
1011
- return servableStale ?? flight.promise;
1116
+ return servableStale ?? flight.promise.then(reading => statusForFreshnessTarget(reading.status, io));
1012
1117
  }
1013
1118
 
1014
1119
  /**
@@ -1018,7 +1123,8 @@ export async function collectCodexAppServerCatalogStateForRequest(
1018
1123
  * write has completed.
1019
1124
  */
1020
1125
  export function resetCodexAppServerCatalogStateCache(): void {
1021
- catalogStateCache = null;
1126
+ processEvidenceCache = null;
1127
+ catalogStatusCache.clear();
1022
1128
  requestCatalogStateGeneration += 1;
1023
1129
  requestCatalogStateCache = null;
1024
1130
  requestCatalogStateFlight = null;
@@ -16,6 +16,7 @@ import {
16
16
  forgetEphemeralSecretPath,
17
17
  hardenSecretPath,
18
18
  } from "../../lib/windows-secret-acl";
19
+ import { resetCodexAppServerCatalogStateCache } from "../app-server-processes";
19
20
 
20
21
  export interface PreparedCatalogFileWrite {
21
22
  readonly path: string;
@@ -167,6 +168,7 @@ export function replaceActiveCodexCatalog(
167
168
  ): void {
168
169
  assertCatalogWritePermit(permit, owningCodexHome);
169
170
  atomicWriteFile(prepared.path, prepared.content, io);
171
+ resetCodexAppServerCatalogStateCache();
170
172
  }
171
173
 
172
174
  /** Atomically publish the catalog-path-keyed immutable backup without clobbering. */
@@ -200,4 +202,5 @@ export function replaceCodexModelsCache(
200
202
  ): void {
201
203
  assertCatalogWritePermit(permit, owningCodexHome);
202
204
  atomicWriteFile(prepared.path, prepared.content, io);
205
+ resetCodexAppServerCatalogStateCache();
203
206
  }
@@ -1,3 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CODEX_CONFIG_PATH } from "./paths";
3
+ import { hasInjectedCodexRouting } from "./injected-marker";
4
+ import { resolveEffectiveProjectModelProvider } from "./project-config-warnings";
5
+
1
6
  /**
2
7
  * Pure, ownership-aware edits for Codex's native `[agents]` defaults.
3
8
  *
@@ -19,6 +24,17 @@ export interface ManagedSubagentDefaults {
19
24
  reasoningEffort?: string;
20
25
  }
21
26
 
27
+ export type NativeDefaultState = "active" | "disabled" | "pending" | "blocked";
28
+
29
+ export interface NativeDefaultStateDeps {
30
+ configPath?: string;
31
+ readConfig?: () => string;
32
+ processIo?: import("./app-server-processes").CodexAppServerProcessIo;
33
+ collectCatalogState?: () => {
34
+ state: "fresh" | "stale" | "not_running" | "unknown";
35
+ } | Promise<{ state: "fresh" | "stale" | "not_running" | "unknown" }>;
36
+ }
37
+
22
38
  export interface ManagedSubagentDefaultsConflict {
23
39
  key: ManagedSubagentDefaultKey;
24
40
  line: number;
@@ -365,6 +381,104 @@ function analyzeToml(lines: readonly SourceLine[]): { shape: TomlShape } | { err
365
381
  };
366
382
  }
367
383
 
384
+ interface ManagedSubagentDefaultsInspection {
385
+ values: Partial<ManagedSubagentDefaults>;
386
+ owned: Record<ManagedSubagentDefaultKey, boolean>;
387
+ }
388
+
389
+ /** Read the effective native defaults without changing the TOML document. */
390
+ function inspectManagedSubagentDefaults(content: string):
391
+ | { ok: true; inspection: ManagedSubagentDefaultsInspection }
392
+ | { ok: false; error: string } {
393
+ const analysis = analyzeToml(splitSourceLines(content));
394
+ if ("error" in analysis) return { ok: false, error: analysis.error };
395
+ let parsed: unknown;
396
+ try {
397
+ parsed = Bun.TOML.parse(content);
398
+ } catch {
399
+ return { ok: false, error: "Codex config is not valid TOML" };
400
+ }
401
+ const root = parsed && typeof parsed === "object" && !Array.isArray(parsed)
402
+ ? parsed as Record<string, unknown>
403
+ : null;
404
+ const agents = root?.agents && typeof root.agents === "object" && !Array.isArray(root.agents)
405
+ ? root.agents as Record<string, unknown>
406
+ : {};
407
+ const values: Partial<ManagedSubagentDefaults> = {};
408
+ const owned = {} as Record<ManagedSubagentDefaultKey, boolean>;
409
+ for (const key of TARGET_KEYS) {
410
+ const definition = analysis.shape.definitions.get(key);
411
+ owned[key] = definition?.owned === true;
412
+ if (!definition) continue;
413
+ const value = agents[key];
414
+ if (typeof value !== "string") return { ok: false, error: `agents.${key} is not a string` };
415
+ if (key === "default_subagent_model") values.model = value;
416
+ else values.reasoningEffort = value;
417
+ }
418
+ return { ok: true, inspection: { values, owned } };
419
+ }
420
+
421
+ /**
422
+ * Resolve whether the configured preference is actually authoritative for omitted-model Codex
423
+ * subagents. This is intentionally read-only; sync/start remains the writer of native defaults.
424
+ */
425
+ export async function resolveNativeDefaultState(
426
+ config: { syncCodexSubagentDefaults?: boolean; injectionModel?: string; injectionEffort?: string },
427
+ deps: NativeDefaultStateDeps = {},
428
+ ): Promise<NativeDefaultState> {
429
+ const model = config.injectionModel?.trim();
430
+ const enabled = config.syncCodexSubagentDefaults === true && Boolean(model);
431
+ if (!enabled) return "disabled";
432
+
433
+ const configPath = deps.configPath ?? CODEX_CONFIG_PATH;
434
+ let content: string;
435
+ try {
436
+ content = (deps.readConfig ?? (() => readFileSync(configPath, "utf8")))();
437
+ } catch (error) {
438
+ // A config that has not been created yet is waiting for the next sync; other read failures
439
+ // cannot be safely diagnosed as a missing desired value.
440
+ return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" ? "pending" : "blocked";
441
+ }
442
+ const inspected = inspectManagedSubagentDefaults(content);
443
+ if (!inspected.ok) return "blocked";
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
+ const provider = resolveEffectiveProjectModelProvider(content).provider;
452
+ 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";
455
+
456
+ const { values, owned } = inspected.inspection;
457
+ for (const key of TARGET_KEYS) {
458
+ if (owned[key] === false && Object.hasOwn(values, key === "default_subagent_model" ? "model" : "reasoningEffort")) {
459
+ return "blocked";
460
+ }
461
+ }
462
+ if (values.model !== model) return "pending";
463
+ const effort = config.injectionEffort?.trim() || undefined;
464
+ if (values.reasoningEffort !== effort) return "pending";
465
+
466
+ let catalogState: { state: "fresh" | "stale" | "not_running" | "unknown" };
467
+ try {
468
+ catalogState = await (deps.collectCatalogState ?? (async () => {
469
+ const { collectCodexAppServerCatalogStateForRequest } = await import("./app-server-processes");
470
+ return collectCodexAppServerCatalogStateForRequest({
471
+ ...deps.processIo,
472
+ freshnessPath: configPath,
473
+ freshnessTarget: "config",
474
+ });
475
+ }))();
476
+ } catch {
477
+ return "pending";
478
+ }
479
+ return catalogState.state === "fresh" || catalogState.state === "not_running" ? "active" : "pending";
480
+ }
481
+
368
482
  function quotedTomlString(value: string): string {
369
483
  // JSON basic strings are valid TOML basic strings and cover quotes,
370
484
  // backslashes, control characters, and newlines. JSON leaves DEL literal,
package/src/codex/sync.ts CHANGED
@@ -8,6 +8,7 @@ import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./cat
8
8
  import { shouldSyncCodexOnStart } from "./desired-state";
9
9
  import { admitCodexWrite, type CodexAdmission } from "./admission";
10
10
  import type { CodexCatalogSyncOptions } from "./catalog/sync";
11
+ import { resetCodexAppServerCatalogStateCache } from "./app-server-processes";
11
12
 
12
13
  export interface CodexSyncResult {
13
14
  /**
@@ -116,6 +117,10 @@ export async function syncModelsToCodex(
116
117
  message: admission.message,
117
118
  };
118
119
  }
120
+ // Config injection is a relevant Codex write even when the catalog bytes are unchanged.
121
+ // Drop cached process evidence before async discovery so a process that appeared since the
122
+ // last read cannot make native-default guidance report active after this sync.
123
+ resetCodexAppServerCatalogStateCache();
119
124
  const p = port ?? config.port ?? 10100;
120
125
  const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)();
121
126