From 2590dfc0cb4160728e56bffb4db527f7351123b1 Mon Sep 17 00:00:00 2001
From: Michael Yong <610102+ymichael@users.noreply.github.com>
Date: Wed, 19 Aug 2026 22:36:57 -0700
Subject: [PATCH 001/232] =?UTF-8?q?Make=20=E2=80=98This=20machine=E2=80=99?=
=?UTF-8?q?=20mean=20the=20client-local=20daemon=20(#2004)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What was wrong
The UI reused the server-resolved primary host as the meaning of “This
machine” in Machines and Updates. On remote and multi-host clients, that
could label an execution default or server host as the device running
the client. The primary-host fallback could also leak into removal
policy.
## What changed
- Drive “This machine” only from the daemon reachable on the client
device, and suppress the badge when only one host is known.
- Keep authoritative primary-host policy and primary markers separate
from client-local identity.
- Avoid promoting the first-connected fallback into primary-host removal
policy.
- On mobile, use “Primary” only where multi-host disambiguation is
useful and never claim the phone is a bb machine.
- Clarify nearby copy that means the selected or primary machine. No
server/daemon wire contract changed, so the protocol version is
unchanged.
## How you verified
- `pnpm exec turbo run test --filter=@bb/app --
src/components/settings/MachinesSettingsSection.test.tsx
src/views/MachineSettingsView.test.tsx
src/components/settings/UpdatesSettingsSection.test.tsx` (48 tests
passed)
- `pnpm exec turbo run typecheck --filter=@bb/app --filter=@bb/mobile`
- `pnpm exec turbo run test --filter=@bb/app --filter=@bb/mobile` (3,858
tests passed, 3 skipped)
- `pnpm exec turbo run lint --filter=@bb/app --filter=@bb/mobile` (0
errors; existing warnings only)
- `git diff --check`
Fixes: N/A (no linked issue)
> AGENT GENERATED: by GPT-5
---
apps/app/.ladle/settings-story-fixtures.tsx | 1 +
.../settings/InstallCliSkillsDialog.tsx | 2 +-
.../settings/MachinesSettingsSection.test.tsx | 78 ++++++++++++++++++-
.../settings/MachinesSettingsSection.tsx | 38 ++++++---
.../UpdatesSettingsSection.stories.tsx | 15 +++-
.../settings/UpdatesSettingsSection.test.tsx | 54 ++++++++++++-
.../settings/UpdatesSettingsSection.tsx | 14 +++-
.../app/src/hooks/useThreadCreationOptions.ts | 2 +-
.../src/views/MachineSettingsView.test.tsx | 56 ++++++++++++-
apps/app/src/views/MachineSettingsView.tsx | 35 ++++++---
.../src/data/compose/execution-options.ts | 2 +-
apps/mobile/src/data/hosts/host-display.ts | 4 +-
.../src/data/updates/use-update-inventory.ts | 5 +-
.../screens/extensions/SkillDetailScreen.tsx | 2 +-
.../screens/machines/MachineDetailScreen.tsx | 13 ++--
.../src/screens/machines/MachinesScreen.tsx | 10 +--
.../src/screens/machines/ProviderCliRows.tsx | 2 +-
.../src/screens/pickers/EnvironmentPicker.tsx | 2 +-
.../mobile/src/screens/pickers/HostPicker.tsx | 2 +-
.../mobile/src/screens/pickers/PathPicker.tsx | 4 +-
.../src/screens/pickers/ProviderPicker.tsx | 2 +-
.../src/screens/projects/NewProjectScreen.tsx | 2 +-
.../projects/ProjectSettingsScreen.tsx | 2 +-
.../src/screens/settings/UpdatesScreen.tsx | 9 ++-
24 files changed, 282 insertions(+), 74 deletions(-)
diff --git a/apps/app/.ladle/settings-story-fixtures.tsx b/apps/app/.ladle/settings-story-fixtures.tsx
index f74a9ae8a0..ee28765403 100644
--- a/apps/app/.ladle/settings-story-fixtures.tsx
+++ b/apps/app/.ladle/settings-story-fixtures.tsx
@@ -179,6 +179,7 @@ export function SettingsUpdatesStory() {
{choosable
? "Choose the machines to install them onto. Each one gets the skills in ~/.agents/skills and ~/.claude/skills, replacing any copy already there."
- : `The skills go in ~/.agents/skills and ~/.claude/skills on ${hosts[0]?.name ?? "this machine"}, replacing any copy already there.`}
+ : `The skills go in ~/.agents/skills and ~/.claude/skills on ${hosts[0]?.name ?? "the selected machine"}, replacing any copy already there.`}
diff --git a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx
index 22edc25007..172841bb68 100644
--- a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx
+++ b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx
@@ -17,7 +17,7 @@ import {
} from "@bb/domain";
import type { SystemConfigResponse } from "@bb/server-contract";
import { MemoryRouter } from "react-router-dom";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sdk } from "@/lib/sdk";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { MachinesSettingsSection } from "./MachinesSettingsSection";
@@ -38,6 +38,18 @@ vi.mock("@/lib/ws", () => ({
wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() },
}));
+const hostDaemon = vi.hoisted(() => ({
+ localDaemonHostId: "host_primary" as string | null,
+ platform: "darwin" as "darwin" | "linux" | "wsl" | "unknown" | null,
+}));
+
+vi.mock("@/hooks/useHostDaemon", () => ({
+ useHostDaemon: () => ({
+ localDaemonHostId: hostDaemon.localDaemonHostId,
+ platform: hostDaemon.platform,
+ }),
+}));
+
const NOW = Date.now();
function host(overrides: Partial & Pick): Host {
@@ -127,6 +139,11 @@ async function openHostMenu(hostName: string): Promise {
);
}
+beforeEach(() => {
+ hostDaemon.localDaemonHostId = "host_primary";
+ hostDaemon.platform = "darwin";
+});
+
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
@@ -144,6 +161,7 @@ describe("MachinesSettingsSection", () => {
expect(await screen.findByText("MacBook Pro")).toBeDefined();
expect(screen.getByText("dev-vm")).toBeDefined();
expect(screen.getByText("this machine")).toBeDefined();
+ expect(screen.getByText("primary")).toBeDefined();
await waitFor(() => {
expect(screen.getByRole("img", { name: "Online" })).toBeDefined();
});
@@ -167,6 +185,62 @@ describe("MachinesSettingsSection", () => {
expect(screen.queryByText(/Online ·/u)).toBeNull();
});
+ it("distinguishes the client-local daemon from the primary machine", async () => {
+ hostDaemon.localDaemonHostId = "host_remote";
+ hostDaemon.platform = "linux";
+ vi.mocked(sdk.system.config).mockResolvedValue(systemConfig());
+ vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]);
+ stubSidebarBootstrapFetch();
+
+ renderSection();
+
+ const primaryName = await screen.findByText("MacBook Pro");
+ const localName = screen.getByText("dev-vm");
+ expect(primaryName.parentElement?.textContent).toContain("primary");
+ expect(primaryName.parentElement?.textContent).not.toContain(
+ "this machine",
+ );
+ expect(localName.parentElement?.textContent).toContain("this machine");
+ expect(localName.parentElement?.textContent).not.toContain("primary");
+ expect(screen.getByText("Linux")).toBeDefined();
+ });
+
+ it("does not infer client-local identity when no daemon is reachable", async () => {
+ hostDaemon.localDaemonHostId = null;
+ hostDaemon.platform = null;
+ vi.mocked(sdk.system.config).mockResolvedValue(systemConfig());
+ vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]);
+ stubSidebarBootstrapFetch();
+
+ renderSection();
+
+ await screen.findByText("MacBook Pro");
+ expect(screen.queryByText("this machine")).toBeNull();
+ expect(screen.getByText("primary")).toBeDefined();
+ });
+
+ it("does not promote a fallback host to primary policy", async () => {
+ hostDaemon.localDaemonHostId = null;
+ vi.mocked(sdk.system.config).mockResolvedValue({
+ ...systemConfig(),
+ primaryHostId: null,
+ primaryHostPlatform: null,
+ });
+ vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]);
+ stubSidebarBootstrapFetch();
+
+ renderSection();
+
+ await screen.findByText("MacBook Pro");
+ expect(screen.queryByText("primary")).toBeNull();
+ await openHostMenu("MacBook Pro");
+ expect(
+ screen
+ .getByRole("menuitem", { name: "Remove machine" })
+ .getAttribute("aria-disabled"),
+ ).toBeNull();
+ });
+
it("shows protocol versions when a machine needs an update", async () => {
vi.mocked(sdk.system.config).mockResolvedValue(systemConfig());
vi.mocked(sdk.hosts.list).mockResolvedValue([
@@ -412,7 +486,7 @@ describe("MachinesSettingsSection", () => {
fireEvent.focus(removeItem);
expect(
await screen.findByRole("tooltip", {
- name: "This machine runs bb and can't be removed.",
+ name: "bb's primary machine can't be removed.",
}),
).toBeDefined();
fireEvent.click(removeItem);
diff --git a/apps/app/src/components/settings/MachinesSettingsSection.tsx b/apps/app/src/components/settings/MachinesSettingsSection.tsx
index 0e28f6816f..70f42e1612 100644
--- a/apps/app/src/components/settings/MachinesSettingsSection.tsx
+++ b/apps/app/src/components/settings/MachinesSettingsSection.tsx
@@ -41,9 +41,10 @@ import {
useRenameHost,
useRetryHostUpdate,
} from "@/hooks/mutations/host-mutations";
-import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries";
+import { useHosts } from "@/hooks/queries/host-queries";
import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query";
import { useSystemConfig } from "@/hooks/queries/system-queries";
+import { useHostDaemon } from "@/hooks/useHostDaemon";
import { PersistentHostIconName } from "@/lib/host-display";
import { getSettingsMachineRoutePath } from "@/lib/route-paths";
import { PERMISSION_MODE_OPTIONS } from "@/lib/permission-mode-options";
@@ -64,8 +65,7 @@ const PERMISSION_MODE_PRESENTATION: Record<
const MACHINES_SECTION_DESCRIPTION =
"Computers that can run your tasks. Pair a machine to run projects and threads on it.";
-const PRIMARY_REMOVE_DISABLED_REASON =
- "This machine runs bb and can't be removed.";
+const PRIMARY_REMOVE_DISABLED_REASON = "bb's primary machine can't be removed.";
const MACHINE_MENU_ITEM_CLASS = "min-h-9 px-2.5 py-2";
@@ -112,6 +112,8 @@ function MachineMetadataIcon({
interface MachineRowProps {
host: Host;
isPrimary: boolean;
+ isThisMachine: boolean;
+ showPrimaryBadge: boolean;
platformLabel: string | null;
projectCount: number;
now: number;
@@ -124,6 +126,8 @@ interface MachineRowProps {
function MachineRow({
host,
isPrimary,
+ isThisMachine,
+ showPrimaryBadge,
platformLabel,
projectCount,
now,
@@ -180,7 +184,10 @@ function MachineRow({
{host.name}
- {isPrimary ? this machine : null}
+ {isThisMachine ? (
+ this machine
+ ) : null}
+ {showPrimaryBadge ? primary : null}
selectPrimaryHost(hosts, serverPrimaryHostId)?.id ?? null,
- [hosts, serverPrimaryHostId],
- );
const projects = sidebarNavigationQuery.data?.projects;
const projectCountByHostId = useMemo(() => {
const counts = new Map();
@@ -303,6 +307,7 @@ export function MachinesSettingsSection() {
const now = Date.now();
const primaryHostPlatform = systemConfig.data?.primaryHostPlatform ?? null;
+ const showMachineIdentityBadges = (hosts?.length ?? 0) > 1;
return (
<>
@@ -338,11 +343,20 @@ export function MachinesSettingsSection() {
+
{app ? (
{children}
+
+ {children}
+
);
}
@@ -473,7 +479,10 @@ export function UpdateStates() {
name="Installing"
note="The provider update is currently running."
>
-
+ ({
useDesktopUpdateInfo: vi.fn(),
}));
+const hostDaemon = vi.hoisted(() => ({
+ localDaemonHostId: null as string | null,
+}));
+
+vi.mock("@/hooks/useHostDaemon", () => ({
+ useHostDaemon: () => ({
+ localDaemonHostId: hostDaemon.localDaemonHostId,
+ }),
+}));
+
const openUrlInExternalBrowserMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/url-open-routing", () => ({
@@ -264,6 +274,7 @@ const useDesktopUpdateInfoMock = vi.mocked(useDesktopUpdateInfo);
const useProviderCliInstallRunnerMock = vi.mocked(useProviderCliInstallRunner);
beforeEach(() => {
+ hostDaemon.localDaemonHostId = null;
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Changelog unavailable offline")),
@@ -431,7 +442,8 @@ The canonical release summary.
});
expect(screen.queryByText("2 up to date")).toBeNull();
expect(screen.getByRole("heading", { name: /workstation/ })).toBeDefined();
- expect(screen.getByText("This machine")).toBeDefined();
+ expect(screen.queryByText("Primary")).toBeNull();
+ expect(screen.queryByText("This machine")).toBeNull();
expect(screen.queryByText("studio-mac")).toBeNull();
expect(screen.queryByText(/Checked/)).toBeNull();
expect(screen.queryByText(/ago$/)).toBeNull();
@@ -935,9 +947,8 @@ The canonical release summary.
expect(machineHeading.className).toContain("font-semibold");
expect(machineHeading.className).toContain("text-foreground");
const machineName = screen.getByText("workstation");
- const thisMachine = screen.getByText("This machine");
expect(machineHeading.querySelector('[data-icon="Laptop"]')).not.toBeNull();
- expect(machineName.nextElementSibling).toBe(thisMachine);
+ expect(machineName.nextElementSibling).toBeNull();
// No summary banner above the rows: with work outstanding the rows are
// the statement, and with none the settled card is the only thing shown.
expect(screen.getByText("bb app")).toBeDefined();
@@ -983,6 +994,43 @@ The canonical release summary.
expect(screen.queryByText("1 up to date")).toBeNull();
});
+ it("badges the client-local daemon independently from the primary update owner", () => {
+ useDesktopUpdateInfoMock.mockReturnValue({
+ desktopApi: null,
+ desktopInfo: null,
+ isDesktop: false,
+ });
+ const primary = makeHost({ id: "host_primary", name: "workstation" });
+ const local = makeHost({ id: "host_local", name: "studio-mac" });
+ hostDaemon.localDaemonHostId = local.id;
+ useUpdateInventoryMock.mockReturnValue(
+ makeInventory({
+ machines: [
+ makeMachine({
+ host: primary,
+ issues: [makeUpdateIssue({ provider: "codex" })],
+ isPrimary: true,
+ }),
+ makeMachine({
+ host: local,
+ issues: [makeUpdateIssue({ provider: "claudeCode" })],
+ }),
+ ],
+ }),
+ );
+
+ renderSection();
+
+ const primaryHeading = screen.getByRole("heading", {
+ name: /workstation/u,
+ });
+ const localHeading = screen.getByRole("heading", { name: /studio-mac/u });
+ expect(primaryHeading.textContent).not.toContain("Primary");
+ expect(primaryHeading.textContent).not.toContain("This machine");
+ expect(localHeading.textContent).toContain("This machine");
+ expect(localHeading.textContent).not.toContain("Primary");
+ });
+
it("lists Cursor updates with the other provider CLIs", () => {
useDesktopUpdateInfoMock.mockReturnValue({
desktopApi: null,
diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.tsx
index 42794e1e61..8f9e33db8b 100644
--- a/apps/app/src/components/settings/UpdatesSettingsSection.tsx
+++ b/apps/app/src/components/settings/UpdatesSettingsSection.tsx
@@ -70,6 +70,7 @@ import {
useUpdateInventory,
type UpdateInventoryMachine,
} from "@/hooks/useUpdateInventory";
+import { useHostDaemon } from "@/hooks/useHostDaemon";
import { useDesktopUpdateInfo } from "@/hooks/useDesktopUpdateInfo";
import { copyToClipboardWithToast } from "@/lib/clipboard";
import {
@@ -1360,10 +1361,12 @@ export function MachineUpdatesRows({
/** One machine owns one settings section; the badge makes local scope explicit. */
export function MachineUpdatesSection({
machine,
+ isThisMachine,
action,
children,
}: {
machine: UpdateInventoryMachine;
+ isThisMachine: boolean;
action?: ReactNode;
children: ReactNode;
}) {
@@ -1379,9 +1382,7 @@ export function MachineUpdatesSection({
aria-hidden
/>
{machine.host.name}
- {machine.isPrimary ? (
- This machine
- ) : null}
+ {isThisMachine ? This machine : null}
}
action={
@@ -1421,6 +1422,7 @@ export function UpdatesSettingsSection({
const queryClient = useQueryClient();
const navigate = useNavigate();
const inventory = useUpdateInventory();
+ const { localDaemonHostId } = useHostDaemon();
const { desktopApi, desktopInfo, isDesktop } = useDesktopUpdateInfo();
const retryHostUpdate = useRetryHostUpdate();
// The check store outlives this view, so an in-flight check stays visible
@@ -1535,7 +1537,7 @@ export function UpdatesSettingsSection({
(candidate) => candidate.host.id === hostId,
);
appToast.success(
- `Retrying the update on ${machine?.host.name ?? "this machine"}`,
+ `Retrying the update on ${machine?.host.name ?? "the requested machine"}`,
);
},
});
@@ -1608,6 +1610,10 @@ export function UpdatesSettingsSection({
1 &&
+ machine.host.id === localDaemonHostId
+ }
action={index === 0 ? bulkActions : null}
>
{ownsApp ? (
diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts
index 34b6afcfcd..f547ebd87c 100644
--- a/apps/app/src/hooks/useThreadCreationOptions.ts
+++ b/apps/app/src/hooks/useThreadCreationOptions.ts
@@ -64,7 +64,7 @@ const EMPTY_COMPOSER_ACTIONS: ProviderComposerAction[] = [];
const DEFAULT_SUPPORTED_PERMISSION_MODES: readonly PermissionMode[] = ["full"];
const PERMISSION_CEILING_REASON =
- "Above this machine's permission limit. Change it in Settings → Machines.";
+ "Above the selected machine's permission limit. Change it in Settings → Machines.";
type StringSelectionSetter = (value: string) => void;
type ServiceTierSelectionSetter = (value: ServiceTier | undefined) => void;
diff --git a/apps/app/src/views/MachineSettingsView.test.tsx b/apps/app/src/views/MachineSettingsView.test.tsx
index 799061cd98..a80384e69d 100644
--- a/apps/app/src/views/MachineSettingsView.test.tsx
+++ b/apps/app/src/views/MachineSettingsView.test.tsx
@@ -20,7 +20,7 @@ import type {
ProviderCliStatusResponse,
} from "@bb/host-daemon-contract";
import { MemoryRouter, Route, Routes } from "react-router-dom";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sdk } from "@/lib/sdk";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { MachineSettingsView } from "./MachineSettingsView";
@@ -42,6 +42,18 @@ vi.mock("@/lib/ws", () => ({
wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() },
}));
+const hostDaemon = vi.hoisted(() => ({
+ localDaemonHostId: null as string | null,
+ platform: null as "darwin" | "linux" | "wsl" | "unknown" | null,
+}));
+
+vi.mock("@/hooks/useHostDaemon", () => ({
+ useHostDaemon: () => ({
+ localDaemonHostId: hostDaemon.localDaemonHostId,
+ platform: hostDaemon.platform,
+ }),
+}));
+
const HOST_ID = "host_remote";
function host(overrides: Partial = {}): Host {
@@ -143,6 +155,11 @@ function stubSupportingFetches(): void {
);
}
+beforeEach(() => {
+ hostDaemon.localDaemonHostId = null;
+ hostDaemon.platform = null;
+});
+
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
@@ -326,7 +343,40 @@ describe("MachineSettingsView", () => {
expect(remove.hasAttribute("disabled")).toBe(true);
expect(remove.className).toContain("bg-destructive");
expect(remove.parentElement?.className).not.toContain("justify-end");
- expect(screen.getAllByText("This machine")).toHaveLength(1);
+ expect(screen.queryByText("This machine")).toBeNull();
+ expect(screen.queryByText("Primary")).toBeNull();
+ expect(
+ screen.getByText("bb's primary machine can't be removed."),
+ ).toBeDefined();
+ });
+
+ it("shows client-local identity only when several machines need disambiguation", async () => {
+ hostDaemon.localDaemonHostId = HOST_ID;
+ hostDaemon.platform = "linux";
+ vi.mocked(sdk.system.config).mockResolvedValue(systemConfig());
+ vi.mocked(sdk.hosts.list).mockResolvedValue([
+ host(),
+ host({ id: "host_primary", name: "workstation" }),
+ ]);
+ stubSupportingFetches();
+
+ renderView();
+
+ expect(await screen.findByText("This machine")).toBeDefined();
+ expect(screen.queryByText("Primary")).toBeNull();
+ expect(screen.getByText(/Linux/u)).toBeDefined();
+ });
+
+ it("suppresses the client-local badge when there is only one machine", async () => {
+ hostDaemon.localDaemonHostId = HOST_ID;
+ vi.mocked(sdk.system.config).mockResolvedValue(systemConfig());
+ vi.mocked(sdk.hosts.list).mockResolvedValue([host()]);
+ stubSupportingFetches();
+
+ renderView();
+
+ await screen.findByRole("heading", { name: /dev-vm/u });
+ expect(screen.queryByText("This machine")).toBeNull();
});
it("explains a machine that is no longer paired", async () => {
@@ -337,7 +387,7 @@ describe("MachineSettingsView", () => {
renderView();
expect(
- await screen.findByText("This machine is no longer paired."),
+ await screen.findByText("Machine is no longer paired."),
).toBeDefined();
});
});
diff --git a/apps/app/src/views/MachineSettingsView.tsx b/apps/app/src/views/MachineSettingsView.tsx
index a372957c41..9dfa27efe9 100644
--- a/apps/app/src/views/MachineSettingsView.tsx
+++ b/apps/app/src/views/MachineSettingsView.tsx
@@ -35,11 +35,12 @@ import {
useRetryHostUpdate,
useUpdateHostPermissionCeiling,
} from "@/hooks/mutations/host-mutations";
-import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries";
+import { useHosts } from "@/hooks/queries/host-queries";
import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query";
import { useSystemConfig } from "@/hooks/queries/system-queries";
import { isProviderCliUpdateIssue } from "@/components/provider-cli/provider-cli-install";
import { useUpdateInventory } from "@/hooks/useUpdateInventory";
+import { useHostDaemon } from "@/hooks/useHostDaemon";
import {
formatHostUpdateStatus,
hostCanRetryUpdate,
@@ -57,11 +58,10 @@ import {
getSettingsRoutePath,
} from "@/lib/route-paths";
-const PRIMARY_REMOVE_DISABLED_REASON =
- "This machine runs bb and can't be removed.";
+const PRIMARY_REMOVE_DISABLED_REASON = "bb's primary machine can't be removed.";
const PERMISSION_LIMIT_DESCRIPTION =
- "Highest permission mode any thread on this machine may run with. Threads that ask for more resolve down to it, and a provider that supports nothing this low can't run here.";
+ "Highest permission mode any thread on the selected machine may run with. Threads that ask for more resolve down to it, and a provider that supports nothing this low can't run here.";
const PLATFORM_LABELS: Record = {
darwin: "macOS",
@@ -203,6 +203,7 @@ export function MachineSettingsView() {
const navigate = useNavigate();
const hostsQuery = useHosts();
const systemConfig = useSystemConfig();
+ const { localDaemonHostId, platform: localDaemonPlatform } = useHostDaemon();
const sidebarNavigationQuery = useSidebarNavigation();
const updateInventory = useUpdateInventory();
const renameHost = useRenameHost();
@@ -214,10 +215,11 @@ export function MachineSettingsView() {
const hosts = hostsQuery.data;
const host = hosts?.find((candidate) => candidate.id === hostId) ?? null;
- const primaryHostId =
- selectPrimaryHost(hosts, systemConfig.data?.primaryHostId ?? null)?.id ??
- null;
+ const primaryHostId = systemConfig.data?.primaryHostId ?? null;
const isPrimary = host !== null && host.id === primaryHostId;
+ const showMachineIdentityBadges = (hosts?.length ?? 0) > 1;
+ const isThisMachine =
+ showMachineIdentityBadges && host !== null && host.id === localDaemonHostId;
const projects: MachineProject[] = useMemo(() => {
const navigation = sidebarNavigationQuery.data?.projects ?? [];
@@ -256,9 +258,13 @@ export function MachineSettingsView() {
const now = Date.now();
const platformLabel =
- isPrimary && systemConfig.data?.primaryHostPlatform
- ? PLATFORM_LABELS[systemConfig.data.primaryHostPlatform]
- : null;
+ host !== null &&
+ host.id === localDaemonHostId &&
+ localDaemonPlatform !== null
+ ? PLATFORM_LABELS[localDaemonPlatform]
+ : isPrimary && systemConfig.data?.primaryHostPlatform
+ ? PLATFORM_LABELS[systemConfig.data.primaryHostPlatform]
+ : null;
if (hosts === undefined) {
return (
@@ -282,7 +288,7 @@ export function MachineSettingsView() {
Machines
- This machine is no longer paired.
+ Machine is no longer paired.
@@ -311,7 +317,12 @@ export function MachineSettingsView() {
aria-hidden
/>
{host.name}
- {isPrimary ? This machine : null}
+ {isThisMachine ? (
+ This machine
+ ) : null}
+ {showMachineIdentityBadges && isPrimary ? (
+ Primary
+ ) : null}
}
titleAction={
diff --git a/apps/mobile/src/data/compose/execution-options.ts b/apps/mobile/src/data/compose/execution-options.ts
index c5724f3ddc..e4bbb5c2cd 100644
--- a/apps/mobile/src/data/compose/execution-options.ts
+++ b/apps/mobile/src/data/compose/execution-options.ts
@@ -61,7 +61,7 @@ export const REASONING_LABELS: Record = {
};
export const PERMISSION_CEILING_REASON =
- "Above this machine's permission limit. Change it in Settings → Machines.";
+ "Above the selected machine's permission limit. Change it in Settings → Machines.";
const DEFAULT_SUPPORTED_PERMISSION_MODES: readonly PermissionMode[] = ["full"];
diff --git a/apps/mobile/src/data/hosts/host-display.ts b/apps/mobile/src/data/hosts/host-display.ts
index 6222016770..f40077c013 100644
--- a/apps/mobile/src/data/hosts/host-display.ts
+++ b/apps/mobile/src/data/hosts/host-display.ts
@@ -17,13 +17,13 @@ export const PERMISSION_MODE_SHORT_LABELS: Record = {
};
export const PRIMARY_HOST_REMOVE_DISABLED_REASON =
- "This machine runs bb and can't be removed.";
+ "bb's primary machine can't be removed.";
export const MACHINES_SECTION_DESCRIPTION =
"Computers that can run your tasks. Pair a machine to run projects and threads on it.";
export const PERMISSION_LIMIT_DESCRIPTION =
- "Highest permission mode any thread on this machine may run with. Threads that ask for more resolve down to it, and a provider that supports nothing this low can't run here.";
+ "Highest permission mode any thread on the selected machine may run with. Threads that ask for more resolve down to it, and a provider that supports nothing this low can't run here.";
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
diff --git a/apps/mobile/src/data/updates/use-update-inventory.ts b/apps/mobile/src/data/updates/use-update-inventory.ts
index 967098832a..c5b6c24526 100644
--- a/apps/mobile/src/data/updates/use-update-inventory.ts
+++ b/apps/mobile/src/data/updates/use-update-inventory.ts
@@ -12,7 +12,6 @@ import {
useHostsProviderCliStatus,
useServerProtocolVersion,
} from "../hosts/host-queries";
-import { selectPrimaryHost } from "../hosts/select-primary-host";
import { useSystemConfig, useSystemVersion } from "../system/system-queries";
import {
buildUpdateInventory,
@@ -48,9 +47,7 @@ export function useUpdateInventory(
const providerStatuses = useHostsProviderCliStatus(connectedHostIds, {
enabled,
});
- const primaryHostId =
- selectPrimaryHost(hosts, configQuery.data?.primaryHostId ?? null)?.id ??
- null;
+ const primaryHostId = configQuery.data?.primaryHostId ?? null;
const inventory = useMemo(
() =>
buildUpdateInventory({
diff --git a/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx b/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx
index cd2430f839..4b42cd4d2b 100644
--- a/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx
+++ b/apps/mobile/src/screens/extensions/SkillDetailScreen.tsx
@@ -208,7 +208,7 @@ export function SkillDetailScreen() {
{isSkillDeletable(skill) ? (
candidate.id === hostId) ?? null;
- const primaryHostId =
- selectPrimaryHost(hosts, configQuery.data?.primaryHostId ?? null)?.id ??
- null;
+ const primaryHostId = configQuery.data?.primaryHostId ?? null;
const isPrimary = host !== null && host.id === primaryHostId;
const online = host?.status === "connected";
@@ -131,7 +128,7 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
if (host === null) {
return (
- This machine is no longer paired.
+ Machine is no longer paired.
@@ -178,9 +175,9 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
>
{host.name}
- {isPrimary ? (
+ {hosts.length > 1 && isPrimary ? (
- this machine
+ Primary
) : null}
@@ -233,7 +230,7 @@ function ConnectedMachineDetailScreen({ hostId }: { hostId: string }) {
/>
-
+
{projects.length === 0 ? (
) : (
diff --git a/apps/mobile/src/screens/machines/MachinesScreen.tsx b/apps/mobile/src/screens/machines/MachinesScreen.tsx
index 860a9d5945..560e24f81d 100644
--- a/apps/mobile/src/screens/machines/MachinesScreen.tsx
+++ b/apps/mobile/src/screens/machines/MachinesScreen.tsx
@@ -11,7 +11,6 @@ import {
machineMetaLine,
PERMISSION_MODE_SHORT_LABELS,
PRIMARY_HOST_REMOVE_DISABLED_REASON,
- selectPrimaryHost,
useHosts,
useAddMachineSession,
useRemoveHost,
@@ -76,12 +75,7 @@ function ConnectedMachinesScreen() {
const retryUpdate = useRetryHostUpdate();
const hosts = hostsQuery.data;
- const primaryHostId = useMemo(
- () =>
- selectPrimaryHost(hosts, configQuery.data?.primaryHostId ?? null)?.id ??
- null,
- [hosts, configQuery.data?.primaryHostId],
- );
+ const primaryHostId = configQuery.data?.primaryHostId ?? null;
const projectCounts = useMemo(
() => countProjectsByHost(bootstrap.data?.projects ?? []),
[bootstrap.data?.projects],
@@ -139,7 +133,7 @@ function ConnectedMachinesScreen() {
key={host.id}
title={host.name}
titleLines={1}
- subtitle={`${isPrimary ? "This machine · " : ""}${machineMetaLine(
+ subtitle={`${hosts.length > 1 && isPrimary ? "Primary · " : ""}${machineMetaLine(
{
host,
platformLabel:
diff --git a/apps/mobile/src/screens/machines/ProviderCliRows.tsx b/apps/mobile/src/screens/machines/ProviderCliRows.tsx
index 876bfb8921..b03d1b30dd 100644
--- a/apps/mobile/src/screens/machines/ProviderCliRows.tsx
+++ b/apps/mobile/src/screens/machines/ProviderCliRows.tsx
@@ -182,7 +182,7 @@ export function ProviderCliRows({
return (
- Couldn't check provider CLIs on this machine.
+ Couldn't check provider CLIs on {host.name}.
);
diff --git a/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx b/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx
index abfafa7967..dfd062a6de 100644
--- a/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx
+++ b/apps/mobile/src/screens/pickers/EnvironmentPicker.tsx
@@ -142,7 +142,7 @@ export function EnvironmentPicker({
hostUnavailableReason ??
(isPersonalProject || hostHasSource
? null
- : `${host?.name ?? "This machine"} has no checkout of this project`);
+ : `${host?.name ?? "The selected machine"} has no checkout of this project`);
const worktreeReason = isPersonalProject
? "Personal threads have no repository"
: (workspaceDisabledReason ?? worktreeDisabledReason);
diff --git a/apps/mobile/src/screens/pickers/HostPicker.tsx b/apps/mobile/src/screens/pickers/HostPicker.tsx
index ea28ece9a0..38bb6f9cb2 100644
--- a/apps/mobile/src/screens/pickers/HostPicker.tsx
+++ b/apps/mobile/src/screens/pickers/HostPicker.tsx
@@ -112,7 +112,7 @@ export function HostPicker({
? onRequestSetup
? "Not set up for this project · tap to set up"
: "Not set up for this project"
- : isPrimary
+ : hosts.length > 1 && isPrimary
? "Primary machine"
: undefined;
return (
diff --git a/apps/mobile/src/screens/pickers/PathPicker.tsx b/apps/mobile/src/screens/pickers/PathPicker.tsx
index 5a07212a0f..b43c3e2894 100644
--- a/apps/mobile/src/screens/pickers/PathPicker.tsx
+++ b/apps/mobile/src/screens/pickers/PathPicker.tsx
@@ -56,7 +56,9 @@ export function PathPicker({
>
{
diff --git a/apps/mobile/src/screens/pickers/ProviderPicker.tsx b/apps/mobile/src/screens/pickers/ProviderPicker.tsx
index 51749671ad..caf118a961 100644
--- a/apps/mobile/src/screens/pickers/ProviderPicker.tsx
+++ b/apps/mobile/src/screens/pickers/ProviderPicker.tsx
@@ -49,7 +49,7 @@ export function ProviderPicker({
),
description: option.available
? undefined
- : "Not available on this machine",
+ : "Not available on the selected machine",
})),
[options, tokens.foreground, tokens.subtleForeground],
);
diff --git a/apps/mobile/src/screens/projects/NewProjectScreen.tsx b/apps/mobile/src/screens/projects/NewProjectScreen.tsx
index e71dc85c8d..47e40636e6 100644
--- a/apps/mobile/src/screens/projects/NewProjectScreen.tsx
+++ b/apps/mobile/src/screens/projects/NewProjectScreen.tsx
@@ -122,7 +122,7 @@ function ConnectedNewProjectScreen() {
subtitle={
host
? host.status === "connected"
- ? host.id === primaryHost?.id
+ ? hosts.length > 1 && host.id === primaryHost?.id
? "Primary machine"
: undefined
: "Offline"
diff --git a/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx b/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx
index 2a69c3347f..9089351947 100644
--- a/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx
+++ b/apps/mobile/src/screens/projects/ProjectSettingsScreen.tsx
@@ -280,7 +280,7 @@ function ConnectedProjectSettingsScreen({ projectId }: { projectId: string }) {
title="Remove this source?"
message={
sourceForMenu
- ? `bb stops using ${sourceForMenu.path} on ${hostById.get(sourceForMenu.hostId)?.name ?? "this machine"} for this project. The folder stays on disk.`
+ ? `bb stops using ${sourceForMenu.path} on ${hostById.get(sourceForMenu.hostId)?.name ?? "that machine"} for this project. The folder stays on disk.`
: undefined
}
actions={[
diff --git a/apps/mobile/src/screens/settings/UpdatesScreen.tsx b/apps/mobile/src/screens/settings/UpdatesScreen.tsx
index ee709c0bb4..1f34f9cef3 100644
--- a/apps/mobile/src/screens/settings/UpdatesScreen.tsx
+++ b/apps/mobile/src/screens/settings/UpdatesScreen.tsx
@@ -131,12 +131,14 @@ function BbAppRow({
function MachineUpdatesBlock({
machine,
+ showPrimaryBadge,
serverProtocolVersion,
runner,
retryPending,
onRetry,
}: {
machine: UpdateInventoryMachine;
+ showPrimaryBadge: boolean;
serverProtocolVersion: number | null;
runner: ReturnType;
retryPending: boolean;
@@ -152,9 +154,9 @@ function MachineUpdatesBlock({
{host.name}
- {machine.isPrimary ? (
+ {showPrimaryBadge ? (
- this machine
+ Primary
) : null}
@@ -455,6 +457,9 @@ function ConnectedUpdatesScreen() {
{index > 0 ? : null}
1 && machine.isPrimary
+ }
serverProtocolVersion={inventory.serverProtocolVersion}
runner={runner}
retryPending={
From ba14b8164291b780b40a53d68276452e4e821166 Mon Sep 17 00:00:00 2001
From: Michael Yong <610102+ymichael@users.noreply.github.com>
Date: Wed, 19 Aug 2026 22:43:16 -0700
Subject: [PATCH 002/232] Delete the newOnboarding experiment and the first-run
flow (#2001)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What was wrong
The first-run setup guide shipped behind the `newOnboarding` experiment,
which defaults to false (`packages/domain/src/experiments.ts`) and was
never enabled by default. Nobody saw the flow unless they turned the
toggle on by hand, so its UI, its telemetry funnel, its persisted
completion timestamp, and a dedicated host-daemon command existed to
serve a surface that never ran — spread across seven packages.
## What changed
Deleted the experiment and everything that existed only for it:
- `OnboardingHost` / `OnboardingFlow` (828 lines) and their `App.tsx`
mount.
- The `newOnboarding` experiment key, its Settings → Experiments toggle
(web and `apps/mobile`), and every config fixture that listed it.
- `appSettingsSchema.onboardingCompletedAt`, the Settings → General
"Setup guide" replay control, and `bb settings replay-onboarding`. No
migration is needed: since #0102 app settings are key/value rows read
through `inArray(key, appSettingsKeys)`, so the retired key's row is
ignored. The legacy wide `app_settings` table keeps its column, which is
deliberately frozen for `seedKeepAwakePluginConfiguration`.
- The five onboarding funnel telemetry events, `POST
/system/onboarding/event`, and `sdk.system.onboardingEvent`.
- `GET /system/onboarding/repos`, `sdk.system.onboardingRepos`, and —
since nothing else sent it — the `workspace.discover_repos` daemon
command with its 460-line handler.
**Wire change: `HOST_DAEMON_PROTOCOL_VERSION` 137 → 138**, because a
command left the protocol. The reason is recorded in the `protocol.ts`
header.
Docs and agent surfaces updated in the same change:
`bb-guide-customization.md` (regenerated), the `bb-cli` SKILL and its
`app-settings` reference, `bb-plugin-authoring`'s SDK method table, and
`docs/configuration.md`.
`getOnboardingAgentOverview` and `GET /system/onboarding/agents`
deliberately stay. Despite the name they are not onboarding-only: the
root composer resolves an unset provider selection through
`useOnboardingAgents` (`useThreadCreationOptions.ts` ←
`RootComposeView.tsx`), which ships unconditionally. Renaming that
endpoint and its SDK method to match its real purpose is a follow-up,
not part of a deletion. `useOnboardingAgents` does lose its `poll`
option, which became constant once the flow's caller went away.
Two test adjustments worth calling out, both because their subject was
deleted rather than because they broke:
- `apps/cli` had a test using `onboardingCompletedAt` as its
nullable-string case for `bb settings general`. `appSettingsSchema` now
holds only booleans, so that path has no live key to exercise; the test
is trimmed to the unknown-key assertion it also covered. The generic
value-parsing code in `updateGeneralSetting` is untouched and still
accepts `null`.
- `packages/db`'s key/value migration test no longer asserts a settings
field that no longer exists.
## How you verified
Nothing here is a behavior fix, so the evidence is that removing a
never-enabled surface changes nothing else:
- `pnpm exec turbo run typecheck` — 72/72 tasks green.
- `pnpm exec turbo run test` for `@bb/app` (3037), `@bb/server` (1787),
`@bb/mobile` (812), `@bb/host-daemon` (47 files), plus `@bb/db`,
`@bb/domain`, `@bb/sdk`, `@bb/cli`, `@bb/desktop`,
`@bb/server-contract`, `@bb/host-daemon-contract`, `@bb/templates`,
`@get-bb/plugin-sdk` — all green.
- `pnpm exec turbo run lint` — 0 errors (144 pre-existing react-compiler
warnings in `@bb/app`, unchanged).
- `packages/host-daemon-contract`'s protocol-version test pins 138, and
its command-fixture map no longer accepts `workspace.discover_repos`, so
a stray sender fails the contract test.
- Repo-wide grep for `newOnboarding`, `onboardingCompletedAt`,
`replay-onboarding`, `onboardingRepos`, `onboardingEvent`, and
`discover_repos` returns only the `protocol.ts` changelog note.
No CHANGELOG entry: per `docs/bb-release-process.md` those sections are
written at release-prep time, and this removes nothing a user could see.
Fixes: N/A — no linked issue.
> AGENT GENERATED: by Claude Opus 5
Co-authored-by: Claude Opus 5 (1M context)
---
apps/app/src/App.tsx | 4 -
.../AppLayout.plugin-panel-header.test.tsx | 1 -
.../AppLayout.root-compose-project.test.tsx | 1 -
.../layout/AppLayout.sidebar-resize.test.tsx | 1 -
.../components/onboarding/OnboardingFlow.tsx | 615 ------------------
.../onboarding/OnboardingHost.test.tsx | 99 ---
.../components/onboarding/OnboardingHost.tsx | 213 ------
apps/app/src/hooks/queries/query-keys.ts | 8 -
.../src/hooks/queries/system-queries.test.tsx | 11 +-
apps/app/src/hooks/queries/system-queries.ts | 28 +-
.../app/src/hooks/useLocalPathPicker.test.tsx | 4 +-
.../app/src/hooks/useThreadCreationOptions.ts | 1 -
apps/app/src/lib/system-config-atoms.ts | 1 -
.../views/SettingsView.experiments.test.tsx | 12 -
apps/app/src/views/SettingsView.stories.tsx | 9 -
apps/app/src/views/SettingsView.tsx | 63 --
.../__tests__/command-output/settings.test.ts | 89 +--
apps/cli/src/commands/settings.ts | 24 -
apps/desktop/scripts/smoke-packaged-app.mjs | 1 -
apps/desktop/test/preload-build.test.ts | 1 -
apps/host-daemon/src/command-dispatch.ts | 8 -
.../command-handlers/discover-repos.test.ts | 116 ----
.../src/command-handlers/discover-repos.ts | 453 -------------
.../settings/ExperimentsSettingsScreen.tsx | 6 -
apps/server/src/routes/system.ts | 15 +-
.../skills/builtin-skills/bb-cli/SKILL.md | 4 -
.../bb-cli/references/app-settings.md | 10 +-
.../bb-plugin-authoring/SKILL.md | 30 +-
apps/server/src/services/system/onboarding.ts | 79 +--
apps/server/src/services/system/telemetry.ts | 34 -
apps/server/test/system/experiments.test.ts | 7 -
docs/configuration.md | 3 +-
packages/db/test/experiments.test.ts | 3 +-
packages/db/test/migrate.test.ts | 70 --
packages/domain/src/app-settings.ts | 11 -
packages/domain/src/experiments.ts | 2 -
packages/host-daemon-contract/src/commands.ts | 52 --
packages/host-daemon-contract/src/protocol.ts | 7 +-
packages/host-daemon-contract/src/session.ts | 1 -
.../test/contract.test.ts | 15 +-
packages/sdk/src/areas/system.ts | 30 +-
packages/sdk/test/public-types.test.ts | 2 -
packages/server-contract/src/api/system.ts | 42 --
packages/server-contract/src/public-api.ts | 25 +-
.../src/templates/bb-guide-customization.md | 8 -
45 files changed, 47 insertions(+), 2172 deletions(-)
delete mode 100644 apps/app/src/components/onboarding/OnboardingFlow.tsx
delete mode 100644 apps/app/src/components/onboarding/OnboardingHost.test.tsx
delete mode 100644 apps/app/src/components/onboarding/OnboardingHost.tsx
delete mode 100644 apps/host-daemon/src/command-handlers/discover-repos.test.ts
delete mode 100644 apps/host-daemon/src/command-handlers/discover-repos.ts
diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx
index 9773f91a1f..9a25e277ed 100644
--- a/apps/app/src/App.tsx
+++ b/apps/app/src/App.tsx
@@ -53,7 +53,6 @@ import {
getSkillDetailRoutePath,
} from "./lib/route-paths";
import { AppCommandProvider } from "./components/commands/AppCommandProvider";
-import { OnboardingHost } from "@/components/onboarding/OnboardingHost";
import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provider-cli-install";
import { PluginSettingsCompatibilityRoute } from "./components/settings/PluginSettingsCompatibilityRoute";
import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton";
@@ -397,9 +396,6 @@ export function App() {
started it, so its failure toast can be clicked from any route —
including auth callback, which renders no app shell. */}
- {/* First-run onboarding. Outside so it is not tied to a
- page. It self-gates on the experiment and completion timestamp. */}
-
diff --git a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx
index ac83ee51c1..ebe6ff7454 100644
--- a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx
@@ -30,7 +30,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({
changelogPreview: false,
editMessages: false,
mobileApp: false,
- newOnboarding: false,
providerSessionReaping: false,
},
},
diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
index 73ecfe6c74..5deb06e07d 100644
--- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx
@@ -35,7 +35,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({
changelogPreview: false,
editMessages: false,
mobileApp: false,
- newOnboarding: false,
providerSessionReaping: false,
},
},
diff --git a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx
index 3d7c200eda..cb134e5772 100644
--- a/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.sidebar-resize.test.tsx
@@ -41,7 +41,6 @@ vi.mock("@/hooks/queries/system-queries", () => ({
data: {
experiments: {
editMessages: false,
- newOnboarding: false,
providerSessionReaping: false,
},
},
diff --git a/apps/app/src/components/onboarding/OnboardingFlow.tsx b/apps/app/src/components/onboarding/OnboardingFlow.tsx
deleted file mode 100644
index ee3140bdc8..0000000000
--- a/apps/app/src/components/onboarding/OnboardingFlow.tsx
+++ /dev/null
@@ -1,615 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
-import type { ReactNode } from "react";
-import type { OnboardingAgent } from "@bb/server-contract";
-import type { DiscoveredRepo } from "@bb/host-daemon-contract";
-import { Badge } from "@bb/shared-ui/badge";
-import { Button } from "@bb/shared-ui/button";
-import { Checkbox } from "@bb/shared-ui/checkbox";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@bb/shared-ui/dialog";
-import { Icon } from "@bb/shared-ui/icon";
-import { cn } from "@bb/shared-ui/lib/utils";
-import { copyToClipboardWithToast } from "@/lib/clipboard";
-import { getProviderIconInfo } from "@/lib/provider-icon";
-import { useLocalPathPicker } from "@/hooks/useLocalPathPicker";
-import { ProjectPathDialog } from "@/components/dialogs/ProjectPathDialog";
-import {
- useOnboardingAgents,
- useOnboardingRepos,
-} from "@/hooks/queries/system-queries";
-
-/**
- * First-run onboarding.
- *
- * Two steps in a modal over the real app shell. The first is a *confirmation*
- * whenever the machine already has a usable agent — bb runs the coding agent
- * CLIs that are already installed and bills inference to those plans, so the
- * honest thing to do is state that rather than ask a question. Only a machine
- * with nothing installed is asked to install something.
- *
- * Escape and the close button both exit to the app; outside-click is blocked so
- * a stray click cannot dismiss setup mid-install.
- */
-
-export type OnboardingAgentState = "connected" | "signed_out" | "none";
-
-export interface OnboardingFlowProps {
- /** Adds the selected projects; resolves once they exist. */
- onAddProjects: (repos: readonly DiscoveredRepo[]) => Promise;
- /** Called when the flow finishes or is dismissed — persists the flag. */
- onClose: (outcome: {
- completed: boolean;
- step: "agents" | "projects";
- projectsAdded: number;
- agentState: OnboardingAgentState;
- }) => void;
- /** Reports funnel events; the app wires this to the server's telemetry. */
- onEvent?: (event: OnboardingUiEvent) => void;
-
- /** Starts the managed CLI install; resolves when the job is queued. */
- onInstallAgent: (agent: OnboardingAgent) => void;
- /** Set of provider ids with an install running or queued. */
- installing: ReadonlySet;
-}
-
-export type OnboardingUiEvent =
- | { name: "started"; agentState: OnboardingAgentState; agentCount: number }
- | { name: "step_completed"; step: "agents" | "projects" }
- /** Distinct from completion so the funnel can tell the two apart. */
- | { name: "step_skipped"; step: "agents" | "projects" };
-
-/** The real brand mark the rest of the app uses for this provider. */
-function ProviderMark({ providerId }: { providerId: string }) {
- const info = getProviderIconInfo(providerId);
- if (!info) {
- return (
-
- );
- }
- const Mark = info.icon;
- return ;
-}
-
-function Card({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-/** Two-step progress, sized for a dialog footer. */
-function StepDots({ current }: { current: number }) {
- return (
-
- {/* Fixed column so plan badges share one right edge, and rows
- without a plan keep the same gutter. Only Claude Code, Codex,
- and Cursor can report a plan; the rest get no badge rather
- than a fabricated one. */}
-
- {agent.planLabel === null ? null : (
-
- {agent.planLabel}
-
- )}
-
-
- {label}
- {isInstalling ? (
-
- ) : null}
-
-
- {expanded && agent.loginCommand !== null ? (
- /* bb deliberately does not drive another tool's login: it shows
- the agent's own command and re-checks, so credentials never
- pass through bb. */
-
-
- Run this in a terminal, then come back:
-
-
-
- {agent.loginCommand}
-
-
-
-
-
- ) : null}
-
- );
- })}
-
- );
-}
-
-export function OnboardingFlow({
- onAddProjects,
- onClose,
- onEvent,
- onInstallAgent,
- installing,
-}: OnboardingFlowProps) {
- const [step, setStep] = useState<0 | 1>(0);
- const [selected, setSelected] = useState>(new Set());
- const [expandedSignIn, setExpandedSignIn] = useState(null);
- /** Folders the user picked by hand, shown and checked alongside the scan. */
- const [addedRepos, setAddedRepos] = useState([]);
- const [addError, setAddError] = useState(null);
- const [adding, setAdding] = useState(false);
- const [startedReported, setStartedReported] = useState(false);
-
- // Poll only while the agents step is visible; the projects step has no use
- // for it and each read is several host round-trips.
- const agentsQuery = useOnboardingAgents({ poll: step === 0 });
- // Re-read after a terminal sign-in rather than waiting out the poll. Using the
- // query's own refetch keeps cache writes inside the query layer.
- const recheck = useCallback(() => {
- void agentsQuery.refetch();
- }, [agentsQuery]);
- const reposQuery = useOnboardingRepos({ enabled: step === 1 });
-
- const agents = useMemo(
- () => agentsQuery.data?.agents ?? [],
- [agentsQuery.data],
- );
- const agentState = agentStateOf(agents);
- const scanningAgents = agentsQuery.isPending;
-
- // Only a machine with nothing installed is asked to install something.
- const nothingInstalled =
- !scanningAgents &&
- agents.length > 0 &&
- agents.every((agent) => agent.status === "not_installed");
- const canContinue =
- !scanningAgents && agents.some((agent) => agent.status === "connected");
-
- useEffect(() => {
- if (startedReported || scanningAgents) return;
- // A failed probe is not evidence of an empty machine; reporting it would
- // inflate `agent_state: none`, the metric this event exists to answer.
- if (agentsQuery.isError || agentsQuery.data === undefined) return;
- setStartedReported(true);
- onEvent?.({
- name: "started",
- agentState,
- agentCount: agents.filter((agent) => agent.status !== "not_installed")
- .length,
- });
- }, [
- agentState,
- agents,
- agentsQuery.data,
- agentsQuery.isError,
- onEvent,
- scanningAgents,
- startedReported,
- ]);
-
- const repos = useMemo(() => {
- const discovered = reposQuery.data?.repos ?? [];
- const seen = new Set(discovered.map((repo) => repo.path));
- // Hand-picked folders lead: the user just chose them.
- return [
- ...addedRepos.filter((repo) => !seen.has(repo.path)),
- ...discovered,
- ];
- }, [addedRepos, reposQuery.data]);
-
- // Same path-entry surface the rest of the app uses (native picker on a
- // single-machine desktop, in-app browser otherwise). Onboarding's submit adds
- // the folder to this step's list instead of creating a project immediately,
- // so one "Add projects" click still creates everything at once.
- const pathPicker = useLocalPathPicker({
- isPending: false,
- submit: ({ path, closeDialog }) => {
- const name = path.split("/").filter(Boolean).pop() ?? path;
- setAddedRepos((current) =>
- current.some((repo) => repo.path === path)
- ? current
- : [
- ...current,
- {
- path,
- name,
- lastActivityAt: new Date().toISOString(),
- originUrl: null,
- agentSeen: false,
- agentSeenAt: null,
- },
- ],
- );
- setSelected((current) => new Set(current).add(path));
- closeDialog();
- },
- });
-
- // Pre-check repos an agent has already worked in — that is the strongest
- // signal the user wants them in bb — and fall back to the most recent.
- useEffect(() => {
- if (reposQuery.data === undefined) return;
- setSelected((current) => {
- if (current.size > 0) return current;
- const seen = repos.filter((repo) => repo.agentSeen).map((r) => r.path);
- return new Set(
- seen.length > 0 ? seen : repos.slice(0, 2).map((r) => r.path),
- );
- });
- }, [repos, reposQuery.data]);
-
- const finish = useCallback(
- (completed: boolean, atStep: "agents" | "projects", added: number) => {
- onClose({ completed, step: atStep, projectsAdded: added, agentState });
- },
- [agentState, onClose],
- );
-
- const toggleRepo = useCallback((path: string) => {
- setSelected((current) => {
- const next = new Set(current);
- if (next.has(path)) next.delete(path);
- else next.add(path);
- return next;
- });
- }, []);
-
- const addProjects = useCallback(async () => {
- const chosen = repos.filter((repo) => selected.has(repo.path));
- setAdding(true);
- setAddError(null);
- try {
- await onAddProjects(chosen);
- onEvent?.({ name: "step_completed", step: "projects" });
- finish(true, "projects", chosen.length);
- } catch (error) {
- // Keep the dialog open and say so, rather than stranding the user with a
- // half-added set and no explanation.
- setAddError(
- error instanceof Error
- ? error.message
- : "Could not add every project. Try again.",
- );
- } finally {
- setAdding(false);
- }
- }, [finish, onAddProjects, onEvent, repos, selected]);
-
- const title =
- step === 0
- ? nothingInstalled
- ? "Install a coding agent"
- : "bb uses your existing coding agents"
- : "Add your projects";
-
- const description =
- step === 0
- ? nothingInstalled
- ? "bb has no inference of its own. It runs coding agent CLIs on your computer and bills usage to their plans. Install one to get started."
- : "It runs the agents below locally, so inference is billed to their plans."
- : "bb works inside your code. Add the folders you want it to work in. You can add more any time.";
-
- return (
-
- );
-}
diff --git a/apps/app/src/components/onboarding/OnboardingHost.test.tsx b/apps/app/src/components/onboarding/OnboardingHost.test.tsx
deleted file mode 100644
index b77f2d34e5..0000000000
--- a/apps/app/src/components/onboarding/OnboardingHost.test.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-// @vitest-environment jsdom
-import { cleanup, render, screen } from "@testing-library/react";
-import { defaultAppSettings, defaultExperiments } from "@bb/domain";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { OnboardingHost } from "./OnboardingHost";
-
-const mocks = vi.hoisted(() => ({
- useCreateProject: vi.fn(),
- useHostProviderCliStatus: vi.fn(),
- usePrimaryHost: vi.fn(),
- useProviderCliInstallRunner: vi.fn(),
- useSidebarNavigation: vi.fn(),
- useSystemConfig: vi.fn(),
- useUpdateGeneralSettings: vi.fn(),
-}));
-
-vi.mock("@/hooks/queries/system-queries", () => ({
- useHostProviderCliStatus: mocks.useHostProviderCliStatus,
- useSystemConfig: mocks.useSystemConfig,
-}));
-vi.mock("@/hooks/mutations/settings-mutations", () => ({
- useUpdateGeneralSettings: mocks.useUpdateGeneralSettings,
-}));
-vi.mock("@/hooks/mutations/project-mutations", () => ({
- useCreateProject: mocks.useCreateProject,
-}));
-vi.mock("@/hooks/queries/host-queries", () => ({
- usePrimaryHost: mocks.usePrimaryHost,
-}));
-vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({
- useSidebarNavigation: mocks.useSidebarNavigation,
-}));
-vi.mock("@/components/provider-cli/provider-cli-install", () => ({
- buildProviderCliIssue: vi.fn(),
- hasProviderCliAction: vi.fn(),
- providerCliEntries: vi.fn(() => []),
- useProviderCliInstallRunner: mocks.useProviderCliInstallRunner,
-}));
-vi.mock("@/components/provider-cli/provider-cli-install-store", () => ({
- providerCliJobKey: vi.fn(() => "job"),
-}));
-vi.mock("./OnboardingFlow", () => ({
- OnboardingFlow: () =>
Onboarding flow
,
-}));
-
-beforeEach(() => {
- mocks.useCreateProject.mockReturnValue({ mutateAsync: vi.fn() });
- mocks.useHostProviderCliStatus.mockReturnValue({ data: undefined });
- mocks.usePrimaryHost.mockReturnValue({ id: "host-1" });
- mocks.useProviderCliInstallRunner.mockReturnValue({
- failuresByJobKey: new Map(),
- queuedJobKeys: new Set(),
- runningJobKey: null,
- startInstall: vi.fn(),
- });
- mocks.useSidebarNavigation.mockReturnValue({ data: { projects: [] } });
- mocks.useUpdateGeneralSettings.mockReturnValue({ mutate: vi.fn() });
-});
-
-afterEach(() => {
- cleanup();
- vi.clearAllMocks();
-});
-
-describe("OnboardingHost", () => {
- it("does not show or run provider checks while the experiment is off", () => {
- mocks.useSystemConfig.mockReturnValue({
- data: {
- experiments: defaultExperiments,
- generalSettings: defaultAppSettings,
- },
- });
-
- render();
-
- expect(screen.queryByText("Onboarding flow")).toBeNull();
- expect(mocks.useHostProviderCliStatus).toHaveBeenCalledWith({
- enabled: false,
- hostId: "host-1",
- });
- });
-
- it("shows onboarding when the experiment is on and setup is incomplete", () => {
- mocks.useSystemConfig.mockReturnValue({
- data: {
- experiments: { ...defaultExperiments, newOnboarding: true },
- generalSettings: defaultAppSettings,
- },
- });
-
- render();
-
- expect(screen.getByText("Onboarding flow")).toBeTruthy();
- expect(mocks.useHostProviderCliStatus).toHaveBeenCalledWith({
- enabled: true,
- hostId: "host-1",
- });
- });
-});
diff --git a/apps/app/src/components/onboarding/OnboardingHost.tsx b/apps/app/src/components/onboarding/OnboardingHost.tsx
deleted file mode 100644
index aae054bfda..0000000000
--- a/apps/app/src/components/onboarding/OnboardingHost.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import type { DiscoveredRepo } from "@bb/host-daemon-contract";
-import { useSystemConfig } from "@/hooks/queries/system-queries";
-import { useUpdateGeneralSettings } from "@/hooks/mutations/settings-mutations";
-import { useCreateProject } from "@/hooks/mutations/project-mutations";
-import { usePrimaryHost } from "@/hooks/queries/host-queries";
-import { useHostProviderCliStatus } from "@/hooks/queries/system-queries";
-import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query";
-import {
- buildProviderCliIssue,
- hasProviderCliAction,
- providerCliEntries,
- useProviderCliInstallRunner,
-} from "@/components/provider-cli/provider-cli-install";
-import { providerCliJobKey } from "@/components/provider-cli/provider-cli-install-store";
-import { sdk } from "@/lib/sdk";
-
-/**
- * Collapse the two spellings of one remote so SSH and HTTPS clones of the same
- * repository compare equal. A repo with no remote returns null and is never
- * matched — path is not available on a project, so those are left to the
- * server's own duplicate handling.
- */
-function normalizeRemote(url: string | null): string | null {
- if (url === null) return null;
- const trimmed = url.trim();
- if (trimmed === "") return null;
- return trimmed
- .replace(/\.git$/u, "")
- .replace(/^git@([^:]+):/u, "https://$1/")
- .replace(/^ssh:\/\/git@/u, "https://")
- .replace(/\/+$/u, "")
- .toLowerCase();
-}
-
-/** Maps an onboarding provider id back to its managed-CLI key. */
-const CLI_KEY_BY_PROVIDER: Record = {
- codex: "codex",
- "claude-code": "claudeCode",
- "acp-cursor": "cursor",
-};
-import {
- OnboardingFlow,
- type OnboardingAgentState,
- type OnboardingUiEvent,
-} from "./OnboardingFlow";
-
-/**
- * Decides whether first-run onboarding is showing, and owns its side effects:
- * creating the chosen projects, persisting the completion timestamp, and
- * reporting the funnel to the server's telemetry.
- *
- * Mounted once by the app shell. The new-onboarding experiment and the
- * `onboardingCompletedAt` timestamp gate the flow. Whether an agent is actually
- * usable is answered live by the agents query, so dismissing onboarding never
- * claims the machine is configured.
- */
-export function OnboardingHost() {
- const configQuery = useSystemConfig();
- const updateSettings = useUpdateGeneralSettings();
- const createProject = useCreateProject();
- const primaryHost = usePrimaryHost();
- const navigationQuery = useSidebarNavigation();
- const installRunner = useProviderCliInstallRunner();
- // Stamped in an effect rather than during render: `Date.now()` in a render
- // body is impure and would drift on every re-render.
- const startedAt = useRef(null);
-
- const settings = configQuery.data?.generalSettings;
- const newOnboardingEnabled =
- configQuery.data?.experiments.newOnboarding ?? false;
- const primaryHostId = primaryHost?.id ?? null;
- // Migration 0085 stamps existing installs as already onboarded, so a null
- // timestamp means exactly one thing here: the flow remains incomplete. That
- // is what lets Settings re-trigger it by clearing the column.
- const neverOnboarded =
- settings !== undefined && settings.onboardingCompletedAt === null;
- const shouldShow =
- newOnboardingEnabled && neverOnboarded && primaryHostId !== null;
- const cliStatusQuery = useHostProviderCliStatus({
- hostId: primaryHostId,
- // Only needed to build an install job, and only while the flow is open.
- // Left ungated this runs provider CLI and package-registry checks on every
- // app start, forever, for users who finished onboarding long ago.
- enabled: shouldShow,
- });
-
- const projects = navigationQuery.data?.projects;
-
- const installingProviders = new Set(
- Object.entries(CLI_KEY_BY_PROVIDER)
- .filter(([, cliKey]) => {
- if (primaryHostId === null) return false;
- const jobKey = providerCliJobKey(primaryHostId, cliKey);
- return (
- installRunner.runningJobKey === jobKey ||
- installRunner.queuedJobKeys.has(jobKey)
- );
- })
- .map(([providerId]) => providerId),
- );
-
- const installAgent = useCallback(
- (agent: { providerId: string }) => {
- const cliKey = CLI_KEY_BY_PROVIDER[agent.providerId];
- if (cliKey === undefined || primaryHostId === null) return;
- const status = cliStatusQuery.data;
- if (status === undefined) return;
- const issue = providerCliEntries(status)
- .filter((entry) => entry.provider === cliKey)
- .map(buildProviderCliIssue)
- .find((candidate) => candidate !== null);
- if (!issue || !hasProviderCliAction(issue)) return;
- installRunner.startInstall({ hostId: primaryHostId, issue });
- },
- [cliStatusQuery.data, installRunner, primaryHostId],
- );
-
- // Stamp when the flow actually opens, so a re-trigger hours into a session
- // does not report the whole session as its duration.
- useEffect(() => {
- if (shouldShow) startedAt.current ??= Date.now();
- else startedAt.current = null;
- }, [shouldShow]);
-
- const addProjects = useCallback(
- async (repos: readonly DiscoveredRepo[]) => {
- if (primaryHostId === null) return;
- // Guard against re-adding a repo bb already tracks on replay. Projects
- // expose their remote, not their path, so the remote is the join key —
- // normalized, because `git@host:o/r.git` and `https://host/o/r` are the
- // same repository.
- const existingRemotes = new Set(
- (projects ?? [])
- .map((project) => normalizeRemote(project.gitRemoteUrl))
- .filter((remote): remote is string => remote !== null),
- );
- // Sequential: project creation touches the host workspace, and a burst of
- // parallel creates would race on the same daemon.
- for (const repo of repos) {
- const remote = normalizeRemote(repo.originUrl);
- if (remote !== null && existingRemotes.has(remote)) continue;
- await createProject.mutateAsync({
- name: repo.name,
- source: {
- type: "local_path",
- hostId: primaryHostId,
- path: repo.path,
- },
- });
- }
- },
- [createProject, primaryHostId, projects],
- );
-
- const report = useCallback((event: OnboardingUiEvent) => {
- void sdk.system
- .onboardingEvent(
- event.name === "started"
- ? {
- name: "onboarding_started",
- agentState: event.agentState,
- detectedAgentCount: event.agentCount,
- }
- : event.name === "step_skipped"
- ? { name: "onboarding_step_skipped", step: event.step }
- : { name: "onboarding_step_completed", step: event.step },
- )
- .catch(() => {
- // Telemetry is analytics, not workflow state.
- });
- }, []);
-
- const close = useCallback(
- (outcome: {
- completed: boolean;
- step: "agents" | "projects";
- projectsAdded: number;
- agentState: OnboardingAgentState;
- }) => {
- if (settings === undefined) return;
- updateSettings.mutate({
- ...settings,
- onboardingCompletedAt: new Date().toISOString(),
- });
- void sdk.system
- .onboardingEvent(
- outcome.completed
- ? {
- name: "onboarding_completed",
- agentState: outcome.agentState,
- projectsAdded: outcome.projectsAdded,
- durationMs: Date.now() - (startedAt.current ?? Date.now()),
- }
- : { name: "onboarding_dismissed", step: outcome.step },
- )
- .catch(() => {});
- },
- [settings, updateSettings],
- );
-
- if (!shouldShow) return null;
-
- return (
-
- );
-}
diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts
index dfd375c037..2e23fda288 100644
--- a/apps/app/src/hooks/queries/query-keys.ts
+++ b/apps/app/src/hooks/queries/query-keys.ts
@@ -63,7 +63,6 @@ export const SYSTEM_VERSION_QUERY_KEY = "systemVersion";
export const HOST_PROVIDER_CLI_STATUS_QUERY_KEY = "hostProviderCliStatus";
export const SYSTEM_USAGE_LIMITS_QUERY_KEY = "systemUsageLimits";
export const ONBOARDING_AGENTS_QUERY_KEY = "onboardingAgents";
-export const ONBOARDING_REPOS_QUERY_KEY = "onboardingRepos";
export const HOST_PATH_EXISTENCE_QUERY_KEY = "hostPathExistence";
export const PROJECT_SKILLS_QUERY_KEY = "projectSkills";
export const SKILL_CONTENT_QUERY_KEY = "skillContent";
@@ -458,9 +457,6 @@ export type OnboardingAgentsQueryKey = readonly [
string | null,
string | null,
];
-export type OnboardingReposQueryKey = readonly [
- typeof ONBOARDING_REPOS_QUERY_KEY,
-];
export type SystemExecutionOptionsQueryKey = readonly [
typeof SYSTEM_EXECUTION_OPTIONS_QUERY_KEY,
string | null,
@@ -1087,10 +1083,6 @@ export function onboardingAgentsQueryKey(
return [ONBOARDING_AGENTS_QUERY_KEY, args.environmentId, args.hostId];
}
-export function onboardingReposQueryKey(): OnboardingReposQueryKey {
- return [ONBOARDING_REPOS_QUERY_KEY];
-}
-
export interface SystemExecutionOptionsQueryKeyArgs {
environmentId: string | null;
hostId: string | null;
diff --git a/apps/app/src/hooks/queries/system-queries.test.tsx b/apps/app/src/hooks/queries/system-queries.test.tsx
index 9487759792..62ce2fe6c0 100644
--- a/apps/app/src/hooks/queries/system-queries.test.tsx
+++ b/apps/app/src/hooks/queries/system-queries.test.tsx
@@ -545,8 +545,8 @@ describe("useOnboardingAgents", () => {
const { result } = renderHook(
() => [
- useOnboardingAgents({ hostId: "host-a", poll: false }),
- useOnboardingAgents({ hostId: "host-b", poll: false }),
+ useOnboardingAgents({ hostId: "host-a" }),
+ useOnboardingAgents({ hostId: "host-b" }),
],
{ wrapper },
);
@@ -577,10 +577,9 @@ describe("useOnboardingAgents", () => {
);
const { wrapper } = createQueryClientTestHarness();
- renderHook(
- () => useOnboardingAgents({ environmentId: "env-remote", poll: false }),
- { wrapper },
- );
+ renderHook(() => useOnboardingAgents({ environmentId: "env-remote" }), {
+ wrapper,
+ });
await waitFor(() => {
expect(sdk.system.onboardingAgents).toHaveBeenCalledWith({
diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts
index 36dae036e5..5a060c7d8d 100644
--- a/apps/app/src/hooks/queries/system-queries.ts
+++ b/apps/app/src/hooks/queries/system-queries.ts
@@ -21,10 +21,9 @@ import type {
SystemVersionResponse,
} from "@bb/server-contract";
import type {
- DiscoverReposResult,
ProviderCliStatusResponse,
+ ProviderUsageResponse,
} from "@bb/host-daemon-contract";
-import type { ProviderUsageResponse } from "@bb/host-daemon-contract";
import { BbHttpError, sdk } from "@/lib/sdk";
import {
modelCatalogCacheKey,
@@ -41,7 +40,6 @@ import {
hostProviderCliStatusQueryKey,
systemCliSkillsQueryKey,
onboardingAgentsQueryKey,
- onboardingReposQueryKey,
systemConfigQueryKey,
systemExecutionOptionsQueryKey,
systemProvidersQueryKey,
@@ -65,7 +63,6 @@ export interface UseSystemExecutionOptionsArgs {
export interface UseOnboardingAgentsOptions extends QueryOptions {
environmentId?: string;
hostId?: string;
- poll?: boolean;
}
interface QueryOptions {
@@ -535,8 +532,8 @@ export function useHostProviderCliStatus({
}
/**
- * Live agent state for onboarding. Polled while the step is open so installing
- * or signing in from a terminal updates the list without a manual refresh.
+ * Install, auth, and plan state per agent provider. The root composer reads it
+ * to default an unset provider selection to one the machine is signed in to.
*/
export function useOnboardingAgents(options: UseOnboardingAgentsOptions = {}) {
const environmentId = options.environmentId ?? null;
@@ -551,22 +548,9 @@ export function useOnboardingAgents(options: UseOnboardingAgentsOptions = {}) {
}),
enabled: options.enabled ?? true,
// Each read runs CLI health checks, known-agent checks, and up to three
- // provider usage requests, so this polls slowly and only while the agents
- // step is actually on screen. An explicit re-check covers the impatient
- // case. Other readers (the composer's provider default) want one answer.
- ...(options.poll === false
- ? { staleTime: 60_000 }
- : { refetchInterval: 15_000 }),
- });
-}
-
-/** Candidate projects on the host. Runs once when the projects step opens. */
-export function useOnboardingRepos(options: QueryOptions = {}) {
- return useQuery({
- queryKey: onboardingReposQueryKey(),
- queryFn: ({ signal }) => sdk.system.onboardingRepos({ signal }),
- enabled: options.enabled ?? true,
- staleTime: Infinity,
+ // provider usage requests on the host, so one answer is cached rather than
+ // polled: the composer only needs a default at open time.
+ staleTime: 60_000,
});
}
diff --git a/apps/app/src/hooks/useLocalPathPicker.test.tsx b/apps/app/src/hooks/useLocalPathPicker.test.tsx
index 4803394308..e5033062d7 100644
--- a/apps/app/src/hooks/useLocalPathPicker.test.tsx
+++ b/apps/app/src/hooks/useLocalPathPicker.test.tsx
@@ -122,8 +122,8 @@ describe("useLocalPathPicker", () => {
/**
* Choosing between the native folder picker and the in-app dialog. This lived
- * in `useQuickCreateProject` until onboarding needed the same behavior; it is
- * shared here so every path-entry caller agrees.
+ * in `useQuickCreateProject` until a second caller needed the same behavior; it
+ * is shared here so every path-entry caller agrees.
*/
describe("useLocalPathPicker openPathEntry", () => {
it("opens the dialog instead of the native picker when several machines exist", () => {
diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts
index f547ebd87c..5849e70bb7 100644
--- a/apps/app/src/hooks/useThreadCreationOptions.ts
+++ b/apps/app/src/hooks/useThreadCreationOptions.ts
@@ -317,7 +317,6 @@ export function useThreadCreationOptions(
const connectedAgentsQuery = useOnboardingAgents({
enabled: shouldResolveConnectedProvider,
...executionOptionsRouting,
- poll: false,
});
const connectedProviderId = shouldResolveConnectedProvider
? connectedAgentsQuery.data?.agents.find(
diff --git a/apps/app/src/lib/system-config-atoms.ts b/apps/app/src/lib/system-config-atoms.ts
index a9fdf66fce..66fb4e24a7 100644
--- a/apps/app/src/lib/system-config-atoms.ts
+++ b/apps/app/src/lib/system-config-atoms.ts
@@ -24,7 +24,6 @@ const unavailableSystemConfig: SystemConfigResponse = {
changelogPreview: false,
editMessages: false,
mobileApp: false,
- newOnboarding: false,
providerSessionReaping: false,
},
appearance: defaultAppTheme,
diff --git a/apps/app/src/views/SettingsView.experiments.test.tsx b/apps/app/src/views/SettingsView.experiments.test.tsx
index 7bbc329639..06a82bba53 100644
--- a/apps/app/src/views/SettingsView.experiments.test.tsx
+++ b/apps/app/src/views/SettingsView.experiments.test.tsx
@@ -8,7 +8,6 @@ afterEach(cleanup);
function renderSection(overrides?: {
onChangelogPreviewEnabledChange?: (enabled: boolean) => void;
onMobileAppEnabledChange?: (enabled: boolean) => void;
- onNewOnboardingEnabledChange?: (enabled: boolean) => void;
onProviderSessionReapingEnabledChange?: (enabled: boolean) => void;
}) {
return render(
@@ -17,16 +16,12 @@ function renderSection(overrides?: {
disabled={false}
editMessagesEnabled={false}
mobileAppEnabled={false}
- newOnboardingEnabled={false}
providerSessionReapingEnabled={false}
onChangelogPreviewEnabledChange={
overrides?.onChangelogPreviewEnabledChange ?? vi.fn()
}
onEditMessagesEnabledChange={vi.fn()}
onMobileAppEnabledChange={overrides?.onMobileAppEnabledChange ?? vi.fn()}
- onNewOnboardingEnabledChange={
- overrides?.onNewOnboardingEnabledChange ?? vi.fn()
- }
onProviderSessionReapingEnabledChange={
overrides?.onProviderSessionReapingEnabledChange ?? vi.fn()
}
@@ -42,13 +37,6 @@ describe("ExperimentsSettingsSection", () => {
expect(onChange).toHaveBeenCalledWith(true);
});
- it("reports new onboarding changes", () => {
- const onChange = vi.fn();
- renderSection({ onNewOnboardingEnabledChange: onChange });
- fireEvent.click(screen.getByLabelText("New onboarding"));
- expect(onChange).toHaveBeenCalledWith(true);
- });
-
it("reports mobile app changes", () => {
const onChange = vi.fn();
renderSection({ onMobileAppEnabledChange: onChange });
diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx
index e48daf83d3..48476fb150 100644
--- a/apps/app/src/views/SettingsView.stories.tsx
+++ b/apps/app/src/views/SettingsView.stories.tsx
@@ -275,14 +275,12 @@ function GeneralSettingsStory({
state.setNavigateToThreadAfterCreate
}
onOpenLinksInAppBrowserChange={state.setOpenLinksInAppBrowser}
- onReplayOnboarding={() => {}}
onRewriteLocalhostLinksChange={state.setRewriteLocalhostLinks}
onRichTextEditingChange={state.setRichTextEditing}
onSteerActiveThreadOnEnterChange={state.setSteerActiveThreadOnEnter}
openLinksInAppBrowser={state.openLinksInAppBrowser}
rewriteLocalhostLinks={state.rewriteLocalhostLinks}
richTextEditing={state.richTextEditing}
- replayOnboardingAvailable={state.experiments.newOnboarding}
steerActiveThreadOnEnter={state.steerActiveThreadOnEnter}
steerActiveThreadOnEnterDisabled={false}
/>
@@ -352,7 +350,6 @@ function ExperimentsStory() {
disabled={false}
editMessagesEnabled={state.experiments.editMessages}
mobileAppEnabled={state.experiments.mobileApp}
- newOnboardingEnabled={state.experiments.newOnboarding}
providerSessionReapingEnabled={state.experiments.providerSessionReaping}
onChangelogPreviewEnabledChange={(enabled) =>
state.setExperiments((current) => ({
@@ -372,12 +369,6 @@ function ExperimentsStory() {
mobileApp: enabled,
}))
}
- onNewOnboardingEnabledChange={(enabled) =>
- state.setExperiments((current) => ({
- ...current,
- newOnboarding: enabled,
- }))
- }
onProviderSessionReapingEnabledChange={(enabled) =>
state.setExperiments((current) => ({
...current,
diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx
index 2e7ebe4977..3b3b334676 100644
--- a/apps/app/src/views/SettingsView.tsx
+++ b/apps/app/src/views/SettingsView.tsx
@@ -176,7 +176,6 @@ export interface AppearanceSettingsSectionProps {
}
export interface GeneralSettingsSectionProps {
- onReplayOnboarding: () => void;
desktopBrowserAvailable: boolean;
navigateToThreadAfterCreate: boolean;
onNavigateToThreadAfterCreateChange: (enabled: boolean) => void;
@@ -187,7 +186,6 @@ export interface GeneralSettingsSectionProps {
openLinksInAppBrowser: boolean;
rewriteLocalhostLinks: boolean;
richTextEditing: boolean;
- replayOnboardingAvailable: boolean;
steerActiveThreadOnEnter: boolean;
steerActiveThreadOnEnterDisabled: boolean;
}
@@ -213,12 +211,10 @@ export interface ExperimentsSettingsSectionProps {
changelogPreviewEnabled: boolean;
editMessagesEnabled: boolean;
mobileAppEnabled: boolean;
- newOnboardingEnabled: boolean;
providerSessionReapingEnabled: boolean;
onChangelogPreviewEnabledChange: (enabled: boolean) => void;
onEditMessagesEnabledChange: (enabled: boolean) => void;
onMobileAppEnabledChange: (enabled: boolean) => void;
- onNewOnboardingEnabledChange: (enabled: boolean) => void;
onProviderSessionReapingEnabledChange: (enabled: boolean) => void;
}
@@ -836,10 +832,8 @@ export function GeneralSettingsSection({
openLinksInAppBrowser,
rewriteLocalhostLinks,
richTextEditing,
- replayOnboardingAvailable,
steerActiveThreadOnEnter,
steerActiveThreadOnEnterDisabled,
- onReplayOnboarding,
}: GeneralSettingsSectionProps) {
return (
@@ -873,39 +867,11 @@ export function GeneralSettingsSection({
enabled={rewriteLocalhostLinks}
onEnabledChange={onRewriteLocalhostLinksChange}
/>
-
- {replayOnboardingAvailable ? (
-
- ) : null}
);
}
-/**
- * The parent only shows this control when the new-onboarding experiment is on.
- * Clearing `onboardingCompletedAt` then reopens the flow on the spot.
- */
-function ReplayOnboardingSettingsControl({
- onReplay,
-}: {
- onReplay: () => void;
-}) {
- return (
-
-
-
Setup guide
-
- Walk through agent detection and adding projects again.
-
-
-
-
- );
-}
-
export function DebugSettingsSection({
disabled,
enabled,
@@ -999,7 +965,6 @@ export function ProviderSettingsSection({
const CHANGELOG_PREVIEW_EXPERIMENT_LABEL = "Changelog preview";
const EDIT_MESSAGES_EXPERIMENT_LABEL = "Edit messages";
const MOBILE_APP_EXPERIMENT_LABEL = "Mobile app";
-const NEW_ONBOARDING_EXPERIMENT_LABEL = "New onboarding";
const PROVIDER_SESSION_REAPING_EXPERIMENT_LABEL =
"Idle provider session release";
export function ExperimentsSettingsSection({
@@ -1007,12 +972,10 @@ export function ExperimentsSettingsSection({
disabled,
editMessagesEnabled,
mobileAppEnabled,
- newOnboardingEnabled,
providerSessionReapingEnabled,
onChangelogPreviewEnabledChange,
onEditMessagesEnabledChange,
onMobileAppEnabledChange,
- onNewOnboardingEnabledChange,
onProviderSessionReapingEnabledChange,
}: ExperimentsSettingsSectionProps) {
return (
@@ -1057,18 +1020,6 @@ export function ExperimentsSettingsSection({
/>
-
-
-
-
- updateExperimentsMutation.mutate({
- ...experiments,
- newOnboarding: enabled,
- })
- }
providerSessionReapingEnabled={experiments.providerSessionReaping}
onProviderSessionReapingEnabledChange={(enabled) =>
updateExperimentsMutation.mutate({
@@ -1290,7 +1234,6 @@ export function SettingsView() {
openLinksInAppBrowser={openLinksInAppBrowser}
rewriteLocalhostLinks={rewriteLocalhostLinks}
richTextEditing={richTextEditing}
- replayOnboardingAvailable={experiments.newOnboarding}
steerActiveThreadOnEnter={generalSettings.steerActiveThreadOnEnter}
steerActiveThreadOnEnterDisabled={
systemConfigQuery.data === undefined ||
@@ -1298,12 +1241,6 @@ export function SettingsView() {
}
onNavigateToThreadAfterCreateChange={setNavigateToThreadAfterCreate}
onOpenLinksInAppBrowserChange={setOpenLinksInAppBrowser}
- onReplayOnboarding={() =>
- updateGeneralSettingsMutation.mutate({
- ...generalSettings,
- onboardingCompletedAt: null,
- })
- }
onRewriteLocalhostLinksChange={setRewriteLocalhostLinks}
onRichTextEditingChange={setRichTextEditing}
onSteerActiveThreadOnEnterChange={(enabled) =>
diff --git a/apps/cli/src/__tests__/command-output/settings.test.ts b/apps/cli/src/__tests__/command-output/settings.test.ts
index 6341576ef5..1785960b45 100644
--- a/apps/cli/src/__tests__/command-output/settings.test.ts
+++ b/apps/cli/src/__tests__/command-output/settings.test.ts
@@ -34,39 +34,15 @@ describe("bb settings commands", () => {
});
});
- // Keys and value shapes come from `appSettingsSchema`, so non-boolean and
- // nullable preferences are settable without a per-key branch in the command.
- it("sets a nullable setting and rejects an unknown key", async () => {
- const put = vi.fn(async ({ json }) => json);
+ // Keys come from `appSettingsSchema`, so an unknown one is rejected by the
+ // command rather than sent to the server.
+ it("rejects an unknown general setting key", async () => {
stubServerApi({
"v1.system.config.$get": vi.fn(async () => ({
- generalSettings: {
- ...defaultAppSettings,
- onboardingCompletedAt: "2026-08-06T00:00:00.000Z",
- },
+ generalSettings: defaultAppSettings,
experiments: defaultExperiments,
})),
- "v1.settings.general.$put": put,
- });
-
- await runCommand(
- ["settings", "general", "onboardingCompletedAt", "null"],
- register,
- );
-
- expect(put).toHaveBeenCalledWith({
- json: { ...defaultAppSettings, onboardingCompletedAt: null },
- });
-
- // "2026" reads as JSON, but this setting takes a string, so the raw text
- // has to win: the setting's own schema decides which reading applies.
- await runCommand(
- ["settings", "general", "onboardingCompletedAt", "2026"],
- register,
- );
-
- expect(put).toHaveBeenLastCalledWith({
- json: { ...defaultAppSettings, onboardingCompletedAt: "2026" },
+ "v1.settings.general.$put": vi.fn(async ({ json }) => json),
});
await expect(
@@ -134,61 +110,6 @@ describe("bb settings commands", () => {
});
});
- it("enables new onboarding before replaying the setup guide", async () => {
- const updateExperiments = vi.fn(async ({ json }) => json);
- const updateGeneralSettings = vi.fn(async ({ json }) => json);
- stubServerApi({
- "v1.system.config.$get": vi.fn(async () => ({
- generalSettings: {
- ...defaultAppSettings,
- onboardingCompletedAt: "2026-08-06T00:00:00.000Z",
- },
- experiments: defaultExperiments,
- })),
- "v1.settings.experiments.$put": updateExperiments,
- "v1.settings.general.$put": updateGeneralSettings,
- });
-
- await runCommand(["settings", "replay-onboarding"], register);
-
- expect(updateExperiments).toHaveBeenCalledWith({
- json: { ...defaultExperiments, newOnboarding: true },
- });
- expect(updateGeneralSettings).toHaveBeenCalledWith({
- json: { ...defaultAppSettings, onboardingCompletedAt: null },
- });
- expect(console.log).toHaveBeenCalledWith(
- "New onboarding is enabled; onboarding will show again",
- );
- });
-
- it("reports both replay side effects as JSON", async () => {
- stubServerApi({
- "v1.system.config.$get": vi.fn(async () => ({
- generalSettings: defaultAppSettings,
- experiments: defaultExperiments,
- })),
- "v1.settings.experiments.$put": vi.fn(async ({ json }) => json),
- "v1.settings.general.$put": vi.fn(async ({ json }) => json),
- });
-
- await runCommand(["settings", "replay-onboarding", "--json"], register);
-
- expect(console.log).toHaveBeenCalledWith(
- JSON.stringify(
- {
- experiments: { ...defaultExperiments, newOnboarding: true },
- generalSettings: {
- ...defaultAppSettings,
- onboardingCompletedAt: null,
- },
- },
- null,
- 2,
- ),
- );
- });
-
it("reads usage from a selected machine", async () => {
const getUsage = vi.fn(async () => ({
codex: { status: "unauthenticated" },
diff --git a/apps/cli/src/commands/settings.ts b/apps/cli/src/commands/settings.ts
index 45ba952250..81ca779c24 100644
--- a/apps/cli/src/commands/settings.ts
+++ b/apps/cli/src/commands/settings.ts
@@ -153,30 +153,6 @@ export function registerSettingsCommands(
}),
);
- settings
- .command("replay-onboarding")
- .description("Show the first-run setup guide again on the next app load")
- .option("--json", "Print machine-readable JSON output")
- .action(
- action(async (opts: JsonOptions) => {
- const sdk = createCliBbSdk(getUrl());
- const config = await sdk.system.config();
- let experiments = config.experiments;
- if (!config.experiments.newOnboarding) {
- experiments = await sdk.system.updateExperiments({
- ...config.experiments,
- newOnboarding: true,
- });
- }
- const generalSettings = await sdk.system.updateGeneralSettings({
- ...config.generalSettings,
- onboardingCompletedAt: null,
- });
- if (outputJson(opts, { experiments, generalSettings })) return;
- console.log("New onboarding is enabled; onboarding will show again");
- }),
- );
-
settings
.command("experiment ")
.description("Set an experiment value")
diff --git a/apps/desktop/scripts/smoke-packaged-app.mjs b/apps/desktop/scripts/smoke-packaged-app.mjs
index 0e0400d0de..140fd09965 100644
--- a/apps/desktop/scripts/smoke-packaged-app.mjs
+++ b/apps/desktop/scripts/smoke-packaged-app.mjs
@@ -145,7 +145,6 @@ async function startSmokeServer({
dataDir,
experiments: {
mobileApp: false,
- newOnboarding: false,
providerSessionReaping: false,
},
featureFlags: {
diff --git a/apps/desktop/test/preload-build.test.ts b/apps/desktop/test/preload-build.test.ts
index 844852885f..6cbe09808e 100644
--- a/apps/desktop/test/preload-build.test.ts
+++ b/apps/desktop/test/preload-build.test.ts
@@ -130,7 +130,6 @@ async function startDesktopSmokeServer(
changelogPreview: false,
editMessages: false,
mobileApp: false,
- newOnboarding: false,
providerSessionReaping: false,
},
featureFlags: {
diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts
index b48d641565..eaa10d07c6 100644
--- a/apps/host-daemon/src/command-dispatch.ts
+++ b/apps/host-daemon/src/command-dispatch.ts
@@ -53,7 +53,6 @@ import {
completeCodexInference,
transcribeCodexVoice,
} from "./codex-chatgpt-client.js";
-import { discoverRepos } from "./command-handlers/discover-repos.js";
import { getProviderUsage } from "./provider-usage.js";
import {
getKnownAcpAgentsStatus,
@@ -683,13 +682,6 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = {
env: providerCliEnvFromShellEnv(options.runtimeManager.getShellEnv()),
}),
"provider_cli.install": installProviderCliOnHost,
- "workspace.discover_repos": async (command, options) =>
- discoverRepos({
- maxDepth: command.maxDepth,
- sinceDays: command.sinceDays,
- limit: command.limit,
- env: options.runtimeManager.getShellEnv(),
- }),
"workspace.status": async (command, options) => {
const resolution = await resolveWorkspaceForCommand({
dataDir: options.dataDir,
diff --git a/apps/host-daemon/src/command-handlers/discover-repos.test.ts b/apps/host-daemon/src/command-handlers/discover-repos.test.ts
deleted file mode 100644
index 817451ddd2..0000000000
--- a/apps/host-daemon/src/command-handlers/discover-repos.test.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { discoverRepos } from "./discover-repos.js";
-
-/**
- * The walk's whole value is what it refuses to enter, so these cover the
- * skip rules rather than the happy path.
- */
-describe("discoverRepos", () => {
- let home: string;
-
- const makeRepo = async (relativePath: string) => {
- const gitDir = join(home, relativePath, ".git");
- await mkdir(gitDir, { recursive: true });
- await writeFile(join(gitDir, "HEAD"), "ref: refs/heads/main\n");
- };
-
- beforeEach(async () => {
- home = await mkdtemp(join(tmpdir(), "bb-discover-"));
- });
-
- afterEach(async () => {
- await rm(home, { recursive: true, force: true });
- });
-
- const run = () =>
- discoverRepos({
- maxDepth: 5,
- sinceDays: 3650,
- limit: 50,
- home,
- env: { PATH: "/nonexistent" },
- });
-
- it("stops descending at a repo root so nested checkouts are not listed", async () => {
- await makeRepo("projects/app");
- await makeRepo("projects/app/vendor/inner");
-
- const { repos } = await run();
-
- expect(repos.map((repo) => repo.name)).toEqual(["app"]);
- });
-
- it("skips dot-directories, which hold tool internals rather than projects", async () => {
- await makeRepo("projects/app");
- await makeRepo(".nvm/versions/thing");
- await makeRepo(".bb-dev/worktrees/copy");
-
- const { repos } = await run();
-
- expect(repos.map((repo) => repo.name)).toEqual(["app"]);
- });
-
- it("skips heavy build directories on the way down", async () => {
- await makeRepo("projects/app");
- await makeRepo("code/node_modules/pkg");
-
- const { repos } = await run();
-
- expect(repos.map((repo) => repo.name)).toEqual(["app"]);
- });
-
- it("drops repos older than the recency window", async () => {
- await makeRepo("projects/app");
- const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
-
- try {
- const { repos } = await discoverRepos({
- maxDepth: 5,
- sinceDays: 30,
- limit: 50,
- home,
- env: { PATH: "/nonexistent" },
- // A "now" far in the future puts the fixture outside the window.
- now: Date.now() + 400 * 86_400_000,
- });
-
- expect(repos).toEqual([]);
- const timerDelays = setTimeoutSpy.mock.calls.flatMap(([, delay]) =>
- typeof delay === "number" ? [delay] : [],
- );
- expect(timerDelays.length).toBeGreaterThan(0);
- expect(Math.max(...timerDelays)).toBeLessThanOrEqual(3_000);
- } finally {
- setTimeoutSpy.mockRestore();
- }
- });
-
- it("detects linked worktrees, whose .git is a file rather than a directory", async () => {
- // `git worktree add` and submodules both write a `.git` file pointing at
- // the real git dir. Treating only directories as repo markers walked
- // straight into them and returned nothing.
- await mkdir(join(home, "projects/linked"), { recursive: true });
- await writeFile(
- join(home, "projects/linked/.git"),
- "gitdir: /home/user/projects/app/.git/worktrees/linked\n",
- );
-
- const { repos } = await run();
-
- expect(repos.map((repo) => repo.name)).toEqual(["linked"]);
- });
-
- it("still returns repos when no agent history is available", async () => {
- await makeRepo("projects/app");
-
- const { repos, truncated } = await run();
-
- expect(truncated).toBe(false);
- expect(repos[0]?.agentSeen).toBe(false);
- expect(repos[0]?.agentSeenAt).toBeNull();
- });
-});
diff --git a/apps/host-daemon/src/command-handlers/discover-repos.ts b/apps/host-daemon/src/command-handlers/discover-repos.ts
deleted file mode 100644
index 339a225460..0000000000
--- a/apps/host-daemon/src/command-handlers/discover-repos.ts
+++ /dev/null
@@ -1,453 +0,0 @@
-import { spawn } from "node:child_process";
-import { readFile, stat } from "node:fs/promises";
-import { opendir } from "node:fs/promises";
-import { homedir } from "node:os";
-import { basename, join } from "node:path";
-import type {
- DiscoverReposResult,
- DiscoveredRepo,
-} from "@bb/host-daemon-contract";
-
-/**
- * Find candidate projects on this host: git repositories under the user's home
- * directory, ranked by how likely the user wants them in bb.
- *
- * The walk is cheap because of one rule: stop descending the moment a directory
- * contains `.git`. Everything below a repo root belongs to that repo, so we
- * never enter `node_modules`, `target`, `dist`, or any other build tree inside
- * a project. Measured on a real developer home directory this visits ~77
- * directories in ~5ms, versus ~5,100 for a naive `find -name .git -prune`.
- *
- * Cold page cache — not CPU — is the real cost, so the walk is time-boxed and
- * reports `truncated` rather than blocking onboarding on a slow or
- * network-mounted home directory.
- */
-
-/**
- * Races a filesystem call against the walk deadline. `opendir`/`stat` on a dead
- * network mount can block indefinitely with no abort signal, so a per-call
- * ceiling is the only thing that keeps discovery bounded.
- */
-async function withDeadline(
- operation: Promise,
- deadline: number,
- /** Releases a result that arrives after the deadline, so nothing leaks. */
- disposeLate?: (value: T) => void,
-): Promise {
- const remaining = deadline - Date.now();
- if (remaining <= 0) {
- void operation.then((value) => disposeLate?.(value)).catch(() => {});
- return null;
- }
- let timer: NodeJS.Timeout | undefined;
- let timedOut = false;
- try {
- const result = await Promise.race([
- operation.then((value) => {
- // The race already resolved null; this value has no owner.
- if (timedOut) disposeLate?.(value);
- return value;
- }),
- new Promise((resolve) => {
- timer = setTimeout(() => {
- timedOut = true;
- resolve(null);
- }, remaining);
- timer.unref?.();
- }),
- ]);
- return timedOut ? null : result;
- } catch {
- return null;
- } finally {
- if (timer) clearTimeout(timer);
- }
-}
-
-/** Directories that never contain a user project and are expensive to enter. */
-const SKIP_DIRECTORIES = new Set([
- "node_modules",
- "Library",
- "Applications",
- "target",
- "vendor",
- "dist",
- "build",
- "out",
- "venv",
- "__pycache__",
- "Pictures",
- "Music",
- "Movies",
-]);
-
-const WALK_BUDGET_MS = 3_000;
-/**
- * Directories opened at once. Unbounded recursion over a wide home directory
- * can start thousands of `opendir` calls together and reach the process file
- * limit, which would disrupt active agent work on the same daemon.
- */
-const WALK_CONCURRENCY = 32;
-const AGENT_HISTORY_BUDGET_MS = 2_000;
-
-interface FoundRepo {
- path: string;
- lastActivityMs: number;
-}
-
-/**
- * Walk `root` breadth-first to `maxDepth`, stopping at each repo root.
- *
- * Dot-directories are skipped deliberately, and not only for speed: on a real
- * machine the repos they hide were inside `.nvm`, `.codex/.tmp`, and `.bb-dev`
- * — tool internals, never user projects.
- */
-async function walkForRepos(
- root: string,
- maxDepth: number,
- deadline: number,
-): Promise<{ repos: FoundRepo[]; truncated: boolean }> {
- const repos: FoundRepo[] = [];
- let truncated = false;
-
- const walk = async (dir: string, depth: number): Promise => {
- if (depth > maxDepth) return;
- if (Date.now() > deadline) {
- truncated = true;
- return;
- }
-
- // Unreadable or hung directory (permissions, broken mount) is not an error
- // here — it just contributes nothing.
- const handle = await withDeadline(opendir(dir), deadline, (late) => {
- void late.close().catch(() => {});
- });
- if (handle === null) {
- if (Date.now() > deadline) truncated = true;
- return;
- }
-
- const children: string[] = [];
- let isRepo = false;
- try {
- for await (const entry of handle) {
- // Linked worktrees and submodules carry `.git` as a file pointing at
- // the real git dir, so the name check has to come before the
- // directory check or those repos are never detected.
- if (entry.name === ".git") {
- isRepo = true;
- continue;
- }
- if (!entry.isDirectory()) continue;
- if (entry.name.startsWith(".")) continue;
- if (SKIP_DIRECTORIES.has(entry.name)) continue;
- children.push(entry.name);
- }
- } catch {
- return;
- }
-
- if (isRepo) {
- // Stop here. Nested repos below a repo root are submodules or vendored
- // copies, not separate projects the user thinks about.
- // `.git/HEAD` is the best activity signal, but a linked worktree or
- // submodule has `.git` as a file, so fall back to stat-ing the marker
- // itself. Leaving mtime 0 would drop those repos at the recency filter.
- const head = await withDeadline(
- stat(join(dir, ".git", "HEAD")),
- deadline,
- );
- const marker =
- head ?? (await withDeadline(stat(join(dir, ".git")), deadline));
- const lastActivityMs = marker?.mtimeMs ?? 0;
- repos.push({ path: dir, lastActivityMs });
- return;
- }
-
- for (let index = 0; index < children.length; index += WALK_CONCURRENCY) {
- if (Date.now() > deadline) {
- truncated = true;
- return;
- }
- await Promise.all(
- children
- .slice(index, index + WALK_CONCURRENCY)
- .map((child) => walk(join(dir, child), depth + 1)),
- );
- }
- };
-
- await walk(root, 0);
- return { repos, truncated };
-}
-
-/** `git config --get remote.origin.url`, read straight from the config file. */
-async function readOriginUrl(repoPath: string): Promise {
- let config: string;
- try {
- config = await readFile(join(repoPath, ".git", "config"), "utf8");
- } catch {
- return null;
- }
- const section = config.split(/\[remote "origin"\]/u)[1];
- if (section === undefined) return null;
- const match = /^\s*url\s*=\s*(.+)$/mu.exec(section.split("[")[0] ?? "");
- return match?.[1]?.trim() ?? null;
-}
-
-/**
- * Normalize a remote URL so `git@github.com:o/r.git` and
- * `https://github.com/o/r` collapse to the same key. Only used to join agent
- * history to repos, never shown to the user.
- */
-function normalizeOrigin(url: string | null): string | null {
- if (!url) return null;
- return url
- .trim()
- .replace(/\.git$/u, "")
- .replace(/^git@([^:]+):/u, "https://$1/")
- .replace(/^ssh:\/\/git@/u, "https://")
- .replace(/\/+$/u, "")
- .toLowerCase();
-}
-
-/**
- * Directories where Claude Code has been run. `~/.claude.json` holds a flat
- * `projects` map keyed by absolute path, with no recency.
- */
-async function readClaudeHistory(home: string): Promise