@bitkyc08/opencodex 2.53.0-preview.20260913 → 2.54.0-preview.20260914

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 (34) hide show
  1. package/gui/dist/assets/{index-D7ynYo2K.js → index-B4VYfZcY.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/devin.ts +39 -7
  5. package/src/cli/capabilities.ts +4 -2
  6. package/src/cli/catalog.ts +39 -10
  7. package/src/cli/dispatch.ts +19 -64
  8. package/src/cli/doctor.ts +1 -1
  9. package/src/cli/internal-command.ts +44 -0
  10. package/src/cli/registry.ts +9 -7
  11. package/src/cli/restart-scope.ts +184 -0
  12. package/src/codex/app-server-processes.ts +20 -5
  13. package/src/codex/app-server-restart-service.ts +29 -0
  14. package/src/codex/catalog/provider-fetch.ts +13 -5
  15. package/src/codex/catalog/sync.ts +12 -3
  16. package/src/codex/desktop-app/darwin.ts +268 -0
  17. package/src/codex/desktop-app/handoff.ts +303 -0
  18. package/src/codex/desktop-app/linux.ts +388 -0
  19. package/src/codex/desktop-app/lock.ts +226 -0
  20. package/src/codex/desktop-app/types.ts +141 -0
  21. package/src/codex/desktop-app/windows.ts +239 -0
  22. package/src/codex/desktop-app-restart.ts +264 -279
  23. package/src/codex/inject.ts +83 -21
  24. package/src/codex/sync.ts +16 -22
  25. package/src/generated/compatibility-version.json +58 -22
  26. package/src/lib/codex-restart-contract.ts +31 -0
  27. package/src/providers/registry.ts +13 -12
  28. package/src/server/responses/core.ts +62 -4
  29. package/src/server/responses/encrypted-payload.ts +161 -0
  30. package/src/server/responses.ts +1 -1
  31. package/src/types/provider.ts +6 -5
  32. package/src/web-search/index.ts +14 -67
  33. package/src/web-search/passthrough-bridge.ts +256 -32
  34. package/src/web-search/sidecar-providers.ts +76 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The restart scope a command was asked for, and the post-write restart itself.
3
+ *
4
+ * Its own module because `sync`, `sync-cache` and `catalog pull` all need it, and
5
+ * having `catalog.ts` import it from `dispatch.ts` would make the two files circular.
6
+ */
7
+ import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes";
8
+ import type { AfterCatalogWriteAppServerResult } from "../codex/app-server-processes";
9
+ import type { DesktopAppRestartResult } from "../codex/desktop-app-restart";
10
+
11
+ /**
12
+ * Which restart a command was asked for.
13
+ *
14
+ * `--restart-codex` used to mean app-server-only, and the desktop restart was a
15
+ * separate Windows-only opt-in. That split existed because quitting the app ends live
16
+ * conversations, which is a bigger consent than restarting a background helper. The
17
+ * reasoning was sound and is superseded by an explicit maintainer decision:
18
+ * `--restart-codex` now means the app is fully stopped and started again. The narrow
19
+ * behaviour did not disappear, it moved to a flag that names it.
20
+ *
21
+ * One reader for every command, so `sync`, `sync-cache` and `catalog pull` cannot
22
+ * drift in what the same flag means.
23
+ */
24
+ export interface RestartScope {
25
+ /** Signal matching app-server / code-mode-host processes. */
26
+ appServers: boolean;
27
+ /** Fully quit and relaunch the Codex desktop app. */
28
+ desktopApp: boolean;
29
+ }
30
+
31
+ export function readRestartScope(
32
+ args: readonly string[],
33
+ log: Pick<Console, "error">,
34
+ ): RestartScope {
35
+ const appServerOnly = args.includes("--restart-app-server-only");
36
+ const legacyDesktop = args.includes("--restart-desktop-app");
37
+ const restartCodex = args.includes("--restart-codex");
38
+ if (legacyDesktop) {
39
+ log.error(
40
+ "--restart-desktop-app is deprecated: --restart-codex now restarts the Codex "
41
+ + "desktop app on every platform. The flag still works and will be removed in a "
42
+ + "future release.",
43
+ );
44
+ }
45
+ if (appServerOnly && (restartCodex || legacyDesktop)) {
46
+ // Contradictory scopes, and the NARROW one wins. Losing live conversations is
47
+ // unrecoverable; a stale model picker is not. A user who typed the app-server-only
48
+ // flag asked not to lose them.
49
+ log.error(
50
+ "--restart-app-server-only overrides --restart-codex/--restart-desktop-app; "
51
+ + "the desktop app was left running.",
52
+ );
53
+ return { appServers: true, desktopApp: false };
54
+ }
55
+ if (appServerOnly) return { appServers: true, desktopApp: false };
56
+ if (restartCodex || legacyDesktop) return { appServers: true, desktopApp: true };
57
+ return { appServers: false, desktopApp: false };
58
+ }
59
+
60
+ export interface RestartScopeOutcome {
61
+ appServers?: AfterCatalogWriteAppServerResult;
62
+ desktopApp?: DesktopAppRestartResult;
63
+ }
64
+
65
+ /**
66
+ * The post-write restart, for every command that performs one.
67
+ *
68
+ * App-servers that belong to the desktop tree are excluded when a desktop restart is
69
+ * also going to run: the app-server is a CHILD of the app on every platform, so
70
+ * signalling it first and then quitting the app interrupts the operator's in-flight
71
+ * turn twice in one command. A discovery or probe failure yields no exclusion, which
72
+ * is the safe direction - a missed exclusion costs an extra interruption, a wrong one
73
+ * leaves a stale app-server serving a roster that no longer exists.
74
+ */
75
+ export async function handleRestartScopeAfterWrite(
76
+ scope: RestartScope,
77
+ log: Pick<Console, "log" | "error">,
78
+ ): Promise<RestartScopeOutcome> {
79
+ const { listCodexDesktopAppPids } = await import("../codex/desktop-app-restart");
80
+ // KNOWN LIMITATION on Windows. The exclusion matches pids against the discovered
81
+ // desktop tree, and the Windows probe enumerates only ChatGPT.exe, while Windows
82
+ // app-servers run as codex.exe / codex-code-mode-host. They therefore never match and
83
+ // still receive SIGTERM before the app quits, so Windows keeps the double interruption
84
+ // this exclusion removes on macOS and Linux - where the app-server executable does live
85
+ // under the bundle or install root and is enumerated.
86
+ //
87
+ // Closing it means widening the Windows probe past ChatGPT.exe, which is the same query
88
+ // that decides what may be killed, so it needs its own verification rather than being
89
+ // appended here. The restart itself is correct on Windows either way; the cost is one
90
+ // extra interrupted turn.
91
+ const excludePids = scope.desktopApp ? (listCodexDesktopAppPids() ?? []) : [];
92
+ const appServers = afterCatalogWriteHandleAppServers({
93
+ restart: scope.appServers, log, excludePids,
94
+ });
95
+ const desktopApp = scope.desktopApp ? await handleDesktopAppRestart(log) : undefined;
96
+ return { appServers, desktopApp };
97
+ }
98
+
99
+ /**
100
+ * Report the outcome of a desktop-app restart. Kept next to the callers so every
101
+ * command tells the user the same thing.
102
+ */
103
+ export async function handleDesktopAppRestart(
104
+ log: Pick<Console, "log" | "error">,
105
+ ): Promise<DesktopAppRestartResult> {
106
+ const { restartCodexDesktopApp } = await import("../codex/desktop-app-restart");
107
+ const { startDesktopRestartHandoff } = await import("../codex/desktop-app/handoff");
108
+ const result = restartCodexDesktopApp({
109
+ // The CLI is the one caller whose exit is exactly the signal the helper waits for,
110
+ // so it is the one caller allowed to hand off. The management service is not (it
111
+ // runs in a proxy that never exits) and the helper itself is not (recursion).
112
+ startHandoff: () => {
113
+ const outcome = startDesktopRestartHandoff();
114
+ return outcome.kind === "started"
115
+ ? { helperPid: outcome.helperPid, logPath: outcome.logPath }
116
+ : null;
117
+ },
118
+ });
119
+ switch (result.reason) {
120
+ case "unsupported_platform":
121
+ log.error(
122
+ `Restarting the Codex desktop app is not supported on ${process.platform}; `
123
+ + "nothing was stopped.",
124
+ );
125
+ return result;
126
+ case "restart_in_flight":
127
+ log.error(
128
+ "Another Codex desktop-app restart is already running; this one did nothing. "
129
+ + "Wait for it to finish and check again.",
130
+ );
131
+ return result;
132
+ case "relaunch_failed":
133
+ log.error(
134
+ "The Codex desktop app was stopped but could not be started again. Launch it manually.",
135
+ );
136
+ return result;
137
+ case "package_discovery_failed":
138
+ log.error(
139
+ "Could not identify the installed Codex desktop package. Quit and relaunch the desktop app "
140
+ + "manually to refresh the model picker.",
141
+ );
142
+ return result;
143
+ case "handoff_started":
144
+ // Saying the session will end is the point. The operator is about to lose the
145
+ // terminal they typed into, and a message that omits that reads as a hang.
146
+ log.log(
147
+ "This command is running inside the Codex app, so the restart was handed off to "
148
+ + `a detached helper (pid ${result.handoff?.helperPid ?? 0}). The app will quit and `
149
+ + `relaunch in a moment; this session will end with it. Outcome: ${result.handoff?.logPath ?? ""}`,
150
+ );
151
+ return result;
152
+ case "self_ancestry":
153
+ log.error(
154
+ "Refusing to restart the desktop app because this command is running inside it, "
155
+ + "and the restart could not be handed off to a detached helper. "
156
+ + "Run 'ocx sync --restart-codex' from a terminal outside the app instead.",
157
+ );
158
+ return result;
159
+ case "process_probe_failed":
160
+ // Distinct from `no_targets`: we could not look, which is not the same as looking and
161
+ // finding nothing. Saying "not running" here sent users away believing there was nothing
162
+ // to restart (#2557).
163
+ log.error(
164
+ "Could not enumerate Codex desktop processes, so the app was not restarted. "
165
+ + "Quit and relaunch the desktop app manually to refresh the model picker.",
166
+ );
167
+ return result;
168
+ case "no_targets":
169
+ log.log("Codex desktop app is not running; nothing to restart.");
170
+ return result;
171
+ case "targets_survived":
172
+ log.error(
173
+ `Codex desktop app PID(s) ${result.surviving.join(", ")} did not exit, so it was not relaunched. `
174
+ + "Quit the desktop app manually to refresh the model picker.",
175
+ );
176
+ return result;
177
+ default:
178
+ if (result.relaunch === "started") {
179
+ log.log("Codex desktop app restarted; its model picker will re-read the catalog.");
180
+ }
181
+ return result;
182
+ }
183
+ }
184
+
@@ -17,8 +17,8 @@ import {
17
17
  import { readCodexCatalogPath } from "./catalog/parsing";
18
18
 
19
19
  export const STALE_CODEX_APP_SERVER_HINT =
20
- "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex). "
21
- + "On Windows the desktop app itself may also need a full restart (ocx sync --restart-desktop-app).";
20
+ "If Codex still shows an older model list, run `ocx sync --restart-codex`: it restarts the long-lived app-server "
21
+ + "processes and fully restarts the Codex desktop app, whose model picker is what actually holds the stale list.";
22
22
 
23
23
  /** Attach the shared dashboard hint only after a catalog or models_cache write. */
24
24
  export function attachStaleAppServerHint<T extends {
@@ -563,8 +563,8 @@ export function formatStaleCodexAppServerWarning(
563
563
  return (
564
564
  `WARNING: ${processes.length} Codex app-server process(es) still running (PID${processes.length === 1 ? "" : "s"}: ${pids}). `
565
565
  + "Disk catalog/cache were updated, but Codex may keep showing the old model list until those processes restart. "
566
- + "Re-run with `ocx sync --restart-codex` (or `ocx sync-cache --restart-codex`) to send SIGTERM only to matching app-server processes. "
567
- + "On Windows the desktop app itself may also need a full restart (`ocx sync --restart-desktop-app`). "
566
+ + "Re-run with `ocx sync --restart-codex` (or `ocx sync-cache --restart-codex`) to restart those processes and the Codex desktop app. "
567
+ + "Use `--restart-app-server-only` to leave the desktop app running. "
568
568
  + "Active turns may be interrupted."
569
569
  );
570
570
  }
@@ -1176,6 +1176,19 @@ export interface AfterCatalogWriteAppServerOptions {
1176
1176
  restart: boolean;
1177
1177
  log?: Pick<Console, "log" | "error"> | null;
1178
1178
  io?: CodexAppServerProcessIo;
1179
+ /**
1180
+ * Pids already covered by a desktop-app restart in this same command.
1181
+ *
1182
+ * The app-server is a CHILD of the Codex desktop app on every platform, so signalling
1183
+ * it and then quitting the app interrupts the operator's in-flight turn twice in one
1184
+ * command. Excluding the desktop tree leaves the quit to do that work once.
1185
+ *
1186
+ * Standalone app-servers - the npm wrapper pair, SSH bootstraps - are not members of
1187
+ * that tree and are still signalled. An empty list means no exclusion, which is what a
1188
+ * failed discovery or probe yields: a missed exclusion costs an extra interruption, a
1189
+ * wrong one leaves a stale app-server serving a roster that no longer exists.
1190
+ */
1191
+ excludePids?: readonly number[];
1179
1192
  }
1180
1193
 
1181
1194
  export interface AfterCatalogWriteAppServerResult {
@@ -1189,7 +1202,9 @@ export interface AfterCatalogWriteAppServerResult {
1189
1202
  export function afterCatalogWriteHandleAppServers(
1190
1203
  options: AfterCatalogWriteAppServerOptions,
1191
1204
  ): AfterCatalogWriteAppServerResult {
1192
- const processes = listCodexAppServerProcesses(options.io);
1205
+ const excluded = new Set(options.excludePids ?? []);
1206
+ const processes = listCodexAppServerProcesses(options.io)
1207
+ .filter(process => !excluded.has(process.pid));
1193
1208
  const hint = STALE_CODEX_APP_SERVER_HINT;
1194
1209
  if (processes.length === 0) {
1195
1210
  return { processes, warned: false, hint };
@@ -28,11 +28,26 @@ import {
28
28
  import type { CodexAppServerProcessIo } from "./app-server-processes";
29
29
  import type {
30
30
  CodexAppServerStateResponse,
31
+ CodexDesktopRestartSummary,
31
32
  CodexRestartResponse,
32
33
  } from "../lib/codex-restart-contract";
33
34
  import { getServerListenPort } from "../server/lifecycle";
34
35
 
36
+ async function defaultRestartDesktopApp(): Promise<CodexDesktopRestartSummary> {
37
+ const { restartCodexDesktopApp } = await import("./desktop-app-restart");
38
+ const outcome = restartCodexDesktopApp({ allowHandoff: false });
39
+ return {
40
+ attempted: outcome.attempted,
41
+ stopped: outcome.stopped,
42
+ surviving: outcome.surviving,
43
+ relaunch: outcome.relaunch,
44
+ ...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
45
+ };
46
+ }
47
+
35
48
  export interface CodexRestartServiceIo {
49
+ /** Desktop-restart seam, so a route test cannot terminate the developer's own Codex. */
50
+ restartDesktopApp?: () => Promise<CodexDesktopRestartSummary>;
36
51
  /** Process-layer seam, forwarded to every app-server-processes call. */
37
52
  processIo?: CodexAppServerProcessIo;
38
53
  /** Catalog refresh seam. Resolves to whether a catalog or cache write happened. */
@@ -114,7 +129,18 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartR
114
129
  (io.resetStateCache ?? resetCodexAppServerCatalogStateCache)();
115
130
  const before = (io.collectState ?? collectCodexAppServerCatalogState)(io.processIo ?? {});
116
131
 
132
+ // Done here, before the early returns, because the model picker lives in the DESKTOP
133
+ // app: "no app-server is running" is not a reason to leave a stale roster on screen,
134
+ // and an operator who pressed restart still wants the app back on the current catalog.
135
+ //
136
+ // allowHandoff is false and that is deliberate. The handoff waits for the CALLING
137
+ // process to exit, and this runs inside a long-lived proxy that does not, so every
138
+ // handoff started here would sit out its window and fail after the operator had
139
+ // already been told it was handed off. An honest refusal beats that.
140
+ const desktop = await (io.restartDesktopApp ?? defaultRestartDesktopApp)();
141
+
117
142
  const nothingToDo = (): CodexRestartResponse => ({
143
+ desktopApp: desktop,
118
144
  success: true,
119
145
  stateBefore: before.state,
120
146
  synced,
@@ -174,6 +200,7 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartR
174
200
  surviving: [],
175
201
  failed: [],
176
202
  code: "nothing_running",
203
+ desktopApp: desktop,
177
204
  };
178
205
  }
179
206
 
@@ -207,8 +234,10 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartR
207
234
  };
208
235
 
209
236
  const result = (io.restart ?? restartCodexAppServers)(targets, guardedProcessIo);
237
+
210
238
  const clean = result.surviving.length === 0 && result.failed.length === 0;
211
239
  return {
240
+ desktopApp: desktop,
212
241
  success: clean,
213
242
  stateBefore: before.state,
214
243
  synced,
@@ -2321,7 +2321,7 @@ async function gatherRoutedModelsWithAuth(
2321
2321
  return models;
2322
2322
  }
2323
2323
 
2324
- /** Bound a proven Codex-forward custom row without changing its stored configuration. */
2324
+ /** Bound a custom row whose model id has pinned native Codex metadata, without changing stored configuration. */
2325
2325
  function boundCustomNativeReasoning(
2326
2326
  model: CatalogModel,
2327
2327
  allowed: readonly string[],
@@ -2625,8 +2625,8 @@ async function gatherRoutedModelsUncached(
2625
2625
  : {}),
2626
2626
  // Explicit custom-row ladder wins over the inherited provider row below: the merge only
2627
2627
  // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept
2628
- // instead of being replaced by that row's metadata. Only proven native aliases are
2629
- // bounded against their own capability source after the merge.
2628
+ // instead of being replaced by that row's metadata. Capability-backed native model ids
2629
+ // are bounded against their own pinned ladder after the merge, including gateways.
2630
2630
  ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
2631
2631
  ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
2632
2632
  ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
@@ -2679,8 +2679,16 @@ async function gatherRoutedModelsUncached(
2679
2679
  ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
2680
2680
  ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
2681
2681
  } : base;
2682
- const reasoningBounded = codexForwardNativeCapabilityAlias
2683
- ? boundCustomNativeReasoning(merged, nativeReasoningEfforts(cm.modelId), nativeAliasDefaultEffort)
2682
+ // Catalog-advertised efforts are bounded whenever the model id is a pinned native
2683
+ // slug. Desktop validates that id, so a gateway such as YYLJ/gpt-6-astra still cannot
2684
+ // advertise none/minimal. Full native identity stays behind the alias predicate.
2685
+ const nativeEffortSource = hasNativeOpenAiCapabilityMetadata(cm.modelId);
2686
+ const reasoningBounded = nativeEffortSource
2687
+ ? boundCustomNativeReasoning(
2688
+ merged,
2689
+ nativeReasoningEfforts(cm.modelId),
2690
+ nativeAliasDefaultEffort ?? nativeDefaultReasoningEffort(cm.modelId),
2691
+ )
2684
2692
  : merged;
2685
2693
  // Vision-sidecar coverage only: when the enriched provider's shared predicate matches
2686
2694
  // noVisionModels or text-without-image modelInputModalities, advertise image input so the
@@ -49,7 +49,7 @@ import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "../account-l
49
49
 
50
50
  import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
51
51
  import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
52
- import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, RETIRED_NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
52
+ import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, hasNativeOpenAiCapabilityMetadata, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, RETIRED_NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
53
53
  import {
54
54
  bundledCatalogCacheState,
55
55
  loadBundledCodexCatalog,
@@ -316,6 +316,13 @@ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick<Ocx
316
316
  return slug;
317
317
  }
318
318
 
319
+ function preservePinnedNativeCustomReasoning(model?: CatalogModel): boolean {
320
+ return model !== undefined
321
+ && model.catalogKind === CODEX_CUSTOM_MODEL_CATALOG_KIND
322
+ && hasNativeOpenAiCapabilityMetadata(model.id)
323
+ && Array.isArray(model.reasoningEfforts);
324
+ }
325
+
319
326
  /**
320
327
  * Cria uma entrada nativa ou roteada a partir do snapshot upstream, de um clone
321
328
  * do template ou de campos mínimos. Aplica os metadados e limites pertinentes
@@ -380,7 +387,9 @@ export function deriveEntry(
380
387
  e,
381
388
  model?.reasoningEfforts,
382
389
  model?.defaultReasoningEffort,
383
- preserveExactReasoning || codexForwardNativeCapabilityAlias !== null,
390
+ preserveExactReasoning
391
+ || codexForwardNativeCapabilityAlias !== null
392
+ || preservePinnedNativeCustomReasoning(model),
384
393
  );
385
394
  // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned
386
395
  // native tool/search/responses-lite contract while preserving the routed slug and wire id.
@@ -429,7 +438,7 @@ export function deriveEntry(
429
438
  };
430
439
  if (isRouted) {
431
440
  applyRoutedCodexToolMode(entry, model?.codexToolMode);
432
- applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning);
441
+ applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning || preservePinnedNativeCustomReasoning(model));
433
442
  }
434
443
  else {
435
444
  applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
@@ -0,0 +1,268 @@
1
+ /**
2
+ * macOS adapter for the Codex desktop-app restart.
3
+ *
4
+ * Measured shape (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md):
5
+ *
6
+ * 15901 1 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT
7
+ * 16733 15901 /Applications/ChatGPT.app/Contents/Resources/codex ... app-server ...
8
+ * 15903 1 .../Contents/Frameworks/Codex Framework.framework/.../browser_crashpad_handler
9
+ *
10
+ * The bundle is named ChatGPT.app but its identifier is com.openai.codex, and the
11
+ * display name is shared with a different OpenAI product. Every identity decision
12
+ * here therefore keys on the identifier, never on the name.
13
+ */
14
+ import { realpathSync } from "node:fs";
15
+ import { execFileSync } from "node:child_process";
16
+ import { join } from "node:path";
17
+ import {
18
+ isUnderRoot,
19
+ type DesktopAppAdapter,
20
+ type DesktopAppInstall,
21
+ type DesktopExec,
22
+ type DesktopProcess,
23
+ } from "./types";
24
+
25
+ const BUNDLE_ID = "com.openai.codex";
26
+ const CONVENTIONAL_BUNDLE = "/Applications/ChatGPT.app";
27
+ const SHELL_SUFFIX = "/Contents/MacOS/ChatGPT";
28
+
29
+ /** Absolute system locations only. PATH is never consulted for any of these. */
30
+ const PS = "/bin/ps";
31
+ const OSASCRIPT = "/usr/bin/osascript";
32
+ const OPEN = "/usr/bin/open";
33
+ const MDFIND = "/usr/bin/mdfind";
34
+ const PLIST_BUDDY = "/usr/libexec/PlistBuddy";
35
+
36
+ const PROBE_TIMEOUT_MS = 10_000;
37
+ const MAX_ANCESTRY_HOPS = 16;
38
+
39
+ function shellPath(bundle: string): string {
40
+ return join(bundle, "Contents", "MacOS", "ChatGPT");
41
+ }
42
+
43
+ function readBundleIdentifier(exec: DesktopExec, bundle: string): string | null {
44
+ try {
45
+ return exec(PLIST_BUDDY, [
46
+ "-c", "Print :CFBundleIdentifier",
47
+ join(bundle, "Contents", "Info.plist"),
48
+ ], { timeout: PROBE_TIMEOUT_MS }).trim();
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function confirmBundle(exec: DesktopExec, candidate: string): DesktopAppInstall | null {
55
+ if (!candidate) return null;
56
+ let resolved: string;
57
+ try {
58
+ // Resolved ONCE here so membership is a pure comparison against a trusted value.
59
+ // A prefix test against an unresolved path admits sibling directories such as
60
+ // /Applications/ChatGPT.app-evil/..., which the same user can create.
61
+ resolved = realpathSync(candidate);
62
+ } catch {
63
+ return null;
64
+ }
65
+ if (readBundleIdentifier(exec, resolved) !== BUNDLE_ID) return null;
66
+ return { id: BUNDLE_ID, root: resolved, relaunch: BUNDLE_ID };
67
+ }
68
+
69
+ interface PsSnapshot {
70
+ pid: number;
71
+ parentPid: number;
72
+ createdAt: string;
73
+ uid: number;
74
+ executable: string;
75
+ }
76
+
77
+ /**
78
+ * Column order matters. lstart is five whitespace-separated tokens, and the executable
79
+ * path itself contains spaces and parentheses on this app (Codex (Service).app), so
80
+ * everything after the uid is taken as the remainder of the line rather than split.
81
+ *
82
+ * comm as the FINAL -o column yields the full, untruncated executable path; this was
83
+ * checked against a 150+ character helper path rather than assumed. The 16-character
84
+ * truncation people expect belongs to ucomm.
85
+ */
86
+ function parsePsLine(line: string): PsSnapshot | null {
87
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(\d+)\s+(.+)$/.exec(line);
88
+ if (!match) return null;
89
+ const pid = Number(match[1]);
90
+ const parentPid = Number(match[2]);
91
+ const uid = Number(match[4]);
92
+ if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(parentPid) || !Number.isSafeInteger(uid)) {
93
+ return null;
94
+ }
95
+ return {
96
+ pid,
97
+ parentPid,
98
+ createdAt: (match[3] ?? "").trim(),
99
+ uid,
100
+ executable: (match[5] ?? "").trim(),
101
+ };
102
+ }
103
+
104
+ function readPsSnapshots(exec: DesktopExec): PsSnapshot[] | null {
105
+ let stdout: string;
106
+ try {
107
+ stdout = exec(PS, ["-Ao", "pid=,ppid=,lstart=,uid=,comm="], { timeout: PROBE_TIMEOUT_MS });
108
+ } catch {
109
+ // A probe that could not RUN is not evidence of absence.
110
+ return null;
111
+ }
112
+ const out: PsSnapshot[] = [];
113
+ for (const line of stdout.split(/\r?\n/)) {
114
+ const parsed = parsePsLine(line);
115
+ if (parsed) out.push(parsed);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Prefer the bundle the RUNNING shell executes out of.
122
+ *
123
+ * Membership is path-scoped while the quit and the relaunch are bundle-id-scoped. If
124
+ * two bundles claim com.openai.codex, discovering by identifier alone could enumerate
125
+ * one installation and quit the other. Starting from the live process makes the thing
126
+ * we quit the same thing we counted.
127
+ */
128
+ function discoverFromRunningShell(exec: DesktopExec): string | null {
129
+ for (const snapshot of readPsSnapshots(exec) ?? []) {
130
+ if (!snapshot.executable.endsWith(SHELL_SUFFIX)) continue;
131
+ return snapshot.executable.slice(0, -SHELL_SUFFIX.length);
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function currentUid(): number | undefined {
137
+ try {
138
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ let killProcess: (pid: number, signal: NodeJS.Signals) => void = (pid, signal) => {
145
+ process.kill(pid, signal);
146
+ };
147
+
148
+ /** Test-only seam, so a kill can be observed without ending a developer's own Codex. */
149
+ export function setDarwinKillForTests(
150
+ next: ((pid: number, signal: NodeJS.Signals) => void) | null,
151
+ ): void {
152
+ killProcess = next ?? ((pid, signal) => { process.kill(pid, signal); });
153
+ }
154
+
155
+ export const darwinDesktopAppAdapter: DesktopAppAdapter = {
156
+ discover(exec): DesktopAppInstall | null {
157
+ const running = discoverFromRunningShell(exec);
158
+ if (running) {
159
+ const confirmed = confirmBundle(exec, running);
160
+ if (confirmed) return confirmed;
161
+ }
162
+ let spotlight = "";
163
+ try {
164
+ const query = "kMDItemCFBundleIdentifier == '" + BUNDLE_ID + "'";
165
+ spotlight = exec(MDFIND, [query], { timeout: PROBE_TIMEOUT_MS })
166
+ .split(/\r?\n/)
167
+ .map(entry => entry.trim())
168
+ .find(entry => entry.length > 0) ?? "";
169
+ } catch {
170
+ spotlight = "";
171
+ }
172
+ return confirmBundle(exec, spotlight) ?? confirmBundle(exec, CONVENTIONAL_BUNDLE);
173
+ },
174
+
175
+ listProcesses(exec, install): DesktopProcess[] | null {
176
+ const snapshots = readPsSnapshots(exec);
177
+ if (snapshots === null) return null;
178
+ const uid = currentUid();
179
+ // Without a uid there is no way to scope the result to this user, and reporting an
180
+ // empty list would tell the caller the app is not running (#2557's failure mode in a
181
+ // different disguise). This is a probe failure.
182
+ if (uid === undefined) return null;
183
+ const out: DesktopProcess[] = [];
184
+ for (const snapshot of snapshots) {
185
+ if (!isUnderRoot(snapshot.executable, install.root)) continue;
186
+ // Same user only.
187
+ if (snapshot.uid !== uid) continue;
188
+ out.push({
189
+ pid: snapshot.pid,
190
+ parentPid: snapshot.parentPid,
191
+ createdAt: snapshot.createdAt,
192
+ executable: snapshot.executable,
193
+ });
194
+ }
195
+ return out;
196
+ },
197
+
198
+ isShell(entry, install): boolean {
199
+ return entry.executable === shellPath(install.root);
200
+ },
201
+
202
+ ancestryPids(exec): number[] {
203
+ const chain: number[] = [process.pid];
204
+ let current = process.pid;
205
+ for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
206
+ let stdout: string;
207
+ try {
208
+ stdout = exec(PS, ["-o", "ppid=", "-p", String(current)], { timeout: PROBE_TIMEOUT_MS });
209
+ } catch (error) {
210
+ // ps -p <pid> exits 1 with empty output when the pid does not exist, and
211
+ // execFileSync turns a non-zero exit into a throw. Without this branch the
212
+ // clean-end handling below is unreachable in production, every dead parent reads
213
+ // as unreadable, and the orphaned handoff helper refuses the one job it exists
214
+ // for. Hop 0 is this process, which always exists, so a failure there is real.
215
+ const status = (error as { status?: unknown } | null)?.status;
216
+ if (hop > 0 && status === 1) return chain;
217
+ // Anything else: could not look, so we cannot conclude we are outside the tree.
218
+ return [];
219
+ }
220
+ const trimmed = stdout.trim();
221
+ // Empty output means the pid has no live parent entry: a CLEAN end of chain, not
222
+ // a read failure. The detached handoff helper reaches exactly this state once its
223
+ // caller exits, and reading it as unreadable would make the helper refuse the one
224
+ // job it exists for.
225
+ if (trimmed === "") return chain;
226
+ const parent = Number(trimmed);
227
+ if (!Number.isSafeInteger(parent) || parent <= 0) return chain;
228
+ if (chain.includes(parent)) return chain;
229
+ chain.push(parent);
230
+ if (parent === 1) return chain;
231
+ current = parent;
232
+ }
233
+ // Bound reached without finding the top. A truncated chain silently defeats the
234
+ // self-ancestry intersection, so this reports "could not establish" instead.
235
+ return [];
236
+ },
237
+
238
+ requestQuit(exec, install): void {
239
+ // The Apple event, so the app runs its own termination path. Delivery is
240
+ // synchronous; termination is not, which is why the ladder always waits and
241
+ // re-verifies identity afterwards.
242
+ exec(OSASCRIPT, ["-e", 'quit app id "' + install.id + '"'], { timeout: PROBE_TIMEOUT_MS });
243
+ },
244
+
245
+ forceStop(_exec, root): void {
246
+ killProcess(root.pid, "SIGKILL");
247
+ },
248
+
249
+ captureRelaunchContext(): Record<string, string> {
250
+ // LaunchServices supplies the session, so nothing needs carrying forward.
251
+ return {};
252
+ },
253
+
254
+ relaunch(exec, install): void {
255
+ // Deliberately without -g: the operator asked for a restart and expects the app in
256
+ // front of them. An unknown bundle id exits non-zero with
257
+ // LSCopyApplicationURLsForBundleIdentifier() failed, which the ladder turns into
258
+ // relaunch_failed rather than a silent no-op. Not -n either: a second instance is
259
+ // both unreliable to obtain and unwanted.
260
+ exec(OPEN, ["-b", install.relaunch], { timeout: PROBE_TIMEOUT_MS });
261
+ },
262
+ };
263
+
264
+ export const darwinDefaultExec: DesktopExec = (file, args, options) => execFileSync(file, [...args], {
265
+ encoding: "utf-8",
266
+ stdio: ["ignore", "pipe", "ignore"],
267
+ timeout: options?.timeout ?? PROBE_TIMEOUT_MS,
268
+ });