@bitkyc08/opencodex 2.19.0 → 2.20.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-DF_UFrGS.css +1 -0
- package/gui/dist/assets/index-DSK3S5HY.js +76 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat.ts +367 -32
- package/src/adapters/registry.ts +144 -0
- package/src/chat/inbound.ts +13 -7
- package/src/cli/claude.ts +2 -1
- package/src/cli/combo.ts +3 -0
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/export-command.ts +2 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +3 -2
- package/src/cli/lab.ts +135 -1
- package/src/cli/minimax.ts +491 -0
- package/src/cli/models-runtime.ts +22 -1
- package/src/cli/models.ts +67 -2
- package/src/cli/opencode.ts +2 -1
- package/src/cli/registry.ts +22 -2
- package/src/clients/config-export.ts +125 -7
- package/src/codex/app-server-processes.ts +57 -2
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/catalog/aggregation.ts +10 -1
- package/src/codex/catalog/effort.ts +15 -3
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +45 -5
- package/src/codex/catalog/sync.ts +74 -4
- package/src/codex/convergence.ts +2 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/request.ts +30 -0
- package/src/combos/types.ts +6 -0
- package/src/config.ts +52 -0
- package/src/generated/compatibility-version.json +224 -76
- package/src/images/loop.ts +11 -1
- package/src/integrations/registry.ts +7 -0
- package/src/lab/conformance/jcs.ts +42 -2
- package/src/lab/conformance/negative-controls.ts +6 -2
- package/src/lab/conformance/runner.ts +16 -5
- package/src/lab/fabric/observe.ts +49 -14
- package/src/lab/index.ts +16 -0
- package/src/lab/ledger/purge.ts +152 -83
- package/src/lab/ledger/store.ts +168 -54
- package/src/lab/observe/from-conformance.ts +8 -6
- package/src/lab/observe/from-live.ts +8 -2
- package/src/lab/paths.ts +23 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +219 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/lab-activation.ts +109 -47
- package/src/lib/lab-live-pinned-sender.ts +16 -5
- package/src/lib/pinned-http.ts +70 -16
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/state-store-registrations.ts +2 -0
- package/src/lib/upstream-reachability.ts +4 -0
- package/src/lib/windows-elevation.ts +10 -1
- package/src/providers/derive.ts +24 -4
- package/src/providers/registry.ts +7 -3
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +143 -0
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/reasoning-effort.ts +27 -1
- package/src/router.ts +23 -6
- package/src/routing/capability.ts +4 -2
- package/src/routing/compatibility/behavior.ts +5 -1
- package/src/server/adapter-resolve.ts +2 -32
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +74 -36
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +371 -0
- package/src/server/management/combo-routes.ts +16 -2
- package/src/server/management/config-routes.ts +6 -4
- package/src/server/management/context.ts +17 -0
- package/src/server/management/lab-routes.ts +181 -19
- package/src/server/management/model-routes.ts +76 -2
- package/src/server/management/model-rows.ts +8 -0
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +76 -4
- package/src/server/management/system-restart.ts +4 -2
- package/src/server/management/system-routes.ts +38 -0
- package/src/server/relay.ts +17 -3
- package/src/server/responses/compact.ts +4 -1
- package/src/server/responses/core.ts +257 -46
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/policy-fallback.ts +16 -2
- package/src/server/responses/terminal-guard.ts +1 -1
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses.ts +17 -2
- package/src/types.ts +66 -4
- package/src/update/index.ts +6 -5
- package/src/update/job.ts +5 -6
- package/src/update/notify.ts +5 -3
- package/src/usage/log.ts +11 -1
- package/gui/dist/assets/index-CQ7bIKee.css +0 -1
- package/gui/dist/assets/index-D_JUZLEC.js +0 -76
package/src/cli/registry.ts
CHANGED
|
@@ -216,8 +216,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
216
216
|
{ name: "api-key", usage: "ocx api-key <list|create|remove> ...", summary: "Alias of ocx access key." },
|
|
217
217
|
{
|
|
218
218
|
name: "export",
|
|
219
|
-
usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh> [--json] [--out <path>] [--force]",
|
|
220
|
-
summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness) wired to the running proxy.",
|
|
219
|
+
usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode> [--json] [--out <path>] [--force]",
|
|
220
|
+
summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code) wired to the running proxy.",
|
|
221
221
|
details: [
|
|
222
222
|
"--json prints the generated document as JSON on stdout; use --out for the client's native format.",
|
|
223
223
|
"--out <path> writes the native config there and refuses to replace an existing file without --force.",
|
|
@@ -282,6 +282,26 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
282
282
|
"Stop using `ocx opencode` and plain `opencode` behaves exactly as before.",
|
|
283
283
|
],
|
|
284
284
|
},
|
|
285
|
+
{
|
|
286
|
+
name: "mcode",
|
|
287
|
+
usage: "ocx mcode [mcode args...]",
|
|
288
|
+
summary: "Launch MiniMax Code through its managed OpenCodex provider.",
|
|
289
|
+
details: [
|
|
290
|
+
"First connect the reversible file integration: ocx integration client enable --client mcode",
|
|
291
|
+
"The launcher verifies that custom_provider.opencodex targets the current loopback proxy before starting MCode.",
|
|
292
|
+
"Select custom_provider:opencodex/<model> from MCode's model picker.",
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: "mmx",
|
|
297
|
+
usage: "ocx mmx text <chat|repl> [mmx args...]",
|
|
298
|
+
summary: "Launch MiniMax CLI text commands through the proxy.",
|
|
299
|
+
details: [
|
|
300
|
+
"Only the official MMX Anthropic-compatible text surface is proxied.",
|
|
301
|
+
"Use plain mmx for MiniMax-native image, video, speech, music, vision, search, quota, auth, config, file, and update commands.",
|
|
302
|
+
"The wrapper isolates ~/.mmx credentials and refuses --api-key/--base-url overrides.",
|
|
303
|
+
],
|
|
304
|
+
},
|
|
285
305
|
{
|
|
286
306
|
name: "restart",
|
|
287
307
|
usage: "ocx restart",
|
|
@@ -399,6 +399,23 @@ export function dshConfigPath(env: OpencodeLaunchEnv = process.env, home: string
|
|
|
399
399
|
return join(dshHomeDir(env, home), "settings.yaml");
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
/**
|
|
403
|
+
* MiniMax Code stores runtime state under `MINIMAX_DATA_DIR`, then the legacy
|
|
404
|
+
* `MAVIS_DATA_DIR`, and finally `~/.minimax`. Relative overrides are refused
|
|
405
|
+
* because a background proxy and a foreground client can have different CWDs.
|
|
406
|
+
*/
|
|
407
|
+
export function mcodeHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
|
|
408
|
+
const primary = env.MINIMAX_DATA_DIR?.trim();
|
|
409
|
+
if (primary) return absoluteClientPath(primary, home, "MINIMAX_DATA_DIR");
|
|
410
|
+
const legacy = env.MAVIS_DATA_DIR?.trim();
|
|
411
|
+
if (legacy) return absoluteClientPath(legacy, home, "MAVIS_DATA_DIR");
|
|
412
|
+
return join(home, ".minimax");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function mcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
|
|
416
|
+
return join(mcodeHomeDir(env, home), "config.yaml");
|
|
417
|
+
}
|
|
418
|
+
|
|
402
419
|
/**
|
|
403
420
|
* One proxy-routed model destined for a client config. Deliberately narrower than
|
|
404
421
|
* `CatalogModel` so a serializer cannot reach for a field that does not survive the
|
|
@@ -438,7 +455,8 @@ export type ExportClientId =
|
|
|
438
455
|
| "openclaw"
|
|
439
456
|
| "kimi"
|
|
440
457
|
| "gajae"
|
|
441
|
-
| "dsh"
|
|
458
|
+
| "dsh"
|
|
459
|
+
| "mcode";
|
|
442
460
|
|
|
443
461
|
export interface ExportClientSpec {
|
|
444
462
|
id: ExportClientId;
|
|
@@ -664,6 +682,15 @@ export interface PiModelEntry {
|
|
|
664
682
|
input: string[];
|
|
665
683
|
contextWindow?: number;
|
|
666
684
|
maxTokens?: number;
|
|
685
|
+
/** Advertised when the catalog row carries a non-empty effort ladder. */
|
|
686
|
+
reasoning?: true;
|
|
687
|
+
/**
|
|
688
|
+
* Constrains pi's own level scale (minimal..max) to the declared ladder: members map to
|
|
689
|
+
* themselves, everything else is hidden (`null`). Without it pi would offer levels the
|
|
690
|
+
* ladder does not contain — harmless for provider-config ladders (the proxy clamps those
|
|
691
|
+
* at the wire) but a real 400 risk for custom-row ladders, which are advertisement-only.
|
|
692
|
+
*/
|
|
693
|
+
thinkingLevelMap?: Record<string, string | null>;
|
|
667
694
|
}
|
|
668
695
|
|
|
669
696
|
export interface PiProviderBlock {
|
|
@@ -832,20 +859,45 @@ export interface DshGeneratedConfig {
|
|
|
832
859
|
};
|
|
833
860
|
}
|
|
834
861
|
|
|
862
|
+
export interface McodeProviderBlock {
|
|
863
|
+
name: "OpenCodex";
|
|
864
|
+
kind: "custom";
|
|
865
|
+
enabled: true;
|
|
866
|
+
api: "anthropic-messages";
|
|
867
|
+
options: {
|
|
868
|
+
apiKey: string;
|
|
869
|
+
baseURL: string;
|
|
870
|
+
authMode: "api-key";
|
|
871
|
+
};
|
|
872
|
+
models: Record<string, Record<string, never>>;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export interface McodeGeneratedConfig {
|
|
876
|
+
custom_provider: Record<string, McodeProviderBlock>;
|
|
877
|
+
}
|
|
878
|
+
|
|
835
879
|
/**
|
|
836
880
|
* Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`),
|
|
837
881
|
* unlike OpenCode's keyed object.
|
|
838
882
|
*
|
|
839
|
-
* Two fields
|
|
840
|
-
* no price data at all, so emitting zeros would assert every routed
|
|
841
|
-
*
|
|
842
|
-
*
|
|
883
|
+
* Two fields were deliberately absent once. `cost` still is: it requires all four price
|
|
884
|
+
* fields and we have no price data at all, so emitting zeros would assert every routed
|
|
885
|
+
* model is free. `reasoning` used to be omitted because Pi's boolean and the catalog's
|
|
886
|
+
* effort ladder did not obviously map — but a NON-EMPTY ladder is the catalog's own
|
|
887
|
+
* statement that the model accepts reasoning parameters (adapters honor `reasoning_effort`),
|
|
888
|
+
* and an empty or absent ladder is the statement that it does not. Emitting `reasoning:
|
|
889
|
+
* true` exactly for rows with a ladder is therefore not a guess; it is what makes Pi's
|
|
890
|
+
* effort control appear for routed models at all. The export also emits a `thinkingLevelMap`
|
|
891
|
+
* that hides every pi level outside the declared ladder, so pi never offers (and sends) an
|
|
892
|
+
* effort the ladder does not contain — custom-row ladders are catalog advertisement only
|
|
893
|
+
* and get no wire clamp, so this map is what keeps pi honest for those. Users who need a
|
|
894
|
+
* different mapping can still hand-tune `thinkingLevelMap` afterwards.
|
|
843
895
|
*
|
|
844
896
|
* Pi's input enum IS verified: its documented model configuration accepts only
|
|
845
897
|
* `text` and `image`, and a validation failure yields an EMPTY model config
|
|
846
898
|
* rather than dropping the offending entry — one bad value costs every routed
|
|
847
|
-
* model. The rest of this contract (omitting `cost`
|
|
848
|
-
*
|
|
899
|
+
* model. The rest of this contract (omitting `cost`) is still ours rather than
|
|
900
|
+
* a claim about Pi's acceptance.
|
|
849
901
|
*/
|
|
850
902
|
function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig {
|
|
851
903
|
const models: PiModelEntry[] = [];
|
|
@@ -862,6 +914,21 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig {
|
|
|
862
914
|
name: exportModelLabel(model),
|
|
863
915
|
input,
|
|
864
916
|
};
|
|
917
|
+
if (Array.isArray(model.reasoningEfforts) && model.reasoningEfforts.length > 0) {
|
|
918
|
+
entry.reasoning = true;
|
|
919
|
+
const efforts = model.reasoningEfforts;
|
|
920
|
+
entry.thinkingLevelMap = {
|
|
921
|
+
// pi's off level maps to the declared `none` sentinel (the proxy omits the
|
|
922
|
+
// reasoning parameter for it); hidden when the ladder does not declare none.
|
|
923
|
+
off: efforts.includes("none") ? "none" : null,
|
|
924
|
+
minimal: efforts.includes("minimal") ? "minimal" : null,
|
|
925
|
+
low: efforts.includes("low") ? "low" : null,
|
|
926
|
+
medium: efforts.includes("medium") ? "medium" : null,
|
|
927
|
+
high: efforts.includes("high") ? "high" : null,
|
|
928
|
+
xhigh: efforts.includes("xhigh") ? "xhigh" : null,
|
|
929
|
+
max: efforts.includes("max") ? "max" : efforts.includes("ultra") ? "ultra" : null,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
865
932
|
const context = authoritativeContextWindow(model.contextWindow);
|
|
866
933
|
if (context !== undefined) {
|
|
867
934
|
entry.contextWindow = context;
|
|
@@ -1115,6 +1182,32 @@ function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig {
|
|
|
1115
1182
|
};
|
|
1116
1183
|
}
|
|
1117
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* MiniMax Code's `provider add` command persists custom providers under
|
|
1187
|
+
* `custom_provider.<id>`. Do not emit `defaultModel`: connecting a client must
|
|
1188
|
+
* not silently replace the user's current model selection.
|
|
1189
|
+
*/
|
|
1190
|
+
function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig {
|
|
1191
|
+
const models: Record<string, Record<string, never>> = {};
|
|
1192
|
+
for (const model of normalizeExportModels(ctx.models)) models[model.namespaced] = {};
|
|
1193
|
+
return {
|
|
1194
|
+
custom_provider: {
|
|
1195
|
+
[OPENCODE_PROVIDER_ID]: {
|
|
1196
|
+
name: "OpenCodex",
|
|
1197
|
+
kind: "custom",
|
|
1198
|
+
enabled: true,
|
|
1199
|
+
api: "anthropic-messages",
|
|
1200
|
+
options: {
|
|
1201
|
+
apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
|
|
1202
|
+
baseURL: ctx.baseUrl.replace(/\/v1\/?$/, ""),
|
|
1203
|
+
authMode: "api-key",
|
|
1204
|
+
},
|
|
1205
|
+
models,
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1118
1211
|
/**
|
|
1119
1212
|
* Per-client model counts, read back off the SERIALIZED document rather than
|
|
1120
1213
|
* recomputed from the input rows: `modelsWithoutLimits` drives a GUI line about
|
|
@@ -1164,6 +1257,12 @@ function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLim
|
|
|
1164
1257
|
return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length };
|
|
1165
1258
|
}
|
|
1166
1259
|
|
|
1260
|
+
function summarizeMcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
|
|
1261
|
+
const models = Object.values((document as McodeGeneratedConfig | undefined)?.custom_provider?.[OPENCODE_PROVIDER_ID]?.models ?? {});
|
|
1262
|
+
// MCode's custom-provider schema does not expose per-model context limits.
|
|
1263
|
+
return { modelCount: models.length, modelsWithoutLimits: 0 };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1167
1266
|
/** One fragment at `path`, built from this client's own document. */
|
|
1168
1267
|
function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution {
|
|
1169
1268
|
return { clientId, fragments: [{ path, value }] };
|
|
@@ -1220,6 +1319,11 @@ function buildDshContribution(ctx: ExportContext): ManagedContribution {
|
|
|
1220
1319
|
return singleFragment("dsh", ["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]);
|
|
1221
1320
|
}
|
|
1222
1321
|
|
|
1322
|
+
function buildMcodeContribution(ctx: ExportContext): ManagedContribution {
|
|
1323
|
+
const doc = buildMcodeClientConfig(ctx);
|
|
1324
|
+
return singleFragment("mcode", ["custom_provider", OPENCODE_PROVIDER_ID], doc.custom_provider[OPENCODE_PROVIDER_ID]);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1223
1327
|
export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
|
|
1224
1328
|
opencode: {
|
|
1225
1329
|
id: "opencode",
|
|
@@ -1329,6 +1433,20 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
|
|
|
1329
1433
|
buildContribution: buildDshContribution,
|
|
1330
1434
|
loopbackOnly: true,
|
|
1331
1435
|
},
|
|
1436
|
+
mcode: {
|
|
1437
|
+
id: "mcode",
|
|
1438
|
+
filename: "mcode-config.yaml",
|
|
1439
|
+
destination: env => mcodeConfigPath(env),
|
|
1440
|
+
apiKeyEnv: "",
|
|
1441
|
+
exportHint: "MiniMax Code reads a non-secret placeholder from config.yaml; loopback needs no key.",
|
|
1442
|
+
build: buildMcodeClientConfig,
|
|
1443
|
+
format: "yaml",
|
|
1444
|
+
summarize: summarizeMcode,
|
|
1445
|
+
buildContribution: buildMcodeContribution,
|
|
1446
|
+
// MCode persists this credential and exposes no dedicated proxy-admission
|
|
1447
|
+
// header field, so real keys are never serialized and remote binds refuse.
|
|
1448
|
+
loopbackOnly: true,
|
|
1449
|
+
},
|
|
1332
1450
|
};
|
|
1333
1451
|
|
|
1334
1452
|
export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
11
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
12
12
|
import { isProcessAlive, waitForExit } from "../lib/process-control";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
resolveTrustedWindowsPowerShellExe,
|
|
15
|
+
resolveTrustedWindowsTaskkillExe,
|
|
16
|
+
} from "../lib/windows-elevation";
|
|
14
17
|
import { readCodexCatalogPath } from "./catalog/parsing";
|
|
15
18
|
|
|
16
19
|
export const STALE_CODEX_APP_SERVER_HINT =
|
|
@@ -83,6 +86,14 @@ export interface CodexAppServerProcessIo {
|
|
|
83
86
|
listSnapshots?: () => ProcessSnapshot[];
|
|
84
87
|
isAlive?: (pid: number) => boolean;
|
|
85
88
|
kill?: (pid: number, signal: NodeJS.Signals) => void;
|
|
89
|
+
/** Windows termination seam: drives the taskkill branch without a real exec. */
|
|
90
|
+
execFile?: (file: string, args: readonly string[]) => void;
|
|
91
|
+
/**
|
|
92
|
+
* Signal seam for the Unix branch and the taskkill fallback. Without it,
|
|
93
|
+
* injecting `kill` bypasses defaultKillCodexAppServer entirely, so the
|
|
94
|
+
* fallback could never be observed.
|
|
95
|
+
*/
|
|
96
|
+
processKill?: (pid: number, signal: NodeJS.Signals) => void;
|
|
86
97
|
waitExit?: (pid: number, timeoutMs: number) => boolean;
|
|
87
98
|
now?: () => number;
|
|
88
99
|
readStartMs?: (pid: number) => number | null;
|
|
@@ -658,13 +669,57 @@ export interface RestartCodexAppServersResult {
|
|
|
658
669
|
failed: Array<{ pid: number; error: string }>;
|
|
659
670
|
}
|
|
660
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Platform-appropriate termination for a matched app-server.
|
|
674
|
+
*
|
|
675
|
+
* On Windows `process.kill(pid, "SIGTERM")` is not a graceful signal — it is an
|
|
676
|
+
* unconditional terminate of that one process, and it leaves the process tree
|
|
677
|
+
* behind. `taskkill /T /F` is therefore not an escalation there: the kill was
|
|
678
|
+
* hard either way, and `/T` adds the child cleanup that keeps an app-server's
|
|
679
|
+
* children from being orphaned when the window is closed without a quit
|
|
680
|
+
* affordance. That matters most on Windows precisely because there is no Ctrl+Q.
|
|
681
|
+
*
|
|
682
|
+
* The asymmetry with Unix is deliberate and must not be "fixed" into symmetry:
|
|
683
|
+
* on Unix, SIGTERM really is graceful, and following it with SIGKILL would ask a
|
|
684
|
+
* harsher consent than a restart click gives. Survivors are reported instead.
|
|
685
|
+
*
|
|
686
|
+
* The executable is resolved from a trusted system directory rather than PATH,
|
|
687
|
+
* matching how this file already resolves PowerShell — an unqualified
|
|
688
|
+
* `taskkill` is a hijack surface.
|
|
689
|
+
*/
|
|
690
|
+
function defaultKillCodexAppServer(
|
|
691
|
+
pid: number,
|
|
692
|
+
signal: NodeJS.Signals,
|
|
693
|
+
io: CodexAppServerProcessIo = {},
|
|
694
|
+
): void {
|
|
695
|
+
const platform = io.platform ?? process.platform;
|
|
696
|
+
const signalProcess = io.processKill ?? ((target: number, sig: NodeJS.Signals) => {
|
|
697
|
+
process.kill(target, sig);
|
|
698
|
+
});
|
|
699
|
+
if (platform !== "win32") {
|
|
700
|
+
signalProcess(pid, signal);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const exec = io.execFile ?? ((file: string, args: readonly string[]) => {
|
|
704
|
+
execFileSync(file, [...args], { stdio: "ignore", timeout: 5_000, windowsHide: true });
|
|
705
|
+
});
|
|
706
|
+
try {
|
|
707
|
+
exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(pid), "/T", "/F"]);
|
|
708
|
+
} catch {
|
|
709
|
+
// Fall back to the previous behavior rather than reporting a failure the old
|
|
710
|
+
// code would not have reported.
|
|
711
|
+
signalProcess(pid, signal);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
|
|
661
716
|
/** Send SIGTERM to matched processes and wait briefly; never escalates to SIGKILL. */
|
|
662
717
|
export function restartCodexAppServers(
|
|
663
718
|
processes: readonly CodexAppServerProcess[] = listCodexAppServerProcesses(),
|
|
664
719
|
io: CodexAppServerProcessIo = {},
|
|
665
720
|
): RestartCodexAppServersResult {
|
|
666
721
|
const isAlive = io.isAlive ?? isProcessAlive;
|
|
667
|
-
const kill = io.kill ?? ((pid, signal) => {
|
|
722
|
+
const kill = io.kill ?? ((pid, signal) => { defaultKillCodexAppServer(pid, signal, io); });
|
|
668
723
|
const wait = io.waitExit ?? waitForExit;
|
|
669
724
|
const now = io.now ?? Date.now;
|
|
670
725
|
const requested = processes.map(process => process.pid);
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard-driven Codex app-server restart (#1046 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* The defect: Codex builds a static model manager from the catalog once at
|
|
5
|
+
* app-server startup and never rereads the file, so an app-server that outlives a
|
|
6
|
+
* catalog write serves a roster that no longer exists on disk. Detection already
|
|
7
|
+
* existed and already fired — but it warns on stderr, and the thing keeping an SSH
|
|
8
|
+
* workspace's app-server alive is the Codex app, not a human at a terminal.
|
|
9
|
+
*
|
|
10
|
+
* This module is the consent boundary the startup path deliberately refuses to
|
|
11
|
+
* cross (see `warnIfStaleCodexAppServersAfterStartupWrite`): a login is not consent
|
|
12
|
+
* to interrupt an in-flight turn, but a dashboard click is.
|
|
13
|
+
*
|
|
14
|
+
* The route is a thin adapter over these two functions. Decisions live here so a
|
|
15
|
+
* test can drive every branch through {@link CodexRestartServiceIo} instead of
|
|
16
|
+
* mocking modules — a route test that could not stub this would really terminate
|
|
17
|
+
* the developer's own Codex.
|
|
18
|
+
*
|
|
19
|
+
* Plan and audit history: `devlog/_plan/260815_gui_codex_restart/010_phase1_backend_endpoint.md`.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
collectCodexAppServerCatalogState,
|
|
23
|
+
listCodexAppServerProcesses,
|
|
24
|
+
readProcessStartMsBatch,
|
|
25
|
+
resetCodexAppServerCatalogStateCache,
|
|
26
|
+
restartCodexAppServers,
|
|
27
|
+
} from "./app-server-processes";
|
|
28
|
+
import type { CodexAppServerProcessIo } from "./app-server-processes";
|
|
29
|
+
import type {
|
|
30
|
+
CodexAppServerStateResponse,
|
|
31
|
+
CodexRestartResponse,
|
|
32
|
+
} from "../lib/codex-restart-contract";
|
|
33
|
+
import { getServerListenPort } from "../server/lifecycle";
|
|
34
|
+
|
|
35
|
+
export interface CodexRestartServiceIo {
|
|
36
|
+
/** Process-layer seam, forwarded to every app-server-processes call. */
|
|
37
|
+
processIo?: CodexAppServerProcessIo;
|
|
38
|
+
/** Catalog refresh seam. Resolves to whether a catalog or cache write happened. */
|
|
39
|
+
syncCatalog?: (port?: number) => Promise<boolean>;
|
|
40
|
+
/**
|
|
41
|
+
* Live listen port. `config.port` names the PREFERRED port; after a fallback
|
|
42
|
+
* start the bound port differs, and syncing the preferred one would point Codex
|
|
43
|
+
* at a dead listener (same reason the CLI startup path passes the live port).
|
|
44
|
+
*/
|
|
45
|
+
listenPort?: () => number | undefined;
|
|
46
|
+
collectState?: typeof collectCodexAppServerCatalogState;
|
|
47
|
+
listProcesses?: typeof listCodexAppServerProcesses;
|
|
48
|
+
restart?: typeof restartCodexAppServers;
|
|
49
|
+
resetStateCache?: () => void;
|
|
50
|
+
/** Start-time reader used to re-confirm process identity before signalling. */
|
|
51
|
+
readStartMs?: (pids: readonly number[]) => Map<number, number | null>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Thrown by the final identity gate when a pid no longer belongs to the process
|
|
56
|
+
* that was classified. restartCodexAppServers turns a kill throw into a `failed`
|
|
57
|
+
* entry, which is the honest outcome: nothing was signalled and the caller is told.
|
|
58
|
+
*/
|
|
59
|
+
class CodexAppServerIdentityChanged extends Error {
|
|
60
|
+
constructor(pid: number) {
|
|
61
|
+
super(`codex app-server identity changed before signal (pid ${pid})`);
|
|
62
|
+
this.name = "CodexAppServerIdentityChanged";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Single-flight latch. Two dashboard surfaces can each hold their own controller,
|
|
69
|
+
* so a user can press restart twice while the first request is still syncing the
|
|
70
|
+
* catalog. Without this, the second call re-signals processes the first already
|
|
71
|
+
* terminated and both report success — and it widens the window in which a pid can
|
|
72
|
+
* be recycled between classification and signalling.
|
|
73
|
+
*/
|
|
74
|
+
let inFlight: Promise<CodexRestartResponse> | null = null;
|
|
75
|
+
|
|
76
|
+
export function readCodexAppServerState(
|
|
77
|
+
io: CodexRestartServiceIo = {},
|
|
78
|
+
): CodexAppServerStateResponse {
|
|
79
|
+
const status = (io.collectState ?? collectCodexAppServerCatalogState)(io.processIo ?? {});
|
|
80
|
+
return { state: status.state, runningCount: status.processes.length };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function performCodexRestart(
|
|
84
|
+
io: CodexRestartServiceIo = {},
|
|
85
|
+
): Promise<CodexRestartResponse> {
|
|
86
|
+
if (inFlight) return inFlight;
|
|
87
|
+
const run = runCodexRestart(io).finally(() => {
|
|
88
|
+
inFlight = null;
|
|
89
|
+
});
|
|
90
|
+
inFlight = run;
|
|
91
|
+
return run;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Test hook: drop the single-flight latch between cases. */
|
|
95
|
+
export function resetCodexRestartInFlightForTests(): void {
|
|
96
|
+
inFlight = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartResponse> {
|
|
100
|
+
// Refresh the catalog FIRST. A user pressing "restart Codex" wants the new
|
|
101
|
+
// roster; stopping app-servers before the write would hand the replacement the
|
|
102
|
+
// same stale file it just lost.
|
|
103
|
+
let synced = false;
|
|
104
|
+
try {
|
|
105
|
+
const port = (io.listenPort ?? getServerListenPort)();
|
|
106
|
+
synced = await (io.syncCatalog ?? defaultSyncCatalog)(port);
|
|
107
|
+
} catch {
|
|
108
|
+
// A sync failure must not block the restart: an operator whose picker is stale
|
|
109
|
+
// still benefits from the app-server exiting and rereading whatever is on disk.
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The classifier memoizes for 5s when every io field is defaulted, so a reading
|
|
113
|
+
// taken before the write above would otherwise be replayed after it.
|
|
114
|
+
(io.resetStateCache ?? resetCodexAppServerCatalogStateCache)();
|
|
115
|
+
const before = (io.collectState ?? collectCodexAppServerCatalogState)(io.processIo ?? {});
|
|
116
|
+
|
|
117
|
+
const nothingToDo = (): CodexRestartResponse => ({
|
|
118
|
+
success: true,
|
|
119
|
+
stateBefore: before.state,
|
|
120
|
+
synced,
|
|
121
|
+
requested: [],
|
|
122
|
+
stopped: [],
|
|
123
|
+
surviving: [],
|
|
124
|
+
failed: [],
|
|
125
|
+
// Enumeration failure reads as `unknown`, never `not_running` (#857): a failed
|
|
126
|
+
// enumeration must not be reported as "nothing was running".
|
|
127
|
+
code: before.state === "unknown" ? "enumeration_unavailable" : "nothing_running",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// `unknown` is not only the empty enumeration-failure case: the classifier also
|
|
131
|
+
// returns it WITH processes when the catalog mtime or a start time is unreadable.
|
|
132
|
+
// In that state it has not established that any server predates the catalog, so
|
|
133
|
+
// signalling would kill a possibly-current app-server on a guess.
|
|
134
|
+
if (before.state === "unknown" || before.processes.length === 0) return nothingToDo();
|
|
135
|
+
|
|
136
|
+
// The classifier carries { pid, startedAtMs } and no command line, but
|
|
137
|
+
// restartCodexAppServers needs the full identity so it can refuse to signal a
|
|
138
|
+
// recycled pid. Re-list and intersect on pid rather than reconstructing an
|
|
139
|
+
// identity we never verified.
|
|
140
|
+
const classifiedStarts = new Map(
|
|
141
|
+
before.processes.map(entry => [entry.pid, entry.startedAtMs] as const),
|
|
142
|
+
);
|
|
143
|
+
const live = (io.listProcesses ?? listCodexAppServerProcesses)(io.processIo ?? {});
|
|
144
|
+
const candidates = live.filter(process => classifiedStarts.has(process.pid));
|
|
145
|
+
|
|
146
|
+
// A pid plus a command line is not an identity: a replacement app-server launched
|
|
147
|
+
// by the same Codex install has both. Re-read start times and drop any candidate
|
|
148
|
+
// whose process started after the reading we classified, so a recycled pid can
|
|
149
|
+
// never receive a signal meant for the process that held it.
|
|
150
|
+
const platform = io.processIo?.platform ?? process.platform;
|
|
151
|
+
const startsNow = candidates.length > 0
|
|
152
|
+
? (io.readStartMs ?? (pids => readProcessStartMsBatch(pids, platform)))(
|
|
153
|
+
candidates.map(process => process.pid),
|
|
154
|
+
)
|
|
155
|
+
: new Map<number, number | null>();
|
|
156
|
+
const targets = candidates.filter(process => {
|
|
157
|
+
const classified = classifiedStarts.get(process.pid) ?? null;
|
|
158
|
+
const current = startsNow.get(process.pid) ?? null;
|
|
159
|
+
// An unreadable start time on either side means we cannot prove sameness.
|
|
160
|
+
if (classified === null || current === null) return false;
|
|
161
|
+
return classified === current;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (targets.length === 0) {
|
|
165
|
+
// Every classified process exited, or the pid now belongs to a different
|
|
166
|
+
// process. Reporting "stopped" would claim credit for work this request did
|
|
167
|
+
// not do.
|
|
168
|
+
return {
|
|
169
|
+
success: true,
|
|
170
|
+
stateBefore: before.state,
|
|
171
|
+
synced,
|
|
172
|
+
requested: [],
|
|
173
|
+
stopped: [],
|
|
174
|
+
surviving: [],
|
|
175
|
+
failed: [],
|
|
176
|
+
code: "nothing_running",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Final identity gate, applied at the moment of signalling rather than before it.
|
|
181
|
+
//
|
|
182
|
+
// restartCodexAppServers re-lists and compares pid+command-line immediately
|
|
183
|
+
// before SIGTERM, but a replacement app-server launched by the same Codex
|
|
184
|
+
// install has BOTH of those. The start time is the field that distinguishes
|
|
185
|
+
// them, and it is not part of that comparison, so the check above (done before
|
|
186
|
+
// the call) still leaves a window: the original can exit and a replacement can
|
|
187
|
+
// claim its pid in between.
|
|
188
|
+
//
|
|
189
|
+
// Wrapping `kill` closes the window: this runs inside restartCodexAppServers'
|
|
190
|
+
// own signalling loop, so the start time is re-read at the last possible moment.
|
|
191
|
+
// An unreadable start time refuses the signal — on a recycled pid, guessing
|
|
192
|
+
// costs the user the turn that is running right now.
|
|
193
|
+
const guardedProcessIo: CodexAppServerProcessIo = {
|
|
194
|
+
...(io.processIo ?? {}),
|
|
195
|
+
kill: (pid, signal) => {
|
|
196
|
+
const classified = classifiedStarts.get(pid) ?? null;
|
|
197
|
+
const current = (io.readStartMs ?? (pids => readProcessStartMsBatch(pids, platform)))([pid])
|
|
198
|
+
.get(pid) ?? null;
|
|
199
|
+
if (classified === null || current === null || classified !== current) {
|
|
200
|
+
throw new CodexAppServerIdentityChanged(pid);
|
|
201
|
+
}
|
|
202
|
+
const send = io.processIo?.kill ?? ((target: number, sig: NodeJS.Signals) => {
|
|
203
|
+
process.kill(target, sig);
|
|
204
|
+
});
|
|
205
|
+
send(pid, signal);
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const result = (io.restart ?? restartCodexAppServers)(targets, guardedProcessIo);
|
|
210
|
+
const clean = result.surviving.length === 0 && result.failed.length === 0;
|
|
211
|
+
return {
|
|
212
|
+
success: clean,
|
|
213
|
+
stateBefore: before.state,
|
|
214
|
+
synced,
|
|
215
|
+
requested: result.requested,
|
|
216
|
+
stopped: result.stopped,
|
|
217
|
+
surviving: result.surviving,
|
|
218
|
+
// Project { pid, error } down to pids: an OS error message can embed a path
|
|
219
|
+
// or the account name.
|
|
220
|
+
failed: result.failed.map(entry => entry.pid),
|
|
221
|
+
code: clean ? "stopped" : "partially_stopped",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function defaultSyncCatalog(port?: number): Promise<boolean> {
|
|
226
|
+
const { syncModelsToCodex } = await import("./sync");
|
|
227
|
+
// `undefined` config takes syncModelsToCodex's own loadConfig() default; `null`
|
|
228
|
+
// log keeps this request path silent.
|
|
229
|
+
const result = await syncModelsToCodex(port, undefined, null);
|
|
230
|
+
return result.catalogWritten || result.cacheSynced;
|
|
231
|
+
}
|
|
232
|
+
|
|
@@ -129,9 +129,13 @@ export function deriveComboCatalogModel(
|
|
|
129
129
|
): CatalogModel | null {
|
|
130
130
|
if (comboCatalogOmissionReason(combo, members) !== null) return null;
|
|
131
131
|
|
|
132
|
-
const
|
|
132
|
+
const derivedInputModalities = intersectStrings(
|
|
133
133
|
members.map(member => member.inputModalities ?? ["text"]),
|
|
134
134
|
);
|
|
135
|
+
const inputModalities = combo.imageInput === "disabled"
|
|
136
|
+
? derivedInputModalities.filter(modality => modality !== "image")
|
|
137
|
+
: derivedInputModalities;
|
|
138
|
+
if (inputModalities.length === 0) return null;
|
|
135
139
|
// Unknown ladders (`undefined`) are wildcards for catalog derivation — same
|
|
136
140
|
// boundary as the GUI picker. An explicit empty ladder still constrains.
|
|
137
141
|
const advertisedLadders = members
|
|
@@ -173,6 +177,11 @@ export function deriveComboCatalogModel(
|
|
|
173
177
|
...(members.every(member => member.parallelToolCalls === true)
|
|
174
178
|
? { parallelToolCalls: true }
|
|
175
179
|
: {}),
|
|
180
|
+
...(members.every(member => member.supportsServiceTier === true)
|
|
181
|
+
? { supportsServiceTier: true }
|
|
182
|
+
: members.some(member => member.supportsServiceTier === false)
|
|
183
|
+
? { supportsServiceTier: false }
|
|
184
|
+
: {}),
|
|
176
185
|
...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}),
|
|
177
186
|
};
|
|
178
187
|
}
|
|
@@ -139,6 +139,15 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel)
|
|
|
139
139
|
if (typeof model.supportsReasoningSummaries === "boolean") {
|
|
140
140
|
entry.supports_reasoning_summaries = model.supportsReasoningSummaries;
|
|
141
141
|
}
|
|
142
|
+
if (model.supportsServiceTier === true) {
|
|
143
|
+
entry.default_service_tier = null;
|
|
144
|
+
entry.service_tiers = [{
|
|
145
|
+
id: "priority",
|
|
146
|
+
name: "Fast",
|
|
147
|
+
description: "1.5x speed, increased usage",
|
|
148
|
+
}];
|
|
149
|
+
entry.additional_speed_tiers = ["fast"];
|
|
150
|
+
}
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
export function applyReasoningLevels(
|
|
@@ -153,8 +162,9 @@ export function applyReasoningLevels(
|
|
|
153
162
|
// (no ultra->max client conversion) and codex-rs validates it by catalog membership,
|
|
154
163
|
// so a missing max rung hard-fails spawn_agent effort overrides. The wire stays honest:
|
|
155
164
|
// routed adapters clamp via clampToSupportedCodexEffort and natives via
|
|
156
|
-
// nativeEffortClamp (max -> the model's real top rung).
|
|
157
|
-
|
|
165
|
+
// nativeEffortClamp (max -> the model's real top rung). A `none`-only ladder is NOT
|
|
166
|
+
// reasoning-capable, so it must not grow synthetic top rungs.
|
|
167
|
+
if (!preserveExact && efforts.length > 0 && efforts.some(effort => effort !== "none" && effort !== "minimal")) {
|
|
158
168
|
const additions: string[] = [];
|
|
159
169
|
if (!efforts.includes("max")) additions.push("max");
|
|
160
170
|
if (!efforts.includes("ultra")) additions.push("ultra");
|
|
@@ -177,7 +187,9 @@ export function applyReasoningLevels(
|
|
|
177
187
|
}
|
|
178
188
|
entry.default_reasoning_level = defaultOverride && efforts.includes(defaultOverride)
|
|
179
189
|
? defaultOverride
|
|
180
|
-
: efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high"
|
|
190
|
+
: efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high"
|
|
191
|
+
// Sentinels never become the implicit default when real rungs are declared.
|
|
192
|
+
: efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0];
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
export function isGpt56NativeSlug(slug: string): boolean {
|
|
@@ -123,6 +123,8 @@ export interface CatalogModel {
|
|
|
123
123
|
codexForwardNativeCapabilityAlias?: boolean;
|
|
124
124
|
/** Whether Codex may send Responses text.verbosity for this routed model. */
|
|
125
125
|
supportsVerbosity?: boolean;
|
|
126
|
+
/** Whether this exact routed model has a verified OpenAI-compatible service tier. */
|
|
127
|
+
supportsServiceTier?: boolean;
|
|
126
128
|
supportsReasoningSummaries?: boolean;
|
|
127
129
|
/** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
|
|
128
130
|
capabilities?: string[];
|
|
@@ -405,7 +407,7 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls =
|
|
|
405
407
|
delete entry.service_tiers;
|
|
406
408
|
delete entry.default_service_tier;
|
|
407
409
|
// Routed rows cloned from native templates must not inherit OpenAI-only summary delivery.
|
|
408
|
-
//
|
|
410
|
+
// Explicit provider/model metadata is re-applied after this normalization step.
|
|
409
411
|
delete entry.supports_reasoning_summaries;
|
|
410
412
|
const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
|
|
411
413
|
// `supports_search_tool` selects Codex's deferred tool-discovery surface; it is not the hosted
|