@bitkyc08/opencodex 2.53.0 → 2.54.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 (34) hide show
  1. package/gui/dist/assets/{index-BlO4Yl6q.js → index-CkvITofZ.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
@@ -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
+ });