@bitkyc08/opencodex 2.18.2 → 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.
Files changed (131) hide show
  1. package/gui/dist/assets/index-DF_UFrGS.css +1 -0
  2. package/gui/dist/assets/index-DSK3S5HY.js +76 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/client-fingerprint.ts +14 -10
  6. package/src/adapters/google-antigravity-wire.ts +4 -3
  7. package/src/adapters/google.ts +1 -1
  8. package/src/adapters/mimo-free.ts +17 -0
  9. package/src/adapters/openai-chat.ts +367 -32
  10. package/src/adapters/registry.ts +144 -0
  11. package/src/chat/inbound.ts +37 -8
  12. package/src/cli/claude.ts +2 -1
  13. package/src/cli/combo.ts +3 -0
  14. package/src/cli/dispatch.ts +8 -0
  15. package/src/cli/export-command.ts +2 -2
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +3 -2
  18. package/src/cli/lab.ts +135 -1
  19. package/src/cli/minimax.ts +491 -0
  20. package/src/cli/models-runtime.ts +22 -1
  21. package/src/cli/models.ts +67 -2
  22. package/src/cli/opencode.ts +2 -1
  23. package/src/cli/registry.ts +22 -2
  24. package/src/clients/config-export.ts +125 -7
  25. package/src/codex/app-server-processes.ts +57 -2
  26. package/src/codex/app-server-restart-service.ts +232 -0
  27. package/src/codex/catalog/aggregation.ts +10 -1
  28. package/src/codex/catalog/effort.ts +15 -3
  29. package/src/codex/catalog/parsing.ts +3 -1
  30. package/src/codex/catalog/provider-fetch.ts +45 -5
  31. package/src/codex/catalog/sync.ts +74 -4
  32. package/src/codex/convergence.ts +2 -0
  33. package/src/codex/shim.ts +100 -5
  34. package/src/combos/index.ts +1 -0
  35. package/src/combos/request.ts +30 -0
  36. package/src/combos/types.ts +6 -0
  37. package/src/config.ts +52 -0
  38. package/src/generated/compatibility-version.json +239 -87
  39. package/src/images/loop.ts +11 -1
  40. package/src/integrations/registry.ts +7 -0
  41. package/src/lab/conformance/jcs.ts +42 -2
  42. package/src/lab/conformance/negative-controls.ts +6 -2
  43. package/src/lab/conformance/runner.ts +16 -5
  44. package/src/lab/fabric/observe.ts +49 -14
  45. package/src/lab/index.ts +16 -0
  46. package/src/lab/ledger/purge.ts +152 -83
  47. package/src/lab/ledger/store.ts +168 -54
  48. package/src/lab/observe/from-conformance.ts +8 -6
  49. package/src/lab/observe/from-live.ts +8 -2
  50. package/src/lab/paths.ts +23 -0
  51. package/src/lab/public/bundle.ts +217 -0
  52. package/src/lab/public/community-authority.ts +175 -0
  53. package/src/lab/public/community-files.ts +29 -0
  54. package/src/lab/public/community.ts +479 -0
  55. package/src/lab/public/file-safety.ts +155 -0
  56. package/src/lab/public/ids.ts +26 -0
  57. package/src/lab/public/index.ts +16 -0
  58. package/src/lab/public/mutation-lock.ts +424 -0
  59. package/src/lab/public/operator.ts +353 -0
  60. package/src/lab/public/origin-purge.ts +79 -0
  61. package/src/lab/public/origin.ts +203 -0
  62. package/src/lab/public/privacy.ts +143 -0
  63. package/src/lab/public/private-file.ts +261 -0
  64. package/src/lab/public/project.ts +124 -0
  65. package/src/lab/public/purge-test-fault.ts +21 -0
  66. package/src/lab/public/purge.ts +223 -0
  67. package/src/lab/public/registry.ts +44 -0
  68. package/src/lab/public/revocation.ts +252 -0
  69. package/src/lab/public/signature.ts +219 -0
  70. package/src/lab/public/storage.ts +105 -0
  71. package/src/lab/public/strict-json.ts +206 -0
  72. package/src/lab/public/time.ts +26 -0
  73. package/src/lab/public/types.ts +172 -0
  74. package/src/lab/public/validate.ts +391 -0
  75. package/src/lib/codex-restart-contract.ts +120 -0
  76. package/src/lib/errors.ts +27 -0
  77. package/src/lib/lab-activation.ts +109 -47
  78. package/src/lib/lab-live-pinned-sender.ts +16 -5
  79. package/src/lib/pinned-http.ts +70 -16
  80. package/src/lib/self-launch-argv.ts +15 -0
  81. package/src/lib/state-store-registrations.ts +2 -0
  82. package/src/lib/upstream-reachability.ts +4 -0
  83. package/src/lib/windows-elevation.ts +10 -1
  84. package/src/providers/derive.ts +24 -4
  85. package/src/providers/registry.ts +9 -3
  86. package/src/providers/request-pacing.ts +310 -0
  87. package/src/providers/service-tier.ts +143 -0
  88. package/src/providers/static-model-discovery.ts +86 -0
  89. package/src/reasoning-effort.ts +27 -1
  90. package/src/responses/spill-store.ts +20 -1
  91. package/src/responses/state.ts +159 -3
  92. package/src/router.ts +23 -6
  93. package/src/routing/capability.ts +4 -2
  94. package/src/routing/compatibility/behavior.ts +5 -1
  95. package/src/server/adapter-resolve.ts +2 -32
  96. package/src/server/auth-cors.ts +8 -0
  97. package/src/server/chat-completions.ts +74 -34
  98. package/src/server/chat-native-sse.ts +331 -0
  99. package/src/server/chat-native.ts +371 -0
  100. package/src/server/effort-policy.ts +18 -0
  101. package/src/server/management/combo-routes.ts +16 -2
  102. package/src/server/management/config-routes.ts +6 -4
  103. package/src/server/management/context.ts +17 -0
  104. package/src/server/management/lab-routes.ts +181 -19
  105. package/src/server/management/model-routes.ts +76 -2
  106. package/src/server/management/model-rows.ts +8 -0
  107. package/src/server/management/provider-capability-config.ts +48 -0
  108. package/src/server/management/provider-routes.ts +76 -4
  109. package/src/server/management/system-restart.ts +4 -2
  110. package/src/server/management/system-routes.ts +38 -0
  111. package/src/server/relay.ts +17 -3
  112. package/src/server/responses/compact.ts +4 -1
  113. package/src/server/responses/core.ts +278 -48
  114. package/src/server/responses/empty-completion-guard.ts +276 -0
  115. package/src/server/responses/fetch-helpers.ts +35 -4
  116. package/src/server/responses/input-admission.ts +169 -0
  117. package/src/server/responses/pacing-overload.ts +13 -0
  118. package/src/server/responses/policy-fallback.ts +16 -2
  119. package/src/server/responses/terminal-guard.ts +1 -1
  120. package/src/server/responses/upstream-error.ts +5 -0
  121. package/src/server/responses.ts +17 -2
  122. package/src/service-manager-probe.ts +2 -3
  123. package/src/types.ts +76 -6
  124. package/src/update/index.ts +6 -5
  125. package/src/update/job.ts +5 -6
  126. package/src/update/notify.ts +5 -3
  127. package/src/usage/log.ts +11 -1
  128. package/src/vision/index.ts +21 -4
  129. package/src/web-search/index.ts +2 -1
  130. package/gui/dist/assets/index-CXI1262_.js +0 -76
  131. package/gui/dist/assets/index-DUCH59lJ.css +0 -1
@@ -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 are deliberately absent. `cost` requires all four price fields and we have
840
- * no price data at all, so emitting zeros would assert every routed model is free.
841
- * `reasoning` is a boolean in Pi while our catalog carries an effort list — mapping one
842
- * to the other would be a guess.
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` and `reasoning`) is still
848
- * ours rather than a claim about Pi's acceptance.
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 { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation";
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) => { process.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 inputModalities = intersectStrings(
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
- if (!preserveExact && efforts.length > 0) {
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" : efforts[0];
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
- // Per-model routed opt-ins can be added once provider metadata exposes this capability.
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