@bitkyc08/opencodex 2.9.1 → 2.10.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 (38) hide show
  1. package/README.md +91 -449
  2. package/gui/dist/assets/index-OY43ubAq.css +1 -0
  3. package/gui/dist/assets/index-YwNnKZcL.js +67 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/pi.svg +21 -0
  6. package/package.json +1 -1
  7. package/src/cli/account.ts +3 -5
  8. package/src/cli/claude-desktop.ts +43 -7
  9. package/src/cli/doctor.ts +12 -0
  10. package/src/cli/help.ts +1 -1
  11. package/src/cli/provider-runtime.ts +7 -0
  12. package/src/cli/star-prompt.ts +25 -4
  13. package/src/cli/status.ts +7 -2
  14. package/src/codex/app-server-processes.ts +299 -54
  15. package/src/codex/catalog/metadata.ts +9 -11
  16. package/src/codex/catalog/provider-fetch.ts +10 -10
  17. package/src/codex/catalog/sync.ts +27 -2
  18. package/src/codex/catalog.ts +1 -1
  19. package/src/config.ts +15 -1
  20. package/src/lib/bun-stream-caps.ts +14 -0
  21. package/src/oauth/index.ts +48 -3
  22. package/src/providers/registry.ts +62 -0
  23. package/src/server/index.ts +39 -3
  24. package/src/server/management/agent-settings-routes.ts +40 -3
  25. package/src/server/management/logs-usage-routes.ts +2 -0
  26. package/src/server/management/provider-routes.ts +4 -1
  27. package/src/server/management/sidebar-routes.ts +3 -1
  28. package/src/server/relay-eager.ts +100 -2
  29. package/src/server/responses/collaboration.ts +21 -0
  30. package/src/server/responses/core.ts +20 -12
  31. package/src/service.ts +393 -14
  32. package/src/storage/cleanup.ts +76 -5
  33. package/src/storage/policy.ts +6 -1
  34. package/src/types.ts +2 -0
  35. package/src/update/index.ts +9 -2
  36. package/src/update/job.ts +87 -11
  37. package/gui/dist/assets/index-CHwf3tTD.css +0 -1
  38. package/gui/dist/assets/index-CuVjugeE.js +0 -67
@@ -629,8 +629,11 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und
629
629
  * configs on the next `ocx start`, instead of only fresh installs. The live `/models` fetch stays
630
630
  * the primary source; this keeps the static fallback (and models-not-in-/models) current.
631
631
  *
632
- * Only touches providers that are registry-managed AND still `authMode: "oauth"`, and only the
633
- * preset fields (never apiKey/baseUrl/user toggles). Persists + returns true when anything changed.
632
+ * Only touches providers that are registry-managed AND still `authMode: "oauth"`. Preset fields
633
+ * are refreshed, while the registry's `liveModels` default is normally filled only when no value
634
+ * is stored. Antigravity has one versioned exception below because its old GUI-generated `true`
635
+ * cannot be distinguished from a hand-written pre-migration `true`. Persists + returns true when
636
+ * anything changed.
634
637
  */
635
638
  function cloneProviderField(value: unknown): unknown {
636
639
  if (Array.isArray(value)) return [...value];
@@ -658,11 +661,30 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [
658
661
  "preserveReasoningContentModels",
659
662
  ];
660
663
 
664
+ const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity";
665
+ const GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION = 1 as const;
666
+
661
667
  export function reconcileOAuthProviders(config: OcxConfig): boolean {
662
668
  let changed = false;
669
+ const migrateAntigravityStaticCatalog =
670
+ config.googleAntigravityStaticCatalogVersion !== GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
663
671
  for (const [name, prov] of Object.entries(config.providers)) {
664
672
  const def = OAUTH_PROVIDERS[name];
665
- if (!def || prov.authMode !== "oauth") continue;
673
+ // Normalize the canonical row before the OAuth-only reconciliation guard. The old GUI and a
674
+ // manual edit both persist the same bare `true`, with no source metadata, so every ambiguous
675
+ // pre-marker value is reset once. A deliberate live-discovery choice can be re-enabled after
676
+ // the marker and is then preserved. Do this before the guard so omitted/non-OAuth authMode
677
+ // rows do not get stamped without actually receiving the new static default.
678
+ if (name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog && prov.liveModels !== false) {
679
+ prov.liveModels = false;
680
+ changed = true;
681
+ }
682
+ // During the one-time Antigravity static-catalog migration, also refresh preset catalog
683
+ // fields when authMode is omitted or non-oauth. Otherwise liveModels flips to static while
684
+ // a stale models[] remains the published catalog forever.
685
+ const migrateAntigravityCatalogFields =
686
+ name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog;
687
+ if (!def || (prov.authMode !== "oauth" && !migrateAntigravityCatalogFields)) continue;
666
688
  const preset = def.providerConfig;
667
689
  for (const field of OAUTH_RECONCILE_FIELDS) {
668
690
  if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
@@ -673,12 +695,23 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean {
673
695
  }
674
696
  changed = true;
675
697
  }
698
+ // Before this marker existed, the GUI materialized an omitted `liveModels` as `true` on any
699
+ // settings save. Since persisted values have no provenance, the pre-guard normalization above
700
+ // intentionally resets all pre-marker `true` values once. Later choices are version-bounded.
701
+ if (prov.liveModels === undefined && preset.liveModels !== undefined) {
702
+ prov.liveModels = preset.liveModels;
703
+ changed = true;
704
+ }
676
705
  // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
677
706
  if (prov.defaultModel && preset.defaultModel && !(prov.models ?? []).includes(prov.defaultModel)) {
678
707
  prov.defaultModel = preset.defaultModel;
679
708
  changed = true;
680
709
  }
681
710
  }
711
+ if (migrateAntigravityStaticCatalog) {
712
+ config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
713
+ changed = true;
714
+ }
682
715
  if (changed) saveConfig(config);
683
716
  return changed;
684
717
  }
@@ -739,6 +772,15 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
739
772
  if (namespaceCollision) throw new Error(namespaceCollision);
740
773
  const existing = config.providers[provider];
741
774
  const next: OcxProviderConfig = { ...def.providerConfig };
775
+ // `liveModels` is a user-facing provider toggle. A registry default seeds new rows, but an
776
+ // explicit post-migration choice must survive re-login and the latest-config upsert. Old GUI
777
+ // saves and manual edits left identical pre-marker `true` values, so that ambiguous state is
778
+ // reset once; users who deliberately forced discovery can re-enable it after migration.
779
+ const preserveExistingLiveModels = provider !== GOOGLE_ANTIGRAVITY_PROVIDER
780
+ || config.googleAntigravityStaticCatalogVersion === GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
781
+ if (preserveExistingLiveModels && typeof existing?.liveModels === "boolean") {
782
+ next.liveModels = existing.liveModels;
783
+ }
742
784
  if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
743
785
  // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
744
786
  let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
@@ -762,6 +804,9 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
762
804
  }
763
805
  }
764
806
  config.providers[provider] = next;
807
+ if (provider === GOOGLE_ANTIGRAVITY_PROVIDER) {
808
+ config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
809
+ }
765
810
  }
766
811
 
767
812
  interface RunLoginDeps {
@@ -507,6 +507,43 @@ const NEURALWATT_REASONING_HISTORY_MODELS = [
507
507
  "kimi-k2.6", "kimi-k2.7-code",
508
508
  "qwen3.5-397b", "qwen3.6-35b",
509
509
  ];
510
+
511
+ // 260728 Baseten Model APIs: `/v1/models` owns the live lineup, while these hints
512
+ // describe only capabilities that Baseten documents per slug. Unlisted live models
513
+ // intentionally inherit the empty provider ladder instead of being advertised with
514
+ // opencodex's generic reasoning defaults. Audio is omitted because the current proxy
515
+ // request model does not carry OpenAI `audio_url` parts.
516
+ // Evidence: https://docs.baseten.co/inference/model-apis/reasoning
517
+ // https://docs.baseten.co/inference/model-apis/vision
518
+ const BASETEN_FULL_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
519
+ const BASETEN_MODEL_REASONING_EFFORTS: Record<string, string[]> = {
520
+ "deepseek-ai/DeepSeek-V4-Pro": BASETEN_FULL_REASONING_EFFORTS,
521
+ "thinkingmachines/inkling": BASETEN_FULL_REASONING_EFFORTS,
522
+ "openai/gpt-oss-120b": BASETEN_FULL_REASONING_EFFORTS,
523
+ "moonshotai/Kimi-K3": ["low", "high", "max"],
524
+ "zai-org/GLM-5.2": ["high", "max"],
525
+ "zai-org/GLM-5.2-Fast": ["high", "max"],
526
+ };
527
+ const BASETEN_MODEL_REASONING_EFFORT_MAP: Record<string, Record<string, string>> = {
528
+ "deepseek-ai/DeepSeek-V4-Pro": { none: "none", minimal: "minimal" },
529
+ "thinkingmachines/inkling": { none: "none", minimal: "minimal" },
530
+ "openai/gpt-oss-120b": { none: "none", minimal: "minimal" },
531
+ "moonshotai/Kimi-K3": { none: "none" },
532
+ "zai-org/GLM-5.2": { none: "none" },
533
+ "zai-org/GLM-5.2-Fast": { none: "none" },
534
+ };
535
+ const BASETEN_MODEL_DEFAULT_REASONING_EFFORTS: Record<string, string> = {
536
+ "deepseek-ai/DeepSeek-V4-Pro": "medium",
537
+ "thinkingmachines/inkling": "high",
538
+ "openai/gpt-oss-120b": "medium",
539
+ "moonshotai/Kimi-K3": "max",
540
+ };
541
+ const BASETEN_MODEL_INPUT_MODALITIES: Record<string, string[]> = {
542
+ "thinkingmachines/inkling": ["text", "image"],
543
+ "moonshotai/Kimi-K2.6": ["text", "image"],
544
+ "moonshotai/Kimi-K2.7-Code": ["text", "image"],
545
+ "moonshotai/Kimi-K3": ["text", "image"],
546
+ };
510
547
  const UMANS_MODELS = [
511
548
  "umans-coder",
512
549
  "umans-kimi-k2.7",
@@ -983,6 +1020,31 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
983
1020
  },
984
1021
  note: "Serverless text and vision-language chat models only; Hyperbolic's separate image, audio, and GPU endpoints are out of scope.",
985
1022
  },
1023
+ {
1024
+ id: "baseten",
1025
+ label: "Baseten Model APIs",
1026
+ baseUrl: "https://inference.baseten.co/v1",
1027
+ adapter: "openai-chat",
1028
+ authKind: "key",
1029
+ dashboardUrl: "https://app.baseten.co/settings/api_keys",
1030
+ liveModels: true,
1031
+ preserveCustomDestination: true,
1032
+ // Baseten's Chat Completions contract documents parallel_tool_calls as default-on.
1033
+ parallelToolCalls: true,
1034
+ // Baseten says models outside its reasoning table do not support reasoning. Keep
1035
+ // unknown/new live slugs conservative until an official-docs registry refresh proves it.
1036
+ reasoningEfforts: [],
1037
+ modelReasoningEfforts: BASETEN_MODEL_REASONING_EFFORTS,
1038
+ modelReasoningEffortMap: BASETEN_MODEL_REASONING_EFFORT_MAP,
1039
+ modelDefaultReasoningEfforts: BASETEN_MODEL_DEFAULT_REASONING_EFFORTS,
1040
+ modelInputModalities: BASETEN_MODEL_INPUT_MODALITIES,
1041
+ modelDiscovery: {
1042
+ path: "models",
1043
+ maxResponseBytes: 1_048_576,
1044
+ maxModels: 256,
1045
+ },
1046
+ note: "Shared Model APIs only (personal API key, or team key with Call Model APIs access); dedicated Truss predict endpoints are outside this preset.",
1047
+ },
986
1048
  // FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
987
1049
  { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" },
988
1050
  { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" },
@@ -474,7 +474,7 @@ export function startServer(port?: number) {
474
474
  }
475
475
  throw error;
476
476
  }
477
- const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
477
+ const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
478
478
  const nativeSlugs = nativeOpenAiSlugs();
479
479
  const goEnabled = filterCatalogVisibleModels(goModels, config);
480
480
  const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
@@ -524,9 +524,45 @@ export function startServer(port?: number) {
524
524
  }
525
525
  // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
526
526
  // (pure availability list — disabled natives are omitted entirely).
527
+ // Grok Build discovers models through this endpoint too, and its model picker only
528
+ // enables /effort for entries that advertise the reasoning ladder in the Grok model
529
+ // catalog shape (supports_reasoning_effort + reasoning_efforts[]). The Codex catalog
530
+ // branch above already carries the same ladders, so mirror them here — native rows
531
+ // from the upstream snapshot, routed rows from the configured provider tiers. The
532
+ // default uses the same canonical fallback as the Codex catalog resolver
533
+ // (configured default, then medium, then high, then the first tier). Extra fields
534
+ // are ignored by plain OpenAI clients.
535
+ const grokEffortOption = (value: string, isDefault: boolean) => ({
536
+ value,
537
+ label: `${value[0].toUpperCase()}${value.slice(1)} Effort`,
538
+ ...(isDefault ? { default: true } : {}),
539
+ });
540
+ const grokEffortFields = (efforts: string[], configuredDefault?: string) => {
541
+ if (efforts.length === 0) return {};
542
+ const defaultEffort = configuredDefault && efforts.includes(configuredDefault)
543
+ ? configuredDefault
544
+ : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
545
+ return {
546
+ supports_reasoning_effort: true,
547
+ reasoning_effort: defaultEffort,
548
+ reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)),
549
+ };
550
+ };
527
551
  const data = [
528
- ...visibleNativeSlugs(config).map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
529
- ...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({ id: m.alias ?? `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
552
+ ...visibleNativeSlugs(config).map(id => ({
553
+ id,
554
+ object: "model",
555
+ created: 0,
556
+ owned_by: "openai",
557
+ ...grokEffortFields(nativeReasoningEfforts(id), nativeDefaultReasoningEffort(id)),
558
+ })),
559
+ ...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({
560
+ id: m.alias ?? `${m.provider}/${m.id}`,
561
+ object: "model",
562
+ created: 0,
563
+ owned_by: m.owned_by ?? m.provider,
564
+ ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
565
+ })),
530
566
  ];
531
567
  return jsonResponse({ object: "list", data }, 200, req, config);
532
568
  }
@@ -460,7 +460,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
460
460
  ...listCatalogNativeSlugs().filter(ns => !disabled.has(ns)),
461
461
  ...visibleRouted,
462
462
  ];
463
- return jsonResponse({ chosen: config.subagentModels ?? [], available });
463
+ // #857: let CLI/GUI show when a running Codex app-server keeps an older
464
+ // in-memory catalog than the one on disk.
465
+ const { collectCodexAppServerCatalogState } = await import("../../codex/app-server-processes");
466
+ const catalogState = collectCodexAppServerCatalogState();
467
+ return jsonResponse({ chosen: config.subagentModels ?? [], available, catalogState });
464
468
  }
465
469
  if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
466
470
  let body: { models?: unknown };
@@ -646,7 +650,40 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
646
650
  }
647
651
  if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") {
648
652
  try {
649
- const state = await buildClaudeDesktopState(config);
653
+ // #859: the CLI delegates here so the registry is built in the serving
654
+ // process. Accept an optional mode; default stays static for back-compat.
655
+ let mode: "static" | "hybrid" | "discovery" = "static";
656
+ const rawBody = await req.text();
657
+ let parsed: unknown;
658
+ if (rawBody.trim()) {
659
+ try {
660
+ parsed = JSON.parse(rawBody);
661
+ } catch {
662
+ return jsonResponse({ error: "invalid JSON body" }, 400);
663
+ }
664
+ const requested = (parsed as { mode?: unknown } | null)?.mode;
665
+ if (requested !== undefined) {
666
+ if (requested === "static" || requested === "hybrid" || requested === "discovery") {
667
+ mode = requested;
668
+ } else {
669
+ return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400);
670
+ }
671
+ }
672
+ }
673
+ // #859: a delegated CLI apply carries the profile it just saved — the
674
+ // daemon's own config can be older, and building state from it would
675
+ // apply (and persist) the stale profile over the newer one.
676
+ const bodyProfile = (parsed as { profile?: unknown } | null)?.profile;
677
+ let profileOverride: Parameters<typeof buildClaudeDesktopState>[1];
678
+ if (bodyProfile !== undefined) {
679
+ const { parseDesktopProfile } = await import("../../claude/desktop-profile");
680
+ try {
681
+ profileOverride = parseDesktopProfile(bodyProfile);
682
+ } catch (error) {
683
+ return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
684
+ }
685
+ }
686
+ const state = await buildClaudeDesktopState(config, profileOverride);
650
687
  config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
651
688
  saveConfigPreservingClaudeCode(config);
652
689
  const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
@@ -662,7 +699,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
662
699
  [...desktopVisibleNativeSlugs(config)],
663
700
  routed,
664
701
  config.apiKeys?.[0]?.key,
665
- "static",
702
+ mode,
666
703
  state.profile,
667
704
  );
668
705
  if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500);
@@ -321,6 +321,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
321
321
  result.error === "codex_busy"
322
322
  || result.error === "stale_preview"
323
323
  || result.error === "referenced_history"
324
+ || result.error === "pinned_thread"
324
325
  || result.error === "storage_mutation_busy"
325
326
  || result.error === "restore_pending_overlap"
326
327
  ? 409
@@ -333,6 +334,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
333
334
  stale_preview: "Archived files changed since preview — run Preview again.",
334
335
  restore_pending_overlap: "Selected archives overlap an incomplete trash restore — finish or retry restore first.",
335
336
  referenced_history: "Selected archives are still referenced by forked or paginated history.",
337
+ pinned_thread: "Selected archives include a pinned thread — unpin it in Codex before cleanup.",
336
338
  invalid_digest: "Preview digest is missing or invalid.",
337
339
  invalid_mode: "mode must be quarantine or permanent.",
338
340
  fs_failed: "Filesystem cleanup failed. Some changes may already be applied — check CODEX_HOME/.trash and any recovery path in the response.",
@@ -365,7 +365,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
365
365
  });
366
366
  }
367
367
  if (prov.liveModels === false) {
368
- return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only upstream not verified" });
368
+ // A static catalog has no live discovery endpoint to test. This is neither
369
+ // positive connectivity evidence nor an outage, and it must stay before
370
+ // credential resolution/network access for providers such as Antigravity.
371
+ return jsonResponse({ applicable: false, reason: "static_catalog", latencyMs: 0 });
369
372
  }
370
373
  const { resolveModelsAuthToken, buildModelsRequest } = await import("../../oauth");
371
374
  const apiKey = await resolveModelsAuthToken(name, prov);
@@ -68,7 +68,9 @@ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Respo
68
68
  message:
69
69
  `Refused: agent session detected (${agentDrivenMarkers().slice(0, 3).join(", ")}) and this request `
70
70
  + `carries no dashboard session. Starring writes to the user's own GitHub account, so ask the user `
71
- + `directly and only if they say yes run: gh api -X PUT /user/starred/${STAR_REPO}`,
71
+ + `directly as a required Yes/No choice — not a soft aside, and no answer is not a No, so re-ask `
72
+ + `it unchanged while it is open — and only if they say yes run: `
73
+ + `gh api -X PUT /user/starred/${STAR_REPO}`,
72
74
  }, 403);
73
75
  }
74
76
  const result = await starRepository();
@@ -25,10 +25,24 @@
25
25
  */
26
26
 
27
27
  import { buildFailedTailPayload } from "./relay";
28
+ import {
29
+ nextSseBlock,
30
+ replaceSseDataPayload,
31
+ sseDataPayload,
32
+ type SsePayloadRewrite,
33
+ } from "./sse-payload-rewrite";
34
+ import type { TranslatorBudget } from "../lib/translator-budget";
28
35
 
29
36
  export type EagerRelayHooks = {
30
37
  /** Feed one upstream chunk through SSE inspection (createSseInspector.feed). */
31
38
  inspectChunk: (chunk: Uint8Array) => void;
39
+ /**
40
+ * Optional inline client-facing payload rewrite, framed to complete SSE
41
+ * blocks inside the single reader. This is what lets win32 rewrite traffic
42
+ * (image_gen restore, item-id repair) use this relay instead of the
43
+ * Bun#32111-unsafe tee()+JS-pull chain (#864).
44
+ */
45
+ rewritePayload?: SsePayloadRewrite;
32
46
  /** Flush inspection at upstream end (createSseInspector.finish). */
33
47
  finishInspection: () => void;
34
48
  /** Drop inspector-owned frame/item state during producer teardown. */
@@ -46,6 +60,8 @@ export type EagerRelayHooks = {
46
60
  export type EagerRelayOptions = {
47
61
  /** Bounded client queue in bytes; producer pauses above it. Default 8 MiB. */
48
62
  maxQueueBytes?: number;
63
+ /** Transient-budget owner for the inline-rewrite frame buffer. */
64
+ rewriteBudget?: TranslatorBudget;
49
65
  /** Post-cancel discard-drain wall-clock bound. Default 15 000 ms. */
50
66
  postCancelDrainMs?: number;
51
67
  /** Post-cancel discard-drain byte bound. Default 32 MiB. */
@@ -76,6 +92,72 @@ export function relaySseEagerBounded(
76
92
  const now = opts?.now ?? Date.now;
77
93
 
78
94
  const reader = body.getReader();
95
+ const rewrite = hooks.rewritePayload;
96
+ const rewriteDecoder = rewrite ? new TextDecoder() : null;
97
+ const rewriteEncoder = rewrite ? new TextEncoder() : null;
98
+ const rewriteBudget = opts?.rewriteBudget;
99
+ let frameBuffer = "";
100
+ let frameBufferBytes = 0;
101
+ /** Frame complete SSE blocks and rewrite each block's data payload in place. */
102
+ const rewriteOutbound = (value: Uint8Array): Uint8Array => {
103
+ let out = "";
104
+ const fragment = rewriteDecoder!.decode(value, { stream: true });
105
+ if (rewriteBudget) {
106
+ const nextBytes = frameBufferBytes + rewriteEncoder!.encode(fragment).byteLength;
107
+ const reservation = rewriteBudget.reserveTransient(nextBytes, { kind: "live_transient" });
108
+ try {
109
+ frameBuffer += fragment;
110
+ reservation.commitRetained();
111
+ rewriteBudget.releaseRetained(frameBufferBytes, { kind: "live_transient" });
112
+ frameBufferBytes = nextBytes;
113
+ } catch (error) {
114
+ reservation.release();
115
+ throw error;
116
+ }
117
+ } else {
118
+ frameBuffer += fragment;
119
+ frameBufferBytes += value.byteLength;
120
+ }
121
+ for (;;) {
122
+ const next = nextSseBlock(frameBuffer);
123
+ if (!next) break;
124
+ const payload = sseDataPayload(next.block);
125
+ const rewrittenPayload = payload === null ? null : rewrite!(payload);
126
+ // Replace only on an actual change: replaceSseDataPayload collapses
127
+ // multi-data-line events and normalizes newline style even when the
128
+ // payload is identical, which corrupts valid streams.
129
+ const block = payload !== null && rewrittenPayload !== payload
130
+ ? replaceSseDataPayload(next.block, rewrittenPayload!)
131
+ : next.block;
132
+ out += block + next.delimiter;
133
+ frameBuffer = next.rest;
134
+ }
135
+ if (rewriteBudget) {
136
+ const remaining = rewriteEncoder!.encode(frameBuffer).byteLength;
137
+ rewriteBudget.releaseRetained(frameBufferBytes - remaining, { kind: "live_transient" });
138
+ frameBufferBytes = remaining;
139
+ } else {
140
+ frameBufferBytes = rewriteEncoder!.encode(frameBuffer).byteLength;
141
+ }
142
+ return rewriteEncoder!.encode(out);
143
+ };
144
+ /** Flush any trailing partial block at upstream end (rewrite applied, matching the pull relay). */
145
+ const flushRewriteTail = (): Uint8Array => {
146
+ if (!rewrite) return new Uint8Array(0);
147
+ // Decoder-flushed bytes logically follow everything already decoded.
148
+ let tail = frameBuffer + rewriteDecoder!.decode();
149
+ const payload = sseDataPayload(tail);
150
+ if (payload !== null) {
151
+ const rewrittenPayload = rewrite(payload);
152
+ if (rewrittenPayload !== payload) tail = replaceSseDataPayload(tail, rewrittenPayload);
153
+ }
154
+ frameBuffer = "";
155
+ if (rewriteBudget && frameBufferBytes > 0) {
156
+ rewriteBudget.releaseRetained(frameBufferBytes, { kind: "live_transient" });
157
+ }
158
+ frameBufferBytes = 0;
159
+ return rewriteEncoder!.encode(tail);
160
+ };
79
161
  let queuedBytes = 0;
80
162
  let cancelled = false;
81
163
  let done = false;
@@ -126,6 +208,13 @@ export function relaySseEagerBounded(
126
208
  const { done: upstreamDone, value } = result;
127
209
  if (upstreamDone) {
128
210
  hooks.finishInspection();
211
+ if (rewrite) {
212
+ const tail = flushRewriteTail();
213
+ if (tail.byteLength > 0 && !cancelled) {
214
+ queuedBytes += tail.byteLength;
215
+ try { controllerRef?.enqueue(tail); } catch { /* client already gone */ }
216
+ }
217
+ }
129
218
  if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
130
219
  syntheticKind = "incomplete";
131
220
  }
@@ -141,9 +230,11 @@ export function relaySseEagerBounded(
141
230
  }
142
231
  continue;
143
232
  }
144
- queuedBytes += value.byteLength;
233
+ const outbound = rewrite ? rewriteOutbound(value) : value;
234
+ if (outbound.byteLength === 0) continue;
235
+ queuedBytes += outbound.byteLength;
145
236
  try {
146
- controllerRef?.enqueue(value);
237
+ controllerRef?.enqueue(outbound);
147
238
  } catch {
148
239
  // Controller already torn down (client went away without cancel()).
149
240
  cancelled = true;
@@ -174,6 +265,13 @@ export function relaySseEagerBounded(
174
265
  }
175
266
  }
176
267
  } finally {
268
+ // Release any retained rewrite-buffer bytes on every teardown path
269
+ // (error, cancel, upstream abort) — consumption/EOF release alone
270
+ // leaves them charged.
271
+ if (rewriteBudget && frameBufferBytes > 0) {
272
+ try { rewriteBudget.releaseRetained(frameBufferBytes, { kind: "live_transient" }); } catch { /* teardown must not throw */ }
273
+ frameBufferBytes = 0;
274
+ }
177
275
  if (syntheticKind) hooks.onSynthetic(syntheticKind);
178
276
  if (cancelled && !hooks.sawTerminal()) {
179
277
  hooks.onClientCancel();
@@ -183,6 +183,18 @@ export interface MultiAgentGuidanceDeps {
183
183
  configuredModels: readonly string[],
184
184
  surface: SpawnAgentSurface,
185
185
  ) => EffectiveSubagentRoster | Promise<EffectiveSubagentRoster>;
186
+ collectCatalogState?: () => { state: "fresh" | "stale" | "not_running" | "unknown" };
187
+ }
188
+
189
+ async function defaultCollectCatalogState(): Promise<{ state: "fresh" | "stale" | "not_running" | "unknown" }> {
190
+ // Explicit override for tests and diagnostics: process state is global and
191
+ // would otherwise leak the host machine's app-server into hermetic tests.
192
+ const override = process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE;
193
+ if (override === "fresh" || override === "stale" || override === "not_running" || override === "unknown") {
194
+ return { state: override };
195
+ }
196
+ const { collectCodexAppServerCatalogState } = await import("../../codex/app-server-processes");
197
+ return collectCodexAppServerCatalogState();
186
198
  }
187
199
 
188
200
 
@@ -214,6 +226,15 @@ export async function multiAgentGuidanceText(
214
226
  if (surface === null) return null;
215
227
 
216
228
  if (surface === "v2") {
229
+ // #857: the disk catalog may be newer than the running app-server's
230
+ // in-memory copy. Advertising preferred models or a roster the running
231
+ // Codex cannot actually spawn makes spawn_agent reject the override, so
232
+ // suppress positive model claims while the state is stale or unknown.
233
+ const catalogState = await (deps.collectCatalogState ?? defaultCollectCatalogState)();
234
+ if (catalogState.state === "stale" || catalogState.state === "unknown") {
235
+ return "<multi_agent_mode>The model catalog changed after Codex started; do not set "
236
+ + "model or reasoning_effort overrides until Codex restarts.</multi_agent_mode>";
237
+ }
217
238
  // codex-rs supplies the Proactive text on v2; the proxy only adds model-designation
218
239
  // guidance, and only when there is something concrete to designate: a configured
219
240
  // injectionModel and/or a roster entry that resolves in the injected catalog.
@@ -144,7 +144,7 @@ import {
144
144
  sanitizePassthroughHeaders,
145
145
  } from "../relay";
146
146
  import { relaySseEagerBounded } from "../relay-eager";
147
- import { selectEagerPath } from "../../lib/bun-stream-caps";
147
+ import { isWin32EagerRewrite, selectEagerPath } from "../../lib/bun-stream-caps";
148
148
  import { cancelBodyOnAbort } from "../../lib/abort";
149
149
  import {
150
150
  createResponsesItemIdPayloadRewrite,
@@ -1759,12 +1759,23 @@ async function handleResponsesInner(
1759
1759
  if (isEventStream && upstreamResponse.body) {
1760
1760
  const repairConfig = route.provider.responsesItemIdRepair;
1761
1761
  const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig);
1762
+ // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
1763
+ const payloadRewrites = [
1764
+ createImageGenCallRestoreRewrite(imageGenCallAliases),
1765
+ hasResponsesItemIdRepair(repairConfig)
1766
+ ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
1767
+ : undefined,
1768
+ ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
1769
+ // #864: win32 rewrite traffic must never enter the tee()+JS-pull chain
1770
+ // (Bun#32111 JS-sink segfault — text frames pass, the terminal block is
1771
+ // lost). The eager single reader applies the same rewrites inline.
1772
+ const win32EagerRewrite = isWin32EagerRewrite(process.platform, needsClientRewrite);
1762
1773
  const eagerPath = selectEagerPath(
1763
1774
  process.platform,
1764
1775
  needsClientRewrite,
1765
1776
  config.streamMode ?? "auto",
1766
1777
  );
1767
- if (eagerPath?.useEagerRelay) {
1778
+ if (eagerPath?.useEagerRelay || win32EagerRewrite) {
1768
1779
  const turnAc = new AbortController();
1769
1780
  linkAbortSignal(upstream, turnAc.signal);
1770
1781
  registerTurn(turnAc, options.turnAdmissionLease);
@@ -1801,6 +1812,9 @@ async function handleResponsesInner(
1801
1812
  finishInspection: () => inspector.finish(),
1802
1813
  disposeInspection: () => inspector.dispose(),
1803
1814
  sawTerminal: () => inspector.reported(),
1815
+ ...(win32EagerRewrite
1816
+ ? { rewritePayload: composeSsePayloadRewrites(...payloadRewrites) }
1817
+ : {}),
1804
1818
  onSynthetic: kind => {
1805
1819
  if (!reportNativeTerminal) return;
1806
1820
  if (kind === "incomplete") {
@@ -1814,9 +1828,10 @@ async function handleResponsesInner(
1814
1828
  },
1815
1829
  onClientCancel: () => options.onNativePassthroughCancel?.(),
1816
1830
  onDone: () => unregisterTurn(turnAc),
1817
- });
1818
- // selectEagerPath admits only no-rewrite traffic, so this stays a pure native relay on
1819
- // both eligible platforms; win32 must never enter an image/item-id JS pull wrapper (#32111).
1831
+ }, win32EagerRewrite ? { rewriteBudget: translatorBudget } : undefined);
1832
+ // selectEagerPath admits only no-rewrite traffic on both eligible platforms;
1833
+ // win32 rewrite traffic reaches this relay too, but with the payload rewrite
1834
+ // applied inline — never via an image/item-id JS pull wrapper (#32111, #864).
1820
1835
  if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
1821
1836
  return markEagerRelaySseResponse(
1822
1837
  markNativePassthroughSseResponse(new Response(eagerBody, {
@@ -1886,13 +1901,6 @@ async function handleResponsesInner(
1886
1901
  // win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
1887
1902
  // relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
1888
1903
  // mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
1889
- // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
1890
- const payloadRewrites = [
1891
- createImageGenCallRestoreRewrite(imageGenCallAliases),
1892
- hasResponsesItemIdRepair(repairConfig)
1893
- ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
1894
- : undefined,
1895
- ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
1896
1904
  const rewrittenBody = payloadRewrites.length > 0
1897
1905
  ? relaySseWithPayloadRewrite(nativeBody, composeSsePayloadRewrites(...payloadRewrites), translatorBudget)
1898
1906
  : nativeBody;