diff --git a/.gitignore b/.gitignore index 26a701af7..44c0ba001 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ output.txt # turborepo .turbo .claude/ +.claude-tmp/ diff --git a/README.md b/README.md index 399d1b399..c1a2574f3 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,10 @@ This monorepo contains the packaged app plus the runtime services it bundles: | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | | [`packages/bb-app`](./packages/bb-app) | Published npm package and `npx bb-app@latest` launcher. | | [`apps/app`](./apps/app) | Web UI for inspecting projects, threads, environments, and running work. | -| [`apps/server`](./apps/server) | HTTP API, WebSocket notifications, state management, and server-owned product policy. | -| [`apps/host-daemon`](./apps/host-daemon) | Host-local runtime that provisions workspaces and runs provider processes. | +| [`apps/server`](./apps/server) | HTTP API, WebSocket notifications, state management, workspace provisioning, and the agent provider runtime. | | [`apps/cli`](./apps/cli) | Scriptable `bb` CLI for users and agents. | | [`packages/server-contract`](./packages/server-contract) | HTTP and WebSocket contract between clients and the server. | -| [`packages/host-daemon-contract`](./packages/host-daemon-contract) | Command/event contract between the server and host daemons. | +| [`packages/host-daemon-contract`](./packages/host-daemon-contract) | Schemas for the server's machine-local API used by the app and CLI (folder picker, open-in-editor, provider CLI status). | ## Development @@ -85,17 +84,16 @@ Development behavior is intentionally split: - the app hot reloads itself - the server does not hot reload -- the host daemon does not hot reload -When you want the server and host daemon to pick up the latest build output, use: +When you want the server to pick up the latest build output, use: ```bash pnpm dev:restart -pnpm dev:restart-server -pnpm dev:restart-host-daemon ``` -These rebuild first, then restart only the targeted stateful services. +This rebuilds first, then restarts the server. A dev restart interrupts any +in-flight agent turns; threads show the standard interrupted state and you +re-send. To test the release-style package launcher from a source checkout: @@ -107,18 +105,6 @@ That builds the local `bb-app` package artifacts and runs `packages/bb-app/dist/bb-app.js`, matching the published `npx bb-app@latest` path without downloading from npm. -To test an additional host against that dev server, use: - -```bash -BB_HOST_DAEMON_PORT=39999 pnpm dev:host-daemon -- --auto-join -``` - -That runs a second host daemon against the dev server and stores its state -under the current checkout's dev data directory. The extra daemon requires an -explicit unused `BB_HOST_DAEMON_PORT` so it does not collide with the primary -dev daemon. On first run, it requests local enrollment from the dev server; -after enrollment, the daemon persists its auth state locally. - ```bash pnpm bb --help # built CLI, targets the default/prod instance pnpm reset # clear production state @@ -135,36 +121,31 @@ These reset commands prompt for confirmation before deleting anything. ### The runtime pieces -| Component | Role | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Server** | Central hub. Stores all state in a SQLite database, exposes an HTTP API, and pushes change notifications over WebSocket. Stateless itself — the DB is the source of truth. Routes work to hosts by queuing commands. | -| **Host daemon** | Runs on each host (your laptop or a remote server). Connects to the server, picks up commands, provisions workspaces, runs agent provider processes, and streams events back. Exposes a local HTTP API for the app and CLI to do machine-local things (open editor, pick folders, check daemon status). | -| **App** | Web UI for inspecting projects and threads, following progress, and steering work. | -| **CLI** (`bb`) | First-class interface for both users and agents. Same capabilities as the app, scriptable. | +| Component | Role | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Server** | One process that runs everything: stores all state in a SQLite database, exposes an HTTP API, pushes change notifications over WebSocket, provisions workspaces, and runs the agent provider processes directly. The DB is the source of truth. It also serves a machine-local API for the app and CLI (open editor, pick folders, provider CLI status). | +| **App** | Web UI for inspecting projects and threads, following progress, and steering work. | +| **CLI** (`bb`) | First-class interface for both users and agents. Same capabilities as the app, scriptable. | ### Data model The core entities and how they relate: -**Project** — the top-level container, usually mapped to a repository. A project has one or more **sources** that say where its code lives: local paths on specific hosts. +**Project** — the top-level container, usually mapped to a repository. A project has one or more **sources** that say where its code lives: local paths on the machine running bb. **Thread** — the unit of work. Each thread tracks a conversation with an agent provider, has lifecycle state, and produces an append-only stream of **events** (messages, tool calls, file changes, etc.). Threads can be **standard** (does work directly) or **manager** (coordinates other threads). Threads can own child threads for delegation. -**Environment** — the execution context for a thread. It binds a workspace (a directory on disk) to a host. An environment can be **unmanaged** (point at an existing directory), or **managed**. Environments managed by bb will be cleaned up when there are no longer any unarchived threads using it. Multiple threads can share an environment. - -**Host** — a long-lived machine that runs a daemon, such as your laptop or a remote server. - -**Commands and events** — the server talks to daemons by queuing commands (provision an environment, start a thread, stop a thread). Daemons report back by posting events. This is an asynchronous command/event protocol — the server queues work, the daemon picks it up, results flow back as events. +**Environment** — the execution context for a thread: a workspace (a directory on disk) on the machine running bb. An environment can be **unmanaged** (point at an existing directory), or **managed**. Environments managed by bb will be cleaned up when there are no longer any unarchived threads using it. Multiple threads can share an environment. ### Contracts and boundaries -Two contract packages define the boundaries between components: +Two contract packages define the boundaries between clients and the server: **`@bb/server-contract`** — the HTTP + WebSocket API between clients (app, CLI) and the server. Route schemas, request/response types, WebSocket notification types. -**`@bb/host-daemon-contract`** — the protocol between the server and host daemons. Command types, event types, session lifecycle, the local API for app/CLI. +**`@bb/host-daemon-contract`** — the schemas for the server's machine-local API: the endpoints the app and CLI use for machine-local things like the folder picker, open-in-editor, and provider CLI status. -Implementation packages never import across these boundaries. The server doesn't know how workspaces are provisioned. The daemon doesn't know about threads or projects beyond what commands tell it. +Clients only talk to the server through these contracts — they never reach into server internals. ## Further Reading @@ -172,7 +153,6 @@ Implementation packages never import across these boundaries. The server doesn't - [Platform support](docs/platform-support.md) - [Configuration](docs/configuration.md) - [Using bb on multiple devices](docs/multiple-devices.md) -- [Adding another host](docs/additional-hosts.md) - [Worktrees and setup scripts](docs/worktrees.md) ## Contributing diff --git a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx index 0c9ee5686..ff4f5aea5 100644 --- a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx +++ b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx @@ -24,6 +24,7 @@ const HTML_APP: AppDetail = { entry: { path: "index.html", kind: "html" }, capabilities: ["data", "message"], icon: { kind: "builtin", name: "ListTodo" }, + source: null, appsRootPath: "/tmp/bb-data/apps", appRootPath: "/tmp/bb-data/apps/status", appDataPath: "/tmp/bb-data/apps/status/data", @@ -35,6 +36,7 @@ const MARKDOWN_APP: AppDetail = { entry: { path: "docs/index.md", kind: "md" }, capabilities: [], icon: { kind: "builtin", name: "GridView" }, + source: null, appsRootPath: "/tmp/bb-data/apps", appRootPath: "/tmp/bb-data/apps/readme", appDataPath: "/tmp/bb-data/apps/readme/data", diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx index 1870b3ab8..b405fcc9a 100644 --- a/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx +++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx @@ -112,6 +112,7 @@ const APP_SUGGESTION = { entry: { path: "index.html", kind: "html" }, capabilities: ["data", "message"], icon: { kind: "builtin", name: "ListTodo" }, + source: null, }, applicationId: "status", name: "Review Board", diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx index 358b97d12..7e8a0cf07 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx @@ -97,6 +97,7 @@ const APPS_RESPONSE: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data", "message"], icon: { kind: "builtin", name: "ListTodo" }, + source: null, }, ]; @@ -107,6 +108,7 @@ const APPS_ROW_APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data", "message"], icon: { kind: "builtin", name: "ListTodo" }, + source: null, }, { applicationId: "app_workspace_map", @@ -114,6 +116,7 @@ const APPS_ROW_APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data"], icon: { kind: "builtin", name: "GridView" }, + source: null, }, { applicationId: "app_release_notes", @@ -121,6 +124,7 @@ const APPS_ROW_APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["message"], icon: { kind: "builtin", name: "File" }, + source: null, }, ]; diff --git a/apps/app/src/components/settings/AppSourcesSection.tsx b/apps/app/src/components/settings/AppSourcesSection.tsx new file mode 100644 index 000000000..232fe379d --- /dev/null +++ b/apps/app/src/components/settings/AppSourcesSection.tsx @@ -0,0 +1,383 @@ +import { useId, useState, type FormEvent } from "react"; +import { timeAgo } from "@bb/core-ui"; +import type { AppSourceAppState, AppSourceStatus } from "@bb/server-contract"; +import { Button } from "@/components/ui/button.js"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.js"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.js"; +import { Icon } from "@/components/ui/icon.js"; +import { Input } from "@/components/ui/input.js"; +import { Pill } from "@/components/ui/pill.js"; +import { + SettingsRow, + SettingsRowList, + SettingsSection, +} from "@/components/ui/settings-section.js"; +import { useAppSources } from "@/hooks/queries/thread-queries"; +import { + useAddAppSource, + useRemoveAppSource, + useSyncAppSource, +} from "@/hooks/mutations/app-source-mutations"; + +interface AppSourceAppStatePillProps { + state: AppSourceAppState; +} + +function AppSourceAppStatePill({ state }: AppSourceAppStatePillProps) { + const variant = + state.status === "installed" + ? "secondary" + : state.status === "modified" + ? "emphasis" + : "destructive"; + return ( + + + {state.applicationId} · {state.status} + + + ); +} + +interface AddAppSourceDialogProps { + open: boolean; + pending: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (args: { origin: string; name?: string; ref?: string }) => void; +} + +function AddAppSourceDialog({ + open, + pending, + onOpenChange, + onAdd, +}: AddAppSourceDialogProps) { + const originId = useId(); + const nameId = useId(); + const refId = useId(); + const [origin, setOrigin] = useState(""); + const [name, setName] = useState(""); + const [ref, setRef] = useState(""); + const [validationMessage, setValidationMessage] = useState( + null, + ); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (pending) return; + const trimmedOrigin = origin.trim(); + if (!trimmedOrigin) { + setValidationMessage("Enter a git URL or local path."); + return; + } + const trimmedName = name.trim(); + const trimmedRef = ref.trim(); + onAdd({ + origin: trimmedOrigin, + ...(trimmedName ? { name: trimmedName } : {}), + ...(trimmedRef ? { ref: trimmedRef } : {}), + }); + }; + + return ( + + +
+ + Add app source + + Install every app from a git repo and keep them updated with + manual syncs. Apps from a source serve browser code and inject + agent skills — only add repos you trust. + + +
+
+ + { + setOrigin(event.target.value); + setValidationMessage(null); + }} + /> +
+
+ + setName(event.target.value)} + /> +
+
+ + setRef(event.target.value)} + /> +
+ {validationMessage ? ( +

{validationMessage}

+ ) : null} +
+ + + +
+
+
+ ); +} + +interface ConfirmAppSourceActionDialogProps { + target: AppSourceStatus | null; + title: string; + description: string; + confirmLabel: string; + pending: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (source: AppSourceStatus) => void; +} + +function ConfirmAppSourceActionDialog({ + target, + title, + description, + confirmLabel, + pending, + onOpenChange, + onConfirm, +}: ConfirmAppSourceActionDialogProps) { + return ( + + + {target ? ( + <> + + {title} + {description} + + + + + + ) : null} + + + ); +} + +function formatLastSynced(source: AppSourceStatus): string { + if (source.syncing) { + return "Syncing…"; + } + if (source.lastSyncedAt === null) { + return "Never synced"; + } + return `Synced ${timeAgo(Date.parse(source.lastSyncedAt))}`; +} + +export function AppSourcesSection() { + const { data: sources = [], isLoading } = useAppSources(); + const addAppSource = useAddAppSource(); + const syncAppSource = useSyncAppSource(); + const removeAppSource = useRemoveAppSource(); + const [addOpen, setAddOpen] = useState(false); + const [removeTarget, setRemoveTarget] = useState( + null, + ); + const [forceSyncTarget, setForceSyncTarget] = + useState(null); + + return ( + setAddOpen(true)}> + Add source + + } + > + {isLoading ? ( +

Loading…

+ ) : ( + + {sources.length === 0 ? ( + + + No app sources. Add a git repo of apps to install them. + + + ) : ( + sources.map((source) => ( + +
+
+ + {source.name} + + {source.ref !== null ? ( + {source.ref} + ) : null} + + {formatLastSynced(source)} + {source.lastCommitSha !== null + ? ` · ${source.lastCommitSha.slice(0, 8)}` + : null} + +
+

+ {source.origin} +

+ {source.lastError !== null ? ( +

+ {source.lastError} +

+ ) : null} + {source.apps.length > 0 ? ( +
+ {source.apps.map((app) => ( + + ))} +
+ ) : null} +
+ + + + + + + syncAppSource.mutate({ + name: source.name, + force: false, + }) + } + > + Sync now + + {source.apps.some((app) => app.status === "modified") ? ( + setForceSyncTarget(source)} + > + Force sync… + + ) : null} + setRemoveTarget(source)} + > + Remove… + + + +
+ )) + )} +
+ )} + + addAppSource.mutate(args, { onSuccess: () => setAddOpen(false) }) + } + /> + { + if (!open) setRemoveTarget(null); + }} + onConfirm={(source) => + removeAppSource.mutate(source.name, { + onSuccess: () => setRemoveTarget(null), + }) + } + /> + { + if (!open) setForceSyncTarget(null); + }} + onConfirm={(source) => + syncAppSource.mutate( + { name: source.name, force: true }, + { onSuccess: () => setForceSyncTarget(null) }, + ) + } + /> +
+ ); +} diff --git a/apps/app/src/components/sidebar/ProjectList.test.tsx b/apps/app/src/components/sidebar/ProjectList.test.tsx index 721bebd32..6f3283d8c 100644 --- a/apps/app/src/components/sidebar/ProjectList.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.test.tsx @@ -163,6 +163,7 @@ function makeApp({ applicationId, name, icon }: MakeAppArgs): AppSummary { entry: { path: "index.html", kind: "html" }, capabilities: [], icon, + source: null, }; } diff --git a/apps/app/src/components/sidebar/SidebarAppsSection.test.tsx b/apps/app/src/components/sidebar/SidebarAppsSection.test.tsx index 409d23fb8..d09161ea2 100644 --- a/apps/app/src/components/sidebar/SidebarAppsSection.test.tsx +++ b/apps/app/src/components/sidebar/SidebarAppsSection.test.tsx @@ -13,6 +13,7 @@ const APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data"], icon: { kind: "builtin", name: "GridView" }, + source: null, }, { applicationId: "beta", @@ -20,6 +21,7 @@ const APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data"], icon: { kind: "builtin", name: "GridView" }, + source: null, }, ]; diff --git a/apps/app/src/components/sidebar/SidebarAppsSection.tsx b/apps/app/src/components/sidebar/SidebarAppsSection.tsx index 4121b987c..68f5e8610 100644 --- a/apps/app/src/components/sidebar/SidebarAppsSection.tsx +++ b/apps/app/src/components/sidebar/SidebarAppsSection.tsx @@ -2,6 +2,7 @@ import { memo, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import type { AppSummary } from "@bb/server-contract"; import { ResolvedAppIcon } from "@/components/secondary-panel/AppIcon"; +import { Icon } from "@/components/ui/icon.js"; import { COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, COARSE_POINTER_GLYPH_BOX_CLASS, @@ -70,6 +71,15 @@ const SidebarAppRow = memo(function SidebarAppRow({ /> {app.name} + {app.source !== null ? ( + // Source-managed apps update on source sync; the glyph marks them as + // externally owned without taking row space from the name. +