@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
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-D7ynYo2K.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-B4VYfZcY.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-BBOZWGB6.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.53.0-preview.20260913",
3
+ "version": "2.54.0-preview.20260914",
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",
@@ -10,6 +10,7 @@ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, Ocx
10
10
  import { namespacedToolName } from "../types";
11
11
  import type { IncomingMeta, ProviderAdapter } from "./base";
12
12
  import { streamChatEvents, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct";
13
+ import type { ContentPart } from "./devin/cloud-direct/chat";
13
14
  import { getCachedCatalog } from "./devin/cloud-direct/catalog";
14
15
  import { collapseDevinModelUid } from "./devin/live-models";
15
16
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
@@ -208,9 +209,31 @@ function textFromParts(content: string | OcxContentPart[] | undefined): string {
208
209
  return content.map((part) => (part.type === "text" ? part.text : "")).filter(Boolean).join("\n");
209
210
  }
210
211
 
211
- function toolResultText(message: OcxToolResultMessage): string {
212
- const body = textFromParts(message.content);
213
- return message.isError ? ("ERROR: " + body) : body;
212
+ /**
213
+ * Convert inbound content parts to the multimodal shape the wire encoder accepts.
214
+ *
215
+ * The wire layer already carries images (ChatMessagePrompt field #10 ImageData),
216
+ * but every image was discarded at this boundary: textFromParts returned a
217
+ * text-only string and a message whose only content was an image was dropped
218
+ * entirely, which is why a pasted screenshot killed the turn and the only
219
+ * workaround was running OCR before sending. A data: URL carries everything
220
+ * field #10 needs; a remote https URL cannot be inlined without a fetch, so it
221
+ * stays as an explicit text reference rather than pretending the model can see
222
+ * a picture it cannot. Video has no Devin field and is skipped.
223
+ */
224
+ function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): string | ContentPart[] {
225
+ if (typeof content === "string" || !Array.isArray(content)) return content ?? "";
226
+ const out: ContentPart[] = [];
227
+ for (const part of content) {
228
+ if (part.type === "text" && part.text) {
229
+ out.push({ type: "text", text: part.text });
230
+ } else if (part.type === "image") {
231
+ const m = part.imageUrl.match(/^data:([^;]+);base64,(.+)$/);
232
+ if (m) out.push({ type: "image", mimeType: m[1]!, base64Data: m[2]! });
233
+ else out.push({ type: "text", text: `[image url: ${part.imageUrl}]` });
234
+ }
235
+ }
236
+ return out;
214
237
  }
215
238
 
216
239
  function assistantToolCalls(message: OcxAssistantMessage): Array<{ id: string; name: string; arguments: string }> {
@@ -290,9 +313,12 @@ export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem
290
313
 
291
314
  function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined {
292
315
  if (message.role === "user" || message.role === "developer") {
293
- const text = textFromParts(message.content).trim();
294
- if (!text) return undefined;
295
- return { role: message.role === "developer" ? "system" : "user", content: text };
316
+ const content = mapOcxContentToWire(message.content);
317
+ // An image with no caption text is a complete user message on its own.
318
+ // Dropping it — which is what the text-only extraction did — is why a
319
+ // pasted screenshot killed the turn before the model ever saw anything.
320
+ if (typeof content === "string" ? !content.trim() : content.length === 0) return undefined;
321
+ return { role: message.role === "developer" ? "system" : "user", content };
296
322
  }
297
323
  if (message.role === "assistant") {
298
324
  const toolCalls = assistantToolCalls(message);
@@ -309,9 +335,15 @@ function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined {
309
335
  };
310
336
  }
311
337
  if (message.role === "toolResult") {
338
+ const wireContent = mapOcxContentToWire(message.content);
339
+ const toolContent = message.isError
340
+ ? (typeof wireContent === "string"
341
+ ? `ERROR: ${wireContent}`
342
+ : [{ type: "text", text: "ERROR:" } as ContentPart, ...wireContent])
343
+ : wireContent;
312
344
  return {
313
345
  role: "tool",
314
- content: toolResultText(message),
346
+ content: toolContent,
315
347
  tool_call_id: message.toolCallId,
316
348
  };
317
349
  }
@@ -720,6 +720,7 @@ export const CAPABILITIES: readonly Capability[] = [
720
720
  json: "payload",
721
721
  details: [
722
722
  "`sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request.",
723
+ "Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request.",
723
724
  "--yes is mandatory because this interrupts a running editor session, which must never happen because an agent guessed a subcommand.",
724
725
  ],
725
726
  },
@@ -785,8 +786,9 @@ export const CAPABILITIES: readonly Capability[] = [
785
786
  summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.",
786
787
  routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }],
787
788
  flags: [
788
- { name: "--restart-codex", value: "boolean", summary: "Restart Codex app-servers after a catalog or cache write." },
789
- { name: "--restart-desktop-app", value: "boolean", summary: "Restart the Codex desktop app after a catalog or cache write." },
789
+ { name: "--restart-codex", value: "boolean", summary: "Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog or cache write, on macOS, Linux and Windows." },
790
+ { name: "--restart-app-server-only", value: "boolean", summary: "Restart only the Codex app-servers and leave the desktop app running; wins over --restart-codex when both are given." },
791
+ { name: "--restart-desktop-app", value: "boolean", summary: "Deprecated alias of --restart-codex." },
790
792
  ],
791
793
  mutates: true,
792
794
  json: "none",
@@ -1,4 +1,4 @@
1
- import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes";
1
+ import { handleRestartScopeAfterWrite, readRestartScope } from "./restart-scope";
2
2
  import { pullRemoteCatalog, RemoteCatalogError } from "../codex/catalog/remote";
3
3
  import { hasHelpFlag, printSubcommandUsage } from "./help";
4
4
 
@@ -9,6 +9,14 @@ export interface CatalogPullEnvelope {
9
9
  catalogWritten: boolean;
10
10
  cacheSynced: boolean;
11
11
  codexRestarted: boolean;
12
+ /**
13
+ * Whether the desktop app was actually restarted. Only ever true for a completed
14
+ * relaunch: a handoff is not a success, because the restart has not happened yet when
15
+ * this envelope is written and a script reading true would proceed on a promise.
16
+ * Separate from codexRestarted so a script reading the existing field is not silently
17
+ * handed a different answer.
18
+ */
19
+ desktopAppRestarted?: boolean;
12
20
  modelCount?: number;
13
21
  code?: string;
14
22
  }
@@ -21,14 +29,18 @@ function optionValue(args: string[], name: string): string | undefined {
21
29
  export async function handleCatalogCommand(args: string[]): Promise<number> {
22
30
  if (hasHelpFlag(args)) { printSubcommandUsage("catalog"); return 0; }
23
31
  const json = args.includes("--json");
24
- const restartCodex = args.includes("--restart-codex");
32
+ const restartScope = readRestartScope(args, console);
25
33
  const authEnv = optionValue(args, "--auth-env");
26
34
  const positionals = args.filter((arg, index) => {
27
35
  if (arg === "--auth-env") return false;
28
36
  if (index > 0 && args[index - 1] === "--auth-env") return false;
29
37
  return !arg.startsWith("-");
30
38
  });
31
- const knownFlags = new Set(["--json", "--restart-codex", "--auth-env"]);
39
+ // A closed set: an unknown flag is a usage error, so the new scope flags have to be
40
+ // listed here or catalog pull would reject the very flags sync accepts.
41
+ const knownFlags = new Set([
42
+ "--json", "--restart-codex", "--restart-desktop-app", "--restart-app-server-only", "--auth-env",
43
+ ]);
32
44
  const unknown = args.find((arg, index) => arg.startsWith("-") && !knownFlags.has(arg) && args[index - 1] !== "--auth-env");
33
45
  const validEnvName = authEnv === undefined || /^[A-Za-z_][A-Za-z0-9_]*$/.test(authEnv);
34
46
  if (positionals[0] !== "pull" || positionals.length !== 2 || unknown || !validEnvName
@@ -38,7 +50,7 @@ export async function handleCatalogCommand(args: string[]): Promise<number> {
38
50
  cacheSynced: false, codexRestarted: false, code: "usage",
39
51
  };
40
52
  if (json) console.log(JSON.stringify(envelope));
41
- else console.error("Usage: ocx catalog pull <https-url> [--auth-env <NAME>] [--json] [--restart-codex]");
53
+ else console.error("Usage: ocx catalog pull <https-url> [--auth-env <NAME>] [--json] [--restart-codex] [--restart-app-server-only]");
42
54
  return 2;
43
55
  }
44
56
  let token: string | undefined;
@@ -57,21 +69,35 @@ export async function handleCatalogCommand(args: string[]): Promise<number> {
57
69
  try {
58
70
  const result = await pullRemoteCatalog(positionals[1]!, { token });
59
71
  let codexRestarted = false;
72
+ let desktopAppRestarted = false;
60
73
  let restartIncomplete = false;
61
74
  if (result.catalogWritten) {
62
75
  const processLog = json
63
76
  ? { log: (...values: unknown[]) => console.error(...values), error: (...values: unknown[]) => console.error(...values) }
64
77
  : console;
65
- const processResult = afterCatalogWriteHandleAppServers({ restart: restartCodex, log: processLog });
66
- const restart = processResult.restart;
78
+ const outcome = await handleRestartScopeAfterWrite(restartScope, processLog);
79
+ const processResult = outcome.appServers;
80
+ const restart = processResult?.restart;
81
+ desktopAppRestarted = outcome.desktopApp?.relaunch === "started";
82
+ // A desktop restart that was asked for and did not relaunch is an incomplete
83
+ // restart, exactly like a surviving app-server. Without this the pull reports
84
+ // ok: true while the picker the operator was fixing is still stale.
85
+ // "Desktop app is not running" is the same nothing-to-do the app-server half
86
+ // already treats as success, so it must not read as an incomplete restart.
87
+ if (restartScope.desktopApp && !desktopAppRestarted
88
+ && outcome.desktopApp?.reason !== "no_targets") {
89
+ restartIncomplete = true;
90
+ }
67
91
  if (restart) {
68
92
  // A partial stop is not a restart. `restartCodexAppServers` reports failures and
69
93
  // survivors without throwing, so counting `stopped` alone reported success while a
70
94
  // stale app-server was still serving the previous catalog from memory.
71
95
  codexRestarted = restart.failed.length === 0
72
96
  && restart.surviving.length === 0
73
- && restart.stopped.length === processResult.processes.length;
74
- restartIncomplete = !codexRestarted;
97
+ && restart.stopped.length === (processResult?.processes.length ?? -1);
98
+ // Do not ASSIGN here: the desktop half may already have set this, and assigning
99
+ // would discard a failed desktop restart whenever any app-server was signalled.
100
+ if (!codexRestarted) restartIncomplete = true;
75
101
  }
76
102
  }
77
103
  if (restartIncomplete) {
@@ -81,7 +107,9 @@ export async function handleCatalogCommand(args: string[]): Promise<number> {
81
107
  const envelope: CatalogPullEnvelope = {
82
108
  schemaVersion: 1, ok: false, status: result.status,
83
109
  catalogWritten: result.catalogWritten, cacheSynced: result.cacheSynced,
84
- codexRestarted: false, modelCount: result.modelCount, code: "restart_incomplete",
110
+ codexRestarted: false,
111
+ ...(restartScope.desktopApp ? { desktopAppRestarted } : {}),
112
+ modelCount: result.modelCount, code: "restart_incomplete",
85
113
  };
86
114
  if (json) console.log(JSON.stringify(envelope));
87
115
  else console.error("Remote Codex catalog installed, but a Codex app-server is still running the previous catalog.");
@@ -90,7 +118,8 @@ export async function handleCatalogCommand(args: string[]): Promise<number> {
90
118
  const envelope: CatalogPullEnvelope = {
91
119
  schemaVersion: 1, ok: true, status: result.status,
92
120
  catalogWritten: result.catalogWritten, cacheSynced: result.cacheSynced,
93
- codexRestarted, modelCount: result.modelCount,
121
+ codexRestarted,
122
+ ...(restartScope.desktopApp ? { desktopAppRestarted } : {}), modelCount: result.modelCount,
94
123
  };
95
124
  if (json) console.log(JSON.stringify(envelope));
96
125
  else if (result.status === "unchanged") console.log("Remote Codex catalog is unchanged; no files or processes were touched.");
@@ -26,7 +26,7 @@ import { syncModelsToCodex } from "../codex/sync";
26
26
  import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
27
27
  import { restoreNativeCodexAsync } from "../codex/inject";
28
28
  import { stripGrokConfig } from "../grok/inject";
29
- import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes";
29
+ import { handleRestartScopeAfterWrite, readRestartScope, type RestartScope } from "./restart-scope";
30
30
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
31
31
  import { isJsonOption, takeFlag } from "./runtime-api";
32
32
  import type { ClientConnectionState } from "../client/state";
@@ -378,10 +378,12 @@ const commandRunners: Record<string, CommandRunner> = {
378
378
  },
379
379
  sync: async deps => {
380
380
  const syncArgs = deps.args.slice(1);
381
- const restartCodex = syncArgs.includes("--restart-codex");
382
- // Separate flag on purpose: --restart-codex promises app-server-only scope,
383
- // and quitting the desktop app ends live conversations.
384
- const restartDesktopApp = syncArgs.includes("--restart-desktop-app");
381
+ const restartScope = readRestartScope(syncArgs, console);
382
+ // The wire field keeps APP-SERVER-ONLY meaning and is deliberately not widened. A
383
+ // remote hub must not end a local user's conversations because a field name acquired
384
+ // a wider meaning underneath it; the maintainer decision widened a local CLI flag and
385
+ // said nothing about remote callers. syncConnectedClient ignores it either way.
386
+ const restartCodex = restartScope.appServers;
385
387
  const { readClientConnectionState } = await import("../client/state");
386
388
  const clientState = readClientConnectionState();
387
389
  if (clientState.kind === "invalid" || clientState.kind === "mismatched") {
@@ -395,7 +397,7 @@ const commandRunners: Record<string, CommandRunner> = {
395
397
  console.log(result.stale
396
398
  ? "Hub unavailable; retained and applied the last-known-good remote catalog (stale)."
397
399
  : "Remote hub catalog synchronized.");
398
- await handleConnectedSyncCatalogWrite(result, restartCodex, restartDesktopApp);
400
+ await handleConnectedSyncCatalogWrite(result, restartScope);
399
401
  // `process.exitCode` rather than a literal 0, for the same reason every other
400
402
  // runner does it (tests/cli/cli-transport-honesty.test.ts): the catalog-write helper
401
403
  // drives app-server restarts, and one of those recording a failure must not be
@@ -434,8 +436,7 @@ const commandRunners: Record<string, CommandRunner> = {
434
436
  // so a sync can fail (`ok: false`) after the catalog was already rewritten — which is
435
437
  // exactly when a long-lived app-server is holding the stale list.
436
438
  if (synced.catalogWritten || synced.cacheSynced) {
437
- afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
438
- if (restartDesktopApp) await handleDesktopAppRestart(console);
439
+ await handleRestartScopeAfterWrite(restartScope, console);
439
440
  }
440
441
  // `ocx sync` is a direct CLI path; it does not call the management
441
442
  // `/api/sync` route. Refresh already-connected file integrations here too,
@@ -496,8 +497,7 @@ const commandRunners: Record<string, CommandRunner> = {
496
497
  },
497
498
  "sync-cache": async deps => {
498
499
  const cacheArgs = deps.args.slice(1);
499
- const restartCodex = cacheArgs.includes("--restart-codex");
500
- const restartDesktopApp = cacheArgs.includes("--restart-desktop-app");
500
+ const restartScope = readRestartScope(cacheArgs, console);
501
501
  const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization");
502
502
  const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync");
503
503
  const { getCodexHome } = await import("../codex/paths");
@@ -514,8 +514,7 @@ const commandRunners: Record<string, CommandRunner> = {
514
514
  : console;
515
515
  // Only warn/restart when models_cache was actually rewritten from a readable catalog.
516
516
  if (invalidated.kind === "completed" && invalidated.value) {
517
- afterCatalogWriteHandleAppServers({ restart: restartCodex, log: jsonSafeLog });
518
- if (restartDesktopApp) await handleDesktopAppRestart(jsonSafeLog);
517
+ await handleRestartScopeAfterWrite(restartScope, jsonSafeLog);
519
518
  } else if (desiredDisabled && !cacheJson) {
520
519
  // Worth saying in the human path, because it explains why nothing was written.
521
520
  // Under --json this belongs on the envelope, not as a second stdout line.
@@ -961,6 +960,12 @@ export async function dispatchCommand(head: CliHead, deps: CliDispatchDeps): Pro
961
960
  printUsage();
962
961
  return 0;
963
962
  }
963
+ if (command === "internal") {
964
+ // Routed here rather than as a runner key so it stays out of DISPATCH_COMMANDS and
965
+ // therefore out of the registry-parity gate. See src/cli/internal-command.ts.
966
+ const { handleInternalCommand } = await import("./internal-command");
967
+ return await handleInternalCommand(deps.args.slice(1));
968
+ }
964
969
  const runner = commandRunners[resolveDispatchCommand(command) ?? ""];
965
970
  if (!runner) {
966
971
  console.error(`Unknown command: ${command}`);
@@ -970,62 +975,12 @@ export async function dispatchCommand(head: CliHead, deps: CliDispatchDeps): Pro
970
975
  return await runner(deps);
971
976
  }
972
977
 
973
- /**
974
- * Report the outcome of an opt-in desktop-app restart. Kept next to the two
975
- * callers so `sync` and `sync-cache` cannot drift in what they tell the user.
976
- */
977
- async function handleDesktopAppRestart(log: Pick<Console, "log" | "error">): Promise<void> {
978
- const { restartCodexDesktopApp } = await import("../codex/desktop-app-restart");
979
- const result = restartCodexDesktopApp();
980
- switch (result.reason) {
981
- case "windows_only":
982
- log.error("--restart-desktop-app is supported on Windows only; nothing was stopped.");
983
- return;
984
- case "package_discovery_failed":
985
- log.error(
986
- "Could not identify the installed Codex desktop package. Quit and relaunch the desktop app "
987
- + "manually to refresh the model picker.",
988
- );
989
- return;
990
- case "self_ancestry":
991
- log.error(
992
- "Refusing to restart the desktop app because this command is running inside it. "
993
- + "Run 'ocx sync --restart-desktop-app' from an external terminal instead.",
994
- );
995
- return;
996
- case "process_probe_failed":
997
- // Distinct from `no_targets`: we could not look, which is not the same as looking and
998
- // finding nothing. Saying "not running" here sent users away believing there was nothing
999
- // to restart (#2557).
1000
- log.error(
1001
- "Could not enumerate Codex desktop processes, so the app was not restarted. "
1002
- + "Quit and relaunch the desktop app manually to refresh the model picker.",
1003
- );
1004
- return;
1005
- case "no_targets":
1006
- log.log("Codex desktop app is not running; nothing to restart.");
1007
- return;
1008
- case "targets_survived":
1009
- log.error(
1010
- `Codex desktop app PID(s) ${result.surviving.join(", ")} did not exit, so it was not relaunched. `
1011
- + "Quit the desktop app manually to refresh the model picker.",
1012
- );
1013
- return;
1014
- default:
1015
- if (result.relaunch === "started") {
1016
- log.log("Codex desktop app restarted; its model picker will re-read the catalog.");
1017
- }
1018
- }
1019
- }
1020
-
1021
978
  async function handleConnectedSyncCatalogWrite(
1022
979
  result: { catalogWritten: boolean; cacheSynced: boolean },
1023
- restartCodex: boolean,
1024
- restartDesktopApp: boolean,
980
+ scope: RestartScope,
1025
981
  ): Promise<void> {
1026
982
  if (!result.catalogWritten && !result.cacheSynced) return;
1027
- afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
1028
- if (restartDesktopApp) await handleDesktopAppRestart(console);
983
+ await handleRestartScopeAfterWrite(scope, console);
1029
984
  }
1030
985
 
1031
986
  async function reconcileClientJournalBeforeLifecycle(
package/src/cli/doctor.ts CHANGED
@@ -1366,7 +1366,7 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1366
1366
  const { collectCodexAppServerCatalogState } = await import("../codex/app-server-processes");
1367
1367
  const catalogState = collectCodexAppServerCatalogState();
1368
1368
  if (catalogState.state === "stale") {
1369
- console.log(` [WARN] Codex app-server (PID(s): ${catalogState.processes.map(p => p.pid).join(", ")}) started before the on-disk catalog changed; its in-memory model list disagrees with ocx. Action: restart Codex (or run \`ocx sync --restart-codex\`; on Windows the desktop app may need \`ocx sync --restart-desktop-app\`)`);
1369
+ console.log(` [WARN] Codex app-server (PID(s): ${catalogState.processes.map(p => p.pid).join(", ")}) started before the on-disk catalog changed; its in-memory model list disagrees with ocx. Action: run \`ocx sync --restart-codex\`, which restarts the app-servers and the Codex desktop app`);
1370
1370
  } else if (catalogState.state === "unknown") {
1371
1371
  console.log(" [WARN] Could not verify whether the running Codex app-server's model catalog is current (start time or catalog unreadable). Action: if the model list looks stale, restart Codex");
1372
1372
  } else if (catalogState.state === "fresh") {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Hidden `ocx internal ...` commands.
3
+ *
4
+ * Deliberately NOT in `src/cli/registry.ts` and NOT in `src/cli/capabilities.ts`, so it
5
+ * is absent from help, from the generated skill surface, and from the registry-parity
6
+ * gate. It is not a user-facing capability and must not become one: it exists so the
7
+ * detached restart helper is the same audited binary running the same audited ladder,
8
+ * rather than a second implementation in a shell script.
9
+ *
10
+ * It is routed before the dispatch table for the same reason `help` is - adding it as a
11
+ * runner key would make it a command the registry-parity test expects to find
12
+ * documented.
13
+ *
14
+ * It is intentionally unauthenticated, and that is not an oversight. Any process running
15
+ * as this user can invoke it with a hand-written plan file, and it gains nothing by
16
+ * doing so: the helper only does what the public `--restart-codex` flag already does for
17
+ * that same user, and a same-uid process could call `kill` directly. A token here would
18
+ * protect nothing and would imply a boundary that does not exist.
19
+ */
20
+ const USAGE = "Usage: ocx internal desktop-restart-handoff --plan <path>";
21
+
22
+ function optionValue(args: readonly string[], name: string): string | undefined {
23
+ const index = args.indexOf(name);
24
+ return index >= 0 ? args[index + 1] : undefined;
25
+ }
26
+
27
+ export async function handleInternalCommand(args: readonly string[]): Promise<number> {
28
+ const sub = args[0];
29
+ if (sub !== "desktop-restart-handoff") {
30
+ console.error(`Unknown internal command: ${sub ?? "(none)"}. ${USAGE}`);
31
+ return 2;
32
+ }
33
+ const plan = optionValue(args, "--plan");
34
+ if (!plan) {
35
+ console.error(USAGE);
36
+ return 2;
37
+ }
38
+ const { runDesktopRestartHandoff } = await import("../codex/desktop-app/handoff");
39
+ const outcome = await runDesktopRestartHandoff(plan);
40
+ // The operator is not watching this process - its terminal died with the app. The
41
+ // exit code exists for a supervisor, and the readable record is the handoff log.
42
+ return outcome === "restarted" ? 0 : 1;
43
+ }
44
+
@@ -125,27 +125,29 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
125
125
  },
126
126
  {
127
127
  name: "sync",
128
- usage: "ocx sync [--restart-codex] [--restart-desktop-app]",
128
+ usage: "ocx sync [--restart-codex] [--restart-app-server-only]",
129
129
  summary: "Fetch provider models and inject them into Codex config.",
130
130
  details: [
131
131
  "After writing the catalog, warns if long-lived Codex app-server processes are still running.",
132
- "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).",
133
- "--restart-desktop-app (Windows only, opt-in) fully restarts the Codex desktop app so its model picker re-reads the catalog. Never implied by --restart-codex: it ends live conversations.",
132
+ "--restart-codex restarts the app-servers AND fully quits and relaunches the Codex desktop app on macOS, Linux and Windows, so its model picker re-reads the catalog. It ends live conversations.",
133
+ "--restart-app-server-only keeps the narrow behaviour: SIGTERM to matching app-server / code-mode-host processes, desktop app left running. It wins over --restart-codex when both are given.",
134
+ "--restart-desktop-app is a deprecated alias of --restart-codex and prints a notice.",
134
135
  ],
135
136
  },
136
137
  {
137
138
  name: "sync-cache",
138
- usage: "ocx sync-cache [--restart-codex] [--restart-desktop-app]",
139
+ usage: "ocx sync-cache [--restart-codex] [--restart-app-server-only]",
139
140
  summary: "Refresh Codex's model cache from the active catalog.",
140
141
  details: [
141
142
  "Warns when Codex app-server processes still hold an in-memory model list.",
142
- "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).",
143
- "--restart-desktop-app (Windows only, opt-in) fully restarts the Codex desktop app so its model picker re-reads the catalog. Never implied by --restart-codex: it ends live conversations.",
143
+ "--restart-codex restarts the app-servers AND fully quits and relaunches the Codex desktop app on macOS, Linux and Windows, so its model picker re-reads the catalog. It ends live conversations.",
144
+ "--restart-app-server-only keeps the narrow behaviour: SIGTERM to matching app-server / code-mode-host processes, desktop app left running. It wins over --restart-codex when both are given.",
145
+ "--restart-desktop-app is a deprecated alias of --restart-codex and prints a notice.",
144
146
  ],
145
147
  },
146
148
  {
147
149
  name: "catalog",
148
- usage: "ocx catalog pull <https-url> [--auth-env <NAME>] [--json] [--restart-codex]",
150
+ usage: "ocx catalog pull <https-url> [--auth-env <NAME>] [--json] [--restart-codex] [--restart-app-server-only]",
149
151
  summary: "Install a validated remote /v1/catalog snapshot into Codex.",
150
152
  details: [
151
153
  "Authentication is read only from the named environment variable and sent as a Bearer token.",