From e042a2ca52cf7029ddb92393581b0a37665162a2 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 20 Aug 2026 23:35:24 +0000 Subject: [PATCH 1/3] Add streamer mode to hide custom models from model lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamer mode is a new Settings → General preference. When it is on, the server omits every config.json customModels entry from the execution options catalog, so the web and mobile pickers, plugin pickers, bb provider models, and sdk.providers.models all hide them. Co-Authored-By: Claude --- .../cache-owners/system-cache-effects.ts | 6 +- .../mutations/settings-mutations.test.tsx | 14 ++++- apps/app/src/views/SettingsView.stories.tsx | 6 ++ apps/app/src/views/SettingsView.tsx | 50 +++++++++++++++ .../settings/GeneralSettingsScreen.tsx | 16 +++++ .../skills/builtin-skills/bb-cli/SKILL.md | 4 ++ .../bb-cli/references/app-settings.md | 11 ++++ .../src/services/system/execution-options.ts | 23 ++++++- .../test/system/execution-options.test.ts | 62 +++++++++++++++++++ docs/configuration.md | 12 +++- packages/db/test/migrate.test.ts | 1 + packages/domain/src/app-settings.ts | 6 ++ .../src/templates/bb-guide-customization.md | 5 ++ 13 files changed, 210 insertions(+), 6 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts index 77c5a4d9e8..26f3233cf3 100644 --- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts @@ -153,7 +153,10 @@ export function invalidateSystemExecutionOptions({ }); } -/** Refresh settings and timeline projections after a General settings write. */ +/** + * Refresh settings, timeline projections, and model catalogs after a General + * settings write. Streamer mode changes which custom models the server lists. + */ export function invalidateGeneralSettingsDependencies({ queryClient, }: QueryClientArg): void { @@ -163,6 +166,7 @@ export function invalidateGeneralSettingsDependencies({ systemConfigQueryKey(), allThreadTimelineQueryKeyPrefix(), allThreadTimelineTurnSummaryDetailsQueryKeyPrefix(), + allSystemExecutionOptionsQueryKeyPrefix(), ], }); } diff --git a/apps/app/src/hooks/mutations/settings-mutations.test.tsx b/apps/app/src/hooks/mutations/settings-mutations.test.tsx index 33774a10f0..9d98fe4d5f 100644 --- a/apps/app/src/hooks/mutations/settings-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/settings-mutations.test.tsx @@ -14,6 +14,7 @@ import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { systemConfigQueryKey, + systemExecutionOptionsQueryKey, threadTimelineQueryKey, threadTimelineTurnSummaryDetailsQueryKey, } from "../queries/query-keys"; @@ -76,7 +77,7 @@ afterEach(() => { }); describe("general settings mutation", () => { - it("invalidates config and timeline projections after visibility changes", async () => { + it("invalidates config, timeline projections, and model catalogs after a write", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); const configKey = systemConfigQueryKey(); const timelineKey = threadTimelineQueryKey("thread-1"); @@ -86,9 +87,17 @@ describe("general settings mutation", () => { sourceSeqStart: 1, sourceSeqEnd: 2, }); + // Streamer mode changes which custom models the server lists, so cached + // pickers must refetch. + const executionOptionsKey = systemExecutionOptionsQueryKey({ + environmentId: null, + hostId: "host-1", + providerId: "claude-code", + }); queryClient.setQueryData(configKey, systemConfig()); queryClient.setQueryData(timelineKey, {}); queryClient.setQueryData(summaryKey, {}); + queryClient.setQueryData(executionOptionsKey, {}); const nextSettings = { ...defaultAppSettings, showUnhandledProviderEvents: true, @@ -104,6 +113,9 @@ describe("general settings mutation", () => { expect(queryClient.getQueryState(configKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(summaryKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(executionOptionsKey)?.isInvalidated).toBe( + true, + ); }); }); diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx index ad0106ac84..0c5d259188 100644 --- a/apps/app/src/views/SettingsView.stories.tsx +++ b/apps/app/src/views/SettingsView.stories.tsx @@ -204,6 +204,7 @@ function useSettingsStoryState() { const [richTextEditing, setRichTextEditing] = useState(false); const [steerActiveThreadOnEnter, setSteerActiveThreadOnEnter] = useState(false); + const [streamerMode, setStreamerMode] = useState(false); const [showUnhandledProviderEvents, setShowUnhandledProviderEvents] = useState(false); const [preferredAudioInputDeviceId, setPreferredAudioInputDeviceId] = @@ -226,6 +227,7 @@ function useSettingsStoryState() { rewriteLocalhostLinks, richTextEditing, steerActiveThreadOnEnter, + streamerMode, showUnhandledProviderEvents, setAppearance, setDirectoryTargetId, @@ -237,6 +239,7 @@ function useSettingsStoryState() { setRewriteLocalhostLinks, setRichTextEditing, setSteerActiveThreadOnEnter, + setStreamerMode, setShowUnhandledProviderEvents, setThemePreference, themePreference, @@ -278,11 +281,14 @@ function GeneralSettingsStory({ onRewriteLocalhostLinksChange={state.setRewriteLocalhostLinks} onRichTextEditingChange={state.setRichTextEditing} onSteerActiveThreadOnEnterChange={state.setSteerActiveThreadOnEnter} + onStreamerModeChange={state.setStreamerMode} openLinksInAppBrowser={state.openLinksInAppBrowser} rewriteLocalhostLinks={state.rewriteLocalhostLinks} richTextEditing={state.richTextEditing} steerActiveThreadOnEnter={state.steerActiveThreadOnEnter} steerActiveThreadOnEnterDisabled={false} + streamerMode={state.streamerMode} + streamerModeDisabled={false} /> void; } +export interface StreamerModeSettingsControlProps { + disabled: boolean; + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; +} + export interface RichTextEditingSettingsControlProps { enabled: boolean; onEnabledChange: (enabled: boolean) => void; @@ -184,11 +190,14 @@ export interface GeneralSettingsSectionProps { onRewriteLocalhostLinksChange: (enabled: boolean) => void; onRichTextEditingChange: (enabled: boolean) => void; onSteerActiveThreadOnEnterChange: (enabled: boolean) => void; + onStreamerModeChange: (enabled: boolean) => void; openLinksInAppBrowser: boolean; rewriteLocalhostLinks: boolean; richTextEditing: boolean; steerActiveThreadOnEnter: boolean; steerActiveThreadOnEnterDisabled: boolean; + streamerMode: boolean; + streamerModeDisabled: boolean; } export type DebugSettingsSectionProps = @@ -564,6 +573,7 @@ const UNHANDLED_PROVIDER_EVENTS_SETTING_LABEL = "Show unhandled provider events"; const STEER_ACTIVE_THREAD_ON_ENTER_SETTING_LABEL = "Steer running threads on Enter"; +const STREAMER_MODE_SETTING_LABEL = "Streamer mode"; export function RootComposeBehaviorSettingsControl({ navigateToThreadAfterCreate, @@ -600,6 +610,26 @@ export function SteerActiveThreadOnEnterSettingsControl({ ); } +export function StreamerModeSettingsControl({ + disabled, + enabled, + onEnabledChange, +}: StreamerModeSettingsControlProps) { + return ( + + + + ); +} + export function InAppBrowserLinkSettingsControl({ enabled, onEnabledChange, @@ -833,11 +863,14 @@ export function GeneralSettingsSection({ onRewriteLocalhostLinksChange, onRichTextEditingChange, onSteerActiveThreadOnEnterChange, + onStreamerModeChange, openLinksInAppBrowser, rewriteLocalhostLinks, richTextEditing, steerActiveThreadOnEnter, steerActiveThreadOnEnterDisabled, + streamerMode, + streamerModeDisabled, }: GeneralSettingsSectionProps) { return ( @@ -871,6 +904,12 @@ export function GeneralSettingsSection({ enabled={rewriteLocalhostLinks} onEnabledChange={onRewriteLocalhostLinksChange} /> + + ); @@ -1275,6 +1314,17 @@ export function SettingsView() { steerActiveThreadOnEnter: enabled, }) } + streamerMode={generalSettings.streamerMode} + streamerModeDisabled={ + systemConfigQuery.data === undefined || + updateGeneralSettingsMutation.isPending + } + onStreamerModeChange={(enabled) => + updateGeneralSettingsMutation.mutate({ + ...generalSettings, + streamerMode: enabled, + }) + } /> diff --git a/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx b/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx index cce9f95ea7..cabee93736 100644 --- a/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx +++ b/apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx @@ -72,6 +72,22 @@ function ConnectedGeneralSettingsScreen() { /> + + + updateGeneral.mutate({ + ...settings, + streamerMode: value, + }) + } + testID="general-streamer-mode" + /> + + `. +- The `streamerMode` General preference defaults to false. Enable it to hide + every `customModels` entry from `~/.bb/config.json` in all model lists + (pickers, `bb provider models`, and the SDK) during a screen share. Update it + with `bb settings general streamerMode `. - Settings → Keyboard records server-backed per-command shortcut overrides. The `showKeyboardHints` preference controls the delayed badges shown while holding Command or Control and defaults to true; update it with diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md index 28e60f7e95..61dda02fa0 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md @@ -50,6 +50,17 @@ every window and client sees the same value. stays a newline; iPadOS WebKit preserves the Enter shortcuts for a connected Magic Keyboard. +## Streamer mode + +- `streamerMode` defaults to false. Set it with + `bb settings general streamerMode `. +- When enabled, every `customModels` entry from `~/.bb/config.json` is hidden + in all model lists: the pickers, `bb provider models`, and + `sdk.providers.models`. Use it during a screen share so a private or + early-access model id does not appear. +- The entries stay in `config.json`. A thread request that names a hidden model + explicitly still runs with it. + ## Mobile app - The `mobileApp` experiment defaults to false while the bb mobile app is in diff --git a/apps/server/src/services/system/execution-options.ts b/apps/server/src/services/system/execution-options.ts index 7dbdf0a0ec..f3b45eef6b 100644 --- a/apps/server/src/services/system/execution-options.ts +++ b/apps/server/src/services/system/execution-options.ts @@ -18,6 +18,7 @@ import { type AvailableModel, type ProviderInfo, } from "@bb/domain"; +import { getAppSettings } from "@bb/db"; import { normalizeHostDaemonAcpLaunchSpec, type HostDaemonRetryableOnlineRpcCommand, @@ -370,7 +371,7 @@ export async function resolveSystemProviderModels( const { models, selectedOnlyModels } = appendCustomModels( deps.providerRegistry, { - customModels: deps.config.customModels, + customModels: listVisibleCustomModels(deps), models: result.models, providerId: provider.id, selectedOnlyModels: result.selectedOnlyModels, @@ -383,6 +384,22 @@ export async function resolveSystemProviderModels( }; } +/** + * The config.json custom models that model lists may show. Streamer mode hides + * all of them: a custom entry is often a private or early-access model id, and + * this is the one place every picker, the CLI, and the SDK read them from. An + * explicit thread model request bypasses the catalog, so a hidden model still + * runs when a caller names it directly. + */ +export function listVisibleCustomModels( + deps: Pick, +): CustomProviderModel[] { + if (deps.config.customModels.length === 0) { + return deps.config.customModels; + } + return getAppSettings(deps.db).streamerMode ? [] : deps.config.customModels; +} + function buildCustomModel( registry: ProviderRegistryService, customModel: CustomProviderModel, @@ -533,7 +550,7 @@ export async function resolveSystemExecutionOptions( const { models, selectedOnlyModels } = appendCustomModels( deps.providerRegistry, { - customModels: deps.config.customModels, + customModels: listVisibleCustomModels(deps), models: [], providerId: modelsProvider.id, selectedOnlyModels: [], @@ -566,7 +583,7 @@ export async function resolveSystemExecutionOptions( const { models, selectedOnlyModels } = appendCustomModels( deps.providerRegistry, { - customModels: deps.config.customModels, + customModels: listVisibleCustomModels(deps), models: modelResult.models, providerId: modelsProvider.id, selectedOnlyModels: modelResult.selectedOnlyModels, diff --git a/apps/server/test/system/execution-options.test.ts b/apps/server/test/system/execution-options.test.ts index aa6f351c13..448998912e 100644 --- a/apps/server/test/system/execution-options.test.ts +++ b/apps/server/test/system/execution-options.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { getAppSettings, setAppSettings } from "@bb/db"; import { hostDaemonServerWsMessageSchema, type HostDaemonOnlineRpcRequestMessage, @@ -858,6 +859,67 @@ describe("resolveSystemExecutionOptions", () => { ); }); + it("hides custom models while streamer mode is on and restores them when it is off", async () => { + await withTestHarness( + { + customModels: [ + { + providerId: "claude-code", + model: "claude-example-preview", + displayName: "Example Preview", + }, + ], + }, + async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-execution-options-streamer-mode", + }); + const catalogModel = availableModelFixture({ model: "claude-opus-5" }); + const responder = registerProviderHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + modelsByProviderId: { + "claude-code": { models: [catalogModel], selectedOnlyModels: [] }, + }, + }); + const listModelIds = async () => + ( + await resolveSystemExecutionOptions(harness.deps, { + hostId: host.id, + providerId: "claude-code", + }) + ).models.map((model) => model.model); + + expect(await listModelIds()).toEqual([ + "claude-opus-5", + "claude-example-preview", + ]); + + setAppSettings(harness.db, { + ...getAppSettings(harness.db), + streamerMode: true, + }); + expect(await listModelIds()).toEqual(["claude-opus-5"]); + + setAppSettings(harness.db, { + ...getAppSettings(harness.db), + streamerMode: false, + }); + expect(await listModelIds()).toEqual([ + "claude-opus-5", + "claude-example-preview", + ]); + // The toggle filters after the memoized probe, so it never re-probes + // the daemon. + expect( + responder.requests.filter( + (request) => request.command.type === "provider.list_models", + ), + ).toHaveLength(1); + }, + ); + }); + it("serves the curated Claude catalog when the model probe fails transiently", async () => { await withTestHarness({}, async (harness) => { const { host, session } = seedHostSession(harness.deps, { diff --git a/docs/configuration.md b/docs/configuration.md index 899186ab62..04a30f0fbe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -176,6 +176,14 @@ defaults to off: Enter queues and Command+Enter steers. When enabled, Enter steers and Command+Enter queues. Set it with `bb settings general steerActiveThreadOnEnter `. +The "Streamer mode" toggle in Settings → General hides every `customModels` +entry from `~/.bb/config.json` in all model lists: the web and mobile pickers, +`bb provider models`, and `sdk.providers.models`. Turn it on before a screen +share so a private or early-access model id does not appear. It defaults to +off. The entries stay in `config.json`, and a thread that names a hidden model +explicitly still runs with it. Set it with +`bb settings general streamerMode `. + Outside an open typeahead menu, Shift+Enter inserts a newline. In zen mode, unmodified Enter also inserts a newline. On coarse-pointer touch devices, the software-keyboard Return path inserts a newline and the submit button sends. @@ -431,7 +439,9 @@ an invalid entry with a warning and keeps the rest of the config. Each entry appears in `bb provider models ` and in the model picker after the provider's own catalog. The provider catalog wins on a model -id collision. +id collision. The "Streamer mode" General setting +(`bb settings general streamerMode true`) hides every entry from these lists +until you turn it off again. A `customModels` entry only makes the id selectable; the provider must still accept it. Built-in providers such as `claude-code` and `codex` accept diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index f85fffe7d4..12e45b5e57 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -1600,6 +1600,7 @@ describe("migrate", () => { codexSubagentsDisabled: true, claudeCodeSubagentsDisabled: false, claudeCodeWorkflowsDisabled: true, + streamerMode: false, }); expect(getAppKeybindingOverrides(db)).toEqual([ { command: "thread.new", shortcut: null }, diff --git a/packages/domain/src/app-settings.ts b/packages/domain/src/app-settings.ts index 88fb35daf1..2319af174d 100644 --- a/packages/domain/src/app-settings.ts +++ b/packages/domain/src/app-settings.ts @@ -29,6 +29,11 @@ export const appSettingsSchema = z claudeCodeSubagentsDisabled: z.boolean(), /** Prevent Claude Code from exposing its native Workflow tool. */ claudeCodeWorkflowsDisabled: z.boolean(), + /** + * Hide the `customModels` entries from `config.json` in every model list + * (pickers, CLI, SDK) so a screen share does not reveal a private model id. + */ + streamerMode: z.boolean(), }) .strict(); export type AppSettings = z.infer; @@ -42,4 +47,5 @@ export const defaultAppSettings: AppSettings = { codexSubagentsDisabled: false, claudeCodeSubagentsDisabled: false, claudeCodeWorkflowsDisabled: false, + streamerMode: false, }; diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index ae761f5d7d..5d6a8d2d13 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -89,6 +89,11 @@ in zen mode. On coarse-pointer touch devices, the software-keyboard Return path inserts a newline. iPadOS WebKit preserves these Enter shortcuts for a connected Magic Keyboard. +Settings → General also includes `streamerMode`, which defaults to false. Turn +it on to hide every `customModels` entry from `~/.bb/config.json` in all model +lists (pickers, `bb provider models`, and the SDK) during a screen share. The +entries stay in the config file. + bb settings show bb settings general bb settings experiment From e2e1a55d929fe42f8ef60278bf1f6cea0a287cba Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 00:13:27 +0000 Subject: [PATCH 2/3] Keep streamer mode out of execution policy and drop stale catalogs - resolveSystemProviderModels keeps the full custom model list, so a thread created without an explicit model resolves the same default with streamer mode on, and a custom-only provider can still start. - A streamer mode flip clears the localStorage model catalog preload and resets the execution-options queries instead of invalidating them, so no stale list can still name a hidden model while the refetch runs. - Other General settings writes no longer refetch model catalogs; the server's config-changed broadcast already covers that. - Provider guide and skill reference the setting; docs state the composer fallback behavior for a hidden stored selection. Co-Authored-By: Claude --- .../cache-owners/system-cache-effects.ts | 23 ++++++-- .../mutations/settings-mutations.test.tsx | 55 +++++++++++++++++-- .../src/hooks/mutations/settings-mutations.ts | 20 +++++-- apps/app/src/lib/last-known-cache.ts | 22 ++++++++ apps/app/src/lib/model-catalog-cache.ts | 5 ++ .../skills/builtin-skills/bb-cli/SKILL.md | 1 + .../bb-cli/references/app-settings.md | 6 +- .../src/services/system/execution-options.ts | 18 +++--- .../test/system/execution-options.test.ts | 38 +++++++++++++ docs/configuration.md | 6 +- .../src/templates/bb-guide-providers.md | 4 +- 11 files changed, 174 insertions(+), 24 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts index 26f3233cf3..8a09d9c1c1 100644 --- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts @@ -33,6 +33,7 @@ import { } from "../queries/query-keys"; import { allThreadDefaultExecutionOptionsQueryKeyPrefix } from "../queries/thread-default-execution-options-query"; import type { QueryClientArg } from "../cache-effect-types"; +import { clearCachedModelCatalogs } from "@/lib/model-catalog-cache"; import { bumpAllDiffPatchEvictionGenerations } from "./environment-diff-patch-cache-owner"; import { invalidateSystemVersion } from "./system-version-cache-owner"; import { @@ -153,10 +154,7 @@ export function invalidateSystemExecutionOptions({ }); } -/** - * Refresh settings, timeline projections, and model catalogs after a General - * settings write. Streamer mode changes which custom models the server lists. - */ +/** Refresh settings and timeline projections after a General settings write. */ export function invalidateGeneralSettingsDependencies({ queryClient, }: QueryClientArg): void { @@ -166,11 +164,26 @@ export function invalidateGeneralSettingsDependencies({ systemConfigQueryKey(), allThreadTimelineQueryKeyPrefix(), allThreadTimelineTurnSummaryDetailsQueryKeyPrefix(), - allSystemExecutionOptionsQueryKeyPrefix(), ], }); } +/** + * Forget every model catalog after streamer mode flips. An invalidation would + * keep showing the previous catalog, and the localStorage preload would replay + * it on the next mount, until a refetch succeeds; both can still name a model + * the server now hides. A reset drops the data first, so open pickers show a + * loading state and refetch instead of the stale list. + */ +export function resetModelCatalogsAfterStreamerModeChange({ + queryClient, +}: QueryClientArg): Promise { + clearCachedModelCatalogs(); + return queryClient.resetQueries({ + queryKey: allSystemExecutionOptionsQueryKeyPrefix(), + }); +} + function getServerReconnectInvalidationQueryKeys(): QueryKey[] { return [ hostsQueryKey(), diff --git a/apps/app/src/hooks/mutations/settings-mutations.test.tsx b/apps/app/src/hooks/mutations/settings-mutations.test.tsx index 9d98fe4d5f..95c6042536 100644 --- a/apps/app/src/hooks/mutations/settings-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/settings-mutations.test.tsx @@ -10,6 +10,11 @@ import { type AppKeybindings, } from "@bb/domain"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + modelCatalogCacheKey, + readCachedModelCatalog, + writeCachedModelCatalog, +} from "@/lib/model-catalog-cache"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { @@ -73,11 +78,12 @@ function systemConfig(): SystemConfigResponse { afterEach(() => { cleanup(); + window.localStorage.clear(); vi.clearAllMocks(); }); describe("general settings mutation", () => { - it("invalidates config, timeline projections, and model catalogs after a write", async () => { + it("invalidates config and timeline projections and leaves model catalogs alone for a non-streamer write", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); const configKey = systemConfigQueryKey(); const timelineKey = threadTimelineQueryKey("thread-1"); @@ -87,8 +93,6 @@ describe("general settings mutation", () => { sourceSeqStart: 1, sourceSeqEnd: 2, }); - // Streamer mode changes which custom models the server lists, so cached - // pickers must refetch. const executionOptionsKey = systemExecutionOptionsQueryKey({ environmentId: null, hostId: "host-1", @@ -97,7 +101,7 @@ describe("general settings mutation", () => { queryClient.setQueryData(configKey, systemConfig()); queryClient.setQueryData(timelineKey, {}); queryClient.setQueryData(summaryKey, {}); - queryClient.setQueryData(executionOptionsKey, {}); + queryClient.setQueryData(executionOptionsKey, { models: ["cached"] }); const nextSettings = { ...defaultAppSettings, showUnhandledProviderEvents: true, @@ -113,9 +117,50 @@ describe("general settings mutation", () => { expect(queryClient.getQueryState(configKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(summaryKey)?.isInvalidated).toBe(true); + // The server's `config-changed` broadcast already refreshes catalogs; an + // unrelated preference must not add a second picker refetch here. expect(queryClient.getQueryState(executionOptionsKey)?.isInvalidated).toBe( - true, + false, + ); + expect(queryClient.getQueryData(executionOptionsKey)).toEqual({ + models: ["cached"], + }); + }); + + // A stale catalog can still name a model the server now hides, both in the + // active query and in the localStorage preload, until a refetch succeeds. + it("drops cached model catalogs when streamer mode flips", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const executionOptionsKey = systemExecutionOptionsQueryKey({ + environmentId: null, + hostId: "host-1", + providerId: "claude-code", + }); + const catalogCacheKey = modelCatalogCacheKey({ + environmentId: null, + hostId: "host-1", + providerId: "claude-code", + }); + queryClient.setQueryData(systemConfigQueryKey(), systemConfig()); + queryClient.setQueryData(executionOptionsKey, { models: ["secret"] }); + writeCachedModelCatalog(catalogCacheKey, { + models: [], + selectedOnlyModels: [], + }); + expect(readCachedModelCatalog(catalogCacheKey)).not.toBeNull(); + const nextSettings = { ...defaultAppSettings, streamerMode: true }; + vi.mocked(sdk.system.updateGeneralSettings).mockResolvedValue(nextSettings); + const { result } = renderHook(() => useUpdateGeneralSettings(), { + wrapper, + }); + + act(() => result.current.mutate(nextSettings)); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + await waitFor(() => + expect(queryClient.getQueryData(executionOptionsKey)).toBeUndefined(), ); + expect(readCachedModelCatalog(catalogCacheKey)).toBeNull(); }); }); diff --git a/apps/app/src/hooks/mutations/settings-mutations.ts b/apps/app/src/hooks/mutations/settings-mutations.ts index c415f84925..f0b2706bda 100644 --- a/apps/app/src/hooks/mutations/settings-mutations.ts +++ b/apps/app/src/hooks/mutations/settings-mutations.ts @@ -5,12 +5,17 @@ import { type AppThemeSelection, type Experiments, } from "@bb/domain"; -import type { SystemInstallCliSkillsRequest } from "@bb/server-contract"; +import type { + SystemConfigResponse, + SystemInstallCliSkillsRequest, +} from "@bb/server-contract"; import { sdk } from "@/lib/sdk"; import { invalidateGeneralSettingsDependencies, invalidateSystemConfig, + resetModelCatalogsAfterStreamerModeChange, } from "../cache-owners/system-cache-effects"; +import { systemConfigQueryKey } from "../queries/query-keys"; import { beginKeyboardSettingsCacheTransaction, rollbackKeyboardSettingsCacheTransaction, @@ -50,8 +55,16 @@ export function useUpdateGeneralSettings() { }, mutationFn: (settings: AppSettings) => sdk.system.updateGeneralSettings(settings), - onSuccess: () => { + onSuccess: (_settings, written) => { + // Read the previous value before the config invalidation replaces it. + const previous = queryClient.getQueryData( + systemConfigQueryKey(), + )?.generalSettings.streamerMode; invalidateGeneralSettingsDependencies({ queryClient }); + // An unknown previous value also resets: a stale preload is the risk. + if (previous !== written.streamerMode) { + void resetModelCatalogsAfterStreamerModeChange({ queryClient }); + } }, }); } @@ -107,8 +120,7 @@ export function useUpdateAppearance() { meta: { errorMessage: "Failed to update appearance.", }, - mutationFn: (selection: AppThemeSelection) => - sdk.theme.set(selection), + mutationFn: (selection: AppThemeSelection) => sdk.theme.set(selection), onSuccess: () => { invalidateSystemConfig({ queryClient }); }, diff --git a/apps/app/src/lib/last-known-cache.ts b/apps/app/src/lib/last-known-cache.ts index 4f520d0850..cbf8b3fd73 100644 --- a/apps/app/src/lib/last-known-cache.ts +++ b/apps/app/src/lib/last-known-cache.ts @@ -11,6 +11,11 @@ export interface LastKnownCache { read(key: string): T | null; /** Best-effort: storage failures (quota, privacy modes) are swallowed. */ write(key: string, value: T): void; + /** + * Forget every scope of this cache's current version. Use it when a policy + * change makes every remembered answer wrong to replay, not merely stale. + */ + clear(): void; } /** @@ -96,5 +101,22 @@ export function createLastKnownCache({ // Best-effort by contract; see above. } }, + clear: () => { + try { + const owned: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const stored = window.localStorage.key(index); + if ( + stored !== null && + (stored === zeroScopeKey || stored.startsWith(versionPrefix)) + ) { + owned.push(stored); + } + } + for (const key of owned) window.localStorage.removeItem(key); + } catch { + // No storage, or none we may enumerate: nothing to clear. + } + }, }; } diff --git a/apps/app/src/lib/model-catalog-cache.ts b/apps/app/src/lib/model-catalog-cache.ts index edf367bf46..2700317cc4 100644 --- a/apps/app/src/lib/model-catalog-cache.ts +++ b/apps/app/src/lib/model-catalog-cache.ts @@ -38,3 +38,8 @@ export function modelCatalogCacheKey({ export const readCachedModelCatalog = modelCatalogCache.read; export const writeCachedModelCatalog = modelCatalogCache.write; +/** + * Drop every remembered catalog. Streamer mode changes which models the server + * lists, so a catalog cached before the toggle must not preload the picker. + */ +export const clearCachedModelCatalogs = modelCatalogCache.clear; diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 76dab5df62..4814c66f4a 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -363,6 +363,7 @@ environment pull-request show `. Diff commands require an explicit target and bb discovers it automatically. An OpenCode agent is a session mode, not a model, and cannot be selected through bb. This list also has no set/unset CLI surface; edit the JSON and run `bb-app config refresh` or restart bb. + The `streamerMode` General preference hides every entry from model lists. - Top-level `sharedSkillRoots` uses the same relative `user` and `project` paths. bb lists these skills as read-only. bb injects them into each provider, so one physical skill collection can support bb and standalone provider CLIs. diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md index 61dda02fa0..79c6797c00 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md @@ -59,7 +59,11 @@ every window and client sees the same value. `sdk.providers.models`. Use it during a screen share so a private or early-access model id does not appear. - The entries stay in `config.json`. A thread request that names a hidden model - explicitly still runs with it. + explicitly still runs with it, and default model resolution for a new thread + keeps the full list. +- A composer whose stored selection is a hidden model falls back to the + provider default, and the next send records that default. Select the custom + model again after you turn streamer mode off. ## Mobile app diff --git a/apps/server/src/services/system/execution-options.ts b/apps/server/src/services/system/execution-options.ts index f3b45eef6b..ce70c221a1 100644 --- a/apps/server/src/services/system/execution-options.ts +++ b/apps/server/src/services/system/execution-options.ts @@ -343,7 +343,10 @@ function findCustomAcpAgentForProviderId( * Load one provider's model catalog on an already-resolved host. Unlike the * full execution-options response, this does not probe for other installed ACP * agents, so thread creation can resolve an omitted model with one targeted - * daemon request. + * daemon request. This is execution policy, not a public list, so it keeps + * every custom model: streamer mode must not change which default model a + * thread resolves to, and a provider whose only models come from config.json + * must still be able to start a thread. */ export async function resolveSystemProviderModels( deps: LoggedWorkSessionDeps, @@ -371,7 +374,7 @@ export async function resolveSystemProviderModels( const { models, selectedOnlyModels } = appendCustomModels( deps.providerRegistry, { - customModels: listVisibleCustomModels(deps), + customModels: deps.config.customModels, models: result.models, providerId: provider.id, selectedOnlyModels: result.selectedOnlyModels, @@ -385,11 +388,12 @@ export async function resolveSystemProviderModels( } /** - * The config.json custom models that model lists may show. Streamer mode hides - * all of them: a custom entry is often a private or early-access model id, and - * this is the one place every picker, the CLI, and the SDK read them from. An - * explicit thread model request bypasses the catalog, so a hidden model still - * runs when a caller names it directly. + * The config.json custom models that public model lists may show. Streamer + * mode hides all of them: a custom entry is often a private or early-access + * model id, and the execution-options response is where every picker, the CLI, + * and the SDK read them from. Execution policy is unaffected: an explicit + * thread model request bypasses the catalog, and `resolveSystemProviderModels` + * keeps the full list for default resolution. */ export function listVisibleCustomModels( deps: Pick, diff --git a/apps/server/test/system/execution-options.test.ts b/apps/server/test/system/execution-options.test.ts index 448998912e..994e95043a 100644 --- a/apps/server/test/system/execution-options.test.ts +++ b/apps/server/test/system/execution-options.test.ts @@ -8,6 +8,7 @@ import { appendCustomModels, listSystemProviderInfos, resolveSystemExecutionOptions, + resolveSystemProviderModels, } from "../../src/services/system/execution-options.js"; import { ApiError } from "../../src/errors.js"; import { availableModelFixture } from "../helpers/available-models.js"; @@ -920,6 +921,43 @@ describe("resolveSystemExecutionOptions", () => { ); }); + it("keeps custom models in the thread-create default catalog while streamer mode is on", async () => { + await withTestHarness( + { + customModels: [ + { providerId: "claude-code", model: "claude-example-preview" }, + ], + }, + async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-provider-models-streamer-mode", + }); + // A provider whose only models come from config.json must still + // resolve a default for a thread created without an explicit model. + registerProviderHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + modelsByProviderId: { + "claude-code": { models: [], selectedOnlyModels: [] }, + }, + }); + setAppSettings(harness.db, { + ...getAppSettings(harness.db), + streamerMode: true, + }); + + const catalog = await resolveSystemProviderModels(harness.deps, { + hostId: host.id, + providerId: "claude-code", + }); + + expect(catalog.models.map((model) => model.model)).toEqual([ + "claude-example-preview", + ]); + }, + ); + }); + it("serves the curated Claude catalog when the model probe fails transiently", async () => { await withTestHarness({}, async (harness) => { const { host, session } = seedHostSession(harness.deps, { diff --git a/docs/configuration.md b/docs/configuration.md index 04a30f0fbe..29b9f26907 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -181,7 +181,11 @@ entry from `~/.bb/config.json` in all model lists: the web and mobile pickers, `bb provider models`, and `sdk.providers.models`. Turn it on before a screen share so a private or early-access model id does not appear. It defaults to off. The entries stay in `config.json`, and a thread that names a hidden model -explicitly still runs with it. Set it with +explicitly still runs with it. Default model resolution for a new thread also +keeps the full list, so a provider whose only models are custom still starts. +A composer whose stored selection is a hidden model treats it as unavailable +and falls back to the provider default; the next send records that default, so +select the custom model again after you turn streamer mode off. Set it with `bb settings general streamerMode `. Outside an open typeahead menu, Shift+Enter inserts a newline. In zen mode, diff --git a/packages/templates/src/templates/bb-guide-providers.md b/packages/templates/src/templates/bb-guide-providers.md index 26d4b43c14..5997248ee5 100644 --- a/packages/templates/src/templates/bb-guide-providers.md +++ b/packages/templates/src/templates/bb-guide-providers.md @@ -107,7 +107,9 @@ in the model picker, but the provider must still accept the id: claude-code and codex accept unlisted ids, while an ACP agent can reject an id it does not know at session start. OpenCode rejects unlisted ids, so add an OpenCode model to the OpenCode config instead. Like customAcpAgents, edit the JSON and -run bb-app config refresh; there is no set/unset CLI surface. +run bb-app config refresh; there is no set/unset CLI surface. The streamerMode +General setting hides every entry from these lists; see the customization +chapter. Custom ACP agents are configured in the app data-dir config.json under customAcpAgents. bb derives provider id acp- from each slug id. Edit the JSON From 8536c86f5366eb549c7893b203cec36a46a9c371 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 00:19:29 +0000 Subject: [PATCH 3/3] Read the cached streamer mode through the system config cache owner Mutation files must not import query keys; the boundary test enforces it. Co-Authored-By: Claude --- .../hooks/cache-owners/system-config-cache-owner.ts | 11 +++++++++++ apps/app/src/hooks/mutations/settings-mutations.ts | 11 +++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/system-config-cache-owner.ts b/apps/app/src/hooks/cache-owners/system-config-cache-owner.ts index bf7551f2d4..c5727f214a 100644 --- a/apps/app/src/hooks/cache-owners/system-config-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/system-config-cache-owner.ts @@ -47,3 +47,14 @@ export function rollbackKeyboardSettingsCacheTransaction({ if (transaction?.previous === undefined) return; queryClient.setQueryData(systemConfigQueryKey(), transaction.previous); } + +/** + * The streamer mode value the cache last saw from the server, or undefined + * when `/system/config` has not resolved in this window. + */ +export function readCachedStreamerMode( + queryClient: QueryClient, +): boolean | undefined { + return queryClient.getQueryData(systemConfigQueryKey()) + ?.generalSettings.streamerMode; +} diff --git a/apps/app/src/hooks/mutations/settings-mutations.ts b/apps/app/src/hooks/mutations/settings-mutations.ts index f0b2706bda..6cf1eb6e55 100644 --- a/apps/app/src/hooks/mutations/settings-mutations.ts +++ b/apps/app/src/hooks/mutations/settings-mutations.ts @@ -5,19 +5,16 @@ import { type AppThemeSelection, type Experiments, } from "@bb/domain"; -import type { - SystemConfigResponse, - SystemInstallCliSkillsRequest, -} from "@bb/server-contract"; +import type { SystemInstallCliSkillsRequest } from "@bb/server-contract"; import { sdk } from "@/lib/sdk"; import { invalidateGeneralSettingsDependencies, invalidateSystemConfig, resetModelCatalogsAfterStreamerModeChange, } from "../cache-owners/system-cache-effects"; -import { systemConfigQueryKey } from "../queries/query-keys"; import { beginKeyboardSettingsCacheTransaction, + readCachedStreamerMode, rollbackKeyboardSettingsCacheTransaction, } from "../cache-owners/system-config-cache-owner"; @@ -57,9 +54,7 @@ export function useUpdateGeneralSettings() { sdk.system.updateGeneralSettings(settings), onSuccess: (_settings, written) => { // Read the previous value before the config invalidation replaces it. - const previous = queryClient.getQueryData( - systemConfigQueryKey(), - )?.generalSettings.streamerMode; + const previous = readCachedStreamerMode(queryClient); invalidateGeneralSettingsDependencies({ queryClient }); // An unknown previous value also resets: a stale preload is the risk. if (previous !== written.streamerMode) {