diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/010_roadmap.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/010_roadmap.md
new file mode 100644
index 00000000000..5e5ac7e883d
--- /dev/null
+++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/010_roadmap.md
@@ -0,0 +1,63 @@
+# Sub-agent surface: v1 as the install default, with an approval guard on base and v2
+
+## Why this unit exists
+
+The v2 collaboration surface cannot deliver a task from a ChatGPT-native parent to a
+routed child. Codex mints that child task as backend `encrypted_content`, and only the
+ChatGPT backend holds the key, so an external provider receives ciphertext it cannot
+read. OpenCodex already fails closed on it (`unreadable_encrypted_agent_task`) and
+documents the limitation as issue #92, but the product still ships `base` as the install
+default, and `base` pins Sol and Terra to v2. A new user who delegates from Sol to Grok
+therefore meets the failure on their first attempt, with no warning that the mode they
+never chose is the reason.
+
+This unit makes v1 the default, and makes base and v2 a choice the operator confirms
+after reading what it costs.
+
+## Locked decisions
+
+**D1 — an absent `multiAgentMode` key still means base.** Roughly fifteen call sites read
+`config.multiAgentMode ?? "default"`, and the management API deletes the key when the
+operator selects base. Re-pointing that fallback to `"v1"` would make the key's absence
+ambiguous: it could no longer distinguish "never configured" from "explicitly base". So
+what changes is the written default — `getDefaultConfig()` now emits an explicit
+`multiAgentMode: "v1"`, exactly the shape `subagentModels` already uses. The resolver is
+untouched, and the merge that could push a default into an existing document runs only
+on the schema-repair path.
+
+**D2 — an existing operator is asked, not flipped.** Rewriting a stored
+`multiAgentMode: "v2"` during an update would change a setting its owner deliberately
+made, on a machine they are not looking at. Instead the runtime computes an advisory,
+the GUI raises it once, and only the operator's answer writes. That is also what makes
+the two buttons meaningful: 계속하기 keeps the current mode, v1으로 바꾸기 applies v1.
+
+**D3 — the advisory is a version counter, not a boolean.** `multiAgentSurfaceAdvisoryVersion`
+mirrors `subagentModelsVersion`. When the encryption limitation is fixed upstream, or when
+a later release needs to say something different about the same setting, bumping the
+constant re-raises the notice for everyone without adding another key.
+
+**D4 — one dialog component, two triggers.** The approval dialog shown when the operator
+selects base or v2 and the post-update advisory differ only in their heading and body
+text. They offer the same two actions and link to the same guide, so they are one
+component with a `reason` discriminator rather than two that drift apart.
+
+**D5 — the guide is a real published page, not an anchor.** The dialog links to
+`https://opencodex.me/guides/subagent-v1-default/`, a new docs-site page whose job is to
+make the failure legible: an inline SVG showing the same delegation succeeding under v1
+and dying at the provider boundary under v2.
+
+## Work phases
+
+| Phase | Outcome |
+| --- | --- |
+| wp1 | This roadmap, with the decisions above locked before any code moves. |
+| wp2 | Runtime: `getDefaultConfig()` emits v1, the advisory state and its version constant exist, and `GET/PUT /api/v2` carry them. |
+| wp3 | GUI: the approval dialog, both triggers wired, and translations for all nine locales. |
+| wp4 | The docs-site guide and its SVG, plus the link from the dialog and the existing surface guide. |
+| wp5 | Focused tests, typecheck, PR to `dev` with a UI screenshot, exact-head CI, merge. |
+
+## Out of scope
+
+Fixing the encryption limitation itself. `agentTaskRecovery` and `plaintextV2AgentMessages`
+remain experimental and default-off; this unit changes which surface an operator lands
+on by default, not what v2 can carry.
diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/020_runtime_default_and_advisory.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/020_runtime_default_and_advisory.md
new file mode 100644
index 00000000000..28a4a55e507
--- /dev/null
+++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/020_runtime_default_and_advisory.md
@@ -0,0 +1,63 @@
+# wp2 — Runtime: v1 default and the advisory state
+
+## What changes
+
+`src/config/multi-agent-surface.ts` (new) owns three things:
+
+- `MULTI_AGENT_SURFACE_ADVISORY_VERSION = 1`.
+- `resolveMultiAgentMode(config)`, the single reader that turns a stored value into
+ `"v1" | "default" | "v2"`. It keeps the existing rule that an absent key means
+ `"default"`; it exists so the advisory and the catalog agree on one answer.
+- `multiAgentSurfaceAdvisoryRequired(config)`, true when the resolved mode is not `"v1"`
+ and the stored `multiAgentSurfaceAdvisoryVersion` is below the constant.
+
+`getDefaultConfig()` gains `multiAgentMode: "v1"` and
+`multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION`. A fresh install
+has nothing to advise about, so it starts acknowledged.
+
+`src/types/config.ts` gains the optional `multiAgentSurfaceAdvisoryVersion?: number`, and
+`configSchema` in `src/config.ts` accepts it as a positive integer with `.catch(undefined)`
+so a hand-edited value cannot fail the parse.
+
+## Management API
+
+`GET /api/v2` adds one response-only object:
+
+```json
+{
+ "multiAgentSurfaceAdvisory": {
+ "required": true,
+ "mode": "v2",
+ "recommended": "v1",
+ "version": 1,
+ "docsUrl": "https://opencodex.me/guides/subagent-v1-default/"
+ }
+}
+```
+
+`PUT /api/v2` accepts `multiAgentSurfaceAdvisoryAcknowledged: true`, which stores the
+current constant and is idempotent. It composes with an existing `multiAgentMode` write in
+the same body, because `v1으로 바꾸기` sends both in one request and must not leave the
+advisory raised if the mode write succeeded.
+
+It stays a boolean in, number out. A client that could post an arbitrary version could
+silence a future advisory it has never seen.
+
+## What deliberately does not change
+
+The catalog stamping in `src/codex/catalog/sync.ts`, the `keepNativeChatGptOnV1` hybrid,
+and every `?? "default"` fallback. This phase moves the written default and adds one
+piece of state; it does not renegotiate what the three modes mean.
+
+## Tests
+
+New tests go into existing files. `tests/test-layout.test.ts` requires every new test file to
+be registered in both `scripts/test-layout/layout.json` and
+`tests/fixtures/test-layout-expected.json`, and a file named after this route does not resolve
+through the regex seeds. Extending files that already own these subsystems is the smaller
+change and keeps the guard quiet.
+
+`tests/server/config.test.ts` — a fresh config resolves to v1 and starts acknowledged.
+`tests/codex-integration/codex-v2-gate.test.ts`, in its existing management-API parity
+describe block — the advisory is required for a v2 config and for a config with no key, not
+required after acknowledgement, and a combined mode plus acknowledgement write persists both.
diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/030_gui_approval_dialog.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/030_gui_approval_dialog.md
new file mode 100644
index 00000000000..71c36d74353
--- /dev/null
+++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/030_gui_approval_dialog.md
@@ -0,0 +1,45 @@
+# wp3 — GUI: the approval dialog and its two triggers
+
+## Component
+
+`gui/src/components/SubagentSurfaceWarningModal.tsx`, modelled on
+`OAuthTosWarningModal.tsx`: a native `
+ {(pendingMaMode || maAdvisoryOpen) && (
+ { void keepMaMode(); }}
+ onChooseV1={() => { void chooseMaV1(); }}
+ onDismiss={dismissMaSurfaceDialog}
+ />
+ )}
>
);
}
diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts
index 8285c02d76f..acc694e6bea 100644
--- a/gui/src/pages/models-shared.ts
+++ b/gui/src/pages/models-shared.ts
@@ -141,6 +141,8 @@ export interface V2Status {
maxConcurrentThreadsPerSession?: number | null;
multiAgentMode?: "v1" | "default" | "v2";
keepNativeChatGptOnV1?: boolean;
+ /** Response-only; absent on a runtime older than the v1-default advisory. */
+ multiAgentSurfaceAdvisory?: unknown;
}
export interface ShadowCallData {
diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts
index abcb5be9ff5..cf7efb98ef4 100644
--- a/gui/src/pages/use-dashboard-data.ts
+++ b/gui/src/pages/use-dashboard-data.ts
@@ -25,6 +25,7 @@ import {
type DashboardEpochRefs,
} from "./dashboard-core-poll";
import { usageSummary30dResourceKey } from "../usage-summary-resource";
+import type { SubagentSurfaceAdvisory } from "../subagent-surface";
import {
type DashboardSection,
type HealthData,
@@ -180,8 +181,20 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) {
const [modelsLoading, setModelsLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [maMode, setMaMode] = useState(() => cachedMaMode ?? "default");
- const [maBusy, setMaBusy] = useState(false);
+const [maBusy, setMaBusy] = useState(false);
const [maError, setMaError] = useState(null);
+ /** The runtime's one-time advisory, and whether this page load has answered it. */
+ /**
+ * The runtime's one-time advisory and any staged base/v2 selection, each tagged with the
+ * endpoint it came from. This hook stays mounted across an endpoint switch, and clearing the
+ * state from an effect would be a cascading render, so the tag is what scopes them.
+ */
+ const [maAdvisoryState, setMaAdvisoryState] = useState<{ advisory: SubagentSurfaceAdvisory | null; apiBase: string } | null>(null);
+ const [maAdvisoryAnsweredFor, setMaAdvisoryAnsweredFor] = useState(null);
+ const [pendingMaModeState, setPendingMaModeState] = useState<{ mode: "default" | "v2"; apiBase: string } | null>(null);
+ const maAdvisory = maAdvisoryState?.apiBase === apiBase ? maAdvisoryState.advisory : null;
+ const maAdvisoryAnswered = maAdvisoryAnsweredFor === apiBase;
+ const pendingMaMode = pendingMaModeState?.apiBase === apiBase ? pendingMaModeState.mode : null;
const [maHelpOpen, setMaHelpOpen] = useState(false);
const [effortCapHelpOpen, setEffortCapHelpOpen] = useState(false);
const [shadowCallHelpOpen, setShadowCallHelpOpen] = useState(false);
@@ -378,6 +391,7 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) {
useEffect(() => {
if (maModePoll.data === undefined) return;
setMaMode(maModePoll.data.maMode);
+ setMaAdvisoryState({ advisory: maModePoll.data.advisory ?? null, apiBase });
writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, maModePoll.data.maMode);
}, [maModePoll.data, apiBase]);
@@ -605,19 +619,22 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) {
}
}
- const switchMaMode = async (mode: "v1" | "default" | "v2") => {
- if (maBusy || maMode === mode) return;
- setMaBusy(true);
+ const writeMaMode = async (mode: "v1" | "default" | "v2", acknowledgeAdvisory = false) => {
+ setMaBusy(true);
setMaError(null);
- try {
- const r = await fetch(`${apiBase}/api/v2`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ multiAgentMode: mode }),
- });
- if (r.ok) {
- setMaMode(mode);
- writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, mode);
+ try {
+ const payload: { multiAgentMode: "v1" | "default" | "v2"; multiAgentSurfaceAdvisoryAcknowledged?: true } = { multiAgentMode: mode };
+ // One request, so the recommended answer cannot leave the notice raised on a mode it applied.
+ if (acknowledgeAdvisory) payload.multiAgentSurfaceAdvisoryAcknowledged = true;
+ const r = await fetch(`${apiBase}/api/v2`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (r.ok) {
+ setMaMode(mode);
+ if (acknowledgeAdvisory) setMaAdvisoryAnsweredFor(apiBase);
+ writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, mode);
} else {
let message = t("dash.maSwitchFailed", { status: String(r.status) });
try {
@@ -625,12 +642,67 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) {
message = (typeof body.error === "string" && body.error) || (typeof body.message === "string" && body.message) || message;
} catch { /* non-JSON error body */ }
setMaError(message);
- }
+ }
+ } catch (e) {
+ setMaError(e instanceof Error ? e.message : t("dash.maNetworkError"));
+ }
+ finally { setMaBusy(false); }
+ };
+
+ const switchMaMode = async (mode: "v1" | "default" | "v2") => {
+ if (maBusy || maMode === mode) return;
+ // v1 applies immediately: confirming a move toward the safe default would be noise. base
+ // and v2 both put ChatGPT-native parents on the surface whose task a routed child cannot
+ // read, so they wait for an answer.
+ if (mode !== "v1") { setPendingMaModeState({ mode, apiBase }); return; }
+ await writeMaMode("v1");
+ };
+
+ /** Answer the advisory without moving the mode. Failure just means it asks again. */
+ const acknowledgeMaAdvisory = async () => {
+ // Answered for this page load either way: the operator did answer. If the write did not
+ // land the runtime raises the notice again next load, so a failure costs one more prompt
+ // rather than a lost setting — but say so instead of swallowing it.
+ setMaAdvisoryAnsweredFor(apiBase);
+ try {
+ const r = await fetch(`${apiBase}/api/v2`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ multiAgentSurfaceAdvisoryAcknowledged: true }),
+ });
+ if (!r.ok) setMaError(t("dash.maSwitchFailed", { status: String(r.status) }));
} catch (e) {
setMaError(e instanceof Error ? e.message : t("dash.maNetworkError"));
}
- finally { setMaBusy(false); }
- };
+ };
+
+ /** The ghost button: apply the mode that was selected, or keep the stored one. */
+ const keepMaMode = async () => {
+ const pending = pendingMaMode;
+ setPendingMaModeState(null);
+ // A selection answers the advisory too. Without that, continuing to base or v2 leaves the
+ // notice raised and the next poll asks the same question the operator just answered.
+ // Unconditionally, not gated on the poll's current `required`: that projection goes false
+ // while the mode is v1 without the version having been stored, so a later confirmed base or
+ // v2 would skip the acknowledgement and be asked all over again.
+ if (pending) { await writeMaMode(pending, true); return; }
+ await acknowledgeMaAdvisory();
+ };
+
+ /** The primary button: v1, and the advisory answered in the same request when it is raised. */
+ const chooseMaV1 = async () => {
+ setPendingMaModeState(null);
+ if (maMode === "v1") { await acknowledgeMaAdvisory(); return; }
+ await writeMaMode("v1", true);
+ };
+
+ /** Escape or backdrop: abandon a selection, or leave the advisory unanswered for next load. */
+ const dismissMaSurfaceDialog = () => {
+ if (pendingMaMode) { setPendingMaModeState(null); return; }
+ setMaAdvisoryAnsweredFor(apiBase);
+ };
+
+ const maAdvisoryOpen = !pendingMaMode && !maAdvisoryAnswered && maAdvisory?.required === true;
const saveInjection = async (patch: {
multiAgentGuidanceEnabled?: boolean;
@@ -844,8 +916,9 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) {
usageLoading: usagePoll.loading && !usage30d,
healthLoading: overviewPoll.loading && !health,
sidecarSaving, shadowCallSaving, modelsLoading, settingsSaving, syncing,
- maMode, maModeResolved, maBusy, setMaHelpOpen, maHelpOpen,
- maError,
+maMode, maModeResolved, maBusy, setMaHelpOpen, maHelpOpen,
+ maError,
+ maAdvisory, maAdvisoryOpen, pendingMaMode, keepMaMode, chooseMaV1, dismissMaSurfaceDialog,
effortCapHelpOpen, setEffortCapHelpOpen, shadowCallHelpOpen, setShadowCallHelpOpen,
injectionModel, injectionEffort, injectionEfforts, injectionAvailable, injectionSaving,
multiAgentGuidanceEnabled, syncCodexSubagentDefaults, saveInjection,
diff --git a/gui/src/pages/use-subagent-delegation.ts b/gui/src/pages/use-subagent-delegation.ts
index 1a7829dc515..cea33a8508d 100644
--- a/gui/src/pages/use-subagent-delegation.ts
+++ b/gui/src/pages/use-subagent-delegation.ts
@@ -40,6 +40,8 @@ export type UltraModePatch = {
multiAgentModeHintText?: string | null;
/** The v1/base/v2 switch. Models owns the catalog-side copy; this is the delegation-side one. */
multiAgentMode?: "v1" | "default" | "v2";
+ /** Answers the one-time surface advisory in the same request as a mode change. */
+ multiAgentSurfaceAdvisoryAcknowledged?: true;
};
type DelegationResponse = {
diff --git a/gui/src/subagent-surface.ts b/gui/src/subagent-surface.ts
new file mode 100644
index 00000000000..b6b405c693f
--- /dev/null
+++ b/gui/src/subagent-surface.ts
@@ -0,0 +1,39 @@
+/**
+ * The sub-agent surface advisory, on the dashboard side.
+ *
+ * The runtime sends `docsUrl` with every `/api/v2` response, so this constant is only the
+ * fallback for a runtime older than the advisory. `gui/tests/subagent-surface-warning.test.tsx`
+ * pins it to `src/config/multi-agent-surface.ts` so the two cannot drift.
+ */
+export const SUBAGENT_SURFACE_GUIDE_URL = "https://opencodex.me/guides/subagent-v1-default/";
+
+export type SubagentSurfaceMode = "v1" | "default" | "v2";
+
+export type SubagentSurfaceAdvisory = {
+ required: boolean;
+ mode: SubagentSurfaceMode;
+ recommended: "v1";
+ version: number;
+ docsUrl: string;
+};
+
+/** Narrow an unknown `/api/v2` field; a runtime that does not send it yields null. */
+export function readSubagentSurfaceAdvisory(value: unknown): SubagentSurfaceAdvisory | null {
+ if (!value || typeof value !== "object") return null;
+ const raw = value as Record;
+ const mode = raw.mode;
+ if (mode !== "v1" && mode !== "default" && mode !== "v2") return null;
+ if (typeof raw.required !== "boolean") return null;
+ return {
+ required: raw.required,
+ mode,
+ recommended: "v1",
+ version: typeof raw.version === "number" ? raw.version : 0,
+ docsUrl: typeof raw.docsUrl === "string" && raw.docsUrl ? raw.docsUrl : SUBAGENT_SURFACE_GUIDE_URL,
+ };
+}
+
+/** What the operator sees for a mode. The stored `"default"` is called base in the UI. */
+export function subagentSurfaceLabel(mode: SubagentSurfaceMode): string {
+ return mode === "default" ? "base" : mode;
+}
diff --git a/gui/tests/models-keep-native-v1-placement.test.ts b/gui/tests/models-keep-native-v1-placement.test.ts
index 4e3969df583..7f9cfa60bb4 100644
--- a/gui/tests/models-keep-native-v1-placement.test.ts
+++ b/gui/tests/models-keep-native-v1-placement.test.ts
@@ -74,7 +74,17 @@ test("both v2 surface setters route through the shared writer", () => {
modelsSource.indexOf("const putV2Threads"),
);
- expect(setters).toContain("putV2Setting({ multiAgentMode: mode })");
+ // v1 still writes straight through the shared writer. base and v2 are staged for the
+ // approval dialog, whose Continue handler calls the same writer — the invariant here is
+ // that neither path falls back to a refetch.
+ expect(setters).toContain('putV2Setting({ multiAgentMode: "v1" })');
+ expect(setters).toContain("setPendingSurface(mode)");
expect(setters).toContain("putV2Setting({ keepNativeChatGptOnV1: next })");
expect(setters).not.toContain("void loadV2()");
+
+ const dialog = modelsSource.slice(modelsSource.indexOf(""));
+ // The Continue handler also answers the surface advisory, so match the call, not the body.
+ expect(props).toContain("putV2Setting({ multiAgentMode: next,");
+ expect(props).not.toContain("void loadV2()");
});
diff --git a/gui/tests/subagent-surface-warning.test.tsx b/gui/tests/subagent-surface-warning.test.tsx
new file mode 100644
index 00000000000..ac785cab480
--- /dev/null
+++ b/gui/tests/subagent-surface-warning.test.tsx
@@ -0,0 +1,254 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { LanguageProvider } from "../src/i18n/provider";
+import { DICTS, LOCALES } from "../src/i18n/shared";
+import SubagentSurfaceWarningModal from "../src/components/SubagentSurfaceWarningModal";
+import { SUBAGENT_SURFACE_GUIDE_URL, readSubagentSurfaceAdvisory, subagentSurfaceLabel } from "../src/subagent-surface";
+
+const KEYS = [
+ "subagentSurface.selectionTitle",
+ "subagentSurface.selectionBody",
+ "subagentSurface.advisoryTitle",
+ "subagentSurface.advisoryBody",
+ "subagentSurface.continue",
+ "subagentSurface.switchToV1",
+ "subagentSurface.learnMore",
+];
+
+/**
+ * The GUI keeps its own copy of the guide URL as a fallback for a runtime that predates the
+ * advisory. Two copies of a URL is exactly the thing that rots, so pin them to each other.
+ */
+test("the fallback guide URL matches the runtime constant", async () => {
+ const runtime = await Bun.file(new URL("../../src/config/multi-agent-surface.ts", import.meta.url)).text();
+
+ expect(runtime).toContain(`SUBAGENT_SURFACE_GUIDE_URL = "${SUBAGENT_SURFACE_GUIDE_URL}"`);
+ expect(SUBAGENT_SURFACE_GUIDE_URL).toStartWith("https://opencodex.me/guides/");
+});
+
+test("every locale defines the dialog keys, and Korean uses the wording that was asked for", () => {
+ // Read the loaded catalogs, not the source text: a key mentioned only in a comment would
+ // satisfy a grep and still render the raw key id to the operator.
+ expect(LOCALES.length).toBe(9);
+ for (const { code } of LOCALES) {
+ for (const key of KEYS) {
+ const value = DICTS[code][key as keyof (typeof DICTS)[typeof code]];
+ expect(typeof value).toBe("string");
+ expect(value.trim().length).toBeGreaterThan(0);
+ expect(value).not.toBe(key);
+ }
+ }
+
+ expect(DICTS.ko["subagentSurface.continue"]).toBe("계속하기");
+ expect(DICTS.ko["subagentSurface.switchToV1"]).toBe("v1으로 바꾸기");
+});
+
+test("a runtime that does not send the advisory yields null instead of a raised notice", () => {
+ expect(readSubagentSurfaceAdvisory(undefined)).toBeNull();
+ expect(readSubagentSurfaceAdvisory({})).toBeNull();
+ expect(readSubagentSurfaceAdvisory({ required: true })).toBeNull();
+ expect(readSubagentSurfaceAdvisory({ required: "yes", mode: "v2" })).toBeNull();
+
+ expect(readSubagentSurfaceAdvisory({ required: true, mode: "v2" })).toEqual({
+ required: true, mode: "v2", recommended: "v1", version: 0, docsUrl: SUBAGENT_SURFACE_GUIDE_URL,
+ });
+ expect(readSubagentSurfaceAdvisory({
+ required: false, mode: "default", version: 3, docsUrl: "https://example.test/guide/",
+ })).toEqual({
+ required: false, mode: "default", recommended: "v1", version: 3, docsUrl: "https://example.test/guide/",
+ });
+});
+
+test("the stored default mode is called base in the UI", () => {
+ expect(subagentSurfaceLabel("default")).toBe("base");
+ expect(subagentSurfaceLabel("v1")).toBe("v1");
+ expect(subagentSurfaceLabel("v2")).toBe("v2");
+});
+
+/**
+ * The same v1/base/v2 switch is rendered on three pages. Every one of them must stage a base
+ * or v2 click instead of writing it, or the dialog is decoration on whichever page was missed.
+ * v1 stays immediate: asking an operator to confirm a move toward the safe default is noise.
+ */
+test("all three mode switches gate base and v2 but let v1 through", async () => {
+ const models = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text();
+ const gate = models.slice(models.indexOf("const setMultiAgentMode"));
+ const body = gate.slice(0, gate.indexOf("};"));
+
+ expect(body).toContain('if (mode === "v1") { await putV2Setting({ multiAgentMode: "v1" }); return; }');
+ expect(body).toContain("setPendingSurface(mode);");
+ expect(body).not.toContain("await putV2Setting({ multiAgentMode: mode })");
+
+ const dash = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text();
+ const switcher = dash.slice(dash.indexOf("const switchMaMode"));
+ const switcherBody = switcher.slice(0, switcher.indexOf("};"));
+
+ expect(switcherBody).toContain('if (mode !== "v1") { setPendingMaModeState({ mode, apiBase }); return; }');
+ expect(switcherBody).toContain('await writeMaMode("v1")');
+
+ // The Subagents page carries a third copy of the switch and used to PUT straight through.
+ const subagents = await Bun.file(new URL("../src/pages/Subagents.tsx", import.meta.url)).text();
+ const handler = subagents.slice(subagents.indexOf("onUltraModeSave: patch =>"));
+ const handlerBody = handler.slice(0, handler.indexOf("},"));
+
+ expect(handlerBody).toContain('patch.multiAgentMode === "default" || patch.multiAgentMode === "v2"');
+ expect(handlerBody).toContain("setPendingSurface({ mode: patch.multiAgentMode, apiBase });");
+ expect(subagents).toContain(" {
+ const dash = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text();
+ const choose = dash.slice(dash.indexOf("const chooseMaV1"));
+ const body = choose.slice(0, choose.indexOf("};"));
+
+ // Unconditional: the poll's `required` goes false while the mode is v1 without the version
+ // having been stored, so gating on it would skip the acknowledgement and ask all over again.
+ expect(body).toContain('await writeMaMode("v1", true)');
+ expect(body).not.toContain("maAdvisory?.required");
+
+ const writer = dash.slice(dash.indexOf("const writeMaMode"));
+ expect(writer.slice(0, writer.indexOf("const switchMaMode"))).toContain(
+ "if (acknowledgeAdvisory) payload.multiAgentSurfaceAdvisoryAcknowledged = true;",
+ );
+
+ // Continuing to base or v2 also answers the notice. Otherwise the next poll asks again.
+ const keep = dash.slice(dash.indexOf("const keepMaMode"));
+ expect(keep.slice(0, keep.indexOf("};"))).toContain("await writeMaMode(pending, true)");
+});
+
+const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const;
+let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>;
+let testWindow: Window;
+let container: HTMLElement;
+let root: Root | null = null;
+
+beforeEach(() => {
+ previousGlobals = Object.fromEntries(
+ globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
+ ) as typeof previousGlobals;
+ testWindow = new Window({ url: "http://localhost/" });
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: testWindow.document },
+ window: { configurable: true, value: testWindow },
+ navigator: { configurable: true, value: testWindow.navigator },
+ IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
+ });
+ // happy-dom has no native modal dialog; the component only needs the call to be safe.
+ const dialogProto = (testWindow as unknown as { HTMLDialogElement?: { prototype: Record } }).HTMLDialogElement?.prototype;
+ if (dialogProto && typeof dialogProto.showModal !== "function") dialogProto.showModal = function showModal() { (this as { open?: boolean }).open = true; };
+ container = testWindow.document.createElement("div") as unknown as HTMLElement;
+ testWindow.document.body.appendChild(container as never);
+ root = null;
+});
+
+afterEach(() => {
+ if (root) act(() => root!.unmount());
+ for (const key of globals) {
+ const descriptor = previousGlobals[key];
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else delete (globalThis as Record)[key];
+ }
+});
+
+function render(node: React.ReactElement): void {
+ root = createRoot(container);
+ act(() => root!.render({node}));
+}
+
+test("the advisory offers both answers and a link to the guide, and each button calls its own handler", () => {
+ const calls: string[] = [];
+ render(
+ calls.push("continue")}
+ onChooseV1={() => calls.push("v1")}
+ onDismiss={() => calls.push("dismiss")}
+ />,
+ );
+
+ const dialog = container.querySelector("dialog");
+ expect(dialog?.getAttribute("data-subagent-surface-reason")).toBe("advisory");
+
+ const link = container.querySelector('a[href="https://example.test/guide/"]');
+ expect(link).not.toBeNull();
+ expect(link?.getAttribute("rel")).toBe("noreferrer");
+
+ const actions = Array.from(container.querySelectorAll(".modal-actions button")) as HTMLButtonElement[];
+ expect(actions).toHaveLength(2);
+ expect(actions[1]!.className).toContain("btn-primary");
+
+ act(() => actions[0]!.click());
+ act(() => actions[1]!.click());
+ expect(calls).toEqual(["continue", "v1"]);
+});
+
+test("a selection names the mode being selected, using the UI label for base", () => {
+ render(
+ {}}
+ onChooseV1={() => {}}
+ onDismiss={() => {}}
+ />,
+ );
+
+ const heading = container.querySelector("h3")?.textContent ?? "";
+ expect(heading).toContain("base");
+ expect(heading).not.toContain("{mode}");
+ expect(container.querySelector("dialog")?.getAttribute("data-subagent-surface-reason")).toBe("selection");
+});
+
+test("the backdrop dismisses without answering either way", () => {
+ const calls: string[] = [];
+ render(
+ calls.push("continue")}
+ onChooseV1={() => calls.push("v1")}
+ onDismiss={() => calls.push("dismiss")}
+ />,
+ );
+
+ const backdrop = container.querySelector(".modal-backdrop-dismiss") as HTMLButtonElement | null;
+ expect(backdrop).not.toBeNull();
+ act(() => backdrop!.click());
+
+ expect(calls).toEqual(["dismiss"]);
+});
+
+test("a dialog with a write in flight cannot be dismissed out from under it", () => {
+ const calls: string[] = [];
+ render(
+ calls.push("continue")}
+ onChooseV1={() => calls.push("v1")}
+ onDismiss={() => calls.push("dismiss")}
+ />,
+ );
+
+ const backdrop = container.querySelector(".modal-backdrop-dismiss") as HTMLButtonElement | null;
+ act(() => backdrop!.click());
+ const actions = Array.from(container.querySelectorAll(".modal-actions button")) as HTMLButtonElement[];
+ act(() => actions[0]!.click());
+
+ expect(calls).toEqual([]);
+});
diff --git a/src/config.ts b/src/config.ts
index 1b513bf2d4d..edb52fc0f78 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import { Database } from "bun:sqlite";
import * as z from "zod/v4";
import { isValidProviderName, hasOwnProvider } from "./config/provider-name";
+import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./config/multi-agent-surface";
import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./config/subagent-models";
export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models";
import {
@@ -1265,6 +1266,9 @@ const configSchema = z.object({
googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined),
subagentModelsVersion: z.number().int().positive().optional().catch(undefined),
subagentModels: z.array(z.string().min(1)).optional().catch(undefined),
+ // A hand-edited advisory version must not cost the operator their providers; a bad
+ // value degrades to undefined, which simply raises the notice again.
+ multiAgentSurfaceAdvisoryVersion: z.number().int().nonnegative().optional().catch(undefined),
clientIntegrations: clientIntegrationsSchema.optional().catch(undefined),
// A malformed profile policy must not fall back to legacy all-profile activation.
asideProfileSync: asideProfileSyncSchema.optional().catch({ allProfiles: false }),
@@ -2465,7 +2469,18 @@ export function loadConfig(): OcxConfig {
// discarding it entirely, so pool accounts and providers survive a missing
// field like defaultProvider.
const defaults = getDefaultConfig();
- const merged = { ...defaults, ...parsed, subagentModelsVersion: parsed.subagentModelsVersion };
+ // Pin the keys whose ABSENCE is meaningful. Spreading defaults underneath means any
+ // key the stored document lacks is inherited, which is right for additive defaults and
+ // wrong for a behavioral mode: a config that reaches this path only because it lost
+ // `defaultProvider` would be repaired into v1 sub-agents and a pre-answered advisory,
+ // silently changing a setting its operator never touched.
+ const merged = {
+ ...defaults,
+ ...parsed,
+ subagentModelsVersion: parsed.subagentModelsVersion,
+ multiAgentMode: parsed.multiAgentMode,
+ multiAgentSurfaceAdvisoryVersion: parsed.multiAgentSurfaceAdvisoryVersion,
+ };
// Ensure providers from both sides survive
if (parsed.providers && defaults.providers) {
merged.providers = { ...defaults.providers, ...parsed.providers };
@@ -2687,7 +2702,14 @@ function mergeConfigDefaults(parsed: unknown): unknown {
if (!parsed || typeof parsed !== "object") return parsed;
const defaults = getDefaultConfig();
const raw = parsed as Record;
- const merged: Record = { ...defaults, ...raw, subagentModelsVersion: raw.subagentModelsVersion };
+ // Same absence-is-meaningful pin as the repair merge above.
+ const merged: Record = {
+ ...defaults,
+ ...raw,
+ subagentModelsVersion: raw.subagentModelsVersion,
+ multiAgentMode: raw.multiAgentMode,
+ multiAgentSurfaceAdvisoryVersion: raw.multiAgentSurfaceAdvisoryVersion,
+ };
if (raw.providers && typeof raw.providers === "object" && defaults.providers) {
merged.providers = { ...defaults.providers, ...(raw.providers as Record) };
}
@@ -4105,6 +4127,12 @@ export function getDefaultConfig(): OcxConfig {
defaultProvider: "openai",
subagentModels: [...DEFAULT_SUBAGENT_MODELS],
subagentModelsVersion: SUBAGENT_MODELS_VERSION,
+ // v1 is the shipped surface while a v2 native-to-routed task is undeliverable
+ // ciphertext. Written explicitly rather than left absent, because an absent key
+ // means base everywhere else. A fresh install starts already acknowledged: there is
+ // nothing to advise an operator who is on the recommended surface.
+ multiAgentMode: "v1",
+ multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION,
multiAgentGuidanceEnabled: true,
websockets: false,
codexAutoStart: true,
diff --git a/src/config/multi-agent-surface.ts b/src/config/multi-agent-surface.ts
new file mode 100644
index 00000000000..eefd7f1a077
--- /dev/null
+++ b/src/config/multi-agent-surface.ts
@@ -0,0 +1,61 @@
+import type { MultiAgentMode } from "../codex/catalog/parsing";
+import type { OcxConfig } from "../types";
+
+/**
+ * Bump to raise the sub-agent surface advisory again for installs that already answered
+ * the previous one. A version rather than a boolean, so that the day the upstream
+ * encrypted-task limitation is fixed, the notice saying so reaches the same operators
+ * without a second config key.
+ */
+export const MULTI_AGENT_SURFACE_ADVISORY_VERSION = 1;
+
+/** The published explanation both advisory surfaces link to. */
+export const SUBAGENT_SURFACE_GUIDE_URL = "https://opencodex.me/guides/subagent-v1-default/";
+
+/**
+ * The surface an operator effectively runs. An absent key still means base: the key is
+ * deleted when base is selected, so its absence cannot be read as "never configured".
+ * What changed in this release is the written default, not this resolution.
+ */
+export function resolveMultiAgentMode(config: Pick): MultiAgentMode {
+ return config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
+}
+
+type AdvisoryConfig = Pick;
+
+/**
+ * True when this install should be told once that v1 is now the default surface.
+ *
+ * A v1 install is silent because it has nothing to decide. A fresh install is silent
+ * because `getDefaultConfig()` writes the current version alongside the v1 default. What
+ * is left is the case this exists for: an install that predates the change and is running
+ * base or v2, where a ChatGPT-native parent cannot hand a task to a routed child.
+ */
+export function multiAgentSurfaceAdvisoryRequired(config: AdvisoryConfig): boolean {
+ if (resolveMultiAgentMode(config) === "v1") return false;
+ return (config.multiAgentSurfaceAdvisoryVersion ?? 0) < MULTI_AGENT_SURFACE_ADVISORY_VERSION;
+}
+
+export type MultiAgentSurfaceAdvisory = {
+ /** Whether the dashboard should raise the notice. */
+ required: boolean;
+ /** The surface stored today. */
+ mode: MultiAgentMode;
+ /** What the notice recommends. */
+ recommended: "v1";
+ /** The advisory revision a client acknowledges. */
+ version: number;
+ /** Where the notice sends a reader for the explanation. */
+ docsUrl: string;
+};
+
+/** Response-only projection; none of this is a writable config field. */
+export function multiAgentSurfaceAdvisory(config: AdvisoryConfig): MultiAgentSurfaceAdvisory {
+ return {
+ required: multiAgentSurfaceAdvisoryRequired(config),
+ mode: resolveMultiAgentMode(config),
+ recommended: "v1",
+ version: MULTI_AGENT_SURFACE_ADVISORY_VERSION,
+ docsUrl: SUBAGENT_SURFACE_GUIDE_URL,
+ };
+}
diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts
index d9757d5b112..ef553702087 100644
--- a/src/server/management/agent-settings-routes.ts
+++ b/src/server/management/agent-settings-routes.ts
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import type { CatalogModel } from "../../codex/catalog";
import { catalogModelSlug, filterCatalogVisibleModels, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError } from "../../config/provider-validation";
+import { MULTI_AGENT_SURFACE_ADVISORY_VERSION, multiAgentSurfaceAdvisory, resolveMultiAgentMode } from "../../config/multi-agent-surface";
import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../config/rebase-provenance";
import {
DEFAULT_SUBAGENT_MODELS,
@@ -243,7 +244,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
enabled,
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
- multiAgentMode: config.multiAgentMode ?? "default",
+ // Resolved, not raw: a hand-edited unsupported value survives config parsing, and the
+ // advisory below already reports the resolved surface. Two answers would disagree.
+ multiAgentMode: resolveMultiAgentMode(config),
+ multiAgentSurfaceAdvisory: multiAgentSurfaceAdvisory(config),
keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
agentsEnabled: getAgentsEnabled(),
agentsMaxDepth: getAgentsMaxDepth(),
@@ -265,6 +269,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
agentsMaxDepth?: unknown;
subagentDeveloperInstructions?: unknown;
multiAgentModeHintText?: unknown;
+ multiAgentSurfaceAdvisoryAcknowledged?: unknown;
};
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
const wantsFlag = body.enabled !== undefined;
@@ -275,8 +280,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
const wantsMaxDepth = body.agentsMaxDepth !== undefined;
const wantsSubagentInstructions = body.subagentDeveloperInstructions !== undefined;
const wantsModeHintText = body.multiAgentModeHintText !== undefined;
- if (!wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText) {
- return jsonResponse({ error: "body must set enabled, multiAgentMode, keepNativeChatGptOnV1, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, subagentDeveloperInstructions, and/or multiAgentModeHintText" }, 400);
+ const wantsAdvisoryAck = body.multiAgentSurfaceAdvisoryAcknowledged !== undefined;
+ if (!wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText && !wantsAdvisoryAck) {
+ return jsonResponse({ error: "body must set enabled, multiAgentMode, keepNativeChatGptOnV1, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, subagentDeveloperInstructions, multiAgentModeHintText, and/or multiAgentSurfaceAdvisoryAcknowledged" }, 400);
}
if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
@@ -311,6 +317,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
&& (typeof body.multiAgentModeHintText !== "string" || body.multiAgentModeHintText.trim().length === 0)) {
return jsonResponse({ error: "body.multiAgentModeHintText must be a non-empty string or null" }, 400);
}
+ // A boolean, and only `true` acknowledges. `false` is an explicit no-op so a client
+ // that always sends the field cannot un-answer an advisory it already dismissed.
+ if (wantsAdvisoryAck && typeof body.multiAgentSurfaceAdvisoryAcknowledged !== "boolean") {
+ return jsonResponse({ error: "body.multiAgentSurfaceAdvisoryAcknowledged must be a boolean" }, 400);
+ }
const mode = wantsMode ? body.multiAgentMode as "v1" | "default" | "v2" : undefined;
const effectiveMode = mode ?? config.multiAgentMode ?? "default";
const effectiveKeepNative = wantsKeepNative
@@ -378,6 +389,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
: "keepNativeChatGptOnV1 is stored but inactive until multi-agent mode is v2. Applies to new sessions.")
: "ChatGPT-native models follow the selected v1/v2/base surface. Applies to new sessions.");
}
+ // Written after the mode, so a landed acknowledgement implies the mode write it was
+ // sent with already landed. The converse does not hold: this writer throws rather than
+ // reporting, exactly as the mode write above it does, so a failure here surfaces the
+ // mode change with the advisory still raised. That is the benign direction — the
+ // operator is asked again — and it is why this is not claimed as a transaction.
+ if (wantsAdvisoryAck && body.multiAgentSurfaceAdvisoryAcknowledged === true) {
+ config.multiAgentSurfaceAdvisoryVersion = MULTI_AGENT_SURFACE_ADVISORY_VERSION;
+ saveConfigPreservingClaudeCode(config);
+ }
// New-key scalar writes: each writer is individually atomic, so apply them in
// sequence after the transition. A failure here is a persistence failure (the
// writers' ok:false result or a throw from the underlying atomic write helper),
@@ -417,7 +437,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
enabled,
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
- multiAgentMode: config.multiAgentMode ?? "default",
+ multiAgentMode: resolveMultiAgentMode(config),
+ multiAgentSurfaceAdvisory: multiAgentSurfaceAdvisory(config),
keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
agentsEnabled: getAgentsEnabled(),
agentsMaxDepth: getAgentsMaxDepth(),
diff --git a/src/types/config.ts b/src/types/config.ts
index 372c5b82d03..b1e814f644f 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -639,6 +639,11 @@ export interface OcxConfig {
* - "v2": force ALL models to v2 surface (override upstream pins)
*/
multiAgentMode?: "v1" | "default" | "v2";
+ /**
+ * Which revision of the sub-agent surface advisory this install has answered.
+ * Absent means it has answered none. Written by the dashboard, never by a mode change.
+ */
+ multiAgentSurfaceAdvisoryVersion?: number;
/**
* When `multiAgentMode` is `"v2"`, keep ChatGPT-native catalog rows on v1.
* Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92).
diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md
index 47bf09b6432..5db4ff66dd3 100644
--- a/structure/gui-and-management-api.md
+++ b/structure/gui-and-management-api.md
@@ -123,7 +123,7 @@ this document owns is which module holds which area and what invariant that area
| Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. |
| OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. |
| Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. |
-| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. |
+| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. |
| Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). |
| Usage | `GET /api/usage` aggregate read-only summary derived from the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. |
| System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. |
diff --git a/structure/subagents.md b/structure/subagents.md
index cea31404e52..3d58b30799e 100644
--- a/structure/subagents.md
+++ b/structure/subagents.md
@@ -32,7 +32,7 @@ encryption marker when the upstream omitted it or returned a nonempty marker.
| Mode | Behavior |
| --- | --- |
| `"v1"` | Force ALL entries to `multi_agent_version = "v1"` — overrides upstream pins (sol/terra included). |
-| `"default"` (install default) | Respect upstream model pins (sol/terra=v2, luna=v1, others=null → codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. |
+| `"default"` | Respect upstream model pins (sol/terra=v2, luna=v1, others=null → codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. |
| `"v2"` | Force ALL entries to `multi_agent_version = "v2"` — overrides upstream pins (luna included). |
The override is applied as a final pass in both `buildCatalogEntries` (live `/v1/models` path) and
@@ -40,6 +40,18 @@ The override is applied as a final pass in both `buildCatalogEntries` (live `/v1
ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from routed entries) does
not clobber the forced value.
+`getDefaultConfig()` (`src/config.ts`) writes `multiAgentMode: "v1"` explicitly, using the version
+constant from `src/config/multi-agent-surface.ts`, so v1 is the install default while a v2
+native-to-routed child task is undeliverable ciphertext. The repair and salvage merges in
+`src/config.ts` pin `multiAgentMode` and `multiAgentSurfaceAdvisoryVersion` to the stored
+document, because spreading the defaults underneath would repair an unrelated missing field
+into a surface change its operator never made.
+An absent key still means `"default"`, because selecting base deletes the key — absence cannot be
+read as "never configured". An install that predates that change is therefore not rewritten; it is
+asked once. `multiAgentSurfaceAdvisoryRequired()` is true while the resolved mode is not v1 and
+the stored `multiAgentSurfaceAdvisoryVersion` is below `MULTI_AGENT_SURFACE_ADVISORY_VERSION`, and
+only the operator answering the dashboard notice writes that version.
+
CLI: `ocx v2 mode v1|default|v2`. GUI: segmented control on the Models page. API: `GET/PUT /api/v2`
with `multiAgentMode` field.
diff --git a/tests/codex-integration/multi-agent-keep-native-v1.test.ts b/tests/codex-integration/multi-agent-keep-native-v1.test.ts
index e7ae3308b3d..31dd690558a 100644
--- a/tests/codex-integration/multi-agent-keep-native-v1.test.ts
+++ b/tests/codex-integration/multi-agent-keep-native-v1.test.ts
@@ -11,6 +11,7 @@ import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "../../src/codex/catalog/kinds";
import { buildCatalogEntriesFromObservedState } from "../../src/codex/catalog/sync";
import { cmdV2 } from "../../src/cli/v2";
import { loadConfig, saveConfig } from "../../src/config";
+import { MULTI_AGENT_SURFACE_ADVISORY_VERSION, SUBAGENT_SURFACE_GUIDE_URL } from "../../src/config/multi-agent-surface";
import { isMultiAgentV2Enabled } from "../../src/codex/features";
import { handleManagementAPI } from "../../src/server/management-api";
import { catalogConvergenceFactory } from "../helpers/catalog-convergence";
@@ -443,3 +444,86 @@ describe("/api/v2 keepNativeChatGptOnV1", () => {
expect(converges).toBe(3);
});
});
+
+describe("/api/v2 sub-agent surface advisory", () => {
+ const url = () => new URL("http://localhost/api/v2");
+ const deps = () => ({
+ createManagementConvergeCodex: catalogConvergenceFactory(),
+ toggleCodexMultiAgentV2: () => {},
+ });
+ const liveConfig = (extra: Partial = {}): OcxConfig => ({
+ providers: {}, hostname: "127.0.0.1", port: 10100, defaultProvider: "openai", ...extra,
+ } as OcxConfig);
+
+ test("base raises it, and the recommended answer writes the mode and the acknowledgement together", async () => {
+ isolateHomes();
+ writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = false\n");
+ const config = liveConfig();
+ const d = deps();
+
+ const raised = await handleManagementAPI(getV2(), url(), config, d);
+ expect(await raised?.json()).toMatchObject({
+ multiAgentMode: "default",
+ multiAgentSurfaceAdvisory: {
+ required: true,
+ mode: "default",
+ recommended: "v1",
+ version: MULTI_AGENT_SURFACE_ADVISORY_VERSION,
+ docsUrl: SUBAGENT_SURFACE_GUIDE_URL,
+ },
+ });
+
+ const switched = await handleManagementAPI(
+ putV2({ multiAgentMode: "v1", multiAgentSurfaceAdvisoryAcknowledged: true }),
+ url(), config, d,
+ );
+ expect(switched?.status).toBe(200);
+ expect(await switched?.json()).toMatchObject({
+ multiAgentMode: "v1",
+ multiAgentSurfaceAdvisory: { required: false, mode: "v1" },
+ });
+ expect(loadConfig().multiAgentMode).toBe("v1");
+ expect(loadConfig().multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION);
+
+ const again = await handleManagementAPI(getV2(), url(), config, d);
+ expect(await again?.json()).toMatchObject({ multiAgentSurfaceAdvisory: { required: false } });
+ });
+
+ test("keeping v2 answers the advisory and leaves the mode alone", async () => {
+ isolateHomes();
+ writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = true\n");
+ const config = liveConfig({ multiAgentMode: "v2" });
+ const d = deps();
+
+ const kept = await handleManagementAPI(putV2({ multiAgentSurfaceAdvisoryAcknowledged: true }), url(), config, d);
+ expect(kept?.status).toBe(200);
+ expect(await kept?.json()).toMatchObject({
+ multiAgentMode: "v2",
+ multiAgentSurfaceAdvisory: { required: false, mode: "v2" },
+ });
+ expect(loadConfig().multiAgentMode).toBe("v2");
+ expect(loadConfig().multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION);
+ });
+
+ test("false is an explicit no-op, so a client that always sends the field cannot un-answer it", async () => {
+ isolateHomes();
+ writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = true\n");
+ const config = liveConfig({ multiAgentMode: "v2", multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION });
+ const d = deps();
+
+ const noop = await handleManagementAPI(putV2({ multiAgentSurfaceAdvisoryAcknowledged: false }), url(), config, d);
+ expect(noop?.status).toBe(200);
+ expect(await noop?.json()).toMatchObject({ multiAgentSurfaceAdvisory: { required: false } });
+ expect(config.multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION);
+ });
+
+ test("a non-boolean acknowledgement is rejected before anything is written", async () => {
+ isolateHomes();
+ writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = false\n");
+ const config = liveConfig();
+ const bad = await handleManagementAPI(putV2({ multiAgentSurfaceAdvisoryAcknowledged: 1 }), url(), config, deps());
+ expect(bad?.status).toBe(400);
+ expect(await bad?.json()).toEqual({ error: "body.multiAgentSurfaceAdvisoryAcknowledged must be a boolean" });
+ expect(config.multiAgentSurfaceAdvisoryVersion).toBeUndefined();
+ });
+});
diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts
index 8dbc4824032..b4b1bc9e73b 100644
--- a/tests/server/config.test.ts
+++ b/tests/server/config.test.ts
@@ -38,6 +38,13 @@ import { AtomicWriteResidualTempError, atomicWriteFile, atomicWriteFileAsync, ha
import { nextAtomicTempSequence } from "../../src/config/atomic-write";
import { flushConfigDirHardeningForTests } from "../../src/config/paths";
import { DEFAULT_SUBAGENT_MODELS, migrateSubagentModels } from "../../src/config/subagent-models";
+import {
+ MULTI_AGENT_SURFACE_ADVISORY_VERSION,
+ SUBAGENT_SURFACE_GUIDE_URL,
+ multiAgentSurfaceAdvisory,
+ multiAgentSurfaceAdvisoryRequired,
+ resolveMultiAgentMode,
+} from "../../src/config/multi-agent-surface";
import { migrateStartupSubagentModels } from "../../src/server/subagent-models-startup";
import { migrateXaiResponsesDefault } from "../../src/providers/xai-responses-opt-in";
import { migrateStartupXaiResponses } from "../../src/server/xai-responses-startup";
@@ -84,6 +91,102 @@ function backupNames(): string[] {
return readdirSync(testDir).filter(name => name.startsWith("config.json.invalid-"));
}
+describe("sub-agent surface default and advisory", () => {
+ test("a fresh config ships v1 and has nothing to advise", () => {
+ const config = getDefaultConfig();
+ expect(config.multiAgentMode).toBe("v1");
+ expect(resolveMultiAgentMode(config)).toBe("v1");
+ expect(config.multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION);
+ expect(multiAgentSurfaceAdvisoryRequired(config)).toBe(false);
+ });
+
+ test("an absent key still resolves to base, which is what raises the advisory", () => {
+ const config = getDefaultConfig();
+ delete config.multiAgentMode;
+ delete config.multiAgentSurfaceAdvisoryVersion;
+ expect(resolveMultiAgentMode(config)).toBe("default");
+ expect(multiAgentSurfaceAdvisoryRequired(config)).toBe(true);
+ });
+
+ test.each(["default", "v2"] as const)("an unanswered %s install is advised", mode => {
+ const config = { ...getDefaultConfig(), multiAgentMode: mode };
+ delete config.multiAgentSurfaceAdvisoryVersion;
+ expect(multiAgentSurfaceAdvisoryRequired(config)).toBe(true);
+ expect(multiAgentSurfaceAdvisory(config)).toEqual({
+ required: true,
+ mode,
+ recommended: "v1",
+ version: MULTI_AGENT_SURFACE_ADVISORY_VERSION,
+ docsUrl: SUBAGENT_SURFACE_GUIDE_URL,
+ });
+ });
+
+ test("answering it silences the notice without moving the mode", () => {
+ const config = {
+ ...getDefaultConfig(),
+ multiAgentMode: "v2" as const,
+ multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION,
+ };
+ expect(multiAgentSurfaceAdvisoryRequired(config)).toBe(false);
+ expect(multiAgentSurfaceAdvisory(config).mode).toBe("v2");
+ });
+
+ test("a stale acknowledgement is advised again", () => {
+ expect(multiAgentSurfaceAdvisoryRequired({
+ multiAgentMode: "v2",
+ multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION - 1,
+ })).toBe(true);
+ });
+
+ test("v1 is silent whatever the stored acknowledgement", () => {
+ expect(multiAgentSurfaceAdvisoryRequired({ multiAgentMode: "v1" })).toBe(false);
+ expect(multiAgentSurfaceAdvisoryRequired({ multiAgentMode: "v1", multiAgentSurfaceAdvisoryVersion: 0 })).toBe(false);
+ });
+
+ test("the fresh default survives save and load", () => {
+ saveConfig(getDefaultConfig());
+ const loaded = loadConfig();
+ expect(loaded.multiAgentMode).toBe("v1");
+ expect(loaded.multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION);
+ });
+
+ test("a hand-edited version degrades to an unanswered advisory, keeping providers", () => {
+ writeConfig({ ...getDefaultConfig(), multiAgentMode: "v2", multiAgentSurfaceAdvisoryVersion: "soon" });
+ const loaded = loadConfig();
+ expect(loaded.providers.openai).toEqual(getDefaultConfig().providers.openai);
+ expect(loaded.multiAgentSurfaceAdvisoryVersion).toBeUndefined();
+ expect(multiAgentSurfaceAdvisoryRequired(loaded)).toBe(true);
+ });
+
+ test("a repaired config keeps the surface its operator chose", () => {
+ // A config that reaches the repair path only because it lost an unrelated field must
+ // not be handed the new v1 default underneath: that would change a setting silently.
+ const stored = { ...getDefaultConfig() } as Record;
+ delete stored.defaultProvider;
+ delete stored.multiAgentMode;
+ delete stored.multiAgentSurfaceAdvisoryVersion;
+ writeConfig(stored);
+
+ const loaded = loadConfig();
+ expect(loaded.defaultProvider).toBe("openai");
+ expect(loaded.multiAgentMode).toBeUndefined();
+ expect(resolveMultiAgentMode(loaded)).toBe("default");
+ expect(loaded.multiAgentSurfaceAdvisoryVersion).toBeUndefined();
+ expect(multiAgentSurfaceAdvisoryRequired(loaded)).toBe(true);
+ });
+
+ test("a repaired v2 config is still v2", () => {
+ const stored = { ...getDefaultConfig(), multiAgentMode: "v2" } as Record;
+ delete stored.defaultProvider;
+ delete stored.multiAgentSurfaceAdvisoryVersion;
+ writeConfig(stored);
+
+ const loaded = loadConfig();
+ expect(loaded.multiAgentMode).toBe("v2");
+ expect(multiAgentSurfaceAdvisoryRequired(loaded)).toBe(true);
+ });
+});
+
describe("Astra-first subagent upgrade", () => {
const defaults = ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"];