@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.
- package/gui/dist/assets/{index-BlO4Yl6q.js → index-CkvITofZ.js} +2 -2
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/devin.ts +39 -7
- package/src/cli/capabilities.ts +4 -2
- package/src/cli/catalog.ts +39 -10
- package/src/cli/dispatch.ts +19 -64
- package/src/cli/doctor.ts +1 -1
- package/src/cli/internal-command.ts +44 -0
- package/src/cli/registry.ts +9 -7
- package/src/cli/restart-scope.ts +184 -0
- package/src/codex/app-server-processes.ts +20 -5
- package/src/codex/app-server-restart-service.ts +29 -0
- package/src/codex/catalog/provider-fetch.ts +13 -5
- package/src/codex/catalog/sync.ts +12 -3
- package/src/codex/desktop-app/darwin.ts +268 -0
- package/src/codex/desktop-app/handoff.ts +303 -0
- package/src/codex/desktop-app/linux.ts +388 -0
- package/src/codex/desktop-app/lock.ts +226 -0
- package/src/codex/desktop-app/types.ts +141 -0
- package/src/codex/desktop-app/windows.ts +239 -0
- package/src/codex/desktop-app-restart.ts +264 -279
- package/src/codex/inject.ts +83 -21
- package/src/codex/sync.ts +16 -22
- package/src/generated/compatibility-version.json +58 -22
- package/src/lib/codex-restart-contract.ts +31 -0
- package/src/providers/registry.ts +13 -12
- package/src/server/responses/core.ts +62 -4
- package/src/server/responses/encrypted-payload.ts +161 -0
- package/src/server/responses.ts +1 -1
- package/src/types/provider.ts +6 -5
- package/src/web-search/index.ts +14 -67
- package/src/web-search/passthrough-bridge.ts +256 -32
- package/src/web-search/sidecar-providers.ts +76 -0
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-CkvITofZ.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-BBOZWGB6.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
package/package.json
CHANGED
package/src/adapters/devin.ts
CHANGED
|
@@ -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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
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:
|
|
346
|
+
content: toolContent,
|
|
315
347
|
tool_call_id: message.toolCallId,
|
|
316
348
|
};
|
|
317
349
|
}
|
package/src/cli/capabilities.ts
CHANGED
|
@@ -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-
|
|
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",
|
package/src/cli/catalog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
66
|
-
const
|
|
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
|
|
74
|
-
|
|
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,
|
|
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,
|
|
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.");
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1024
|
-
restartDesktopApp: boolean,
|
|
980
|
+
scope: RestartScope,
|
|
1025
981
|
): Promise<void> {
|
|
1026
982
|
if (!result.catalogWritten && !result.cacheSynced) return;
|
|
1027
|
-
|
|
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:
|
|
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
|
+
|
package/src/cli/registry.ts
CHANGED
|
@@ -125,27 +125,29 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
125
125
|
},
|
|
126
126
|
{
|
|
127
127
|
name: "sync",
|
|
128
|
-
usage: "ocx sync [--restart-codex] [--restart-
|
|
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
|
|
133
|
-
"--restart-
|
|
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-
|
|
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
|
|
143
|
-
"--restart-
|
|
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.",
|
|
@@ -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
|
+
|