diff --git a/.bb/skills/verify-bb/features/plugin-inline-vis.md b/.bb/skills/verify-bb/features/plugin-inline-vis.md
index b17b6f0c4a6..fdfd12966df 100644
--- a/.bb/skills/verify-bb/features/plugin-inline-vis.md
+++ b/.bb/skills/verify-bb/features/plugin-inline-vis.md
@@ -22,9 +22,10 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs.
| Feature | Drive | Observable success |
| --- | --- | --- |
-| Render and open | Emit the directive with a relative .html/.htm path, open its header file action, and reload the thread. | The file renders inline and opens from the correct thread workspace. |
+| Render and open | Emit the directive with a relative .html/.htm path, open the action that appears over the page on hover, and reload the thread. | The file renders inline with no card or border, follows the theme, and opens from the correct thread workspace. |
+| Fenced block | Have the reply contain an `inline-vis` fenced code block whose body is an HTML page; watch it stream, then use the hover action. | A status line shows while the block is written; the page appears when the fence closes and is not reloaded as the reply continues; the action reveals the source; an unclosed block stays a code block. |
| Relative assets | Reference a fixture stylesheet, script, and image beside the HTML. | Assets resolve from the authorized workspace location and render without unrelated filesystem access. |
-| Height | Try omitted height and values around the declared 120–1200 bounds. | Omitted height is 224px; whole numbers from 120 to 1200 are accepted. Invalid and out-of-range values show an explicit error instead of clamping. |
+| Height | Try omitted height and values around the declared 80–2000 bounds. | With no height an HTML frame fits its page and a Markdown card previews 256px, expanding in place with "Show more"; whole numbers from 80 to 2000 are accepted (a cap for HTML, the preview height for Markdown). Invalid and out-of-range values show an explicit error instead of clamping. |
| Sandbox | Have the fixture attempt to read parent DOM, cookies, and origin storage; report only success/failure. | The sandbox blocks parent-origin access while allowing the intended visualization interaction. |
| Invalid artifacts | Try missing, non-HTML, invalid UTF-8, and greater-than-5MiB fixtures. | Clear artifact errors replace the frame; no silent blank success or unrestricted fallback occurs. |
@@ -39,4 +40,5 @@ External account changes use authorized disposable targets.
## Maintenance notes
-- Omitted height uses 224px. Whole-number heights from 120 through 1200 are accepted; out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/app.tsx:17`, `plugins/inline-vis/app.tsx:30`.
+- An HTML frame fits its page (80–2000px) and `height` caps it; a Markdown card previews 256px and expands in place. Out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/frame.ts` (`FRAME_MIN_HEIGHT`, `FRAME_MAX_HEIGHT`, `frameHeight`), `plugins/inline-vis/app.tsx` (`parseRequestedHeight`).
+- The borderless frame, the 80–2000 bounds, the fenced-block recipe, and the expanding Markdown card date from 2026-10-12 and are not covered by the 2026-09-05 validation run.
diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx
index 20eba9363d0..d1b150124a3 100644
--- a/apps/app/src/components/commands/CommandPalette.tsx
+++ b/apps/app/src/components/commands/CommandPalette.tsx
@@ -1,3 +1,4 @@
+import { recordTelemetryEvent } from "@/components/onboarding/onboarding-telemetry";
import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import { defineSplit } from "@/lib/define-split";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -188,6 +189,15 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) {
(invocation) => {
const target = invocationTarget(invocation);
prepareOpen(target);
+ recordTelemetryEvent({
+ name: "feature_used",
+ properties: {
+ feature: "search",
+ feature_scope: "core",
+ interaction: "opened",
+ trigger: "unknown",
+ },
+ });
setSearchingThreads(true);
setOpen(true);
return true;
diff --git a/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx b/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx
index 0f7c7b61b86..113f17c62a4 100644
--- a/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx
+++ b/apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx
@@ -35,25 +35,28 @@ function renderDialog({
describe("ThreadArchiveDialog", () => {
it("announces the cascade with singular and plural child counts", () => {
const { view } = renderDialog({ childThreadCount: 1 });
- expect(
- screen.getByText(/1 child thread will be archived with this thread\./),
- ).toBeTruthy();
+ expect(screen.getByRole("dialog").textContent).toContain(
+ "Its 1 child thread will be archived too.",
+ );
+ expect(screen.getByText("1 child thread").className).toContain(
+ "font-semibold",
+ );
view.unmount();
renderDialog({ childThreadCount: 3 });
- expect(
- screen.getByText(/3 child threads will be archived with this thread\./),
- ).toBeTruthy();
+ expect(screen.getByRole("dialog").textContent).toContain(
+ "Its 3 child threads will be archived too.",
+ );
});
- it("names the total number of threads in the title and action", () => {
+ it("names the single thread in the title and action", () => {
renderDialog({ childThreadCount: 3 });
expect(
- screen.getByRole("heading", { name: "Archive 4 threads?" }),
+ screen.getByRole("heading", { name: "Archive thread?" }),
).toBeTruthy();
expect(
- screen.getByRole("button", { name: "Archive 4 threads" }),
+ screen.getByRole("button", { name: "Archive" }),
).toBeTruthy();
});
@@ -73,7 +76,7 @@ describe("ThreadArchiveDialog", () => {
it("focuses the archive action when opened for keyboard confirmation", () => {
renderDialog();
expect(document.activeElement).toBe(
- screen.getByRole("button", { name: "Archive 2 threads" }),
+ screen.getByRole("button", { name: "Archive" }),
);
});
@@ -86,7 +89,7 @@ describe("ThreadArchiveDialog", () => {
it("ignores confirmation while archiving is pending", () => {
const { onArchive } = renderDialog({ pending: true });
- fireEvent.click(screen.getByRole("button", { name: "Archive 2 threads" }));
+ fireEvent.click(screen.getByRole("button", { name: "Archive" }));
expect(onArchive).not.toHaveBeenCalled();
});
@@ -99,7 +102,7 @@ describe("ThreadArchiveDialog", () => {
expect(onArchive).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
- fireEvent.click(screen.getByRole("button", { name: "Archive 3 threads" }));
+ fireEvent.click(screen.getByRole("button", { name: "Archive" }));
expect(onArchive).toHaveBeenCalledWith({ thread, childThreadCount: 2 });
});
});
diff --git a/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx b/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
index 4e4fe745e58..885026ce528 100644
--- a/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
+++ b/apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
@@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@bb/shared-ui/dialog";
+import { formatChildThreadCount } from "@/lib/child-thread-copy";
export interface ThreadArchiveDialogTarget {
thread: Thread;
@@ -68,16 +69,10 @@ export function ThreadArchiveDialogContent({
onArchive,
}: ThreadArchiveDialogContentProps) {
const { childThreadCount } = target;
- const archivedThreadCount = childThreadCount + 1;
const active =
target.thread.status === "starting" ||
target.thread.status === "active" ||
target.thread.status === "stopping";
- const sentences = [
- active ? "This will stop current work." : null,
- `${childThreadCount} child ${childThreadCount === 1 ? "thread" : "threads"} will be archived with this thread.`,
- "Archived threads stay available and can be unarchived.",
- ].filter((sentence): sentence is string => sentence !== null);
return (
diff --git a/apps/app/src/components/dialogs/ThreadDeleteDialog.tsx b/apps/app/src/components/dialogs/ThreadDeleteDialog.tsx
index 02e61c012f1..7d8abb58d31 100644
--- a/apps/app/src/components/dialogs/ThreadDeleteDialog.tsx
+++ b/apps/app/src/components/dialogs/ThreadDeleteDialog.tsx
@@ -3,6 +3,7 @@ import {
ConfirmDeleteDialog,
ConfirmDeleteDialogContent,
} from "@bb/shared-ui/confirm-delete-dialog";
+import { formatChildThreadCount } from "@/lib/child-thread-copy";
export interface ThreadDeleteDialogTarget {
thread: Thread;
@@ -50,15 +51,24 @@ export function ThreadDeleteDialogContent({
onDelete,
}: ThreadDeleteDialogContentProps) {
const label = "thread";
- const sentences = [
- target.childThreadCount ? "Child threads will be deleted." : null,
- "This action cannot be undone.",
- ].filter((part): part is string => part !== null);
return (
+ {target.childThreadCount ? (
+ <>
+ Its{" "}
+
+ {formatChildThreadCount(target.childThreadCount)}
+ {" "}
+ will be deleted too.{" "}
+ >
+ ) : null}
+ This action cannot be undone.
+ >
+ }
confirmLabel={`Delete ${label}`}
pending={pending}
onConfirm={() => onDelete(target)}
diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx
index fc5e07d624b..5bc3e0ab36e 100644
--- a/apps/app/src/components/layout/AppLayout.tsx
+++ b/apps/app/src/components/layout/AppLayout.tsx
@@ -116,6 +116,7 @@ import { useMobileVisualViewportHeight } from "./useMobileVisualViewportHeight";
import { isIOSWebKit } from "@/lib/ios-webkit";
import { wsManager } from "@/lib/ws";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
+import { useSplitViewTelemetry } from "@/lib/split-layout/useSplitViewTelemetry";
import { findPaneByThread } from "@/lib/split-layout";
import { applyThreadOpenToLayout } from "@/views/thread-detail/splitThreadNavigation";
import { useAppSettingsRouteMemory } from "@/hooks/useAppSettingsRouteMemory";
@@ -526,6 +527,7 @@ export function AppLayout({ children }: AppLayoutProps) {
useServerDaemonLogsCommand();
useDataDirectoryCommand();
useWindowReloadCommand();
+ useSplitViewTelemetry();
usePluginSafeModeCommands();
usePluginCachePruneCommand();
const archivedSectionId = isArchivedView
diff --git a/apps/app/src/components/onboarding/OnboardingFlow.tsx b/apps/app/src/components/onboarding/OnboardingFlow.tsx
index f61a8eed9c6..2038e4ba40a 100644
--- a/apps/app/src/components/onboarding/OnboardingFlow.tsx
+++ b/apps/app/src/components/onboarding/OnboardingFlow.tsx
@@ -255,11 +255,13 @@ interface SignInSession {
function AgentStepContainer({
chrome,
+ entry,
hostId,
hostName,
onContinue,
}: {
chrome: StepChrome;
+ entry: OnboardingEntry;
hostId: string | null;
hostName: string | null;
onContinue: () => void;
@@ -285,14 +287,21 @@ function AgentStepContainer({
const { mutate: closeTerminalMutate } = closeTerminal;
const { mutateAsync: createTerminalAsync } = createTerminal;
- const endSignIn = useCallback(() => {
- const current = signInRef.current;
- if (current === null) return;
- signInRef.current = null;
- setSignIn(null);
- closeTerminalMutate({ mode: "force", terminalId: current.session.id });
- void refetchStates();
- }, [closeTerminalMutate, refetchStates]);
+ const endSignIn = useCallback(
+ (outcome: "ready" | "exited" | "failed" | "cancelled" = "cancelled") => {
+ const current = signInRef.current;
+ if (current === null) return;
+ signInRef.current = null;
+ recordTelemetryEvent({
+ name: "onboarding_sign_in",
+ properties: { entry, outcome },
+ });
+ setSignIn(null);
+ closeTerminalMutate({ mode: "force", terminalId: current.session.id });
+ void refetchStates();
+ },
+ [closeTerminalMutate, entry, refetchStates],
+ );
const signInRequestRef = useRef(0);
useEffect(
@@ -309,7 +318,7 @@ function AgentStepContainer({
if (current !== null && exitCode !== 0) {
setSignInFailedProviderId(current.providerId);
}
- endSignIn();
+ endSignIn(exitCode === 0 ? "exited" : "failed");
},
[endSignIn],
);
@@ -329,7 +338,7 @@ function AgentStepContainer({
states?.find((state) => state.providerId === signingInProviderId)
?.status === "ready";
useEffect(() => {
- if (signingInProviderReady) endSignIn();
+ if (signingInProviderReady) endSignIn("ready");
}, [endSignIn, signingInProviderReady]);
const { runningJobKey } = installRunner;
@@ -351,6 +360,10 @@ function AgentStepContainer({
setSignInFailedProviderId(null);
signInRequestRef.current += 1;
const request = signInRequestRef.current;
+ recordTelemetryEvent({
+ name: "onboarding_sign_in",
+ properties: { entry, outcome: "started" },
+ });
const title = `Sign in to ${state.displayName}`;
await closeAbandonedSignInTerminals({ hostId, title });
let session: TerminalSession;
@@ -363,9 +376,17 @@ function AgentStepContainer({
target: { kind: "host_path", hostId, cwd: null },
});
} catch {
+ recordTelemetryEvent({
+ name: "onboarding_sign_in",
+ properties: { entry, outcome: "failed" },
+ });
return;
}
if (signInRequestRef.current !== request) {
+ recordTelemetryEvent({
+ name: "onboarding_sign_in",
+ properties: { entry, outcome: "cancelled" },
+ });
void sdk.terminals
.close({ mode: "force", terminalId: session.id })
.catch(() => null);
@@ -478,7 +499,7 @@ function AgentStepContainer({
onSignIn={(providerId) => void startSignIn(providerId)}
onInstall={startInstall}
onViewInstallLog={viewInstallLog}
- onCancelSignIn={endSignIn}
+ onCancelSignIn={() => endSignIn()}
onRecheck={() => {
void refetchStates();
void cliStatusQuery.refetch();
@@ -912,11 +933,37 @@ export function OnboardingFlow({
const primaryHost = usePrimaryHost();
const hostId = primaryHost?.id ?? null;
useHostDiscoveredRepos(hostId);
- const agentStates = useSystemProviderStates({
+ const agentStatesQuery = useSystemProviderStates({
enabled: hostId !== null,
poll: false,
...(hostId === null ? {} : { hostId }),
- }).data?.providers;
+ });
+ const agentStates = agentStatesQuery.data?.providers;
+ const agentStateRef = useRef(null);
+ useEffect(() => {
+ if (agentStates === undefined && !agentStatesQuery.isError) return;
+ const knownStates = agentStatesQuery.isError ? undefined : agentStates;
+ const hasUnknown =
+ knownStates?.some((state) => state.status === "unknown") ?? true;
+ const installed =
+ knownStates?.some(
+ (state) =>
+ state.status !== "not_installed" && state.status !== "unknown",
+ ) ?? false;
+ const properties = {
+ entry,
+ agent_installed: installed ? true : hasUnknown ? null : false,
+ agent_ready: hasReadyAgent(knownStates)
+ ? true
+ : hasUnknown
+ ? null
+ : false,
+ };
+ const key = JSON.stringify(properties);
+ if (agentStateRef.current === key) return;
+ agentStateRef.current = key;
+ recordTelemetryEvent({ name: "onboarding_agent_state", properties });
+ }, [agentStates, agentStatesQuery.isError, entry]);
const startReportedRef = useRef(entry !== "first_run");
useEffect(() => {
if (startReportedRef.current || agentStates === undefined) return;
@@ -994,6 +1041,7 @@ export function OnboardingFlow({
return (
undefined);
+ try {
+ void sdk.system
+ .experimental_recordTelemetryEvent(event)
+ .catch(() => undefined);
+ } catch {}
}
diff --git a/apps/app/src/components/pickers/ParentThreadPicker.tsx b/apps/app/src/components/pickers/ParentThreadPicker.tsx
index e408ea61343..49c9760a314 100644
--- a/apps/app/src/components/pickers/ParentThreadPicker.tsx
+++ b/apps/app/src/components/pickers/ParentThreadPicker.tsx
@@ -141,7 +141,7 @@ export function ParentThreadPicker({
No matching threads.
) : (
-
+
{filteredOptions.map((option) => (
({
}));
vi.mock("@/lib/sdk", () => ({
- sdk: { projects: { attachments: { copy: mocks.copyAttachments } } },
+ sdk: {
+ projects: { attachments: { copy: mocks.copyAttachments } },
+ system: {
+ experimental_recordTelemetryEvent: vi
+ .fn()
+ .mockRejectedValue(new Error("Telemetry unavailable")),
+ },
+ },
}));
const PROJECT = makeProjectWithThreadsResponse({
diff --git a/apps/app/src/components/plugin/PluginSlotMount.tsx b/apps/app/src/components/plugin/PluginSlotMount.tsx
index 2c49682d295..acd4305d2c0 100644
--- a/apps/app/src/components/plugin/PluginSlotMount.tsx
+++ b/apps/app/src/components/plugin/PluginSlotMount.tsx
@@ -8,7 +8,17 @@ import {
type PluginSlotOwnershipRegistry,
} from "./plugin-context";
-const crashedSlotInstances = new Set();
+const crashedSlotInstances = new Map();
+
+function isSlotInstanceCrashed(
+ instanceKey: string,
+ resetKey: string | undefined,
+): boolean {
+ return (
+ crashedSlotInstances.has(instanceKey) &&
+ crashedSlotInstances.get(instanceKey) === resetKey
+ );
+}
const ownedStateReleasesBySlotInstance = new Map<
string,
Map void>
@@ -55,7 +65,7 @@ function pluginSlotInstanceKey(
export function resetCrashedPluginSlots(pluginId: string): void {
const prefix = `${pluginId}/`;
- for (const key of [...crashedSlotInstances]) {
+ for (const key of [...crashedSlotInstances.keys()]) {
if (key.startsWith(prefix)) crashedSlotInstances.delete(key);
}
}
@@ -77,11 +87,13 @@ interface PluginSlotBoundaryProps {
instanceKey: string;
children: ReactNode;
fallback?: ReactNode;
+ resetKey?: string;
onCrash?: (pluginId: string) => void;
}
interface PluginSlotBoundaryState {
crashed: boolean;
+ resetKey: string | undefined;
}
class PluginSlotBoundary extends Component<
@@ -103,19 +115,37 @@ class PluginSlotBoundary extends Component<
constructor(props: PluginSlotBoundaryProps) {
super(props);
- this.state = { crashed: crashedSlotInstances.has(props.instanceKey) };
+ this.state = {
+ crashed: isSlotInstanceCrashed(props.instanceKey, props.resetKey),
+ resetKey: props.resetKey,
+ };
}
- static getDerivedStateFromError(): PluginSlotBoundaryState {
+ static getDerivedStateFromError(): Pick {
return { crashed: true };
}
+ static getDerivedStateFromProps(
+ props: PluginSlotBoundaryProps,
+ state: PluginSlotBoundaryState,
+ ): PluginSlotBoundaryState | null {
+ if (props.resetKey === state.resetKey) {
+ return null;
+ }
+ return {
+ crashed: isSlotInstanceCrashed(props.instanceKey, props.resetKey),
+ resetKey: props.resetKey,
+ };
+ }
+
override componentDidCatch(error: Error, info: ErrorInfo): void {
releaseSlotInstanceOwnedState(this.props.instanceKey);
this.ownedStateReleases.clear();
- crashedSlotInstances.add(this.props.instanceKey);
+ crashedSlotInstances.set(this.props.instanceKey, this.props.resetKey);
console.warn(
- `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled for this session: ${error.message}`,
+ this.props.resetKey === undefined
+ ? `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled for this session: ${error.message}`
+ : `[plugin:${this.props.pluginId}] slot "${this.props.instanceKey}" crashed and is disabled until its input changes: ${error.message}`,
info.componentStack,
);
try {
@@ -144,7 +174,7 @@ class PluginSlotBoundary extends Component<
override render(): ReactNode {
if (
this.state.crashed ||
- crashedSlotInstances.has(this.props.instanceKey)
+ isSlotInstanceCrashed(this.props.instanceKey, this.props.resetKey)
) {
return this.props.fallback === undefined ? (
@@ -166,6 +196,7 @@ interface PluginSlotMountProps {
slotId: string;
children: ReactNode;
crashFallback?: ReactNode;
+ crashResetKey?: string;
instanceId?: string;
onCrash?: (pluginId: string) => void;
}
@@ -176,6 +207,7 @@ export function PluginSlotMount({
slotId,
children,
crashFallback,
+ crashResetKey,
instanceId,
onCrash,
}: PluginSlotMountProps) {
@@ -192,6 +224,7 @@ export function PluginSlotMount({
instanceId,
)}
fallback={crashFallback}
+ {...(crashResetKey === undefined ? {} : { resetKey: crashResetKey })}
{...(onCrash ? { onCrash } : {})}
>
{
title: "Shared context",
relation: null,
},
+ {
+ kind: "thread",
+ path: "thread:thr_child",
+ replacement: "thread:thr_child",
+ projectId: "proj_app",
+ threadId: "thr_child",
+ title: "Shared context",
+ relation: "child",
+ },
];
const { promptBoxRef } = renderPromptBox("@shared", {
mentionSuggestions: suggestions,
@@ -4847,7 +4856,10 @@ describe("PromptBoxInternal mention triggers", () => {
"Shared context · parent",
"Shared context · same environment",
"Shared context",
+ "Shared context · child thread",
]);
+ expect(within(menu).getByText("parent")).toBeTruthy();
+ expect(within(menu).getByText("child thread")).toBeTruthy();
expect(within(menu).getByText("same environment")).toBeTruthy();
expect(within(menu).getByText("same env").className).toContain("hidden");
});
diff --git a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx
index 0874a81262f..c06ce6efa43 100644
--- a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx
+++ b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx
@@ -32,6 +32,7 @@ import {
type ThreadMentionRelation,
type TypeaheadMenuState,
} from "@bb/client-core";
+import { CHILD_THREAD_NOUN } from "@/lib/child-thread-copy";
export type TypeaheadSuggestion =
| PromptMentionSuggestion
@@ -108,7 +109,7 @@ function getPathSectionLabel(item: PathMentionSuggestion): string {
const THREAD_MENTION_RELATION_LABEL: Record
= {
parent: "parent",
- child: "child",
+ child: CHILD_THREAD_NOUN,
"same-parent": "same parent",
"same-environment": "same environment",
};
@@ -118,7 +119,7 @@ const THREAD_MENTION_RELATION_COMPACT_LABEL: Record<
string
> = {
parent: "parent",
- child: "child",
+ child: CHILD_THREAD_NOUN,
"same-parent": "same parent",
"same-environment": "same env",
};
diff --git a/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx b/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx
index 3efb9bc518a..e621f1545a4 100644
--- a/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx
+++ b/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx
@@ -400,7 +400,7 @@ export function Overview() {
+
{isHostActive ? (
<>
diff --git a/apps/app/src/components/secondary-panel/info/RelatedThreadsSection.tsx b/apps/app/src/components/secondary-panel/info/RelatedThreadsSection.tsx
index 19da5aec7cb..bc26037c716 100644
--- a/apps/app/src/components/secondary-panel/info/RelatedThreadsSection.tsx
+++ b/apps/app/src/components/secondary-panel/info/RelatedThreadsSection.tsx
@@ -3,9 +3,10 @@ import {
resolveThreadListIndicator,
threadListIndicatorStateForThread,
} from "@bb/client-core";
+import { Icon, type IconName } from "@bb/shared-ui/icon";
import { ThreadStatusGlyph } from "@/components/thread/ThreadStatusGlyph";
import { ThreadTitle } from "@/components/thread/ThreadTitleMentions";
-import { useThreads } from "@/hooks/queries/thread-queries";
+import { useChildThreads, useThreads } from "@/hooks/queries/thread-queries";
import { getThreadRoutePath } from "@/lib/route-paths";
import { getThreadDisplayTitle } from "@/lib/thread-title";
import { InfoList, InfoListRow, InfoRowTime, InfoSection } from "./info-list";
@@ -15,12 +16,14 @@ interface RelatedThreadsSectionProps {
sectionId: string;
label: string;
threads: readonly ThreadListEntry[];
+ idleIcon?: IconName;
}
export function RelatedThreadsSection({
sectionId,
label,
threads,
+ idleIcon,
}: RelatedThreadsSectionProps) {
const collapse = useInfoSectionCollapse(sectionId);
if (threads.length === 0) return null;
@@ -31,16 +34,18 @@ export function RelatedThreadsSection({
getKey={(relatedThread) => relatedThread.id}
renderItem={(relatedThread) => {
const title = getThreadDisplayTitle(relatedThread);
+ const indicator = resolveThreadListIndicator(
+ threadListIndicatorStateForThread(relatedThread, false),
+ );
return (
-
+ {idleIcon !== undefined && indicator === "none" ? (
+
+ ) : (
+
+ )}
}
name={ }
@@ -61,6 +66,23 @@ export function RelatedThreadsSection({
);
}
+export function ChildThreadsSection({ thread }: { thread: Thread }) {
+ const childThreadsQuery = useChildThreads({
+ enabled: true,
+ parentThreadId: thread.id,
+ });
+ return (
+ childThread.originKind === null,
+ )}
+ idleIcon="ChildThread"
+ />
+ );
+}
+
export function ForksSection({ thread }: { thread: Thread }) {
const forksQuery = useThreads({
projectId: thread.projectId,
diff --git a/apps/app/src/components/settings/ArchivedThreadsSettingsSection.tsx b/apps/app/src/components/settings/ArchivedThreadsSettingsSection.tsx
index f4855267eb8..6bf75caaf3b 100644
--- a/apps/app/src/components/settings/ArchivedThreadsSettingsSection.tsx
+++ b/apps/app/src/components/settings/ArchivedThreadsSettingsSection.tsx
@@ -30,6 +30,7 @@ import {
ThreadTitle,
useResolveThreadTitle,
} from "@/components/thread/ThreadTitleMentions";
+import { CHILD_THREAD_NOUN } from "@/lib/child-thread-copy";
const ALL_PROJECTS = "all";
const ARCHIVED_THREAD_SEARCH_LIMIT = 50;
@@ -39,7 +40,7 @@ const KIND_OPTIONS: ReadonlyArray<{
value: ArchivedThreadsKindFilter;
}> = [
{ label: "All threads", value: "all" },
- { label: "Root threads", value: "root" },
+ { label: "Threads", value: "root" },
{ label: "Child threads", value: "child" },
];
@@ -264,7 +265,14 @@ export function ArchivedThreadsSettingsSection() {
{thread.parentThreadId !== null ? (
- child
+
+
+ {CHILD_THREAD_NOUN}
+
) : null}
diff --git a/apps/app/src/components/settings/MarketplacesSettingsSection.test.tsx b/apps/app/src/components/settings/MarketplacesSettingsSection.test.tsx
index 440c6613e27..ed5a7059137 100644
--- a/apps/app/src/components/settings/MarketplacesSettingsSection.test.tsx
+++ b/apps/app/src/components/settings/MarketplacesSettingsSection.test.tsx
@@ -101,6 +101,82 @@ describe("MarketplacesSettingsSection", () => {
});
});
+ it("offers a subdirectory for a git source and sends it with the source", async () => {
+ const requests = stubFetch([OFFICIAL]);
+ const { wrapper } = createQueryClientTestHarness();
+ render( , { wrapper });
+
+ expect(screen.queryByLabelText("Marketplace subdirectory")).toBe(null);
+ fireEvent.change(screen.getByLabelText("Marketplace source"), {
+ target: { value: "git:https://github.com/acme/agent-plugins.git@main" },
+ });
+ fireEvent.change(screen.getByLabelText("Marketplace subdirectory"), {
+ target: { value: " bb " },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
+
+ await vi.waitFor(() => {
+ const post = requests.find(
+ (request) =>
+ request.url === "/api/v1/marketplaces" &&
+ request.init?.method === "POST",
+ );
+ expect(JSON.parse(String(post?.init?.body))).toEqual({
+ source: "git:https://github.com/acme/agent-plugins.git@main",
+ subdirectory: "bb",
+ });
+ });
+ });
+
+ it("drops a typed subdirectory when the source is no longer a git source", async () => {
+ const requests = stubFetch([OFFICIAL]);
+ const { wrapper } = createQueryClientTestHarness();
+ render( , { wrapper });
+
+ fireEvent.change(screen.getByLabelText("Marketplace source"), {
+ target: { value: "git:https://github.com/acme/agent-plugins.git" },
+ });
+ fireEvent.change(screen.getByLabelText("Marketplace subdirectory"), {
+ target: { value: "bb" },
+ });
+ fireEvent.change(screen.getByLabelText("Marketplace source"), {
+ target: { value: "https://acme.test/marketplace.json" },
+ });
+ expect(screen.queryByLabelText("Marketplace subdirectory")).toBe(null);
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
+
+ await vi.waitFor(() => {
+ const post = requests.find(
+ (request) =>
+ request.url === "/api/v1/marketplaces" &&
+ request.init?.method === "POST",
+ );
+ expect(JSON.parse(String(post?.init?.body))).toEqual({
+ source: "https://acme.test/marketplace.json",
+ });
+ });
+ });
+
+ it("shows the subdirectory a git marketplace is read from", async () => {
+ stubFetch([
+ OFFICIAL,
+ {
+ ...ACME,
+ sourceKind: "git",
+ source: "git:https://acme.test/repo.git@main",
+ subdirectory: "bb",
+ },
+ ]);
+ const { wrapper } = createQueryClientTestHarness();
+ render( , { wrapper });
+
+ expect(
+ await screen.findByText(
+ "git:https://acme.test/repo.git@main · subdirectory bb",
+ ),
+ ).toBeTruthy();
+ });
+
it("retains one alert when adding a marketplace fails", async () => {
stubFetch(
[OFFICIAL],
diff --git a/apps/app/src/components/settings/MarketplacesSettingsSection.tsx b/apps/app/src/components/settings/MarketplacesSettingsSection.tsx
index fc87bba97f8..cd78167c884 100644
--- a/apps/app/src/components/settings/MarketplacesSettingsSection.tsx
+++ b/apps/app/src/components/settings/MarketplacesSettingsSection.tsx
@@ -20,6 +20,7 @@ import {
} from "@/hooks/queries/plugin-catalog-queries";
const SOURCE_PLACEHOLDER = "https://example.com/marketplace.json";
+const SUBDIRECTORY_PLACEHOLDER = "Subdirectory (optional)";
function formatRefreshedAt(marketplace: PluginMarketplace): string {
if (marketplace.lastError !== null) {
@@ -32,6 +33,8 @@ function formatRefreshedAt(marketplace: PluginMarketplace): string {
export function MarketplacesSettingsSection() {
const queryClient = useQueryClient();
const [source, setSource] = useState("");
+ const [subdirectory, setSubdirectory] = useState("");
+ const isGitSource = source.trimStart().startsWith("git:");
const [removing, setRemoving] = useState(null);
const marketplacesQuery = usePluginMarketplaces({ enabled: true });
const marketplaces = marketplacesQuery.data ?? [];
@@ -40,9 +43,11 @@ export function MarketplacesSettingsSection() {
const add = useMutation({
meta: { showErrorToast: false },
- mutationFn: (value: string) => addPluginMarketplace(fetch, value),
+ mutationFn: (value: { source: string; subdirectory: string | null }) =>
+ addPluginMarketplace(fetch, value),
onSuccess: (marketplace) => {
setSource("");
+ setSubdirectory("");
invalidate();
appToast.success(`Added ${marketplace.displayName}`, {
description: `${marketplace.entryCount} plugins listed. Adding a marketplace installs nothing.`,
@@ -104,18 +109,37 @@ export function MarketplacesSettingsSection() {
>
@@ -146,6 +173,9 @@ export function MarketplacesSettingsSection() {
{marketplace.source}
+ {marketplace.subdirectory === null
+ ? null
+ : ` · subdirectory ${marketplace.subdirectory}`}
{marketplace.entryCount} plugins ·{" "}
diff --git a/apps/app/src/components/thread/ArchivedThreadToastDescription.tsx b/apps/app/src/components/thread/ArchivedThreadToastDescription.tsx
index d4ed09c9152..1adce411875 100644
--- a/apps/app/src/components/thread/ArchivedThreadToastDescription.tsx
+++ b/apps/app/src/components/thread/ArchivedThreadToastDescription.tsx
@@ -2,6 +2,7 @@ import {
ThreadTitle,
useThreadTitleDisplayText,
} from "@/components/thread/ThreadTitleMentions";
+import { formatChildThreadCount } from "@/lib/child-thread-copy";
interface ArchivedThreadToastDescriptionProps {
archivedThreadCount: number;
@@ -10,9 +11,7 @@ interface ArchivedThreadToastDescriptionProps {
}
function formatArchivedChildThreadLabel(childThreadCount: number): string {
- return childThreadCount === 1
- ? "and 1 child thread"
- : `and ${childThreadCount} child threads`;
+ return `and ${formatChildThreadCount(childThreadCount)}`;
}
export function ArchivedThreadToastDescription({
diff --git a/apps/app/src/components/thread/ThreadActionsProvider.test.tsx b/apps/app/src/components/thread/ThreadActionsProvider.test.tsx
index 6d691c9e5fc..59770067603 100644
--- a/apps/app/src/components/thread/ThreadActionsProvider.test.tsx
+++ b/apps/app/src/components/thread/ThreadActionsProvider.test.tsx
@@ -1,7 +1,13 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ within,
+} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Thread } from "@bb/domain";
import { makeThread as makeThreadFixture } from "@bb/test-helpers/domain-fixtures";
@@ -398,11 +404,9 @@ describe("ThreadActionsProvider archive confirmation", () => {
fireEvent.click(screen.getByRole("button", { name: "Archive" }));
- expect(
- await screen.findByText(
- /4 child threads will be archived with this thread\./,
- ),
- ).not.toBeNull();
+ expect((await screen.findByRole("dialog")).textContent).toContain(
+ "Its 4 child threads will be archived too.",
+ );
expect(sdk.threads.archiveAll).not.toHaveBeenCalled();
});
@@ -469,7 +473,9 @@ describe("ThreadActionsProvider archive feedback", () => {
fireEvent.click(screen.getByRole("button", { name: "Archive" }));
fireEvent.click(
- await screen.findByRole("button", { name: "Archive 2 threads" }),
+ within(await screen.findByRole("dialog")).getByRole("button", {
+ name: "Archive",
+ }),
);
await vi.waitFor(() => {
@@ -514,7 +520,9 @@ describe("ThreadActionsProvider archive feedback", () => {
fireEvent.click(screen.getByRole("button", { name: "Archive" }));
fireEvent.click(
- await screen.findByRole("button", { name: "Archive 2 threads" }),
+ within(await screen.findByRole("dialog")).getByRole("button", {
+ name: "Archive",
+ }),
);
await vi.waitFor(() => {
@@ -551,7 +559,9 @@ describe("ThreadActionsProvider archive feedback", () => {
fireEvent.click(screen.getByRole("button", { name: "Archive" }));
fireEvent.click(
- await screen.findByRole("button", { name: "Archive 2 threads" }),
+ within(await screen.findByRole("dialog")).getByRole("button", {
+ name: "Archive",
+ }),
);
await vi.waitFor(() => {
diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx
index 9616b24b8a7..2970d58e369 100644
--- a/apps/app/src/components/thread/ThreadActionsProvider.tsx
+++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx
@@ -494,7 +494,7 @@ export function ThreadActionsProvider({
closeDialog?.();
showMutationErrorToast({
error,
- fallbackMessage: "Failed to archive thread and children",
+ fallbackMessage: "Failed to archive thread and its child threads",
lifecycleOperation: "archive_thread",
});
},
diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx
index ea704452ce8..02174f6c068 100644
--- a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx
+++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx
@@ -46,6 +46,10 @@ import {
useMessageDirectiveRegistry,
type MarkdownMessageDirectives,
} from "@/components/ui/markdown-message-directives.js";
+import {
+ CodeFenceRendererHost,
+ useCodeFenceRendererRegistry,
+} from "@/components/ui/markdown-code-fence-renderers.js";
import {
boundedMarkdownPreview,
closeUnterminatedMarkdownCodeSpan,
@@ -585,6 +589,43 @@ function AssistantConversationMessage({
onOpenPluginPanel,
]);
+ const codeFenceRendererRegistry = useCodeFenceRendererRegistry();
+ const codeFenceMessage = useMemo(
+ () => ({ id, threadId, turnId, projectId: projectId ?? null }),
+ [id, threadId, turnId, projectId],
+ );
+ const markdown = (
+ <>
+
+ {streamingSplit === null ? null : (
+
+ )}
+ >
+ );
+
return (
-
- {streamingSplit === null ? null : (
-
+ {codeFenceRendererRegistry === null ? (
+ markdown
+ ) : (
+
+ {markdown}
+
)}
getPluginSlotSnapshot().messageDirectives,
() => EMPTY_PLUGIN_SLOT_SNAPSHOT.messageDirectives,
);
+ const codeFenceRendererSlots = useSyncExternalStore(
+ subscribePluginSlots,
+ () => getPluginSlotSnapshot().codeFenceRenderers,
+ () => EMPTY_PLUGIN_SLOT_SNAPSHOT.codeFenceRenderers,
+ );
const messageActionSlots = useSyncExternalStore(
subscribePluginSlots,
() => getPluginSlotSnapshot().messageActions,
@@ -2298,6 +2307,10 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
() => buildMessageDirectiveRegistry(messageDirectiveSlots),
[messageDirectiveSlots],
);
+ const codeFenceRendererRegistry = useMemo(
+ () => buildCodeFenceRendererRegistry(codeFenceRendererSlots),
+ [codeFenceRendererSlots],
+ );
const onSelectionAddToChat = props.onSelectionAddToChat;
const timelineThreadId = props.threadId;
const hasPluginSelectionActions =
@@ -2447,70 +2460,72 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
);
return (
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+ {hasSelectionActions ? (
+
-
-
- {hasSelectionActions ? (
-
- ) : null}
-
-
-
-
-
-
-
-
+ ) : null}
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/app/src/components/tools/PluginCapabilities.tsx b/apps/app/src/components/tools/PluginCapabilities.tsx
index e42e1685211..d598aaf5211 100644
--- a/apps/app/src/components/tools/PluginCapabilities.tsx
+++ b/apps/app/src/components/tools/PluginCapabilities.tsx
@@ -366,6 +366,14 @@ function pluginAppSurfaceItems(
detail: "Renders plugin-provided content inside thread messages.",
mono: true,
})),
+ ...slots.codeFenceRenderers
+ .filter((slot) => slot.pluginId === pluginId)
+ .map((slot) => ({
+ key: `code-fence:${slot.id}`,
+ label: slot.languages.map((language) => `\`\`\`${language}`).join(", "),
+ detail: "Renders these fenced code blocks inside assistant messages.",
+ mono: true,
+ })),
];
}
diff --git a/apps/app/src/components/ui/icon.stories.tsx b/apps/app/src/components/ui/icon.stories.tsx
index 1da31c98891..95de53a43e7 100644
--- a/apps/app/src/components/ui/icon.stories.tsx
+++ b/apps/app/src/components/ui/icon.stories.tsx
@@ -97,9 +97,10 @@ const USAGE: Partial> = {
Square: "Stop button while running, in-progress and pending todo glyphs",
TextWrap: "Line-wrap toggle for diff cards and source file previews",
Trash2: "Delete queued message, remove project source",
- UserRound:
- "Thread mentions, parent-thread indicators, and active child-thread banner",
- UserRoundPlus: "unused legacy parent-action icon",
+ ChildThread:
+ "Child thread badges, running child threads, and thread assignment events",
+ UserRound: "Thread mentions, thread links, and the Parent card and row",
+ UserRoundPlus: "Subagent timeline rows",
Workflow: "Workflow card indicator in the prompt stack",
X: "Close dialogs/drawers, clear search input, remove attachment, close metadata panel",
Zap: "Fast-mode indicator in model picker trigger, Fast-mode toggle row",
diff --git a/apps/app/src/components/ui/markdown-code-fence-renderers.test.tsx b/apps/app/src/components/ui/markdown-code-fence-renderers.test.tsx
new file mode 100644
index 00000000000..b99d130e974
--- /dev/null
+++ b/apps/app/src/components/ui/markdown-code-fence-renderers.test.tsx
@@ -0,0 +1,507 @@
+// @vitest-environment jsdom
+
+import { useEffect, useState, type ComponentType } from "react";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ExperimentalCodeFenceRendererProps } from "@get-bb/plugin-sdk";
+import { MemoryRouter } from "react-router-dom";
+import { RouteNavigationProvider } from "@/components/ui/app-route-anchor";
+import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount";
+import { ConversationMessageContent } from "@/components/thread/timeline/ConversationMessageContent";
+import { ThreadTimelineRows } from "@/components/thread/timeline/ThreadTimelineRows";
+import { conversationRow } from "@/test/fixtures/thread-timeline-rows";
+import { makePluginRegistrationSet } from "@/test/fixtures/plugins";
+import {
+ resetPluginSlotStoreForTest,
+ setPluginSlotRegistrations,
+ type PluginCodeFenceRendererSlot,
+} from "@/lib/plugin-slots";
+import {
+ buildCodeFenceRendererRegistry,
+ CodeFenceRendererHost,
+ CodeFenceRendererRegistryProvider,
+ type CodeFenceRendererRegistry,
+} from "./markdown-code-fence-renderers";
+import { MarkdownPreview } from "./markdown-preview";
+
+const MESSAGE = {
+ id: "msg_1",
+ threadId: "thr_1",
+ turnId: "turn_1",
+ projectId: "proj_1",
+} as const;
+
+function UiFence(props: ExperimentalCodeFenceRendererProps) {
+ return (
+
+ );
+}
+
+function slot(
+ component: ComponentType,
+ overrides: Partial = {},
+): PluginCodeFenceRendererSlot {
+ return {
+ id: "generative-ui",
+ pluginId: "demo",
+ generation: 1,
+ languages: ["ui"],
+ component,
+ ...overrides,
+ };
+}
+
+function renderedFences() {
+ return screen.getAllByTestId("ui-fence").map((node) => ({
+ code: node.getAttribute("data-code"),
+ status: node.getAttribute("data-status"),
+ index: node.getAttribute("data-index"),
+ }));
+}
+
+function assistantMessage(
+ registry: CodeFenceRendererRegistry,
+ text: string,
+ streaming = false,
+) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function hostedMarkdown(
+ registry: CodeFenceRendererRegistry,
+ content: string,
+ streaming = false,
+) {
+ return (
+
+
+
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ resetAllCrashedPluginSlotsForTest();
+ resetPluginSlotStoreForTest();
+ vi.restoreAllMocks();
+});
+
+beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+});
+
+describe("code fence renderers in assistant messages", () => {
+ it("renders claimed fences with code, language, per-language index, status, and message", () => {
+ const registry = buildCodeFenceRendererRegistry([slot(UiFence)]);
+ const view = render(
+ assistantMessage(
+ registry,
+ [
+ "Intro",
+ "",
+ "```ui",
+ "{ first }",
+ "```",
+ "",
+ "```ts",
+ "const between = 1;",
+ "```",
+ "",
+ "```UI extra-meta",
+ "{ second }",
+ "```",
+ ].join("\n"),
+ ),
+ );
+
+ expect(renderedFences()).toEqual([
+ { code: "{ first }", status: "closed", index: "0" },
+ { code: "{ second }", status: "closed", index: "1" },
+ ]);
+ const first = screen.getAllByTestId("ui-fence")[0];
+ expect(first?.getAttribute("data-language")).toBe("ui");
+ expect(first?.getAttribute("data-message-id")).toBe("msg_a");
+ expect(first?.getAttribute("data-thread-id")).toBe("thr_a");
+ expect(first?.getAttribute("data-turn-id")).toBe("turn_a");
+ expect(first?.getAttribute("data-project-id")).toBe("proj_a");
+ expect(view.container.textContent).toContain("const between = 1;");
+ expect(
+ view.container.querySelectorAll('button[aria-label="Wrap long lines"]'),
+ ).toHaveLength(1);
+ });
+
+ it("renders claimed fences in document order between the surrounding prose", () => {
+ const registry = buildCodeFenceRendererRegistry([slot(UiFence)]);
+ const view = render(
+ assistantMessage(registry, "Before\n\n```ui\n{ a }\n```\n\nAfter"),
+ );
+ const fence = screen.getByTestId("ui-fence");
+ const before = screen.getByText("Before");
+ const after = screen.getByText("After");
+ expect(view.container.contains(fence)).toBe(true);
+ expect(
+ before.compareDocumentPosition(fence) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ fence.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("keeps BB's code block in user messages", () => {
+ const registry = buildCodeFenceRendererRegistry([slot(UiFence)]);
+ const view = render(
+
+
+
+
+
+
+ ,
+ );
+ expect(screen.queryByTestId("ui-fence")).toBeNull();
+ expect(view.container.textContent).toContain("{ mine }");
+ });
+
+ it("keeps one component instance and its state from the first streamed line until the reply has finished", () => {
+ let mounts = 0;
+ function StatefulFence(props: ExperimentalCodeFenceRendererProps) {
+ const [clicks, setClicks] = useState(0);
+ useEffect(() => {
+ mounts += 1;
+ }, []);
+ return (
+ setClicks((value) => value + 1)}
+ >
+ clicks {clicks}
+
+ );
+ }
+ const registry = buildCodeFenceRendererRegistry([slot(StatefulFence)]);
+ const steps: ReadonlyArray = [
+ ["Intro\n\n```ui\n{ par", "{ par", "streaming"],
+ ["Intro\n\n```ui\n{ partial }\n", "{ partial }", "streaming"],
+ ["Intro\n\n```ui\n{ partial }\n```\n", "{ partial }", "closed"],
+ [
+ "Intro\n\n```ui\n{ partial }\n```\n\nLater prose.\n\nMore pro",
+ "{ partial }",
+ "closed",
+ ],
+ ];
+ const view = render(assistantMessage(registry, steps[0]![0], true));
+ fireEvent.click(screen.getByTestId("stateful-fence"));
+ for (const [text, code, status] of steps) {
+ view.rerender(assistantMessage(registry, text, true));
+ const fence = screen.getByTestId("stateful-fence");
+ expect(fence.getAttribute("data-code")).toBe(code);
+ expect(fence.getAttribute("data-status")).toBe(status);
+ expect(fence.getAttribute("data-index")).toBe("0");
+ expect(fence.textContent).toBe("clicks 1");
+ }
+ expect(
+ view.container.querySelectorAll("[data-markdown-preview]"),
+ ).toHaveLength(2);
+ expect(
+ view.container
+ .querySelectorAll("[data-markdown-preview]")[0]
+ ?.contains(screen.getByTestId("stateful-fence")),
+ ).toBe(true);
+
+ view.rerender(
+ assistantMessage(
+ registry,
+ "Intro\n\n```ui\n{ partial }\n```\n\nLater prose.\n\nMore prose.",
+ false,
+ ),
+ );
+ const settled = screen.getByTestId("stateful-fence");
+ expect(settled.textContent).toBe("clicks 1");
+ expect(settled.getAttribute("data-status")).toBe("closed");
+ expect(screen.getByText("More prose.")).toBeTruthy();
+ expect(mounts).toBe(1);
+ });
+
+ it("numbers fences across the settled and streaming regions of one reply", () => {
+ const registry = buildCodeFenceRendererRegistry([slot(UiFence)]);
+ const view = render(
+ assistantMessage(
+ registry,
+ "```ui\n{ settled }\n```\n\nMiddle prose.\n\n```ui\n{ tail",
+ true,
+ ),
+ );
+ expect(
+ view.container.querySelectorAll("[data-markdown-preview]"),
+ ).toHaveLength(2);
+ expect(renderedFences()).toEqual([
+ { code: "{ settled }", status: "closed", index: "0" },
+ { code: "{ tail", status: "streaming", index: "1" },
+ ]);
+ });
+
+ it("reports a fence the reply never closed as unclosed once streaming stops", () => {
+ const registry = buildCodeFenceRendererRegistry([slot(UiFence)]);
+ const view = render(assistantMessage(registry, "```ui\n{ cut", true));
+ expect(renderedFences()).toEqual([
+ { code: "{ cut", status: "streaming", index: "0" },
+ ]);
+ view.rerender(assistantMessage(registry, "```ui\n{ cut", false));
+ expect(renderedFences()).toEqual([
+ { code: "{ cut", status: "unclosed", index: "0" },
+ ]);
+ });
+
+ it("removes a fence's component when the fence leaves the message", async () => {
+ const registry = buildCodeFenceRendererRegistry([slot(UiFence)]);
+ const view = render(assistantMessage(registry, "```ui\n{ gone }\n```"));
+ expect(screen.getAllByTestId("ui-fence")).toHaveLength(1);
+ view.rerender(assistantMessage(registry, "Plain prose only."));
+ await waitFor(() => {
+ expect(screen.queryByTestId("ui-fence")).toBeNull();
+ });
+ expect(
+ view.container.querySelector("[data-code-fence-parking]"),
+ ).toBeNull();
+ });
+
+ it("keeps claimed fences through the sanitized HTML pipeline", () => {
+ const registry = buildCodeFenceRendererRegistry([slot(UiFence)]);
+ render(
+ assistantMessage(
+ registry,
+ "More hidden \n\n```ui\npage
\n```",
+ ),
+ );
+ return screen.findByTestId("ui-fence").then((fence) => {
+ expect(fence.getAttribute("data-code")).toBe("page
");
+ expect(fence.getAttribute("data-status")).toBe("closed");
+ });
+ });
+
+ it("isolates a crash to one fence and tries that fence again when its code changes", () => {
+ function FragileFence(props: ExperimentalCodeFenceRendererProps) {
+ if (props.code.includes("{ bad")) {
+ throw new Error("fence boom");
+ }
+ return ;
+ }
+ const registry = buildCodeFenceRendererRegistry([slot(FragileFence)]);
+ const view = render(
+ assistantMessage(registry, "```ui\n{ good }\n```\n\n```ui\n{ bad", true),
+ );
+ expect(renderedFences()).toEqual([
+ { code: "{ good }", status: "closed", index: "0" },
+ ]);
+ expect(view.container.textContent).toContain("{ bad");
+ expect(view.container.textContent).not.toContain("crashed");
+ expect(
+ view.container.querySelectorAll('button[aria-label="Wrap long lines"]'),
+ ).toHaveLength(1);
+
+ view.rerender(
+ assistantMessage(
+ registry,
+ "```ui\n{ good }\n```\n\n```ui\n{ fixed }\n```",
+ true,
+ ),
+ );
+ expect(renderedFences()).toEqual([
+ { code: "{ good }", status: "closed", index: "0" },
+ { code: "{ fixed }", status: "closed", index: "1" },
+ ]);
+ expect(
+ view.container.querySelectorAll('button[aria-label="Wrap long lines"]'),
+ ).toHaveLength(0);
+ });
+
+ it("renders BB's code block for a language two plugins claim, from the slot store", () => {
+ function ZetaFence() {
+ return
;
+ }
+ setPluginSlotRegistrations(
+ "zeta",
+ makePluginRegistrationSet({
+ codeFenceRenderers: [
+ { id: "zeta-ui", languages: ["ui", "app"], component: ZetaFence },
+ ],
+ }),
+ );
+ setPluginSlotRegistrations(
+ "alpha",
+ makePluginRegistrationSet({
+ codeFenceRenderers: [
+ { id: "alpha-ui", languages: ["ui"], component: UiFence },
+ ],
+ }),
+ );
+
+ const view = render(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId("ui-fence")).toBeNull();
+ expect(screen.getAllByTestId("zeta-fence")).toHaveLength(1);
+ expect(view.container.textContent).toContain("{ contested }");
+ expect(console.warn).toHaveBeenCalledWith(
+ '[plugin] code fence language "ui" claimed by multiple plugins (alpha, zeta); rendering BB\'s code block',
+ );
+ });
+});
+
+describe("MarkdownPreview inside a code fence host", () => {
+ it.each([
+ ["an empty closed fence", "```ui\n```", "closed"],
+ ["a closed fence with a blank body", "```ui\n\n```", "closed"],
+ ["a longer closing fence", "```ui\na\n`````", "closed"],
+ ["a shorter closing run", "````ui\na\n```", "unclosed"],
+ ["an over-indented closing run", "```ui\na\n ```", "unclosed"],
+ ["a trailing newline without a closing fence", "```ui\na\n", "unclosed"],
+ [
+ "a closed fence in a nested list",
+ "- x\n\n - y\n\n ```ui\n a\n ```",
+ "closed",
+ ],
+ [
+ "an open fence in a nested list",
+ "- x\n\n - y\n\n ```ui\n a\n",
+ "unclosed",
+ ],
+ ["a closed fence in a blockquote", "> ```ui\n> a\n> ```", "closed"],
+ ["an open fence in a blockquote", "> ```ui\n> a\n\nafter", "unclosed"],
+ ["a closed tilde fence", "~~~ui\na\n~~~", "closed"],
+ ])("derives the status from the source for %s", (_label, content, status) => {
+ render(
+ hostedMarkdown(buildCodeFenceRendererRegistry([slot(UiFence)]), content),
+ );
+ expect(screen.getByTestId("ui-fence").getAttribute("data-status")).toBe(
+ status,
+ );
+ });
+
+ it("renders BB's original code block through Original", () => {
+ function DelegatingFence({ Original }: ExperimentalCodeFenceRendererProps) {
+ return (
+
+ );
+ }
+ render(
+ hostedMarkdown(
+ buildCodeFenceRendererRegistry([slot(DelegatingFence)]),
+ "```ui\n{ delegated }\n```",
+ ),
+ );
+ const wrapper = screen.getByTestId("delegating-fence");
+ expect(wrapper.textContent).toContain("{ delegated }");
+ expect(
+ wrapper.querySelector('button[aria-label="Wrap long lines"]'),
+ ).not.toBeNull();
+ });
+
+ it("leaves fences on BB's code block outside a host", () => {
+ const view = render( );
+ expect(screen.queryByTestId("ui-fence")).toBeNull();
+ expect(view.container.textContent).toContain("{ plain }");
+ });
+});
+
+describe("buildCodeFenceRendererRegistry", () => {
+ it("drops a language two plugins claim, keeps the rest, and warns once", () => {
+ const warn = vi.fn();
+ const alpha = slot(UiFence, {
+ pluginId: "alpha",
+ languages: ["ui", "app"],
+ });
+ const zeta = slot(UiFence, { pluginId: "zeta", languages: ["ui"] });
+ const registry = buildCodeFenceRendererRegistry([zeta, alpha], { warn });
+
+ expect(registry.has("ui")).toBe(false);
+ expect(registry.get("app")).toBe(alpha);
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(warn).toHaveBeenCalledWith(
+ '[plugin] code fence language "ui" claimed by multiple plugins (alpha, zeta); rendering BB\'s code block',
+ );
+ });
+});
diff --git a/apps/app/src/components/ui/markdown-code-fence-renderers.tsx b/apps/app/src/components/ui/markdown-code-fence-renderers.tsx
new file mode 100644
index 00000000000..ce28ce94e14
--- /dev/null
+++ b/apps/app/src/components/ui/markdown-code-fence-renderers.tsx
@@ -0,0 +1,410 @@
+import {
+ createContext,
+ memo,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
+import type { Code, Root } from "mdast";
+import type {} from "mdast-util-to-hast";
+import type {
+ ExperimentalCodeFenceRendererProps,
+ ExperimentalCodeFenceStatus,
+} from "@get-bb/plugin-sdk";
+import { visit } from "unist-util-visit";
+import { PluginSlotMount } from "@/components/plugin/PluginSlotMount.js";
+import { PluginThreadPanelNavigationProvider } from "@/components/plugin/plugin-thread-panel-navigation.js";
+import { resolveCodeFenceRendererRegistry } from "@/lib/plugin-slot-resolvers.js";
+import type { PluginCodeFenceRendererSlot } from "@/lib/plugin-slots.js";
+import type { MarkdownMessageDirectiveOpenThreadPanel } from "./markdown-message-directives.js";
+import {
+ closesAnyIndentMarkdownFence,
+ parseMarkdownFenceStart,
+} from "./markdown-prompt-blockquote-boundaries.js";
+
+const CODE_FENCE_OFFSET_PROPERTY = "dataCodeFenceOffset";
+const CODE_FENCE_CLOSED_PROPERTY = "dataCodeFenceClosed";
+const MARKDOWN_CONTAINER_PREFIX_PATTERN = /^[ \t>]*/u;
+
+export type CodeFenceRendererRegistry = ReadonlyMap<
+ string,
+ PluginCodeFenceRendererSlot
+>;
+
+export interface CodeFenceElementProps {
+ "data-code-fence-offset"?: string;
+ "data-code-fence-closed"?: string;
+}
+
+interface RemarkCodeFenceRenderersFile {
+ value: unknown;
+}
+
+export function buildCodeFenceRendererRegistry(
+ slots: readonly PluginCodeFenceRendererSlot[],
+ options?: { warn?: (message: string) => void },
+): CodeFenceRendererRegistry {
+ const warn = options?.warn ?? ((message: string) => console.warn(message));
+ const registry = new Map();
+ for (const [language, resolved] of resolveCodeFenceRendererRegistry(slots)) {
+ if (resolved.status === "collision") {
+ warn(
+ `[plugin] code fence language "${language}" claimed by multiple plugins (${resolved.pluginIds.join(", ")}); rendering BB's code block`,
+ );
+ continue;
+ }
+ registry.set(language, resolved.slot);
+ }
+ return registry;
+}
+
+function isClosedCodeFence(node: Code, markdownSource: string): boolean {
+ const start = node.position?.start.offset;
+ const end = node.position?.end.offset;
+ if (start === undefined || end === undefined) {
+ return false;
+ }
+ const lines = markdownSource.slice(start, end).split("\n");
+ const fence = parseMarkdownFenceStart(lines[0] ?? "");
+ const closingLine = lines.at(-1);
+ if (fence === null || lines.length < 2 || closingLine === undefined) {
+ return false;
+ }
+ return (
+ closesAnyIndentMarkdownFence(
+ closingLine.replace(MARKDOWN_CONTAINER_PREFIX_PATTERN, ""),
+ fence,
+ ) &&
+ (node.value === "" || node.value.split("\n").length === lines.length - 2)
+ );
+}
+
+export function remarkCodeFenceRenderers(args: {
+ registry: CodeFenceRendererRegistry;
+ sourceOffset: number;
+}) {
+ const { registry, sourceOffset } = args;
+ return (tree: Root, file: RemarkCodeFenceRenderersFile): void => {
+ const markdownSource =
+ typeof file.value === "string" ? file.value : String(file.value ?? "");
+ visit(tree, "code", (node) => {
+ const language = node.lang?.toLowerCase();
+ const offset = node.position?.start.offset;
+ if (
+ language === undefined ||
+ offset === undefined ||
+ !registry.has(language)
+ ) {
+ return;
+ }
+ node.data ??= {};
+ node.data.hProperties = {
+ ...node.data.hProperties,
+ [CODE_FENCE_OFFSET_PROPERTY]: String(sourceOffset + offset),
+ [CODE_FENCE_CLOSED_PROPERTY]: String(
+ isClosedCodeFence(node, markdownSource),
+ ),
+ };
+ });
+ };
+}
+
+interface CodeFenceBlock {
+ closed: boolean;
+ code: string;
+ language: string;
+ original: ReactNode;
+}
+
+interface CodeFenceEntry {
+ block: CodeFenceBlock | null;
+ container: HTMLDivElement;
+ offset: number;
+ placeholder: HTMLElement | null;
+}
+
+interface CodeFenceHost {
+ registry: CodeFenceRendererRegistry;
+ attach: (offset: number, placeholder: HTMLElement) => void;
+ detach: (offset: number, placeholder: HTMLElement) => void;
+ update: (offset: number, block: CodeFenceBlock) => void;
+}
+
+const CodeFenceHostContext = createContext(null);
+
+const CodeFenceOriginalContext = createContext(null);
+
+function CodeFenceOriginal() {
+ return <>{useContext(CodeFenceOriginalContext)}>;
+}
+
+function placeCodeFenceContainer(
+ parent: HTMLElement,
+ container: HTMLDivElement,
+): void {
+ if (container.parentNode === parent) {
+ return;
+ }
+ if (
+ container.isConnected &&
+ parent.isConnected &&
+ typeof parent.moveBefore === "function"
+ ) {
+ try {
+ parent.moveBefore(container, null);
+ return;
+ } catch {
+ parent.appendChild(container);
+ return;
+ }
+ }
+ parent.appendChild(container);
+}
+
+export function useCodeFenceLanguages(): CodeFenceRendererRegistry | null {
+ const registry = useContext(CodeFenceHostContext)?.registry;
+ return registry === undefined || registry.size === 0 ? null : registry;
+}
+
+interface CodeFenceRendererHostProps {
+ children: ReactNode;
+ message: ExperimentalCodeFenceRendererProps["message"];
+ openThreadPanel: MarkdownMessageDirectiveOpenThreadPanel | null;
+ registry: CodeFenceRendererRegistry;
+ streaming: boolean;
+}
+
+export function CodeFenceRendererHost({
+ children,
+ message,
+ openThreadPanel,
+ registry,
+ streaming,
+}: CodeFenceRendererHostProps) {
+ const [entries] = useState(() => new Map());
+ const parkingRef = useRef(null);
+ const [, rerender] = useReducer((revision: number) => revision + 1, 0);
+
+ const host = useMemo(
+ (): CodeFenceHost => ({
+ registry,
+ attach(offset, placeholder) {
+ let entry = entries.get(offset);
+ if (entry === undefined) {
+ entry = {
+ block: null,
+ container: document.createElement("div"),
+ offset,
+ placeholder,
+ };
+ entries.set(offset, entry);
+ } else {
+ entry.placeholder = placeholder;
+ }
+ placeCodeFenceContainer(placeholder, entry.container);
+ },
+ detach(offset, placeholder) {
+ const entry = entries.get(offset);
+ if (entry === undefined || entry.placeholder !== placeholder) {
+ return;
+ }
+ entry.placeholder = null;
+ if (parkingRef.current !== null) {
+ placeCodeFenceContainer(parkingRef.current, entry.container);
+ }
+ queueMicrotask(() => {
+ if (entry.placeholder !== null || entries.get(offset) !== entry) {
+ return;
+ }
+ entry.container.remove();
+ entries.delete(offset);
+ rerender();
+ });
+ },
+ update(offset, block) {
+ const entry = entries.get(offset);
+ if (entry === undefined) {
+ return;
+ }
+ const previous = entry.block;
+ entry.block = block;
+ if (
+ previous === null ||
+ previous.code !== block.code ||
+ previous.closed !== block.closed ||
+ previous.language !== block.language
+ ) {
+ rerender();
+ }
+ },
+ }),
+ [entries, registry],
+ );
+
+ const ordered = [...entries.values()].sort(
+ (left, right) => left.offset - right.offset,
+ );
+ const ordinals = new Map();
+ const mounts: ReactNode[] = [];
+ for (const entry of ordered) {
+ const { block } = entry;
+ if (block === null) {
+ continue;
+ }
+ const index = ordinals.get(block.language) ?? 0;
+ ordinals.set(block.language, index + 1);
+ const slot = registry.get(block.language);
+ mounts.push(
+ createPortal(
+
+ {slot === undefined ? (
+
+ ) : (
+
+ )}
+ ,
+ entry.container,
+ String(entry.offset),
+ ),
+ );
+ }
+
+ return (
+
+ {children}
+ {mounts.length === 0 ? null : (
+
+ )}
+ {mounts}
+
+ );
+}
+
+interface CodeFenceMountProps {
+ code: string;
+ index: number;
+ language: string;
+ message: ExperimentalCodeFenceRendererProps["message"];
+ openThreadPanel: MarkdownMessageDirectiveOpenThreadPanel | null;
+ slot: PluginCodeFenceRendererSlot;
+ status: ExperimentalCodeFenceStatus;
+}
+
+const CodeFenceMount = memo(function CodeFenceMount({
+ code,
+ index,
+ language,
+ message,
+ openThreadPanel,
+ slot,
+ status,
+}: CodeFenceMountProps) {
+ const Component = slot.component;
+ const element = (
+
+ );
+ return (
+ }
+ >
+ {openThreadPanel === null ? (
+ element
+ ) : (
+
+ {element}
+
+ )}
+
+ );
+});
+
+interface CodeFencePlaceholderProps {
+ closed: boolean;
+ code: string;
+ language: string;
+ offset: number;
+ original: ReactNode;
+}
+
+export function CodeFencePlaceholder({
+ closed,
+ code,
+ language,
+ offset,
+ original,
+}: CodeFencePlaceholderProps) {
+ const host = useContext(CodeFenceHostContext);
+ const elementRef = useRef(null);
+ const claimed = host !== null && host.registry.has(language);
+
+ useLayoutEffect(() => {
+ const element = elementRef.current;
+ if (host === null || !claimed || element === null) {
+ return;
+ }
+ host.attach(offset, element);
+ return () => {
+ host.detach(offset, element);
+ };
+ }, [host, claimed, offset]);
+
+ useLayoutEffect(() => {
+ if (host !== null && claimed) {
+ host.update(offset, { closed, code, language, original });
+ }
+ });
+
+ if (!claimed) {
+ return original;
+ }
+ return
;
+}
+
+const CodeFenceRendererRegistryContext =
+ createContext(null);
+
+export function CodeFenceRendererRegistryProvider({
+ registry,
+ children,
+}: {
+ registry: CodeFenceRendererRegistry;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCodeFenceRendererRegistry(): CodeFenceRendererRegistry | null {
+ return useContext(CodeFenceRendererRegistryContext);
+}
diff --git a/apps/app/src/components/ui/markdown-html.tsx b/apps/app/src/components/ui/markdown-html.tsx
index f33e5a79232..0217d0fe331 100644
--- a/apps/app/src/components/ui/markdown-html.tsx
+++ b/apps/app/src/components/ui/markdown-html.tsx
@@ -46,6 +46,11 @@ const MARKDOWN_HTML_REHYPE_PLUGINS: MarkdownRehypePlugins = [
"title",
"ariaLabel",
],
+ code: [
+ ...(defaultSchema.attributes?.code ?? []),
+ "dataCodeFenceOffset",
+ "dataCodeFenceClosed",
+ ],
"bb-thread-mention": [
"dataThreadId",
"dataRawThreadId",
diff --git a/apps/app/src/components/ui/markdown-incremental-pieces.test.ts b/apps/app/src/components/ui/markdown-incremental-pieces.test.ts
index 004b65f3376..69db39d4f01 100644
--- a/apps/app/src/components/ui/markdown-incremental-pieces.test.ts
+++ b/apps/app/src/components/ui/markdown-incremental-pieces.test.ts
@@ -45,6 +45,7 @@ const registry = buildMessageDirectiveRegistry([
]);
const config: MarkdownPieceRenderConfig = {
+ codeFenceRendererRegistry: null,
components: {},
messageDirectiveRegistry: registry,
rehypePlugins: [rehypeKatex],
diff --git a/apps/app/src/components/ui/markdown-incremental-pieces.ts b/apps/app/src/components/ui/markdown-incremental-pieces.ts
index cdfa3b9b51b..68e64a4e0fe 100644
--- a/apps/app/src/components/ui/markdown-incremental-pieces.ts
+++ b/apps/app/src/components/ui/markdown-incremental-pieces.ts
@@ -19,6 +19,10 @@ import {
type MessageDirectiveRegistry,
type MountedMessageDirective,
} from "./markdown-message-directives.js";
+import {
+ remarkCodeFenceRenderers,
+ type CodeFenceRendererRegistry,
+} from "./markdown-code-fence-renderers.js";
import {
BARE_FENCE_PATTERN,
TRAILING_CLOSE_PATTERN,
@@ -37,6 +41,7 @@ import {
type MarkdownPluginList = NonNullable;
export interface MarkdownPieceRenderConfig {
+ codeFenceRendererRegistry: CodeFenceRendererRegistry | null;
components: Components;
messageDirectiveRegistry: MessageDirectiveRegistry | null;
rehypePlugins: MarkdownPluginList;
@@ -369,6 +374,12 @@ function parseMarkdownPiece({
},
]);
}
+ if (config.codeFenceRendererRegistry !== null) {
+ remarkPlugins.push([
+ remarkCodeFenceRenderers,
+ { registry: config.codeFenceRendererRegistry, sourceOffset },
+ ]);
+ }
remarkPlugins.push(() => (tree: Root) => {
visit(tree, "image", (node) => {
if (node.position?.start.offset === undefined) return;
diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx
index ce775eaaf9a..e7d6d4e2257 100644
--- a/apps/app/src/components/ui/markdown-preview.tsx
+++ b/apps/app/src/components/ui/markdown-preview.tsx
@@ -93,6 +93,12 @@ import {
type MarkdownMessageDirectives,
type MountedMessageDirective,
} from "./markdown-message-directives.js";
+import {
+ CodeFencePlaceholder,
+ remarkCodeFenceRenderers,
+ useCodeFenceLanguages,
+ type CodeFenceElementProps,
+} from "./markdown-code-fence-renderers.js";
import {
createMarkdownPieceCache,
resolveMarkdownPieces,
@@ -251,8 +257,11 @@ type MarkdownPreviewPropsEqual = (
type MarkdownAnchorEvent = ReactMouseEvent;
type MarkdownBlockquoteProps = ComponentPropsWithoutRef<"blockquote"> &
ExtraProps;
-type MarkdownCodeProps = ComponentPropsWithoutRef<"code"> & ExtraProps;
+type MarkdownCodeProps = ComponentPropsWithoutRef<"code"> &
+ ExtraProps &
+ CodeFenceElementProps;
interface MarkdownCodeRendererProps extends MarkdownCodeProps {
+ codeFenceSourceOffset: number;
imagePolicy: MarkdownImagePolicy;
linkRouting?: MarkdownLinkRouting;
preferredTheme: Theme;
@@ -750,87 +759,125 @@ function renderMarkdownLocalFileContextMenuItem(
);
}
-function MarkdownCode({
- className: codeClassName,
- children,
+function MarkdownCodeBlock({
+ codeProps,
+ codeText,
imagePolicy,
- linkRouting,
- node: _node,
+ language,
preferredTheme,
- rewriteLocalhostLinks,
- ...props
-}: MarkdownCodeRendererProps) {
- const codeText =
- typeof children === "string" ? children.replace(/\n$/, "") : "";
- const language = getMarkdownCodeLanguage({ className: codeClassName });
- const isBlock = isMarkdownCodeBlock({ codeText, language });
+}: {
+ codeProps: ComponentPropsWithoutRef<"code">;
+ codeText: string;
+ imagePolicy: MarkdownImagePolicy;
+ language: string | null;
+ preferredTheme: Theme;
+}) {
const [softWrap, setSoftWrap] = useState(false);
const highlightedHtml = useMemo(
() =>
- isBlock && language !== "mermaid"
+ language !== "mermaid"
? highlightMarkdownCode({ code: codeText, language })
: null,
- [isBlock, language, codeText],
+ [language, codeText],
);
const highlightedMarkup = useMemo(
() => (highlightedHtml === null ? null : { __html: highlightedHtml }),
[highlightedHtml],
);
- if (isBlock) {
- if (language === "mermaid" && imagePolicy === "render") {
- return (
-
- );
- }
-
+ if (language === "mermaid" && imagePolicy === "render") {
return (
-
-
-
- {language ?? ""}
-
-
- {
- setSoftWrap((value) => !value);
- }}
- className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground aria-pressed:text-foreground"
- >
-
-
-
-
+
+ );
+ }
+
+ return (
+
+
+
+ {language ?? ""}
+
+
+ {
+ setSoftWrap((value) => !value);
+ }}
+ className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground aria-pressed:text-foreground"
+ >
+
+
+
-
- {highlightedMarkup === null ? (
-
- {codeText}
-
- ) : (
-
- )}
-
+
+ {highlightedMarkup === null ? (
+
+ {codeText}
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function MarkdownCode({
+ className: codeClassName,
+ children,
+ codeFenceSourceOffset,
+ imagePolicy,
+ linkRouting,
+ node: _node,
+ preferredTheme,
+ rewriteLocalhostLinks,
+ "data-code-fence-offset": codeFenceOffset,
+ "data-code-fence-closed": codeFenceClosed,
+ ...props
+}: MarkdownCodeRendererProps) {
+ const codeText =
+ typeof children === "string" ? children.replace(/\n$/, "") : "";
+ const language = getMarkdownCodeLanguage({ className: codeClassName });
+ const isBlock = isMarkdownCodeBlock({ codeText, language });
+ if (isBlock) {
+ const original = (
+
+ );
+ return codeFenceOffset === undefined || language === null ? (
+ original
+ ) : (
+
);
}
@@ -1306,6 +1353,7 @@ function buildMarkdownComponents({
return (
- messageDirectiveMounts === null
- ? baseRemarkPlugins
- : [
- ...baseRemarkPlugins,
- [
- remarkMessageDirectives,
- {
- indexBase: 0,
- limit: MESSAGE_DIRECTIVE_MOUNT_LIMIT,
- mounts: messageDirectiveMounts.mounts,
- registry: messageDirectiveMounts.registry,
- },
- ],
- ],
- [baseRemarkPlugins, messageDirectiveMounts],
- );
+ const codeFenceRegistry = useCodeFenceLanguages();
+ const remarkPlugins = useMemo((): MarkdownRemarkPlugins => {
+ const plugins: MarkdownRemarkPlugins = [...baseRemarkPlugins];
+ if (messageDirectiveMounts !== null) {
+ plugins.push([
+ remarkMessageDirectives,
+ {
+ indexBase: 0,
+ limit: MESSAGE_DIRECTIVE_MOUNT_LIMIT,
+ mounts: messageDirectiveMounts.mounts,
+ registry: messageDirectiveMounts.registry,
+ },
+ ]);
+ }
+ if (codeFenceRegistry !== null) {
+ plugins.push([
+ remarkCodeFenceRenderers,
+ { registry: codeFenceRegistry, sourceOffset: 0 },
+ ]);
+ }
+ return plugins;
+ }, [baseRemarkPlugins, messageDirectiveMounts, codeFenceRegistry]);
const resolvedUrlTransform = useMemo(
() =>
localFileRouting || localImageRouting
@@ -1875,6 +1927,7 @@ function MarkdownPreviewComponent({
const markdownPieceRenderConfig = useMemo(
(): MarkdownPieceRenderConfig => ({
+ codeFenceRendererRegistry: codeFenceRegistry,
components: markdownComponents,
messageDirectiveRegistry: messageDirectiveMounts?.registry ?? null,
rehypePlugins,
@@ -1882,6 +1935,7 @@ function MarkdownPreviewComponent({
urlTransform: resolvedUrlTransform,
}),
[
+ codeFenceRegistry,
markdownComponents,
messageDirectiveMounts,
rehypePlugins,
diff --git a/apps/app/src/components/ui/sonner.stories.tsx b/apps/app/src/components/ui/sonner.stories.tsx
index 1dc64da9538..25124a12443 100644
--- a/apps/app/src/components/ui/sonner.stories.tsx
+++ b/apps/app/src/components/ui/sonner.stories.tsx
@@ -261,7 +261,7 @@ const TOAST_EXAMPLES: readonly ToastExample[] = [
usage: ["Thread archive fails", "Title varies by thread type/error"],
current: {
tone: "error",
- title: "Failed to archive thread and children",
+ title: "Failed to archive thread and its child threads",
},
},
{
diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.ts b/apps/app/src/hooks/mutations/thread-state-mutations.ts
index 9e6479168bf..9a01ab90b5d 100644
--- a/apps/app/src/hooks/mutations/thread-state-mutations.ts
+++ b/apps/app/src/hooks/mutations/thread-state-mutations.ts
@@ -191,7 +191,7 @@ export function useArchiveThreadAndChildren() {
return useMutation({
meta: {
- errorMessage: "Failed to archive thread and children.",
+ errorMessage: "Failed to archive thread and its child threads.",
lifecycleOperation: "archive_thread",
showErrorToast: false,
},
diff --git a/apps/app/src/hooks/queries/plugin-catalog-queries.ts b/apps/app/src/hooks/queries/plugin-catalog-queries.ts
index e7df866531b..aa09556fb28 100644
--- a/apps/app/src/hooks/queries/plugin-catalog-queries.ts
+++ b/apps/app/src/hooks/queries/plugin-catalog-queries.ts
@@ -101,9 +101,12 @@ async function listPluginMarketplaces(
export async function addPluginMarketplace(
fetchImpl: FetchLike,
- source: string,
+ args: { source: string; subdirectory: string | null },
): Promise {
- return createPluginsClient(fetchImpl).marketplaces.add({ source });
+ return createPluginsClient(fetchImpl).marketplaces.add({
+ source: args.source,
+ ...(args.subdirectory === null ? {} : { subdirectory: args.subdirectory }),
+ });
}
export async function removePluginMarketplace(
diff --git a/apps/app/src/lib/app-navigation-host.tsx b/apps/app/src/lib/app-navigation-host.tsx
index 284b3922fc0..e2298407f1e 100644
--- a/apps/app/src/lib/app-navigation-host.tsx
+++ b/apps/app/src/lib/app-navigation-host.tsx
@@ -1,3 +1,4 @@
+import { recordTelemetryEvent } from "@/components/onboarding/onboarding-telemetry";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type {
ExperimentalAppPanelSurface,
@@ -64,18 +65,35 @@ export function AppNavigationHostProvider({
children: ReactNode;
}) {
const parent = useContext(AppNavigationHostContext);
+ const openFilePreview = capabilities.openFilePreview;
const value = useMemo(
() => ({
openFileExternally:
capabilities.openFileExternally ?? parent.openFileExternally,
- openFilePreview: capabilities.openFilePreview ?? parent.openFilePreview,
+ openFilePreview:
+ openFilePreview === undefined
+ ? parent.openFilePreview
+ : (intent) => {
+ const opened = openFilePreview(intent);
+ if (opened)
+ recordTelemetryEvent({
+ name: "feature_used",
+ properties: {
+ feature: "file",
+ feature_scope: "core",
+ interaction: "opened",
+ trigger: "unknown",
+ },
+ });
+ return opened;
+ },
openFixedTab: capabilities.openFixedTab ?? parent.openFixedTab,
openTerminal: capabilities.openTerminal ?? parent.openTerminal,
openUrl: capabilities.openUrl ?? parent.openUrl,
}),
[
capabilities.openFileExternally,
- capabilities.openFilePreview,
+ openFilePreview,
capabilities.openFixedTab,
capabilities.openTerminal,
capabilities.openUrl,
diff --git a/apps/app/src/lib/child-thread-copy.ts b/apps/app/src/lib/child-thread-copy.ts
new file mode 100644
index 00000000000..4870be08ed7
--- /dev/null
+++ b/apps/app/src/lib/child-thread-copy.ts
@@ -0,0 +1,9 @@
+export const CHILD_THREAD_NOUN = "child thread";
+
+export function childThreadNoun(count: number): string {
+ return count === 1 ? CHILD_THREAD_NOUN : `${CHILD_THREAD_NOUN}s`;
+}
+
+export function formatChildThreadCount(count: number): string {
+ return `${count} ${childThreadNoun(count)}`;
+}
diff --git a/apps/app/src/lib/fixed-panel-tabs.ts b/apps/app/src/lib/fixed-panel-tabs.ts
index 8f0289b1292..00929606d25 100644
--- a/apps/app/src/lib/fixed-panel-tabs.ts
+++ b/apps/app/src/lib/fixed-panel-tabs.ts
@@ -1,3 +1,4 @@
+import { recordTelemetryEvent } from "@/components/onboarding/onboarding-telemetry";
import {
useCallback,
useEffect,
@@ -421,6 +422,15 @@ export function useSetFixedSecondaryPanelTab(
const updateState = useUpdateFixedPanelTabsState(panelStateId, syncThreadId);
return useCallback(
(panel: ThreadSecondaryPanel) => {
+ recordTelemetryEvent({
+ name: "feature_used",
+ properties: {
+ feature: panel === "git-diff" ? "git" : "thread",
+ feature_scope: "core",
+ interaction: "opened",
+ trigger: "unknown",
+ },
+ });
updateState((current) => {
const tabs = ensureSecondaryPanelTab(current.secondary.tabs, panel);
const activeTabId = getSecondaryPanelTabId(panel);
diff --git a/apps/app/src/lib/plugin-slot-resolvers.ts b/apps/app/src/lib/plugin-slot-resolvers.ts
index b1f047a2129..166a5890f0a 100644
--- a/apps/app/src/lib/plugin-slot-resolvers.ts
+++ b/apps/app/src/lib/plugin-slot-resolvers.ts
@@ -4,6 +4,7 @@ import type {
PluginComposerScope,
} from "@get-bb/plugin-sdk";
import type {
+ PluginCodeFenceRendererSlot,
PluginComposerCustomizationSlot,
PluginFileOpenerSlot,
PluginMessageDirectiveSlot,
@@ -273,6 +274,43 @@ export function resolveMessageDirectiveRegistry(
return resolved;
}
+export type ResolvedCodeFenceRenderer =
+ | { status: "ok"; slot: PluginCodeFenceRendererSlot }
+ | { status: "collision"; pluginIds: readonly string[] };
+
+export function resolveCodeFenceRendererRegistry(
+ registrations: readonly PluginCodeFenceRendererSlot[],
+): ReadonlyMap {
+ const claimantsByLanguage = new Map();
+ for (const registration of registrations) {
+ for (const language of registration.languages) {
+ const claimants = claimantsByLanguage.get(language);
+ if (claimants === undefined) {
+ claimantsByLanguage.set(language, [registration]);
+ } else {
+ claimants.push(registration);
+ }
+ }
+ }
+ const resolved = new Map();
+ for (const [language, claimants] of claimantsByLanguage) {
+ const [first] = claimants;
+ if (first === undefined) continue;
+ resolved.set(
+ language,
+ claimants.length === 1
+ ? { status: "ok", slot: first }
+ : {
+ status: "collision",
+ pluginIds: [
+ ...new Set(claimants.map((claimant) => claimant.pluginId)),
+ ].sort(),
+ },
+ );
+ }
+ return resolved;
+}
+
export type ResolvedReplacement =
| { kind: "owner" }
| { kind: "plugin"; registration: Registration };
diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts
index a8a47bd0a9a..449aa5c4ee8 100644
--- a/apps/app/src/lib/plugin-slots.ts
+++ b/apps/app/src/lib/plugin-slots.ts
@@ -3,6 +3,7 @@ import { useSyncExternalStore } from "react";
import type {
ComposerCustomization,
ExperimentalAppOverlayRegistration,
+ ExperimentalCodeFenceRendererRegistration,
PluginDiffRendererRegistration,
PluginEnvironmentProviderInputsRegistration,
PluginMachineProviderInputsRegistration,
@@ -53,6 +54,7 @@ export interface PluginRegistrationSet {
sourceCodeRenderers?: readonly PluginSourceCodeRendererRegistration[];
diffRenderers?: readonly PluginDiffRendererRegistration[];
messageDirectives: readonly PluginMessageDirectiveRegistration[];
+ codeFenceRenderers?: readonly ExperimentalCodeFenceRendererRegistration[];
messageActions?: readonly PluginMessageActionRegistration[];
commandPaletteActions?: readonly CollectedPluginCommandRegistration[];
providerIcons?: readonly CollectedPluginProviderIconRegistration[];
@@ -101,6 +103,8 @@ export interface PluginDiffRendererSlot
extends PluginDiffRendererRegistration, PluginSlotBase {}
export interface PluginMessageDirectiveSlot
extends PluginMessageDirectiveRegistration, PluginSlotBase {}
+export interface PluginCodeFenceRendererSlot
+ extends ExperimentalCodeFenceRendererRegistration, PluginSlotBase {}
export interface PluginMessageActionSlot
extends PluginMessageActionRegistration, PluginSlotBase {}
export type PluginCommandPaletteActionSlot =
@@ -133,6 +137,7 @@ export interface PluginSlotSnapshot {
sourceCodeRenderers: readonly PluginSourceCodeRendererSlot[];
diffRenderers: readonly PluginDiffRendererSlot[];
messageDirectives: readonly PluginMessageDirectiveSlot[];
+ codeFenceRenderers: readonly PluginCodeFenceRendererSlot[];
messageActions: readonly PluginMessageActionSlot[];
commandPaletteActions: readonly PluginCommandPaletteActionSlot[];
providerIcons: readonly PluginProviderIconSlot[];
@@ -160,6 +165,7 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = {
sourceCodeRenderers: [],
diffRenderers: [],
messageDirectives: [],
+ codeFenceRenderers: [],
messageActions: [],
commandPaletteActions: [],
providerIcons: [],
@@ -194,6 +200,7 @@ const SLOT_KINDS: readonly SlotKind[] = [
"sourceCodeRenderers",
"diffRenderers",
"messageDirectives",
+ "codeFenceRenderers",
"messageActions",
"commandPaletteActions",
"providerIcons",
@@ -252,6 +259,7 @@ function flattenRegistrations(
sourceCodeRenderers: stamp(set.sourceCodeRenderers),
diffRenderers: stamp(set.diffRenderers),
messageDirectives: stamp(set.messageDirectives),
+ codeFenceRenderers: stamp(set.codeFenceRenderers),
messageActions: stamp(set.messageActions),
commandPaletteActions: stamp(set.commandPaletteActions),
providerIcons: stamp(set.providerIcons),
diff --git a/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx b/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx
new file mode 100644
index 00000000000..31b77ac69ec
--- /dev/null
+++ b/apps/app/src/lib/split-layout/useSplitViewTelemetry.test.tsx
@@ -0,0 +1,101 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, renderHook } from "@testing-library/react";
+import { Provider, createStore } from "jotai";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { splitLayoutAtom } from "./atoms";
+import { removePane, splitPane } from "./ops";
+import type { SplitLayout } from "./types";
+import { useSplitViewTelemetry } from "./useSplitViewTelemetry";
+
+const mocks = vi.hoisted(() => ({ recordTelemetryEvent: vi.fn() }));
+
+vi.mock("@/components/onboarding/onboarding-telemetry", () => ({
+ recordTelemetryEvent: mocks.recordTelemetryEvent,
+}));
+
+afterEach(() => {
+ cleanup();
+ mocks.recordTelemetryEvent.mockReset();
+ window.sessionStorage.clear();
+ window.localStorage.clear();
+});
+
+const thread = (threadId: string) => ({
+ kind: "thread" as const,
+ projectId: "project-1",
+ threadId,
+});
+
+function singlePane(): SplitLayout {
+ return {
+ root: { type: "pane", paneId: "pane-1", content: thread("thread-1") },
+ focusedPaneId: "pane-1",
+ };
+}
+
+function mount(initial: SplitLayout | null) {
+ const store = createStore();
+ store.set(splitLayoutAtom, initial);
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ renderHook(() => useSplitViewTelemetry(), { wrapper });
+ const update = (next: (layout: SplitLayout) => SplitLayout) =>
+ act(() => {
+ store.set(
+ splitLayoutAtom,
+ next(store.get(splitLayoutAtom) ?? singlePane()),
+ );
+ });
+ return { update };
+}
+
+describe("useSplitViewTelemetry", () => {
+ it("reports each added pane as feature use, and nothing for closing panes", () => {
+ const { update } = mount(singlePane());
+ update((layout) =>
+ splitPane(layout, "pane-1", "right", thread("thread-2")),
+ );
+ update((layout) =>
+ splitPane(layout, layout.focusedPaneId, "bottom", thread("thread-3")),
+ );
+ update((layout) => removePane(layout, layout.focusedPaneId));
+ expect(mocks.recordTelemetryEvent.mock.calls).toEqual([
+ [
+ {
+ name: "feature_used",
+ properties: {
+ feature: "split_view",
+ feature_scope: "core",
+ interaction: "opened",
+ trigger: "unknown",
+ },
+ },
+ ],
+ [
+ {
+ name: "feature_used",
+ properties: {
+ feature: "split_view",
+ feature_scope: "core",
+ interaction: "opened",
+ trigger: "unknown",
+ },
+ },
+ ],
+ ]);
+ });
+
+ it("does not report a split layout restored when the app opens", () => {
+ const restored = splitPane(
+ singlePane(),
+ "pane-1",
+ "right",
+ thread("thread-2"),
+ );
+ mount(restored);
+ expect(mocks.recordTelemetryEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts b/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts
new file mode 100644
index 00000000000..f1fea5e8c95
--- /dev/null
+++ b/apps/app/src/lib/split-layout/useSplitViewTelemetry.ts
@@ -0,0 +1,25 @@
+import { useAtomValue } from "jotai";
+import { useEffect, useRef } from "react";
+import { recordTelemetryEvent } from "@/components/onboarding/onboarding-telemetry";
+import { splitLayoutAtom } from "./atoms";
+import { countPanes } from "./ops";
+
+export function useSplitViewTelemetry(): void {
+ const layout = useAtomValue(splitLayoutAtom);
+ const panes = layout === null ? 1 : countPanes(layout.root);
+ const previousPanes = useRef(panes);
+ useEffect(() => {
+ if (panes > previousPanes.current && panes >= 2) {
+ recordTelemetryEvent({
+ name: "feature_used",
+ properties: {
+ feature: "split_view",
+ feature_scope: "core",
+ interaction: "opened",
+ trigger: "unknown",
+ },
+ });
+ }
+ previousPanes.current = panes;
+ }, [panes]);
+}
diff --git a/apps/app/src/test/fixtures/plugins.ts b/apps/app/src/test/fixtures/plugins.ts
index cf264653aa4..401a86ac5c2 100644
--- a/apps/app/src/test/fixtures/plugins.ts
+++ b/apps/app/src/test/fixtures/plugins.ts
@@ -39,6 +39,7 @@ export function makePluginRegistrationSet(
sourceCodeRenderers: [],
diffRenderers: [],
messageDirectives: [],
+ codeFenceRenderers: [],
messageActions: [],
commandPaletteActions: [],
providerIcons: [],
diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx
index 2db25c69931..4a934931426 100644
--- a/apps/app/src/views/SettingsView.tsx
+++ b/apps/app/src/views/SettingsView.tsx
@@ -953,7 +953,7 @@ export function GeneralSettingsSection({
{
{
{
{
>
{
) : null}
>
)}
- childPillLabel={null}
isSecondaryPanelOpen={false}
onClosePane={vi.fn()}
onOpenThreadGitAction={vi.fn()}
@@ -306,7 +301,6 @@ describe("ThreadDetailHeader", () => {
) : null}
>
)}
- childPillLabel={null}
isSecondaryPanelOpen={false}
onClosePane={vi.fn()}
onOpenThreadGitAction={vi.fn()}
@@ -332,7 +326,6 @@ describe("ThreadDetailHeader", () => {
{
{
{
{
{
{
{
{
>
{
menuArgs.current = args;
return null;
}}
- childPillLabel={null}
isSecondaryPanelOpen={false}
onOpenThreadGitAction={vi.fn()}
onToggleSecondaryPanel={vi.fn()}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx b/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx
index dd47e8c47b3..93cce139210 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx
@@ -12,7 +12,6 @@ import { Button } from "@bb/shared-ui/button";
import { useAtomValue } from "jotai";
import { COARSE_POINTER_TOOLBAR_ACTION_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing";
import { Icon } from "@bb/shared-ui/icon";
-import { Pill } from "@bb/shared-ui/pill";
import { SplitButton } from "@/components/ui/split-button.js";
import {
AppPageHeader,
@@ -63,7 +62,6 @@ export interface ThreadDetailHeaderActionsMenuArgs {
interface ThreadDetailHeaderProps {
actionsMenu: ((args: ThreadDetailHeaderActionsMenuArgs) => ReactNode) | null;
- childPillLabel: "child" | null;
isSecondaryPanelOpen: boolean;
onClosePane?: () => void;
onOpenThreadGitAction: (target: ThreadGitActionDialogTarget) => void;
@@ -77,7 +75,6 @@ interface ThreadDetailHeaderProps {
export function ThreadDetailHeader({
actionsMenu,
- childPillLabel,
isSecondaryPanelOpen,
onClosePane,
onOpenThreadGitAction,
@@ -218,11 +215,6 @@ export function ThreadDetailHeader({
{isEditing ? editor : }
- {childPillLabel ? (
-
- {childPillLabel}
-
- ) : null}
{actionsMenu == null ? null : (
)}
- childPillLabel={parentThreadId ? "child" : null}
isSecondaryPanelOpen={isSecondaryPanelOpen}
onClosePane={onRequestClose ?? undefined}
onOpenThreadGitAction={gitActions.threadGitActionDialog.onOpen}
diff --git a/apps/cli/src/__tests__/command-output/marketplace.test.ts b/apps/cli/src/__tests__/command-output/marketplace.test.ts
index c11015fa521..7be0d09af6e 100644
--- a/apps/cli/src/__tests__/command-output/marketplace.test.ts
+++ b/apps/cli/src/__tests__/command-output/marketplace.test.ts
@@ -74,6 +74,44 @@ describe("bb marketplace", () => {
expect(output).toContain("bb plugin install
@acme-plugins");
});
+ it("sends --subdirectory with a git source and prints the stored subdirectory", async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(
+ json({
+ ok: true,
+ marketplace: {
+ ...acme,
+ sourceKind: "git",
+ source: "git:https://github.com/acme/agent-plugins.git@main",
+ subdirectory: "bb",
+ resolvedCommit: "0123456789abcdef0123456789abcdef01234567",
+ },
+ }),
+ );
+
+ await runCommand(
+ [
+ "marketplace",
+ "add",
+ "git:https://github.com/acme/agent-plugins.git@main",
+ "--subdirectory",
+ "bb",
+ ],
+ register,
+ );
+
+ expect(
+ JSON.parse(String(vi.mocked(fetch).mock.calls[0]?.[1]?.body)),
+ ).toEqual({
+ source: "git:https://github.com/acme/agent-plugins.git@main",
+ subdirectory: "bb",
+ });
+ const output = collectLogPayloads(vi.mocked(console.log)).join("\n");
+ expect(output).toContain(
+ "source: git:https://github.com/acme/agent-plugins.git@main",
+ );
+ expect(output).toContain("subdirectory: bb");
+ });
+
it("resolves a relative path: source on the invoking machine", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
json({ ok: true, marketplace: { ...acme, sourceKind: "path" } }),
@@ -101,6 +139,30 @@ describe("bb marketplace", () => {
expect(output).toContain("bb-community (official)");
expect(output).toContain("acme-plugins");
expect(output).toContain("https://acme.test/marketplace.json");
+ expect(output).not.toContain("Subdirectory");
+ });
+
+ it("lists the subdirectory a git marketplace is read from", async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(
+ json({
+ marketplaces: [
+ {
+ ...acme,
+ sourceKind: "git",
+ source: "git:https://acme.test/repo.git@main",
+ subdirectory: "bb",
+ },
+ ],
+ }),
+ );
+
+ await runCommand(["marketplace", "list"], register);
+
+ const output = collectLogPayloads(vi.mocked(console.log)).join("\n");
+ expect(output).toMatch(/Source\s+Subdirectory\s+Entries/u);
+ expect(output).toMatch(
+ /git:https:\/\/acme\.test\/repo\.git@main\s+bb\s+2\s/u,
+ );
});
it("reports a failed refresh and exits non-zero without hiding the rest", async () => {
diff --git a/apps/cli/src/commands/marketplace.ts b/apps/cli/src/commands/marketplace.ts
index 0d22ae7f871..42f178fdeaf 100644
--- a/apps/cli/src/commands/marketplace.ts
+++ b/apps/cli/src/commands/marketplace.ts
@@ -22,6 +22,9 @@ function formatTimestamp(value: number | null): string {
function printMarketplace(marketplace: PluginMarketplace): void {
console.log(`${marketplace.name} ${marketplace.displayName}`);
console.log(` source: ${marketplace.source}`);
+ if (marketplace.subdirectory !== null) {
+ console.log(` subdirectory: ${marketplace.subdirectory}`);
+ }
if (marketplace.resolvedCommit !== null) {
console.log(` commit: ${marketplace.resolvedCommit}`);
}
@@ -45,22 +48,36 @@ export function registerMarketplaceCommands(
.description(
"Add a marketplace from an https manifest URL, git:[@[], or path:] (validates and caches the catalog; installs nothing)",
)
+ .option(
+ "--subdirectory ",
+ "Read marketplace.json from this directory of a git: repository instead of its root",
+ )
.option("--json", "Output JSON")
.action(
- action(async (source: string, opts: JsonOutputOptions) => {
- const added = await createCliBbSdk(getUrl()).plugins.marketplaces.add({
- source: normalizeSource(source),
- });
- if (opts.json) {
- outputJson(opts, added);
- return;
- }
- console.log(`Added marketplace ${added.name}:`);
- printMarketplace(added);
- console.log(
- `Adding a marketplace installs nothing. Install an entry with \`bb plugin install @${added.name}\`.`,
- );
- }),
+ action(
+ async (
+ source: string,
+ opts: JsonOutputOptions & { subdirectory?: string },
+ ) => {
+ const added = await createCliBbSdk(getUrl()).plugins.marketplaces.add(
+ {
+ source: normalizeSource(source),
+ ...(opts.subdirectory === undefined
+ ? {}
+ : { subdirectory: opts.subdirectory }),
+ },
+ );
+ if (opts.json) {
+ outputJson(opts, added);
+ return;
+ }
+ console.log(`Added marketplace ${added.name}:`);
+ printMarketplace(added);
+ console.log(
+ `Adding a marketplace installs nothing. Install an entry with \`bb plugin install @${added.name}\`.`,
+ );
+ },
+ ),
);
marketplace
@@ -75,16 +92,32 @@ export function registerMarketplaceCommands(
outputJson(opts, marketplaces);
return;
}
+ const showSubdirectory = marketplaces.some(
+ (entry) => entry.subdirectory !== null,
+ );
console.log(
renderBorderlessTable(
{
- head: ["Name", "Source", "Entries", "Refreshed"],
- colWidths: [24, 58, 9, 26],
+ head: [
+ "Name",
+ "Source",
+ ...(showSubdirectory ? ["Subdirectory"] : []),
+ "Entries",
+ "Refreshed",
+ ],
+ colWidths: [
+ 24,
+ showSubdirectory ? 42 : 58,
+ ...(showSubdirectory ? [16] : []),
+ 9,
+ 26,
+ ],
trimTrailingWhitespace: true,
},
marketplaces.map((entry) => [
entry.official ? `${entry.name} (official)` : entry.name,
entry.source,
+ ...(showSubdirectory ? [entry.subdirectory ?? ""] : []),
String(entry.entryCount),
entry.lastError === null
? formatTimestamp(entry.lastRefreshAt)
diff --git a/apps/desktop/src/menu.ts b/apps/desktop/src/menu.ts
index 7cb280e113c..ac86afe09ba 100644
--- a/apps/desktop/src/menu.ts
+++ b/apps/desktop/src/menu.ts
@@ -1,6 +1,7 @@
import {
app,
Menu,
+ shell,
type BaseWindow,
type MenuItemConstructorOptions,
} from "electron";
@@ -16,6 +17,10 @@ const NEW_THREAD_MENU_LABEL = "New Thread";
const NEW_WINDOW_MENU_LABEL = "New Window";
const CLOSE_WINDOW_MENU_LABEL = "Close Window";
const OPEN_SETTINGS_MENU_LABEL = "Settings…";
+const VIEW_LICENSE_MENU_LABEL = "View License";
+const LICENSE_URL = "https://github.com/get-bb/bb/blob/main/LICENSE";
+const UNINSTALL_MENU_LABEL = "How to Uninstall bb";
+const UNINSTALL_URL = "https://getbb.app/uninstall";
const TOGGLE_DEVELOPER_TOOLS_MENU_LABEL = "Toggle Developer Tools";
const TOGGLE_DEVELOPER_TOOLS_ACCELERATOR = "Command+Option+I";
const RELOAD_ACCELERATOR = "CommandOrControl+R";
@@ -306,6 +311,23 @@ export function buildApplicationMenuTemplate(
: []),
],
},
+ {
+ role: "help",
+ submenu: [
+ {
+ label: VIEW_LICENSE_MENU_LABEL,
+ click: () => {
+ void shell.openExternal(LICENSE_URL);
+ },
+ },
+ {
+ label: UNINSTALL_MENU_LABEL,
+ click: () => {
+ void shell.openExternal(UNINSTALL_URL);
+ },
+ },
+ ],
+ },
];
}
diff --git a/apps/server/src/routes/plugin-catalog.ts b/apps/server/src/routes/plugin-catalog.ts
index ab941784f9a..57f92605031 100644
--- a/apps/server/src/routes/plugin-catalog.ts
+++ b/apps/server/src/routes/plugin-catalog.ts
@@ -121,12 +121,18 @@ export function registerPluginCatalogRoutes(
const json: unknown = await context.req.json().catch(() => null);
const body = pluginMarketplaceAddRequestSchema.safeParse(json);
if (!body.success) {
- return context.json({ error: 'expected { "source": string }' }, 422);
+ return context.json(
+ { error: 'expected { "source": string, "subdirectory"?: string }' },
+ 422,
+ );
}
try {
return context.json({
ok: true as const,
- marketplace: await catalog.addMarketplace(body.data.source),
+ marketplace: await catalog.addMarketplace({
+ source: body.data.source,
+ subdirectory: body.data.subdirectory ?? null,
+ }),
});
} catch (error) {
return context.json({ error: errorMessage(error) }, 422);
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 855880acf14..c3ed46669c9 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -1,3 +1,4 @@
+import { coreFeatureTelemetry } from "./services/system/feature-telemetry.js";
import { createPluginUpdateJobs } from "./services/plugins/plugin-update-jobs.js";
import { registerPluginUpdateJobRoutes } from "./routes/plugin-update-jobs.js";
import { recheckEnvironmentProvisioning } from "./services/threads/thread-environment-providers.js";
@@ -855,6 +856,10 @@ export function createApp(
app.use(path, serverMoveWriteFreezeMiddleware(daemonWriteFreezeState));
}
const publicApi = new Hono();
+ publicApi.use(
+ "*",
+ coreFeatureTelemetry(() => deps.telemetry),
+ );
publicApi.use("*", async (context, next) => {
if (PLUGIN_WIRE_HTTP_PATH.test(context.req.path)) {
return next();
diff --git a/apps/server/src/services/plugin-catalog/marketplace-source.ts b/apps/server/src/services/plugin-catalog/marketplace-source.ts
index d75db8ea6d1..1ff0d648026 100644
--- a/apps/server/src/services/plugin-catalog/marketplace-source.ts
+++ b/apps/server/src/services/plugin-catalog/marketplace-source.ts
@@ -3,7 +3,9 @@ import { mkdir, readFile, rm, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { PluginMarketplaceSourceKind } from "@bb/db";
import {
+ normalizePluginSubdirectory,
parsePluginSource,
+ pluginRootDir,
realPathInside,
runInstallCommand,
} from "../plugins/install-sources.js";
@@ -20,7 +22,7 @@ const MARKETPLACE_MANIFEST_FILENAME = "marketplace.json";
type MarketplaceSource =
| { kind: "https"; manifestUrl: string }
- | { kind: "git"; url: string; ref: string }
+ | { kind: "git"; url: string; ref: string; subdirectory: string | null }
| { kind: "path"; directory: string };
const GIT_CLONE_ARGS = ["-c", "core.hooksPath=/dev/null"] as const;
@@ -28,7 +30,21 @@ const GIT_CLONE_ARGS = ["-c", "core.hooksPath=/dev/null"] as const;
const SOURCE_FORMS =
'expected "https://", "git:[@[]", or "path:]"';
-export function parseMarketplaceSource(raw: string): MarketplaceSource {
+function assertNoSubdirectory(
+ source: string,
+ subdirectory: string | null,
+): void {
+ if (subdirectory !== null) {
+ throw new Error(
+ `invalid marketplace source "${source}": a subdirectory applies to git: sources only`,
+ );
+ }
+}
+
+export function parseMarketplaceSource(
+ raw: string,
+ subdirectory: string | null,
+): MarketplaceSource {
const source = raw.trim();
if (source.length === 0) {
throw new Error(`invalid marketplace source: ${SOURCE_FORMS}`);
@@ -38,6 +54,7 @@ export function parseMarketplaceSource(raw: string): MarketplaceSource {
if (directory.length === 0) {
throw new Error("marketplace source has an empty path");
}
+ assertNoSubdirectory(source, subdirectory);
return { kind: "path", directory: resolve(directory) };
}
if (source.startsWith("git:")) {
@@ -50,9 +67,21 @@ export function parseMarketplaceSource(raw: string): MarketplaceSource {
`invalid marketplace git source "${source}": a marketplace ref names one branch, tag, or commit`,
);
}
- return { kind: "git", url: parsed.url, ref: parsed.selector.ref };
+ return {
+ kind: "git",
+ url: parsed.url,
+ ref: parsed.selector.ref,
+ subdirectory:
+ subdirectory === null
+ ? null
+ : normalizePluginSubdirectory(
+ subdirectory,
+ "marketplace subdirectory",
+ ),
+ };
}
if (/^https:\/\//iu.test(source)) {
+ assertNoSubdirectory(source, subdirectory);
return { kind: "https", manifestUrl: source };
}
if (/^http:\/\//iu.test(source)) {
@@ -73,6 +102,7 @@ export function marketplaceSourceFromRow(row: {
sourceKind: PluginMarketplaceSourceKind;
manifestUrl: string;
sourceGitRef: string | null;
+ sourceGitSubdirectory: string | null;
}): MarketplaceSource {
if (row.sourceKind === "https") {
return { kind: "https", manifestUrl: row.manifestUrl };
@@ -83,19 +113,26 @@ export function marketplaceSourceFromRow(row: {
if (row.sourceGitRef === null) {
throw new Error("git marketplace row has no ref");
}
- return { kind: "git", url: row.manifestUrl, ref: row.sourceGitRef };
+ return {
+ kind: "git",
+ url: row.manifestUrl,
+ ref: row.sourceGitRef,
+ subdirectory: row.sourceGitSubdirectory,
+ };
}
export function marketplaceSourceColumns(source: MarketplaceSource): {
sourceKind: PluginMarketplaceSourceKind;
manifestUrl: string;
sourceGitRef: string | null;
+ sourceGitSubdirectory: string | null;
} {
if (source.kind === "https") {
return {
sourceKind: "https",
manifestUrl: source.manifestUrl,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
};
}
if (source.kind === "path") {
@@ -103,12 +140,14 @@ export function marketplaceSourceColumns(source: MarketplaceSource): {
sourceKind: "path",
manifestUrl: source.directory,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
};
}
return {
sourceKind: "git",
manifestUrl: source.url,
sourceGitRef: source.ref,
+ sourceGitSubdirectory: source.subdirectory,
};
}
@@ -318,6 +357,23 @@ async function materializeLocal(
}
}
+async function gitMarketplaceRoot(
+ checkout: string,
+ subdirectory: string | null,
+): Promise {
+ if (subdirectory === null) return checkout;
+ const directory = pluginRootDir(checkout, subdirectory);
+ const isDirectory = await stat(directory)
+ .then((entry) => entry.isDirectory())
+ .catch(() => false);
+ if (!isDirectory) {
+ throw new Error(
+ `marketplace subdirectory "${subdirectory}" is not a directory in the repository`,
+ );
+ }
+ return realPathInside(checkout, directory, "marketplace subdirectory");
+}
+
async function materializeGit(
source: Extract,
stagingDir: string,
@@ -335,6 +391,23 @@ async function materializeGit(
source.url,
checkout,
]);
+ const commit = await runInstallCommand("git", [
+ "-C",
+ checkout,
+ "rev-parse",
+ "--verify",
+ "--end-of-options",
+ `${source.ref}^{commit}`,
+ ]).catch(() =>
+ runInstallCommand("git", [
+ "-C",
+ checkout,
+ "rev-parse",
+ "--verify",
+ "--end-of-options",
+ `refs/remotes/origin/${source.ref}^{commit}`,
+ ]),
+ );
await runInstallCommand("git", [
...GIT_CLONE_ARGS,
"-C",
@@ -342,16 +415,15 @@ async function materializeGit(
"checkout",
"--quiet",
"--detach",
- source.ref,
- ]);
- const commit = await runInstallCommand("git", [
- "-C",
- checkout,
- "rev-parse",
- "HEAD",
+ commit,
]);
await rm(join(checkout, ".git"), { recursive: true, force: true });
- return await materializeLocal(checkout, commit, dispose, warn);
+ return await materializeLocal(
+ await gitMarketplaceRoot(checkout, source.subdirectory),
+ commit,
+ dispose,
+ warn,
+ );
} catch (error) {
await dispose();
throw error;
diff --git a/apps/server/src/services/plugin-catalog/plugin-catalog-service.ts b/apps/server/src/services/plugin-catalog/plugin-catalog-service.ts
index 5f599a71b16..abd27300313 100644
--- a/apps/server/src/services/plugin-catalog/plugin-catalog-service.ts
+++ b/apps/server/src/services/plugin-catalog/plugin-catalog-service.ts
@@ -138,7 +138,10 @@ export interface PluginCatalogService {
entryId: string,
): Promise;
listMarketplaces(): PluginMarketplace[];
- addMarketplace(source: string): Promise;
+ addMarketplace(input: {
+ source: string;
+ subdirectory: string | null;
+ }): Promise;
removeMarketplace(name: string): Promise<{ convertedPluginIds: string[] }>;
startPeriodicRefresh(): void;
stopPeriodicRefresh(): void;
@@ -209,6 +212,7 @@ export function createPluginCatalogService(deps: {
sourceKind: "path",
manifestUrl: bundled.directory,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
manifestJson: bundled.manifestJson,
statsJson: null,
@@ -245,6 +249,7 @@ export function createPluginCatalogService(deps: {
sourceKind: "https",
manifestUrl: curatedManifestUrls.primary,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
manifestJson: existing.manifestJson,
statsJson: existing.statsJson,
@@ -267,6 +272,7 @@ export function createPluginCatalogService(deps: {
sourceKind: "https",
manifestUrl: curatedManifestUrls.primary,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
manifestJson: JSON.stringify(BUNDLED_CURATED_MARKETPLACE),
statsJson: existing?.statsJson ?? null,
@@ -310,6 +316,7 @@ export function createPluginCatalogService(deps: {
official: isReservedMarketplace(row.name),
sourceKind: row.sourceKind,
source: marketplaceSourceDisplay(marketplaceSourceFromRow(row)),
+ subdirectory: row.sourceGitSubdirectory,
resolvedCommit: row.sourceGitCommit,
entryCount: catalog?.plugins.length ?? 0,
lastRefreshAt: row.lastSuccessfulRefreshAt,
@@ -1018,9 +1025,9 @@ export function createPluginCatalogService(deps: {
return [...reservedCollections.categories];
},
- async addMarketplace(rawSource) {
+ async addMarketplace(input) {
return withLock(ADD_LOCK_KEY, async () => {
- const source = parseMarketplaceSource(rawSource);
+ const source = parseMarketplaceSource(input.source, input.subdirectory);
if (source.kind === "git") await prepareMarketplaceStaging();
const materialized = await materializeMarketplace({
source,
diff --git a/apps/server/src/services/plugins/install-sources.ts b/apps/server/src/services/plugins/install-sources.ts
index 4b17da611d4..57acf206a5c 100644
--- a/apps/server/src/services/plugins/install-sources.ts
+++ b/apps/server/src/services/plugins/install-sources.ts
@@ -280,7 +280,10 @@ export function parsePluginSource(source: string): ParsedPluginSource {
return { kind: "path", path };
}
-export function normalizePluginSubdirectory(value: string): string {
+export function normalizePluginSubdirectory(
+ value: string,
+ label = "plugin subdirectory",
+): string {
const trimmed = value.startsWith("./") ? value.slice(2) : value;
if (
trimmed.length === 0 ||
@@ -288,11 +291,11 @@ export function normalizePluginSubdirectory(value: string): string {
trimmed.includes("\\") ||
/^[a-zA-Z]:/.test(trimmed)
) {
- throw new Error(`invalid plugin subdirectory "${value}"`);
+ throw new Error(`invalid ${label} "${value}"`);
}
- assertSafeSegments(trimmed, "plugin subdirectory");
+ assertSafeSegments(trimmed, label);
if (trimmed.split("/").includes(".git")) {
- throw new Error(`invalid plugin subdirectory "${value}"`);
+ throw new Error(`invalid ${label} "${value}"`);
}
return trimmed;
}
diff --git a/apps/server/src/services/system/feature-telemetry.ts b/apps/server/src/services/system/feature-telemetry.ts
new file mode 100644
index 00000000000..cf7d6dc2411
--- /dev/null
+++ b/apps/server/src/services/system/feature-telemetry.ts
@@ -0,0 +1,152 @@
+import {
+ publicApiRoutes as routes,
+ featureUsedPropertiesSchema,
+ type CoreFeature,
+ type FeatureUsedProperties,
+} from "@bb/server-contract";
+import type { Context, Next } from "hono";
+import { resolveRequestAppSurface } from "../../request-context.js";
+import type { TelemetryService } from "./telemetry.js";
+
+const registry = new Map();
+
+function register(
+ feature: CoreFeature,
+ entries: readonly { path: string; method: string }[],
+): void {
+ for (const entry of entries)
+ registry.set(`${entry.method.toUpperCase()} /api/v1${entry.path}`, feature);
+}
+
+register("thread", [
+ routes.threads.create,
+ routes.threads.fork,
+ routes.threads.update,
+ routes.threads.delete,
+ routes.threads.send,
+ routes.threads.editMessage,
+ routes.threads.retry,
+ routes.threads.stop,
+ routes.threads.compact,
+ routes.threads.clearContext,
+ routes.threads.cancelPlan,
+ routes.threads.clearGoal,
+ routes.threads.open,
+ routes.threads.pin,
+ routes.threads.unpin,
+ routes.threads.archiveAll,
+ routes.threads.unarchive,
+ routes.threads.resolveInteraction,
+ routes.threads.respondToInteraction,
+ routes.threads.cancelInteraction,
+ routes.threads.createQueuedMessage,
+ routes.threads.updateQueuedMessage,
+ routes.threads.sendQueuedMessage,
+ routes.threads.deleteQueuedMessage,
+ routes.threads.reorderQueuedMessage,
+ routes.threads.setQueuedMessageGroupBoundary,
+ routes.threadSections.create,
+ routes.threadSections.update,
+ routes.threadSections.delete,
+]);
+register("project", [
+ routes.projects.create,
+ routes.projects.update,
+ routes.projects.delete,
+ routes.projects.createSource,
+ routes.projects.updateSource,
+ routes.projects.deleteSource,
+]);
+register("environment", [
+ routes.environments.cleanup,
+ routes.environments.delete,
+ routes.environments.update,
+ routes.environments.archiveThreads,
+ routes.threads.restoreEnvironment,
+]);
+register("file", [
+ routes.files.write,
+ routes.files.mkdir,
+ routes.files.move,
+ routes.files.remove,
+ routes.files.createPreview,
+ routes.projects.uploadAttachment,
+ routes.projects.copyAttachments,
+ routes.projects.updateSkill,
+ routes.projects.deleteSkill,
+]);
+register("git", [routes.environments.actions]);
+register("terminal", [
+ routes.terminals.create,
+ routes.terminals.restart,
+ routes.terminals.close,
+]);
+register("browser", [
+ routes.desktopBrowsers.createTab,
+ routes.desktopBrowsers.acquireControl,
+ routes.desktopBrowsers.closeTab,
+ routes.desktopBrowsers.revealTab,
+ routes.desktopBrowsers.captureTab,
+ routes.desktopBrowsers.importCookies,
+]);
+register("remote_access", [
+ routes.hosts.create,
+ routes.hosts.createJoinCode,
+ routes.hosts.reconnect,
+ routes.hosts.suspend,
+ routes.hosts.resume,
+ routes.hosts.delete,
+ routes.hosts.updatePermissionCeiling,
+ routes.server.startMove,
+ routes.server.cancelMove,
+ routes.server.export,
+]);
+register("provider", [
+ routes.hosts.providerCliInstall,
+ routes.system.providerEnabled,
+]);
+register("voice", [routes.system.voiceTranscription]);
+register("settings", [
+ routes.system.generalSettings,
+ routes.system.keyboardSettings,
+ routes.system.appearance,
+ routes.system.experiments,
+ routes.system.setAiServiceSelection,
+ routes.system.installCliSkills,
+ routes.system.applyAppUpdate,
+ routes.system.setMachineEnvironmentVariable,
+ routes.system.deleteMachineEnvironmentVariable,
+ routes.system.replaceMachineEnvironment,
+ routes.projects.replaceMachineEnvironment,
+ routes.projects.setMachineEnvironmentVariable,
+ routes.projects.deleteMachineEnvironmentVariable,
+]);
+
+export function captureFeatureUsed(
+ telemetry: TelemetryService,
+ properties: FeatureUsedProperties,
+): void {
+ try {
+ const result = featureUsedPropertiesSchema.safeParse(properties);
+ if (result.success)
+ telemetry.capture({ name: "feature_used", properties: result.data });
+ } catch {}
+}
+
+export function coreFeatureTelemetry(getTelemetry: () => TelemetryService) {
+ return async (context: Context, next: Next): Promise => {
+ await next();
+ if (context.res.status < 200 || context.res.status >= 300) return;
+ const feature = registry.get(
+ `${context.req.method} ${context.req.routePath}`,
+ );
+ if (feature === undefined) return;
+ captureFeatureUsed(getTelemetry(), {
+ feature,
+ feature_scope: "core",
+ interaction: "invoked",
+ trigger:
+ resolveRequestAppSurface(context) === "api" ? "unknown" : "person",
+ });
+ };
+}
diff --git a/apps/server/src/services/system/telemetry.ts b/apps/server/src/services/system/telemetry.ts
index 10273995a65..6b16bfc6c65 100644
--- a/apps/server/src/services/system/telemetry.ts
+++ b/apps/server/src/services/system/telemetry.ts
@@ -5,7 +5,10 @@ import { DEFAULTS } from "@bb/config/defaults";
import type { ServerConfig } from "@bb/config/server";
import { readOrCreateSecretFile } from "@bb/secret-storage";
import type { AppSurface, RequestAppSurface } from "@bb/config/app-surface";
-import type { RecordTelemetryEventRequest } from "@bb/server-contract";
+import type {
+ FeatureUsedProperties,
+ RecordTelemetryEventRequest,
+} from "@bb/server-contract";
import type { ServerLogger } from "../../types.js";
const POSTHOG_INGESTION_URL = "https://us.i.posthog.com/capture/";
@@ -14,6 +17,7 @@ const TELEMETRY_ID_FILE_NAME = "telemetry-id";
const telemetryAppSurfaceStorage = new AsyncLocalStorage();
export type TelemetryEvent =
+ | { name: "feature_used"; properties: FeatureUsedProperties }
| { name: "app_started" }
| { name: "telemetry_disabled" }
| {
diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts
index 37622fecac9..c0c82e63639 100644
--- a/apps/server/test/public/public-thread-data.test.ts
+++ b/apps/server/test/public/public-thread-data.test.ts
@@ -2896,7 +2896,11 @@ describe("public thread data routes", () => {
.where(eq(events.threadId, thread.id))
.all(),
).toEqual([]);
- expect(capture).not.toHaveBeenCalled();
+ expect(
+ capture.mock.calls.filter(
+ ([event]) => event.name === "user_message_sent",
+ ),
+ ).toEqual([]);
});
});
diff --git a/apps/server/test/services/plugin-catalog/plugin-catalog-routes.test.ts b/apps/server/test/services/plugin-catalog/plugin-catalog-routes.test.ts
index 11701b30ef9..6bf718647c9 100644
--- a/apps/server/test/services/plugin-catalog/plugin-catalog-routes.test.ts
+++ b/apps/server/test/services/plugin-catalog/plugin-catalog-routes.test.ts
@@ -281,6 +281,23 @@ describe("plugin catalog routes", () => {
const badBody = await postJson(app, "/marketplaces", { url: ACME_URL });
expect(badBody.status).toBe(422);
+ const emptySubdirectory = await postJson(app, "/marketplaces", {
+ source: ACME_URL,
+ subdirectory: "",
+ });
+ expect(emptySubdirectory.status).toBe(422);
+
+ const misplacedSubdirectory = await postJson(app, "/marketplaces", {
+ source: ACME_URL,
+ subdirectory: "bb",
+ });
+ expect(misplacedSubdirectory.status).toBe(422);
+ await expect(misplacedSubdirectory.json()).resolves.toMatchObject({
+ error: expect.stringContaining(
+ "a subdirectory applies to git: sources only",
+ ),
+ });
+
const badName = await app.request("/marketplaces/Not%20A%20Name", {
method: "DELETE",
});
diff --git a/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts b/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts
index 6e4cd3a217e..580ec563b6f 100644
--- a/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts
+++ b/apps/server/test/services/plugin-catalog/plugin-catalog-service.test.ts
@@ -376,6 +376,7 @@ describe("plugin catalog service", () => {
sourceKind: "https",
manifestUrl: V1_MANIFEST_URL,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
manifestJson: JSON.stringify(savedManifest),
statsJson: null,
@@ -604,6 +605,7 @@ describe("plugin catalog service", () => {
sourceKind: "path",
manifestUrl: dataDir,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
manifestJson: JSON.stringify(
manifestV2([remoteEntry({ icon: "Zap" })], {
@@ -642,6 +644,7 @@ describe("plugin catalog service", () => {
sourceKind: "https",
manifestUrl: MANIFEST_URL,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
manifestJson: JSON.stringify(
manifestV2([], {
@@ -1114,7 +1117,10 @@ describe("plugin catalog service", () => {
},
});
- await catalog.addMarketplace(thirdPartyManifest);
+ await catalog.addMarketplace({
+ source: thirdPartyManifest,
+ subdirectory: null,
+ });
await catalog.refreshMarketplaces({ attemptedAt: 2_000 });
expect((await catalog.search("widgets"))[0]?.installs).toBeNull();
expect(statsRequests).toEqual([STATS_URL]);
diff --git a/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts b/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts
index 6c15215ac3c..52078a1e22b 100644
--- a/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts
+++ b/apps/server/test/services/plugin-catalog/third-party-marketplaces.test.ts
@@ -1,5 +1,13 @@
import { execFile } from "node:child_process";
-import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
+import {
+ mkdir,
+ mkdtemp,
+ readdir,
+ rm,
+ stat,
+ symlink,
+ writeFile,
+} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -162,6 +170,7 @@ describe("third-party marketplaces", () => {
name: string;
plugins: unknown[];
icon?: Buffer;
+ subdirectory?: string;
}): Promise {
const repo = await mkdtemp(join(tmpdir(), "bb-marketplace-repo-"));
cleanup.push(repo);
@@ -170,13 +179,18 @@ describe("third-party marketplaces", () => {
cwd: repo,
});
await run("git", ["config", "user.name", "Test"], { cwd: repo });
+ const catalogDir =
+ args.subdirectory === undefined
+ ? repo
+ : join(repo, ...args.subdirectory.split("/"));
+ await mkdir(catalogDir, { recursive: true });
await writeFile(
- join(repo, "marketplace.json"),
+ join(catalogDir, "marketplace.json"),
JSON.stringify(manifest(args.name, args.plugins)),
);
if (args.icon !== undefined) {
- await mkdir(join(repo, "icons"), { recursive: true });
- await writeFile(join(repo, "icons", "notes.svg"), args.icon);
+ await mkdir(join(catalogDir, "icons"), { recursive: true });
+ await writeFile(join(catalogDir, "icons", "notes.svg"), args.icon);
}
await run("git", ["-c", "core.excludesFile=/dev/null", "add", "."], {
cwd: repo,
@@ -196,10 +210,71 @@ describe("third-party marketplaces", () => {
});
});
+ it.each([
+ "catalog-next",
+ "origin/catalog-next",
+ "catalog-release",
+ "commit",
+ ])("reads the selected catalog commit for ref %s", async (ref) => {
+ await run("git", ["checkout", "-qb", "catalog-next"], { cwd: repo });
+ await writeFile(
+ join(repo, "marketplace.json"),
+ JSON.stringify(manifest("acme-plugins", [entry({ id: "next-notes" })])),
+ );
+ await run("git", ["commit", "-qam", "next catalog"], { cwd: repo });
+ await run("git", ["tag", "-am", "catalog release", "catalog-release"], {
+ cwd: repo,
+ });
+ const commit = (
+ await run("git", ["rev-parse", "HEAD"], { cwd: repo })
+ ).stdout.trim();
+ await run("git", ["checkout", "-q", "main"], { cwd: repo });
+ const catalog = service({ fetch: marketplaceFetch({}) });
+
+ const added = await catalog.addMarketplace({
+ source: `git:${repo}@${ref === "commit" ? commit : ref}`,
+ subdirectory: null,
+ });
+
+ expect(added.resolvedCommit).toBe(commit);
+ expect(await catalog.search("Acme Notes")).toEqual([
+ expect.objectContaining({ entryId: "next-notes" }),
+ ]);
+ if (ref === "catalog-next") {
+ await run("git", ["checkout", "-q", "catalog-next"], { cwd: repo });
+ await writeFile(
+ join(repo, "marketplace.json"),
+ JSON.stringify(
+ manifest("acme-plugins", [entry({ id: "refreshed-notes" })]),
+ ),
+ );
+ await run("git", ["commit", "-qam", "refresh catalog"], { cwd: repo });
+ const refreshedCommit = (
+ await run("git", ["rev-parse", "HEAD"], { cwd: repo })
+ ).stdout.trim();
+ await run("git", ["checkout", "-q", "main"], { cwd: repo });
+
+ const [refreshed] = await catalog.refreshMarketplaces({
+ name: "acme-plugins",
+ });
+
+ expect(refreshed).toMatchObject({
+ ok: true,
+ marketplace: { resolvedCommit: refreshedCommit },
+ });
+ expect(await catalog.search("Acme Notes")).toEqual([
+ expect.objectContaining({ entryId: "refreshed-notes" }),
+ ]);
+ }
+ });
+
it("adds, refreshes, and removes a git marketplace read from a real checkout", async () => {
const catalog = service({ fetch: marketplaceFetch({}) });
- const added = await catalog.addMarketplace(`git:${repo}@main`);
+ const added = await catalog.addMarketplace({
+ source: `git:${repo}@main`,
+ subdirectory: null,
+ });
expect(added).toMatchObject({
name: "acme-plugins",
displayName: "Acme Plugins",
@@ -250,7 +325,10 @@ describe("third-party marketplaces", () => {
"bb-community",
]);
- const readded = await catalog.addMarketplace(`git:${repo}@main`);
+ const readded = await catalog.addMarketplace({
+ source: `git:${repo}@main`,
+ subdirectory: null,
+ });
expect(readded).toMatchObject({
name: "acme-plugins",
entryCount: 2,
@@ -262,6 +340,162 @@ describe("third-party marketplaces", () => {
});
});
+ describe("git marketplace in a subdirectory", () => {
+ let repo: string;
+
+ beforeEach(async () => {
+ repo = await gitMarketplace({
+ name: "acme-plugins",
+ plugins: [entry({ icon: { url: "./icons/notes.svg" } })],
+ icon: VALID_SVG,
+ subdirectory: "tools/bb",
+ });
+ await writeFile(
+ join(repo, "marketplace.json"),
+ JSON.stringify(manifest("root-plugins", [entry({ id: "root" })])),
+ );
+ await run("git", ["add", "marketplace.json"], { cwd: repo });
+ await run("git", ["commit", "-qm", "root catalog"], { cwd: repo });
+ });
+
+ async function stagedCheckouts(): Promise {
+ return readdir(join(dataDir, "marketplaces", "staging")).catch(() => []);
+ }
+
+ it("reads the catalog and its icons from the subdirectory on add and on refresh", async () => {
+ const catalog = service({ fetch: marketplaceFetch({}) });
+
+ const added = await catalog.addMarketplace({
+ source: `git:${repo}@main`,
+ subdirectory: "./tools/bb",
+ });
+
+ expect(added).toMatchObject({
+ name: "acme-plugins",
+ sourceKind: "git",
+ source: `git:${repo}@main`,
+ subdirectory: "tools/bb",
+ entryCount: 1,
+ });
+ expect(
+ getPluginMarketplaceIcon(db, "acme-plugins", "notes")?.contentType,
+ ).toBe("image/svg+xml");
+ expect(await stagedCheckouts()).toEqual([]);
+
+ await writeFile(
+ join(repo, "tools", "bb", "marketplace.json"),
+ JSON.stringify(
+ manifest("acme-plugins", [
+ entry({ icon: { url: "./icons/notes.svg" } }),
+ entry({ id: "status", displayName: "Acme Status", icon: "Zap" }),
+ ]),
+ ),
+ );
+ await run("git", ["commit", "-qam", "second"], { cwd: repo });
+ const [refreshed] = await catalog.refreshMarketplaces({
+ name: "acme-plugins",
+ });
+
+ expect(refreshed).toMatchObject({
+ name: "acme-plugins",
+ ok: true,
+ marketplace: { subdirectory: "tools/bb", entryCount: 2 },
+ });
+ expect(getPluginMarketplace(db, "acme-plugins")).toMatchObject({
+ sourceGitRef: "main",
+ sourceGitSubdirectory: "tools/bb",
+ });
+ expect(
+ catalog
+ .listMarketplaces()
+ .map((marketplace) => [marketplace.name, marketplace.subdirectory]),
+ ).toEqual([
+ ["bb-official", null],
+ ["bb-community", null],
+ ["acme-plugins", "tools/bb"],
+ ]);
+ });
+
+ it("keeps reading the repository root when no subdirectory is given", async () => {
+ const catalog = service({ fetch: marketplaceFetch({}) });
+
+ const added = await catalog.addMarketplace({
+ source: `git:${repo}@main`,
+ subdirectory: null,
+ });
+
+ expect(added).toMatchObject({ name: "root-plugins", subdirectory: null });
+ });
+
+ it.each([
+ ["../outside", /invalid marketplace subdirectory/u],
+ ["/etc", /invalid marketplace subdirectory/u],
+ ["tools/../..", /invalid marketplace subdirectory/u],
+ [".git", /invalid marketplace subdirectory/u],
+ ["tools\\bb", /invalid marketplace subdirectory/u],
+ [
+ "tools/missing",
+ /"tools\/missing" is not a directory in the repository/u,
+ ],
+ [
+ "tools/bb/marketplace.json",
+ /"tools\/bb\/marketplace.json" is not a directory in the repository/u,
+ ],
+ ])("refuses the subdirectory %s", async (subdirectory, message) => {
+ const catalog = service({ fetch: marketplaceFetch({}) });
+
+ await expect(
+ catalog.addMarketplace({ source: `git:${repo}@main`, subdirectory }),
+ ).rejects.toThrow(message);
+
+ expect(getPluginMarketplace(db, "acme-plugins")).toBeUndefined();
+ expect(await stagedCheckouts()).toEqual([]);
+ });
+
+ it.skipIf(process.platform === "win32")(
+ "refuses a subdirectory that is a symlink out of the checkout",
+ async () => {
+ const outside = await mkdtemp(join(tmpdir(), "bb-marketplace-out-"));
+ cleanup.push(outside);
+ await writeFile(
+ join(outside, "marketplace.json"),
+ JSON.stringify(manifest("acme-plugins", [entry()])),
+ );
+ await symlink(outside, join(repo, "escape"));
+ await run("git", ["add", "escape"], { cwd: repo });
+ await run("git", ["commit", "-qm", "escape"], { cwd: repo });
+ const catalog = service({ fetch: marketplaceFetch({}) });
+
+ await expect(
+ catalog.addMarketplace({
+ source: `git:${repo}@main`,
+ subdirectory: "escape",
+ }),
+ ).rejects.toThrow(
+ /marketplace subdirectory resolves outside its root/u,
+ );
+
+ expect(getPluginMarketplace(db, "acme-plugins")).toBeUndefined();
+ expect(await stagedCheckouts()).toEqual([]);
+ },
+ );
+
+ it.each([ACME_URL, "path:/work/acme-marketplace"])(
+ "refuses a subdirectory for the source %s",
+ async (source) => {
+ const catalog = service({
+ fetch: marketplaceFetch({
+ [ACME_URL]: manifest("acme-plugins", [entry()]),
+ }),
+ });
+
+ await expect(
+ catalog.addMarketplace({ source, subdirectory: "bb" }),
+ ).rejects.toThrow(/a subdirectory applies to git: sources only/u);
+ },
+ );
+ });
+
it("removes a checkout left by a prior process crash", async () => {
const repo = await gitMarketplace({
name: "acme-plugins",
@@ -277,7 +511,10 @@ describe("third-party marketplaces", () => {
await writeFile(join(staleCheckout, "marketplace.json"), "stale");
const catalog = service({ fetch: marketplaceFetch({}) });
- await catalog.addMarketplace(`git:${repo}@main`);
+ await catalog.addMarketplace({
+ source: `git:${repo}@main`,
+ subdirectory: null,
+ });
expect(await stat(staleCheckout).catch(() => null)).toBeNull();
});
@@ -297,7 +534,10 @@ describe("third-party marketplaces", () => {
);
const catalog = service({ fetch: marketplaceFetch({}) });
- const added = await catalog.addMarketplace(`path:${directory}`);
+ const added = await catalog.addMarketplace({
+ source: `path:${directory}`,
+ subdirectory: null,
+ });
expect(added).toMatchObject({
name: "acme-plugins",
sourceKind: "path",
@@ -321,9 +561,9 @@ describe("third-party marketplaces", () => {
}),
});
- await expect(catalog.addMarketplace(bundledUrl)).rejects.toThrow(
- /not allowed in fetched or third-party documents/u,
- );
+ await expect(
+ catalog.addMarketplace({ source: bundledUrl, subdirectory: null }),
+ ).rejects.toThrow(/not allowed in fetched or third-party documents/u);
});
it("refuses name collisions and reserved marketplace names", async () => {
@@ -343,15 +583,24 @@ describe("third-party marketplaces", () => {
}),
});
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
await expect(
- catalog.addMarketplace("https://other.test/marketplace.json"),
+ catalog.addMarketplace({
+ source: "https://other.test/marketplace.json",
+ subdirectory: null,
+ }),
).rejects.toThrow('marketplace "acme-plugins" is already added');
await expect(
- catalog.addMarketplace("https://impostor.test/marketplace.json"),
+ catalog.addMarketplace({
+ source: "https://impostor.test/marketplace.json",
+ subdirectory: null,
+ }),
).rejects.toThrow(/"bb-community" is reserved/);
await expect(
- catalog.addMarketplace("https://official-impostor.test/marketplace.json"),
+ catalog.addMarketplace({
+ source: "https://official-impostor.test/marketplace.json",
+ subdirectory: null,
+ }),
).rejects.toThrow(/"bb-official" is reserved/);
await expect(catalog.removeMarketplace("bb-community")).rejects.toThrow(
/cannot be removed/,
@@ -382,7 +631,7 @@ describe("third-party marketplaces", () => {
}),
});
await refreshCuratedMarketplace(catalog, 1_000);
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
await expect(catalog.install({ entryId: "notes" })).rejects.toThrow(
"notes@bb-community, notes@acme-plugins",
@@ -454,7 +703,7 @@ describe("third-party marketplaces", () => {
}),
});
await refreshCuratedMarketplace(catalog, 1_000);
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
await expect(
catalog.install({
@@ -485,7 +734,7 @@ describe("third-party marketplaces", () => {
)
: new Response("not found", { status: 404 }),
});
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
const plan = await catalog.installPlan({
entryId: "notes",
marketplace: "acme-plugins",
@@ -511,7 +760,7 @@ describe("third-party marketplaces", () => {
[ACME_URL]: manifest("acme-plugins", [entry()]),
}),
});
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
upsertInstalledPlugin(db, {
id: "notes",
source: "git:https://github.com/acme/plugins.git@semver:^1.0.0",
@@ -586,7 +835,7 @@ describe("third-party marketplaces", () => {
),
});
await refreshCuratedMarketplace(catalog, 1_000);
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
const official = getPluginMarketplaceIcon(
db,
@@ -630,7 +879,7 @@ describe("third-party marketplaces", () => {
},
});
await refreshCuratedMarketplace(catalog, 1_000);
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
acmeFails = true;
const results = await catalog.refreshMarketplaces({ attemptedAt: 2_000 });
@@ -677,7 +926,7 @@ describe("third-party marketplaces", () => {
}),
});
await refreshCuratedMarketplace(catalog, 1_000);
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
const results = await catalog.search("");
expect(
@@ -738,7 +987,7 @@ describe("third-party marketplaces", () => {
]),
}),
});
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
const plan = await catalog.installPlan({
entryId: "notes",
@@ -779,7 +1028,7 @@ describe("third-party marketplaces", () => {
]),
}),
});
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
const plan = await catalog.installPlan({
entryId: "notes",
@@ -814,7 +1063,7 @@ describe("third-party marketplaces", () => {
]),
}),
});
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
const plan = await catalog.installPlan({
entryId: "notes",
@@ -882,7 +1131,10 @@ describe("third-party marketplaces", () => {
);
const catalog = service({ fetch: marketplaceFetch({}) });
- await catalog.addMarketplace(`path:${directory}`);
+ await catalog.addMarketplace({
+ source: `path:${directory}`,
+ subdirectory: null,
+ });
expect(getPluginMarketplace(db, "acme-plugins")?.lastError).toBeNull();
expect(await catalog.search("Acme Notes")).toEqual([
expect.objectContaining({ entryId: "notes" }),
@@ -905,7 +1157,7 @@ describe("third-party marketplaces", () => {
integrity: `sha512-${resolvedVersion}`,
}),
});
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
const plan = await catalog.installPlan({
entryId: "notes",
@@ -981,7 +1233,7 @@ describe("third-party marketplaces", () => {
};
},
});
- await catalog.addMarketplace(ACME_URL);
+ await catalog.addMarketplace({ source: ACME_URL, subdirectory: null });
const plan = await catalog.installPlan({
entryId: "notes",
marketplace: "acme-plugins",
@@ -1023,22 +1275,31 @@ describe("third-party marketplaces", () => {
}),
});
- await expect(catalog.addMarketplace(ACME_URL)).rejects.toThrow(
- /invalid marketplace manifest/u,
- );
+ await expect(
+ catalog.addMarketplace({ source: ACME_URL, subdirectory: null }),
+ ).rejects.toThrow(/invalid marketplace manifest/u);
expect(getPluginMarketplace(db, "a".repeat(65))).toBeUndefined();
});
it("refuses a marketplace source bb cannot interpret", async () => {
const catalog = service({ fetch: marketplaceFetch({}) });
- await expect(catalog.addMarketplace("acme/marketplace")).rejects.toThrow(
- /expected "https:/u,
- );
await expect(
- catalog.addMarketplace("http://acme.test/marketplace.json"),
+ catalog.addMarketplace({
+ source: "acme/marketplace",
+ subdirectory: null,
+ }),
+ ).rejects.toThrow(/expected "https:/u);
+ await expect(
+ catalog.addMarketplace({
+ source: "http://acme.test/marketplace.json",
+ subdirectory: null,
+ }),
).rejects.toThrow(/plain http is refused/u);
await expect(
- catalog.addMarketplace("git:https://acme.test/repo.git@^1.0.0"),
+ catalog.addMarketplace({
+ source: "git:https://acme.test/repo.git@^1.0.0",
+ subdirectory: null,
+ }),
).rejects.toThrow(/names one branch, tag, or commit/u);
});
});
diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
index a118d0f3eda..fc31a8f77b0 100644
--- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
+++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
@@ -21,6 +21,7 @@ import {
type PluginMessageActionRegistration,
type ThreadChatMessageReference,
type PluginMessageDirectiveProps,
+ type ExperimentalCodeFenceRendererProps,
type PluginNavPanelProps,
type PluginNavPanelRegistration,
type PluginNewThreadPanelProps,
@@ -279,6 +280,7 @@ type SlotPropsByName = {
experimental_sourceCodeRenderer: PluginSourceCodeRendererProps;
experimental_diffRenderer: PluginDiffRendererProps;
messageDirective: PluginMessageDirectiveProps;
+ experimental_codeFenceRenderer: ExperimentalCodeFenceRendererProps;
messageAction: PluginMessageActionContext;
commandPaletteAction: PluginCommandContext;
experimental_providerIcon: PluginProviderIconRegistration;
@@ -386,6 +388,14 @@ const FRONTEND_SLOT_PROP_FIELDS = {
"Original",
],
messageDirective: ["attributes", "source", "message", "openWorkspaceFile"],
+ experimental_codeFenceRenderer: [
+ "code",
+ "language",
+ "status",
+ "index",
+ "message",
+ "Original",
+ ],
messageAction: [
"threadId",
"message",
diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts
index 82d19257790..1aeda04cbc1 100644
--- a/apps/server/test/services/plugins/plugin-service.test.ts
+++ b/apps/server/test/services/plugins/plugin-service.test.ts
@@ -940,6 +940,7 @@ describe("plugin service", () => {
sourceKind: "https",
manifestUrl: "https://plugins.acme.test/marketplace.json",
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
manifestJson: JSON.stringify({
schemaVersion: 2,
@@ -1032,6 +1033,7 @@ describe("plugin service", () => {
sourceKind: "https",
manifestUrl: "https://plugins.acme.test/marketplace.json",
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
manifestJson: JSON.stringify({
schemaVersion: 2,
diff --git a/apps/server/test/services/plugins/plugin-update.test.ts b/apps/server/test/services/plugins/plugin-update.test.ts
index a9d20848eec..b72f22d64da 100644
--- a/apps/server/test/services/plugins/plugin-update.test.ts
+++ b/apps/server/test/services/plugins/plugin-update.test.ts
@@ -676,6 +676,7 @@ describe("plugin update service and routes", () => {
sourceKind: "path",
manifestUrl: workDir,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
statsJson: null,
manifestJson: JSON.stringify({
diff --git a/apps/server/test/services/telemetry.test.ts b/apps/server/test/services/telemetry.test.ts
index 925490d07b5..842cdaad8a3 100644
--- a/apps/server/test/services/telemetry.test.ts
+++ b/apps/server/test/services/telemetry.test.ts
@@ -330,7 +330,7 @@ describe("telemetry service", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
expect((await put(defaultAppSettings)).status).toBe(200);
telemetry.capture({ name: "app_started" });
- expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(fetchMock).toHaveBeenCalledTimes(4);
});
});
diff --git a/apps/server/test/system/telemetry-events.test.ts b/apps/server/test/system/telemetry-events.test.ts
index 9d81b54bdf2..6d6c98c1eaa 100644
--- a/apps/server/test/system/telemetry-events.test.ts
+++ b/apps/server/test/system/telemetry-events.test.ts
@@ -1,3 +1,4 @@
+import { defaultAppSettings } from "@bb/domain";
import { describe, expect, it, vi } from "vitest";
import type { TelemetryService } from "../../src/services/system/telemetry.js";
import { readJson } from "../helpers/json.js";
@@ -16,16 +17,43 @@ describe("client telemetry events", () => {
await withTestHarness(async (harness) => {
const capture = vi.fn();
harness.deps.telemetry = { ...harness.deps.telemetry, capture };
- const event = {
- name: "onboarding_step_skipped",
- properties: { step: "projects", entry: "first_run" },
- };
-
- const response = await post(harness, event);
-
- expect(response.status).toBe(200);
- await expect(readJson(response)).resolves.toEqual({ ok: true });
- expect(capture).toHaveBeenCalledExactlyOnceWith(event);
+ const events = [
+ {
+ name: "onboarding_step_skipped",
+ properties: { step: "projects", entry: "first_run" },
+ },
+ {
+ name: "onboarding_agent_state",
+ properties: {
+ entry: "first_run",
+ agent_installed: true,
+ agent_ready: false,
+ },
+ },
+ {
+ name: "onboarding_agent_state",
+ properties: {
+ entry: "replay",
+ agent_installed: null,
+ agent_ready: null,
+ },
+ },
+ {
+ name: "onboarding_sign_in",
+ properties: { entry: "first_run", outcome: "failed" },
+ },
+ {
+ name: "new_thread_submit",
+ properties: { outcome: "failed" },
+ },
+ ];
+ for (const event of events) {
+ capture.mockClear();
+ const response = await post(harness, event);
+ expect(response.status).toBe(200);
+ await expect(readJson(response)).resolves.toEqual({ ok: true });
+ expect(capture).toHaveBeenCalledExactlyOnceWith(event);
+ }
});
});
@@ -45,6 +73,26 @@ describe("client telemetry events", () => {
expect(unknown.status).toBe(400);
expect(extra.status).toBe(400);
+ for (const event of [
+ {
+ name: "onboarding_agent_state",
+ properties: {
+ entry: "first_run",
+ agent_installed: "account@example.com",
+ agent_ready: false,
+ },
+ },
+ {
+ name: "onboarding_sign_in",
+ properties: { entry: "first_run", outcome: "account@example.com" },
+ },
+ {
+ name: "new_thread_submit",
+ properties: { outcome: "failed", message: "private prompt" },
+ },
+ ]) {
+ expect((await post(harness, event)).status).toBe(400);
+ }
expect(capture).not.toHaveBeenCalled();
});
});
@@ -102,4 +150,107 @@ describe("client telemetry events", () => {
expect(capture.mock.calls).toEqual([[shown], [used]]);
});
});
+ it("accepts automation and split view events and rejects values outside their enums or pane range", async () => {
+ await withTestHarness(async (harness) => {
+ const capture = vi.fn();
+ harness.deps.telemetry = { ...harness.deps.telemetry, capture };
+ const automation = {
+ name: "automation_created",
+ properties: { trigger: "schedule", mode: "script", origin: "agent" },
+ };
+ const split = { name: "split_view_opened", properties: { panes: 2 } };
+
+ const accepted = [
+ await post(harness, automation),
+ await post(harness, split),
+ ];
+ const rejected = [
+ await post(harness, {
+ name: "automation_created",
+ properties: { ...automation.properties, cron: "0 9 * * *" },
+ }),
+ await post(harness, {
+ name: "automation_created",
+ properties: { ...automation.properties, trigger: "interval" },
+ }),
+ await post(harness, {
+ name: "split_view_opened",
+ properties: { panes: 1 },
+ }),
+ await post(harness, {
+ name: "split_view_opened",
+ properties: { panes: 9 },
+ }),
+ ];
+
+ expect(accepted.map((response) => response.status)).toEqual([200, 200]);
+ expect(rejected.map((response) => response.status)).toEqual([
+ 400, 400, 400, 400,
+ ]);
+ expect(capture.mock.calls).toEqual([[automation], [split]]);
+ });
+ });
+ it("counts explicit core actions once, without exporting their inputs or background reads", async () => {
+ await withTestHarness(async (harness) => {
+ const capture = vi.fn();
+ harness.deps.telemetry = { ...harness.deps.telemetry, capture };
+ const response = await harness.app.request("/api/v1/settings/general", {
+ method: "PUT",
+ headers: {
+ "content-type": "application/json",
+ "x-bb-app-surface": "web",
+ },
+ body: JSON.stringify({
+ ...defaultAppSettings,
+ showKeyboardHints: false,
+ }),
+ });
+ expect(response.status).toBe(200);
+ expect(capture).toHaveBeenCalledExactlyOnceWith({
+ name: "feature_used",
+ properties: {
+ feature: "settings",
+ feature_scope: "core",
+ interaction: "invoked",
+ trigger: "person",
+ },
+ });
+ capture.mockClear();
+ await harness.app.request("/api/v1/system/config");
+ const rejected = await harness.app.request("/api/v1/settings/general", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ expect(rejected.status).toBe(400);
+ expect(capture).not.toHaveBeenCalled();
+ });
+ });
+
+ it("accepts fixed feature values but rejects identities and caller-supplied plugin provenance", async () => {
+ await withTestHarness(async (harness) => {
+ const capture = vi.fn();
+ harness.deps.telemetry = { ...harness.deps.telemetry, capture };
+ const event = {
+ name: "feature_used",
+ properties: {
+ feature: "split_view",
+ feature_scope: "core",
+ interaction: "opened",
+ trigger: "unknown",
+ },
+ };
+ expect((await post(harness, event)).status).toBe(200);
+ for (const properties of [
+ { ...event.properties, feature: "my-private-command" },
+ { ...event.properties, threadId: "private-thread" },
+ { ...event.properties, plugin_source: "builtin" },
+ { ...event.properties, feature_scope: "plugin" },
+ ])
+ expect((await post(harness, { ...event, properties })).status).toBe(
+ 400,
+ );
+ expect(capture).toHaveBeenCalledExactlyOnceWith(event);
+ });
+ });
});
diff --git a/apps/web/src/landing/landing.css b/apps/web/src/landing/landing.css
index 3f304a771dd..5a56f078d5e 100644
--- a/apps/web/src/landing/landing.css
+++ b/apps/web/src/landing/landing.css
@@ -3473,3 +3473,9 @@ html.js .mock[data-construct]:not(.constructing):not(.constructed) {
animation: none;
}
}
+
+.footer-brand .footer-publisher {
+ margin-top: 14px;
+ font-size: 12.5px;
+ color: var(--dim);
+}
diff --git a/apps/web/src/landing/site-chrome.tsx b/apps/web/src/landing/site-chrome.tsx
index dd65cc37320..558ee1c28f0 100644
--- a/apps/web/src/landing/site-chrome.tsx
+++ b/apps/web/src/landing/site-chrome.tsx
@@ -285,6 +285,9 @@ export function SiteFooter({ current }: { current?: string }) {
Software License
+
+ © {new Date().getFullYear()} Terragon Labs, Inc.
+
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 15d4ad6f6dd..aca3d01766a 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root";
+import { Route as UninstallRouteImport } from "./routes/uninstall";
import { Route as SitemapDotxmlRouteImport } from "./routes/sitemap[.]xml";
import { Route as RobotsDottxtRouteImport } from "./routes/robots[.]txt";
import { Route as PrivacyRouteImport } from "./routes/privacy";
@@ -46,6 +47,11 @@ import { Route as ApiAccountMeRouteImport } from "./routes/api.account.me";
import { Route as ApiAccountLinkStartRouteImport } from "./routes/api.account.link.start";
import { Route as ApiAccountLinkPollRouteImport } from "./routes/api.account.link.poll";
+const UninstallRoute = UninstallRouteImport.update({
+ id: "/uninstall",
+ path: "/uninstall",
+ getParentRoute: () => rootRouteImport,
+} as any);
const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({
id: "/sitemap.xml",
path: "/sitemap.xml",
@@ -244,6 +250,7 @@ export interface FileRoutesByFullPath {
"/privacy": typeof PrivacyRoute;
"/robots.txt": typeof RobotsDottxtRoute;
"/sitemap.xml": typeof SitemapDotxmlRoute;
+ "/uninstall": typeof UninstallRoute;
"/.well-known/apple-app-site-association": typeof DotwellKnownAppleAppSiteAssociationRoute;
"/.well-known/assetlinks.json": typeof DotwellKnownAssetlinksDotjsonRoute;
"/api/subscribe": typeof ApiSubscribeRoute;
@@ -282,6 +289,7 @@ export interface FileRoutesByTo {
"/privacy": typeof PrivacyRoute;
"/robots.txt": typeof RobotsDottxtRoute;
"/sitemap.xml": typeof SitemapDotxmlRoute;
+ "/uninstall": typeof UninstallRoute;
"/.well-known/apple-app-site-association": typeof DotwellKnownAppleAppSiteAssociationRoute;
"/.well-known/assetlinks.json": typeof DotwellKnownAssetlinksDotjsonRoute;
"/api/subscribe": typeof ApiSubscribeRoute;
@@ -321,6 +329,7 @@ export interface FileRoutesById {
"/privacy": typeof PrivacyRoute;
"/robots.txt": typeof RobotsDottxtRoute;
"/sitemap.xml": typeof SitemapDotxmlRoute;
+ "/uninstall": typeof UninstallRoute;
"/.well-known/apple-app-site-association": typeof DotwellKnownAppleAppSiteAssociationRoute;
"/.well-known/assetlinks.json": typeof DotwellKnownAssetlinksDotjsonRoute;
"/api/subscribe": typeof ApiSubscribeRoute;
@@ -361,6 +370,7 @@ export interface FileRouteTypes {
| "/privacy"
| "/robots.txt"
| "/sitemap.xml"
+ | "/uninstall"
| "/.well-known/apple-app-site-association"
| "/.well-known/assetlinks.json"
| "/api/subscribe"
@@ -399,6 +409,7 @@ export interface FileRouteTypes {
| "/privacy"
| "/robots.txt"
| "/sitemap.xml"
+ | "/uninstall"
| "/.well-known/apple-app-site-association"
| "/.well-known/assetlinks.json"
| "/api/subscribe"
@@ -437,6 +448,7 @@ export interface FileRouteTypes {
| "/privacy"
| "/robots.txt"
| "/sitemap.xml"
+ | "/uninstall"
| "/.well-known/apple-app-site-association"
| "/.well-known/assetlinks.json"
| "/api/subscribe"
@@ -476,6 +488,7 @@ export interface RootRouteChildren {
PrivacyRoute: typeof PrivacyRoute;
RobotsDottxtRoute: typeof RobotsDottxtRoute;
SitemapDotxmlRoute: typeof SitemapDotxmlRoute;
+ UninstallRoute: typeof UninstallRoute;
DotwellKnownAppleAppSiteAssociationRoute: typeof DotwellKnownAppleAppSiteAssociationRoute;
DotwellKnownAssetlinksDotjsonRoute: typeof DotwellKnownAssetlinksDotjsonRoute;
ApiSubscribeRoute: typeof ApiSubscribeRoute;
@@ -502,6 +515,13 @@ export interface RootRouteChildren {
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
+ "/uninstall": {
+ id: "/uninstall";
+ path: "/uninstall";
+ fullPath: "/uninstall";
+ preLoaderRoute: typeof UninstallRouteImport;
+ parentRoute: typeof rootRouteImport;
+ };
"/sitemap.xml": {
id: "/sitemap.xml";
path: "/sitemap.xml";
@@ -784,6 +804,7 @@ const rootRouteChildren: RootRouteChildren = {
PrivacyRoute: PrivacyRoute,
RobotsDottxtRoute: RobotsDottxtRoute,
SitemapDotxmlRoute: SitemapDotxmlRoute,
+ UninstallRoute: UninstallRoute,
DotwellKnownAppleAppSiteAssociationRoute:
DotwellKnownAppleAppSiteAssociationRoute,
DotwellKnownAssetlinksDotjsonRoute: DotwellKnownAssetlinksDotjsonRoute,
diff --git a/apps/web/src/routes/privacy.tsx b/apps/web/src/routes/privacy.tsx
index 539ac89f552..e7a48d3777e 100644
--- a/apps/web/src/routes/privacy.tsx
+++ b/apps/web/src/routes/privacy.tsx
@@ -9,8 +9,8 @@ const PAGE_TITLE = "Privacy — bb";
const PAGE_DESCRIPTION =
"What bb collects, what stays on your own machines, and what bb connect can see.";
-const LAST_UPDATED = "October 8, 2026";
-const CONTACT_EMAIL = "sawyer@terragonlabs.com";
+const LAST_UPDATED = "October 12, 2026";
+const CONTACT_EMAIL = "support@terragonlabs.com";
export const Route = createFileRoute("/privacy")({
head: () => ({
@@ -29,7 +29,7 @@ function PrivacyRoute() {
-
+
Last updated {LAST_UPDATED}
Privacy
@@ -64,22 +64,23 @@ function PrivacyRoute() {
The apps talk to a bb server that you run. We do not operate that
- server and we do not receive its data. This includes your prompts,
- your agent conversations, your source code, your files, your
- terminal output, and your provider API keys.
+ server. It does not send us your prompts, your agent conversations,
+ your source code, your files, your terminal output, or your provider
+ API keys.
- Unless you turn it off, the bb server sends us anonymous usage
- events through PostHog: app starts, thread and message counts,
- plugin installs, setup progress, such as which first-run steps you
- completed or skipped, and
- which built-in tips were shown or used. Each event carries a random
- install ID, the app version, and your operating system and
- architecture. Events never include your prompts, code, file names,
- project names, or account details. Turn this off in Settings →
- General → Share anonymous usage data, or set{" "}
- BB_TELEMETRY=false.
+ Unless you turn it off, the bb app sends us anonymous usage events
+ through PostHog about how you use bb, including starting the app,
+ setting it up, and using threads and plugins. We use these events
+ to see how bb is used and to decide what to improve. Each
+ event carries a random install ID, the app version, and basic system
+ details such as your operating system, processor type, and Node.js
+ version. PostHog also records the IP address each event comes from
+ and the approximate location it maps to. Events never include your
+ prompts, code, file names, project names, or account details. To
+ turn this off, go to Settings → General → Share anonymous usage
+ data, or set BB_TELEMETRY=false.
The iOS and Android apps store the following on your device:
diff --git a/apps/web/src/routes/uninstall.tsx b/apps/web/src/routes/uninstall.tsx
new file mode 100644
index 00000000000..0fda0b44160
--- /dev/null
+++ b/apps/web/src/routes/uninstall.tsx
@@ -0,0 +1,70 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+import { useInitAnalytics } from "../landing/analytics";
+import { pageMeta, siteHeadLinks } from "../landing/page-head";
+import { SiteFooter, SiteNav } from "../landing/site-chrome";
+import blogCss from "../blog/blog.css?url";
+
+const PAGE_TITLE = "Uninstall bb";
+const PAGE_DESCRIPTION =
+ "Remove the bb desktop app on macOS, Windows, or Linux, and delete the data bb keeps on your computer.";
+
+export const Route = createFileRoute("/uninstall")({
+ head: () => ({
+ meta: [
+ ...pageMeta(PAGE_TITLE, PAGE_DESCRIPTION, "/uninstall"),
+ { name: "robots", content: "noindex" },
+ ],
+ links: siteHeadLinks(blogCss),
+ }),
+ component: UninstallRoute,
+});
+
+function UninstallRoute() {
+ useInitAnalytics();
+
+ return (
+
+
+
+
+
+
Uninstall bb
+
+
+ Remove the app, then delete bb’s data folder if you
+ don’t want to keep your threads and settings.
+
+
+
1. Quit bb
+
+
Quit the bb desktop app.
+
+
2. Remove the app
+
+
+
+ Mac: drag bb from Applications to the Trash.
+
+
+ Windows: open Settings, then Apps, choose bb ,
+ then Uninstall .
+
+ Linux: delete the bb AppImage.
+
+
+
3. Delete your data (optional)
+
+
+ Delete ~/.bb (on Windows,{" "}
+ %USERPROFILE%\.bb). It holds your threads, settings,
+ plugins, and the worktrees bb created, so push any work you want to
+ keep first. Your project folders aren’t touched.
+
+
+
+
+
+
+ );
+}
diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md
index b847364756d..b9b75ed20d3 100644
--- a/docs/api_to_audit.md
+++ b/docs/api_to_audit.md
@@ -1634,12 +1634,12 @@ batches. Confirm the platform coverage (Linux
`/proc`, macOS `lsof`) and whether the grace should be per call.
0b. **Process identity.** `experimental_readProcessIdentity(pid)` from
- `@get-bb/plugin-sdk/host` returns `{ command, startedAt }` (`ps` on POSIX,
- CIM on Windows) or null. bb's launcher uses the same probe to confirm a
- recorded PID before stopping it. Storage & retention checks a development
- instance's recorded launcher entry path and start time before signalling
- it, so a reused PID is never killed. Confirm start-time precision per
- platform and whether a combined verified-stop helper should replace it.
+`@get-bb/plugin-sdk/host` returns `{ command, startedAt }` (`ps` on POSIX,
+CIM on Windows) or null. bb's launcher uses the same probe to confirm a
+recorded PID before stopping it. Storage & retention checks a development
+instance's recorded launcher entry path and start time before signalling
+it, so a reused PID is never killed. Confirm start-time precision per
+platform and whether a combined verified-stop helper should replace it.
0. **Call timeout.** `ExperimentalHostCallOptions.timeoutMs` (default 30s,
capped at 30 minutes) lets a plugin run a long host call — a setup
@@ -2938,6 +2938,70 @@ is temporarily unavailable renders BB's renderer without erasing the pin.
5. **Two slots or one.** Confirm source and diff should stay separately
replaceable rather than one "code renderer" registration.
+## `app.slots.experimental_codeFenceRenderer` (`@get-bb/plugin-sdk/app`)
+
+**What it does.** `app.slots.experimental_codeFenceRenderer({ id, languages,
+component })` renders fenced code blocks of the claimed languages in assistant
+and nested-agent message Markdown with a plugin component — the same places
+`messageDirective` activates. User messages, file previews, and plugin
+Markdown keep BB's code block. `id` is unique within the plugin (letters,
+digits, `-`, `_`); `languages` is non-empty, each lowercase
+`/^[a-z0-9][a-z0-9+#._-]*$/`, and a language may be claimed once per plugin
+(duplicates across that plugin's registrations are rejected at load).
+
+The component receives `{ code, language, status, index, message, Original }`.
+`code` is the fence body so far and grows while the reply streams. `status` is
+`"streaming"` (no closing delimiter yet, reply still being written),
+`"closed"` (closing delimiter present, `code` final), or `"unclosed"` (the
+reply ended without one). `index` is the zero-based ordinal among fences of
+the same language in the message. `Original` renders BB's code block (syntax
+highlighting, copy button, mermaid diagram for `mermaid`). Inside the
+component `useBbNavigate().openThreadPanel` works as it does for
+`messageDirective`.
+
+BB mounts the component once per fence and keeps that instance for as long as
+the message stays on screen. The Markdown tree renders an empty placeholder
+where the fence sits; a host around the message's Markdown owns the component
+and portals it into a container that follows the placeholder, so the instance
+survives the placeholder moving from the streaming tail to the settled region
+and the reply finishing. A fence is identified by the source offset of its
+opening delimiter, which does not move while a reply is appended to. Where
+the browser supports `Element.moveBefore`, an iframe inside the component
+also survives those moves without reloading; elsewhere component state
+survives and the iframe reloads. State that must outlive the mounted
+component belongs under `${message.id}#${language}#${index}`.
+
+Fallbacks: BB's code block renders when two plugins claim one language (BB
+logs a console warning naming them), when the plugin is missing or disabled,
+and for a fence whose component throws. A throw affects that fence only, and
+BB tries the component again when that fence's `code` or `status` changes.
+
+**Core callers on the same path.** Assistant and nested-agent message Markdown
+rendering — `MarkdownPreview`'s code renderer, the pipeline that also resolves
+`messageDirective`. The built-in `inline-vis` plugin renders its
+` ```inline-vis ` pages through this slot. BB's `mermaid` diagram stays
+a core special case inside `Original`, because it renders on every Markdown
+surface and this slot is scoped to messages. The SDK testing harness exposes
+registrations as `loadPluginApp(...).codeFenceRenderers`, renderable with
+`renderSlot`.
+
+**Audit before stabilizing.**
+
+1. **Collision model.** Confirm that a contested language falling back to
+ BB's code block is enough, or whether users need a per-language picker
+ like the source/diff renderer pin.
+2. **Streaming contract.** Confirm `code`/`status`/`index` cover real
+ generative-UI renderers (partial JSON/DSL parsing), and whether the rest
+ of the info string should be exposed as `meta`.
+3. **Scope.** Confirm the slot should stay limited to assistant and
+ nested-agent Markdown, whether common languages (`json`, `ts`) should be
+ claimable, and whether `mermaid` should be claimable or reserved for BB.
+4. **Identity.** Confirm keying a fence by source offset holds for every
+ path that rewrites a message's text, and decide whether iframes must
+ survive the tail-to-settled move in browsers without `moveBefore`.
+5. **Source visibility.** Decide whether BB should offer its own "view
+ source" control for claimed fences instead of leaving it to each plugin.
+
## `experimental_useSidebarThreads` / `experimental_useSidebarThreadActions` (`@get-bb/plugin-sdk/app`)
**New-thread machine selection (Sep 2026).**
@@ -4139,7 +4203,7 @@ Stabilization requires exercising reconnect/reload, concurrent deduplication, ro
## `PluginBbSdk.system.experimental_recordTelemetryEvent`
-`system.experimental_recordTelemetryEvent(event)` posts one anonymous product event to `POST /api/v1/system/telemetry/events`, which forwards it to the server's usage telemetry under the install's anonymous id and drops it when usage data sharing is off. The request schema is a strict allow-list of setup-guide events with fixed enum or boolean properties; anything else is rejected with 400. Core callers, all through `recordTelemetryEvent` in `apps/app/src/components/onboarding/onboarding-telemetry.ts`: the setup guide (`OnboardingFlow`). Stabilize after deciding whether third-party plugins may emit product events at all (and, if so, how events are namespaced per plugin and rate limited), then rename project-wide and remove this entry.
+`system.experimental_recordTelemetryEvent(event)` posts one anonymous product event to `POST /api/v1/system/telemetry/events`, which forwards it to the server's usage telemetry under the install's anonymous id and drops it when usage data sharing is off. The request schema is a strict allow-list of setup-guide, tip and core `feature_used` events with fixed enum, boolean, or small-count properties (legacy automation/split variants remain accepted); anything else is rejected with 400. Core callers, all through `recordTelemetryEvent` in `apps/app/src/components/onboarding/onboarding-telemetry.ts`: the setup guide (`OnboardingFlow`) and the split view (`useSplitViewTelemetry` in `apps/app/src/lib/split-layout/`). Bundled plugin caller: Tips from its app code. Core feature use also captures explicit file/side-panel opens. Plugin source classification is server-owned; clients cannot submit plugin provenance. Stabilize after deciding whether third-party plugins may emit product events at all (and, if so, how events are namespaced per plugin and rate limited), then rename project-wide and remove this entry.
## `PluginBbSdk.hosts.experimental_discoverRepos`
diff --git a/docs/configuration.md b/docs/configuration.md
index c61aa8c227e..e31864778a1 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1069,21 +1069,21 @@ schema, a default, and a revision that increments on every write. Writes name
the revision they expect and receive `409 ui_preference_conflict` when another
client wrote first, so a stale window cannot silently clobber a newer value.
-| Key | Value |
-| ------------------------------------ | ----------------------------------------------------------------------------------------- |
-| `sidebar.organizationMode` | `project`, `chronological`, or `machine` |
-| `sidebar.threadGrouping.environment` | `auto`, `true`, or `false` |
-| `sidebar.chronologicalSort` | `updated`, `created`, `alpha`, or `none` |
-| `sidebar.sectionOrder` | Section id list for **By project** |
-| `sidebar.manualSectionOrder` | Section id list for **Manually** |
-| `sidebar.machineSectionOrder` | Section id list for **By machine** |
-| `sidebar.hiddenGroups` | Legacy project, custom section, and machine ids migrated once into the Thread list plugin |
-| `sidebar.footerOrder` | Footer action order |
-| `sidebar.hiddenFooterItems` | Footer actions moved into More |
-| `sidebar.pluginPanelOrder` | Rail destination order |
-| `sidebar.visiblePluginPanels` | Rail destinations shown, or `null` for every destination |
-| `sidebar.threadListProvider` | Plugin key or `__automatic__` (default) |
-| `infoPanel.collapsedSections` | Collapsed thread Info panel sections (`commits`, `uncommittedChanges`, `forks`, `threadStorage`) |
+| Key | Value |
+| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
+| `sidebar.organizationMode` | `project`, `chronological`, or `machine` |
+| `sidebar.threadGrouping.environment` | `auto`, `true`, or `false` |
+| `sidebar.chronologicalSort` | `updated`, `created`, `alpha`, or `none` |
+| `sidebar.sectionOrder` | Section id list for **By project** |
+| `sidebar.manualSectionOrder` | Section id list for **Manually** |
+| `sidebar.machineSectionOrder` | Section id list for **By machine** |
+| `sidebar.hiddenGroups` | Legacy project, custom section, and machine ids migrated once into the Thread list plugin |
+| `sidebar.footerOrder` | Footer action order |
+| `sidebar.hiddenFooterItems` | Footer actions moved into More |
+| `sidebar.pluginPanelOrder` | Rail destination order |
+| `sidebar.visiblePluginPanels` | Rail destinations shown, or `null` for every destination |
+| `sidebar.threadListProvider` | Plugin key or `__automatic__` (default) |
+| `infoPanel.collapsedSections` | Collapsed thread Info panel sections (`commits`, `uncommittedChanges`, `childThreads`, `forks`, `threadStorage`) |
The sidebar thread list defaults to `__automatic__`: the first installed thread list
plugin other than the bundled Thread list plugin (`thread-list/thread-list`), or the
@@ -1607,7 +1607,10 @@ not on disk. `bb marketplace add|list|refresh|remove` and Settings → Plugin
marketplaces manage them; the reserved `bb-community` marketplace comes from
`BB_MARKETPLACE_URL` and cannot be added or removed. Adding a marketplace
installs nothing, and removing one keeps its installed plugins as direct
-installs.
+installs. A `git:` marketplace reads `marketplace.json` from the repository
+root, or from the relative directory given with
+`bb marketplace add --subdirectory ` (the Subdirectory field in
+Settings); bb stores that directory and reuses it on every refresh.
### Multi-plugin repositories
@@ -2101,12 +2104,42 @@ through `POST /api/v1/system/telemetry/events` (`sdk.system.experimental_recordT
`onboarding_started` (whether an agent was installed or ready at first launch),
`onboarding_step_reached`, `onboarding_step_completed`, and
`onboarding_step_skipped` (per setup-guide step, marked first run or replay),
-`onboarding_finished` (completed or skipped), plus
-`tip_shown` (once per tip per visit to the New thread page) and `tip_used` (on
-click) from the bundled Tips plugin, each with the built-in tip id, its position
-in the feed (1-3), and its action type (`prompt`, `open-page`, `run-command`,
-`open-plugin`, or `learn-more`). That route accepts only those events and their
-fixed, non-identifying properties.
+`onboarding_finished` (completed or skipped), `onboarding_agent_state`
+(aggregate installed/ready booleans when the guide's health snapshot changes;
+null means unknown or a failed health query), and `onboarding_sign_in`
+(started, ready, exited, failed, or cancelled, marked first run or replay).
+Sign-in `ready` means the selected provider's health became ready during the
+attempt. `exited` means its command exited successfully before readiness was
+observed; it does not establish successful authentication. Cancellation
+includes leaving the Agent step or switching sign-in methods.
+
+`new_thread_submit` records started, accepted, or failed composer submissions.
+It covers all new-thread submissions, so analytics must restrict first-send
+analysis to the installation's earliest attempt and check prior thread history.
+Accepted means the creation callback succeeded, not that the provider produced
+an answer. Disabled send controls and later turn failures are not measured.
+Attempts without a delivered outcome remain unknown, not inferred failures.
+
+The route accepts only these events and their fixed enums, booleans, and unknown
+markers. These additions include no provider IDs, account details, prompts,
+project or file names, terminal output, error messages, or session identifiers.
+They do not measure foreground time, app exit, or install/update error reasons.
+
+The bundled Tips plugin sends `tip_shown` and `tip_used` with fixed tip IDs,
+positions and action types. `feature_used` uses a finite capability registry:
+threads, child threads, providers, remote access, automations, browser,
+terminals, files, git, settings, projects, environments, split view, search,
+voice and plugin management. Core actions carry only `feature`,
+`feature_scope=core`, `interaction=opened/invoked/ran`, and
+`trigger=person/agent/schedule/system/unknown`. Triggers are a breakdown of
+engagement, including automated work. Unobserved provenance stays unknown.
+
+Successful explicit core API actions are captured at their shared route
+boundary for UI, SDK and CLI callers. File previews, side-panel opens and
+split additions cover local UI actions. Background queries, resize/persistence
+writes and layout restoration are excluded. The telemetry endpoint rejects
+unknown fields, feature codes and caller-supplied plugin provenance. Legacy
+client event variants remain accepted for compatibility.
Anonymous usage telemetry can be disabled in Settings → General → Privacy & diagnostics → Share anonymous usage data,
or with `bb settings general telemetryEnabled false`. The saved server-wide preference
diff --git a/packages/db/drizzle/0142_burly_baron_strucker.sql b/packages/db/drizzle/0142_burly_baron_strucker.sql
new file mode 100644
index 00000000000..9a9844ac71b
--- /dev/null
+++ b/packages/db/drizzle/0142_burly_baron_strucker.sql
@@ -0,0 +1 @@
+ALTER TABLE `plugin_marketplaces` ADD `source_git_subdirectory` text;
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/0142_snapshot.json b/packages/db/drizzle/meta/0142_snapshot.json
new file mode 100644
index 00000000000..2098d966265
--- /dev/null
+++ b/packages/db/drizzle/meta/0142_snapshot.json
@@ -0,0 +1,5166 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "5d5a1cb9-1fe8-406a-920a-2734560120b2",
+ "prevId": "1e22449c-23e6-4c64-a644-306bd27ffeb6",
+ "tables": {
+ "app_settings": {
+ "name": "app_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "caffeinate": {
+ "name": "caffeinate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "show_keyboard_hints": {
+ "name": "show_keyboard_hints",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "steer_active_thread_on_enter": {
+ "name": "steer_active_thread_on_enter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "show_unhandled_provider_events": {
+ "name": "show_unhandled_provider_events",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "codex_memory_enabled": {
+ "name": "codex_memory_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "claude_code_memory_enabled": {
+ "name": "claude_code_memory_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "codex_subagents_disabled": {
+ "name": "codex_subagents_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "claude_code_subagents_disabled": {
+ "name": "claude_code_subagents_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "claude_code_workflows_disabled": {
+ "name": "claude_code_workflows_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "keybinding_overrides": {
+ "name": "keybinding_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "app_settings_values": {
+ "name": "app_settings_values",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "app_theme": {
+ "name": "app_theme",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_id": {
+ "name": "theme_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "favicon_color": {
+ "name": "favicon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'default'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apikey": {
+ "name": "apikey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "start": {
+ "name": "start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "referenceId": {
+ "name": "referenceId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refillInterval": {
+ "name": "refillInterval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refillAmount": {
+ "name": "refillAmount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastRefillAt": {
+ "name": "lastRefillAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rateLimitEnabled": {
+ "name": "rateLimitEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rateLimitTimeWindow": {
+ "name": "rateLimitTimeWindow",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rateLimitMax": {
+ "name": "rateLimitMax",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requestCount": {
+ "name": "requestCount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remaining": {
+ "name": "remaining",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastRequest": {
+ "name": "lastRequest",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "configId": {
+ "name": "configId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apikey_key_unique": {
+ "name": "apikey_key_unique",
+ "columns": [
+ "key"
+ ],
+ "isUnique": true
+ },
+ "apikey_reference_id_idx": {
+ "name": "apikey_reference_id_idx",
+ "columns": [
+ "referenceId"
+ ],
+ "isUnique": false
+ },
+ "apikey_config_id_idx": {
+ "name": "apikey_config_id_idx",
+ "columns": [
+ "configId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "apikey_referenceId_user_id_fk": {
+ "name": "apikey_referenceId_user_id_fk",
+ "tableFrom": "apikey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "referenceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "environment_hook_operations": {
+ "name": "environment_hook_operations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "operation_id": {
+ "name": "operation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "environment_variables": {
+ "name": "environment_variables",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ciphertext": {
+ "name": "ciphertext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encryption_version": {
+ "name": "encryption_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "environment_variables_global_name": {
+ "name": "environment_variables_global_name",
+ "columns": [
+ "name"
+ ],
+ "isUnique": true,
+ "where": "\"environment_variables\".\"project_id\" IS NULL"
+ },
+ "environment_variables_project_name": {
+ "name": "environment_variables_project_name",
+ "columns": [
+ "project_id",
+ "name"
+ ],
+ "isUnique": true,
+ "where": "\"environment_variables\".\"project_id\" IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "environment_variables_project_id_projects_id_fk": {
+ "name": "environment_variables_project_id_projects_id_fk",
+ "tableFrom": "environment_variables",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "environments": {
+ "name": "environments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_git_repo": {
+ "name": "is_git_repo",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_worktree": {
+ "name": "is_worktree",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "branch_name": {
+ "name": "branch_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "base_branch": {
+ "name": "base_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_branch": {
+ "name": "default_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "merge_base_branch": {
+ "name": "merge_base_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "environment_provider_id": {
+ "name": "environment_provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "environment_provider_plugin_id": {
+ "name": "environment_provider_plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_owns_path": {
+ "name": "provider_owns_path",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "environment_provider_selection": {
+ "name": "environment_provider_selection",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "environment_provider_instance_key": {
+ "name": "environment_provider_instance_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retire_at": {
+ "name": "retire_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "teardown_attempt": {
+ "name": "teardown_attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "teardown_status": {
+ "name": "teardown_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "teardown_message": {
+ "name": "teardown_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "owner_thread_id": {
+ "name": "owner_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attempt": {
+ "name": "attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "status_message": {
+ "name": "status_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pending_log": {
+ "name": "pending_log",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "claim_path": {
+ "name": "claim_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'provisioning'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "environments_project_host_path_idx": {
+ "name": "environments_project_host_path_idx",
+ "columns": [
+ "project_id",
+ "host_id",
+ "path"
+ ],
+ "isUnique": true
+ },
+ "environments_host_path_lookup_idx": {
+ "name": "environments_host_path_lookup_idx",
+ "columns": [
+ "host_id",
+ "path"
+ ],
+ "isUnique": false
+ },
+ "environments_owner_thread_idx": {
+ "name": "environments_owner_thread_idx",
+ "columns": [
+ "owner_thread_id"
+ ],
+ "isUnique": true,
+ "where": "\"environments\".\"owner_thread_id\" IS NOT NULL"
+ },
+ "environments_claim_idx": {
+ "name": "environments_claim_idx",
+ "columns": [
+ "host_id",
+ "claim_path"
+ ],
+ "isUnique": false
+ },
+ "environments_project_idx": {
+ "name": "environments_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "environments_status_idx": {
+ "name": "environments_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "environments_provider_instance_idx": {
+ "name": "environments_provider_instance_idx",
+ "columns": [
+ "environment_provider_id",
+ "environment_provider_instance_key"
+ ],
+ "isUnique": false
+ },
+ "environments_provider_lifecycle_idx": {
+ "name": "environments_provider_lifecycle_idx",
+ "columns": [
+ "environment_provider_id"
+ ],
+ "isUnique": false,
+ "where": "\"environments\".\"status\" <> 'destroyed' OR \"environments\".\"teardown_status\" IS NOT 'removed'"
+ }
+ },
+ "foreignKeys": {
+ "environments_project_id_projects_id_fk": {
+ "name": "environments_project_id_projects_id_fk",
+ "tableFrom": "environments",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "environments_host_id_hosts_id_fk": {
+ "name": "environments_host_id_hosts_id_fk",
+ "tableFrom": "environments",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "events": {
+ "name": "events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope_kind": {
+ "name": "scope_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_id": {
+ "name": "turn_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_thread_id": {
+ "name": "provider_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sequence": {
+ "name": "sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_kind": {
+ "name": "item_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parent_tool_call_id": {
+ "name": "parent_tool_call_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "events_thread_sequence_idx": {
+ "name": "events_thread_sequence_idx",
+ "columns": [
+ "thread_id",
+ "sequence"
+ ],
+ "isUnique": true
+ },
+ "events_delegating_item_lookup_idx": {
+ "name": "events_delegating_item_lookup_idx",
+ "columns": [
+ "thread_id",
+ "item_id",
+ "sequence",
+ "item_kind"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')"
+ },
+ "events_plan_steps_thread_sequence_idx": {
+ "name": "events_plan_steps_thread_sequence_idx",
+ "columns": [
+ "thread_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'"
+ },
+ "events_parent_tool_call_thread_parent_sequence_idx": {
+ "name": "events_parent_tool_call_thread_parent_sequence_idx",
+ "columns": [
+ "thread_id",
+ "parent_tool_call_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL"
+ },
+ "events_thread_type_item_kind_sequence_idx": {
+ "name": "events_thread_type_item_kind_sequence_idx",
+ "columns": [
+ "thread_id",
+ "type",
+ "item_kind",
+ "sequence"
+ ],
+ "isUnique": false
+ },
+ "events_background_task_thread_type_item_sequence_idx": {
+ "name": "events_background_task_thread_type_item_sequence_idx",
+ "columns": [
+ "thread_id",
+ "type",
+ "item_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"item_kind\" = 'backgroundTask'"
+ },
+ "events_thread_type_sequence_idx": {
+ "name": "events_thread_type_sequence_idx",
+ "columns": [
+ "thread_id",
+ "type",
+ "sequence"
+ ],
+ "isUnique": false
+ },
+ "events_thread_turn_type_item_sequence_idx": {
+ "name": "events_thread_turn_type_item_sequence_idx",
+ "columns": [
+ "thread_id",
+ "turn_id",
+ "type",
+ "item_id",
+ "sequence"
+ ],
+ "isUnique": false
+ },
+ "events_item_lifecycle_thread_item_sequence_idx": {
+ "name": "events_item_lifecycle_thread_item_sequence_idx",
+ "columns": [
+ "thread_id",
+ "item_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')"
+ },
+ "events_environment_idx": {
+ "name": "events_environment_idx",
+ "columns": [
+ "environment_id"
+ ],
+ "isUnique": false
+ },
+ "events_provider_identity_idx": {
+ "name": "events_provider_identity_idx",
+ "columns": [
+ "provider_thread_id",
+ "created_at"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"type\" = 'thread/identity'"
+ },
+ "events_completed_item_truncation_idx": {
+ "name": "events_completed_item_truncation_idx",
+ "columns": [
+ "item_kind",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"type\" = 'item/completed'"
+ },
+ "events_thread_state_thread_sequence_idx": {
+ "name": "events_thread_state_thread_sequence_idx",
+ "columns": [
+ "thread_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')"
+ }
+ },
+ "foreignKeys": {
+ "events_thread_id_threads_id_fk": {
+ "name": "events_thread_id_threads_id_fk",
+ "tableFrom": "events",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "events_environment_id_environments_id_fk": {
+ "name": "events_environment_id_environments_id_fk",
+ "tableFrom": "events",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "events_scope_shape_check": {
+ "name": "events_scope_shape_check",
+ "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )"
+ }
+ }
+ },
+ "host_daemon_sessions": {
+ "name": "host_daemon_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "instance_id": {
+ "name": "instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_name": {
+ "name": "host_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data_dir": {
+ "name": "data_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "protocol_version": {
+ "name": "protocol_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "heartbeat_interval_ms": {
+ "name": "heartbeat_interval_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lease_timeout_ms": {
+ "name": "lease_timeout_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "closed_at": {
+ "name": "closed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "close_reason": {
+ "name": "close_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "host_daemon_sessions_host_status_idx": {
+ "name": "host_daemon_sessions_host_status_idx",
+ "columns": [
+ "host_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "host_daemon_sessions_host_latest_idx": {
+ "name": "host_daemon_sessions_host_latest_idx",
+ "columns": [
+ "host_id",
+ "updated_at",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "host_daemon_sessions_closed_prune_idx": {
+ "name": "host_daemon_sessions_closed_prune_idx",
+ "columns": [
+ "status",
+ "closed_at",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "host_daemon_sessions_host_id_hosts_id_fk": {
+ "name": "host_daemon_sessions_host_id_hosts_id_fk",
+ "tableFrom": "host_daemon_sessions",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "hosts": {
+ "name": "hosts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connect_machine_id": {
+ "name": "connect_machine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "machine_provider_id": {
+ "name": "machine_provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "launch_key": {
+ "name": "launch_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "machine_inputs": {
+ "name": "machine_inputs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "machine_attempt": {
+ "name": "machine_attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "pending_log": {
+ "name": "pending_log",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "machine_operation_id": {
+ "name": "machine_operation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "server_access_provider_id": {
+ "name": "server_access_provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "server_access_grant_id": {
+ "name": "server_access_grant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resource": {
+ "name": "resource",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "phase": {
+ "name": "phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "suspended_at": {
+ "name": "suspended_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status_message": {
+ "name": "status_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "suspend_retry_at": {
+ "name": "suspend_retry_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "remove_retry_at": {
+ "name": "remove_retry_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "teardown_attempt": {
+ "name": "teardown_attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "teardown_status": {
+ "name": "teardown_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_permission_mode": {
+ "name": "max_permission_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'full'"
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_rejected_protocol_version": {
+ "name": "last_rejected_protocol_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "hosts_last_seen_idx": {
+ "name": "hosts_last_seen_idx",
+ "columns": [
+ "last_seen_at"
+ ],
+ "isUnique": false
+ },
+ "hosts_pending_provider_idx": {
+ "name": "hosts_pending_provider_idx",
+ "columns": [
+ "machine_provider_id"
+ ],
+ "isUnique": false,
+ "where": "\"hosts\".\"destroyed_at\" IS NULL AND \"hosts\".\"phase\" <> 'destroyed'"
+ },
+ "hosts_live_launch_key_idx": {
+ "name": "hosts_live_launch_key_idx",
+ "columns": [
+ "launch_key"
+ ],
+ "isUnique": true,
+ "where": "\"hosts\".\"destroyed_at\" is null"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugins": {
+ "name": "plugins",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provenance": {
+ "name": "provenance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'direct'"
+ },
+ "catalog_entry_id": {
+ "name": "catalog_entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "catalog_marketplace_name": {
+ "name": "catalog_marketplace_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'path'"
+ },
+ "source_path": {
+ "name": "source_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_builtin_name": {
+ "name": "source_builtin_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_package": {
+ "name": "source_npm_package",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_registry": {
+ "name": "source_npm_registry",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_requested_spec": {
+ "name": "source_npm_requested_spec",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_spec_kind": {
+ "name": "source_npm_spec_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_url": {
+ "name": "source_git_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_subdirectory": {
+ "name": "source_git_subdirectory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_requested_ref": {
+ "name": "source_git_requested_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_ref_kind": {
+ "name": "source_git_ref_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_range": {
+ "name": "source_git_range",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_tag_prefix": {
+ "name": "source_git_tag_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_resolved_tag": {
+ "name": "source_git_resolved_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npm_resolved_version": {
+ "name": "npm_resolved_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npm_integrity": {
+ "name": "npm_integrity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "git_resolved_commit": {
+ "name": "git_resolved_commit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_update_check_at": {
+ "name": "last_update_check_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "available_compatible_version": {
+ "name": "available_compatible_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "newest_incompatible_version": {
+ "name": "newest_incompatible_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "update_status_detail": {
+ "name": "update_status_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_failure_version": {
+ "name": "last_failure_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_failure_at": {
+ "name": "last_failure_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_failure_detail": {
+ "name": "last_failure_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "active_artifact_id": {
+ "name": "active_artifact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "normalization_version": {
+ "name": "normalization_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "root_dir": {
+ "name": "root_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "enabled_follows_default": {
+ "name": "enabled_follows_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "installed_at": {
+ "name": "installed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "plugins_active_artifact_id_plugin_artifacts_id_fk": {
+ "name": "plugins_active_artifact_id_plugin_artifacts_id_fk",
+ "tableFrom": "plugins",
+ "tableTo": "plugin_artifacts",
+ "columnsFrom": [
+ "active_artifact_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "maintenance_scan_cursors": {
+ "name": "maintenance_scan_cursors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "policy": {
+ "name": "policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_kind": {
+ "name": "item_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "output_path": {
+ "name": "output_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_created_at": {
+ "name": "last_created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_event_id": {
+ "name": "last_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "maintenance_scan_cursors_path_idx": {
+ "name": "maintenance_scan_cursors_path_idx",
+ "columns": [
+ "policy",
+ "version",
+ "item_kind",
+ "output_path"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "pending_interactions": {
+ "name": "pending_interactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "origin_kind": {
+ "name": "origin_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'provider'"
+ },
+ "turn_id": {
+ "name": "turn_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_thread_id": {
+ "name": "provider_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_request_id": {
+ "name": "provider_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "renderer_id": {
+ "name": "renderer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolution": {
+ "name": "resolution",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status_reason": {
+ "name": "status_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "pending_interactions_provider_request_idx": {
+ "name": "pending_interactions_provider_request_idx",
+ "columns": [
+ "provider_id",
+ "provider_thread_id",
+ "provider_request_id"
+ ],
+ "isUnique": true
+ },
+ "pending_interactions_thread_created_idx": {
+ "name": "pending_interactions_thread_created_idx",
+ "columns": [
+ "thread_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "pending_interactions_thread_status_created_idx": {
+ "name": "pending_interactions_thread_status_created_idx",
+ "columns": [
+ "thread_id",
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "pending_interactions_status_created_idx": {
+ "name": "pending_interactions_status_created_idx",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "pending_interactions_plugin_status_created_idx": {
+ "name": "pending_interactions_plugin_status_created_idx",
+ "columns": [
+ "plugin_id",
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "pending_interactions_thread_id_threads_id_fk": {
+ "name": "pending_interactions_thread_id_threads_id_fk",
+ "tableFrom": "pending_interactions",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_artifacts": {
+ "name": "plugin_artifacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "npm_resolved_version": {
+ "name": "npm_resolved_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "git_resolved_commit": {
+ "name": "git_resolved_commit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "git_checkout_root": {
+ "name": "git_checkout_root",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integrity": {
+ "name": "integrity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "validation_result": {
+ "name": "validation_result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "validated_at": {
+ "name": "validated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "plugin_artifacts_plugin_idx": {
+ "name": "plugin_artifacts_plugin_idx",
+ "columns": [
+ "plugin_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_kv": {
+ "name": "plugin_kv",
+ "columns": {
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_kv_plugin_id_key_pk": {
+ "columns": [
+ "plugin_id",
+ "key"
+ ],
+ "name": "plugin_kv_plugin_id_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_marketplace_icons": {
+ "name": "plugin_marketplace_icons",
+ "columns": {
+ "marketplace_name": {
+ "name": "marketplace_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entry_id": {
+ "name": "entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "etag": {
+ "name": "etag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bytes": {
+ "name": "bytes",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_marketplace_icons_marketplace_name_entry_id_pk": {
+ "columns": [
+ "marketplace_name",
+ "entry_id"
+ ],
+ "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_marketplaces": {
+ "name": "plugin_marketplaces",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'https'"
+ },
+ "manifest_url": {
+ "name": "manifest_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_git_ref": {
+ "name": "source_git_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_subdirectory": {
+ "name": "source_git_subdirectory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_commit": {
+ "name": "source_git_commit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "manifest_json": {
+ "name": "manifest_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stats_json": {
+ "name": "stats_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "etag": {
+ "name": "etag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_modified": {
+ "name": "last_modified",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_successful_refresh_at": {
+ "name": "last_successful_refresh_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_attempted_refresh_at": {
+ "name": "last_attempted_refresh_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_schedules": {
+ "name": "plugin_schedules",
+ "columns": {
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron": {
+ "name": "cron",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_schedules_plugin_id_name_pk": {
+ "columns": [
+ "plugin_id",
+ "name"
+ ],
+ "name": "plugin_schedules_plugin_id_name_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_settings": {
+ "name": "plugin_settings",
+ "columns": {
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_settings_plugin_id_key_pk": {
+ "columns": [
+ "plugin_id",
+ "key"
+ ],
+ "name": "plugin_settings_plugin_id_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_state_snapshots": {
+ "name": "plugin_state_snapshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_artifact_id": {
+ "name": "from_artifact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "to_artifact_id": {
+ "name": "to_artifact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_path": {
+ "name": "snapshot_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "database_path": {
+ "name": "database_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state_path": {
+ "name": "state_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secrets_path": {
+ "name": "secrets_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "registration_path": {
+ "name": "registration_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rollback_candidate_version": {
+ "name": "rollback_candidate_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_source_fingerprint": {
+ "name": "rollback_source_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_bb_version": {
+ "name": "rollback_bb_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_sdk_version": {
+ "name": "rollback_sdk_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_detail": {
+ "name": "rollback_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_until": {
+ "name": "retained_until",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "plugin_state_snapshots_plugin_idx": {
+ "name": "plugin_state_snapshots_plugin_idx",
+ "columns": [
+ "plugin_id"
+ ],
+ "isUnique": false
+ },
+ "plugin_state_snapshots_retention_idx": {
+ "name": "plugin_state_snapshots_retention_idx",
+ "columns": [
+ "retained_until"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_attachment_backfills": {
+ "name": "project_attachment_backfills",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "phase": {
+ "name": "phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_cursor": {
+ "name": "thread_cursor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input_cursor": {
+ "name": "input_cursor",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input_id": {
+ "name": "input_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input_sequence": {
+ "name": "input_sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempted_at": {
+ "name": "attempted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "project_attachment_backfills_project_id_projects_id_fk": {
+ "name": "project_attachment_backfills_project_id_projects_id_fk",
+ "tableFrom": "project_attachment_backfills",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_attachment_threads": {
+ "name": "project_attachment_threads",
+ "columns": {
+ "attachment_id": {
+ "name": "attachment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "project_attachment_threads_thread_idx": {
+ "name": "project_attachment_threads_thread_idx",
+ "columns": [
+ "thread_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "project_attachment_threads_attachment_id_project_attachments_id_fk": {
+ "name": "project_attachment_threads_attachment_id_project_attachments_id_fk",
+ "tableFrom": "project_attachment_threads",
+ "tableTo": "project_attachments",
+ "columnsFrom": [
+ "attachment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "project_attachment_threads_thread_id_threads_id_fk": {
+ "name": "project_attachment_threads_thread_id_threads_id_fk",
+ "tableFrom": "project_attachment_threads",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "project_attachment_threads_attachment_id_thread_id_pk": {
+ "columns": [
+ "attachment_id",
+ "thread_id"
+ ],
+ "name": "project_attachment_threads_attachment_id_thread_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_attachments": {
+ "name": "project_attachments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stored_path": {
+ "name": "stored_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "original_name": {
+ "name": "original_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ready_at": {
+ "name": "ready_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deletion_claimed_at": {
+ "name": "deletion_claimed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "project_attachments_project_path_idx": {
+ "name": "project_attachments_project_path_idx",
+ "columns": [
+ "project_id",
+ "stored_path"
+ ],
+ "isUnique": true
+ },
+ "project_attachments_project_created_idx": {
+ "name": "project_attachments_project_created_idx",
+ "columns": [
+ "project_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "project_attachments_deletion_idx": {
+ "name": "project_attachments_deletion_idx",
+ "columns": [
+ "project_id",
+ "deletion_claimed_at",
+ "id"
+ ],
+ "isUnique": false,
+ "where": "\"project_attachments\".\"deletion_claimed_at\" IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "project_attachments_project_id_projects_id_fk": {
+ "name": "project_attachments_project_id_projects_id_fk",
+ "tableFrom": "project_attachments",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "project_attachments_size_check": {
+ "name": "project_attachments_size_check",
+ "value": "\"project_attachments\".\"size_bytes\" >= 0"
+ }
+ }
+ },
+ "project_execution_defaults": {
+ "name": "project_execution_defaults",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "service_tier": {
+ "name": "service_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reasoning_level": {
+ "name": "reasoning_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission_mode": {
+ "name": "permission_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "project_execution_defaults_project_idx": {
+ "name": "project_execution_defaults_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "project_execution_defaults_project_id_projects_id_fk": {
+ "name": "project_execution_defaults_project_id_projects_id_fk",
+ "tableFrom": "project_execution_defaults",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_sources": {
+ "name": "project_sources",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "owns_path": {
+ "name": "owns_path",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "project_sources_project_idx": {
+ "name": "project_sources_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "project_sources_host_idx": {
+ "name": "project_sources_host_idx",
+ "columns": [
+ "host_id"
+ ],
+ "isUnique": false
+ },
+ "project_sources_project_host_idx": {
+ "name": "project_sources_project_host_idx",
+ "columns": [
+ "project_id",
+ "host_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "project_sources_project_id_projects_id_fk": {
+ "name": "project_sources_project_id_projects_id_fk",
+ "tableFrom": "project_sources",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "project_sources_host_id_hosts_id_fk": {
+ "name": "project_sources_host_id_hosts_id_fk",
+ "tableFrom": "project_sources",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "project_sources_shape_check": {
+ "name": "project_sources_shape_check",
+ "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )"
+ }
+ }
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'standard'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "git_remote_url": {
+ "name": "git_remote_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_key": {
+ "name": "sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'V'"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "projects_updated_idx": {
+ "name": "projects_updated_idx",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "projects_deleted_idx": {
+ "name": "projects_deleted_idx",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "projects_sort_idx": {
+ "name": "projects_sort_idx",
+ "columns": [
+ "sort_key",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "projects_personal_singleton_idx": {
+ "name": "projects_personal_singleton_idx",
+ "columns": [
+ "kind"
+ ],
+ "isUnique": true,
+ "where": "\"projects\".\"kind\" = 'personal'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "prompt_history_entries": {
+ "name": "prompt_history_entries",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_sequence": {
+ "name": "request_sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input": {
+ "name": "input",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "prompt_history_entries_thread_request_idx": {
+ "name": "prompt_history_entries_thread_request_idx",
+ "columns": [
+ "thread_id",
+ "request_sequence"
+ ],
+ "isUnique": true
+ },
+ "prompt_history_entries_project_scope_created_idx": {
+ "name": "prompt_history_entries_project_scope_created_idx",
+ "columns": [
+ "project_id",
+ "scope",
+ "created_at",
+ "request_sequence",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "prompt_history_entries_created_idx": {
+ "name": "prompt_history_entries_created_idx",
+ "columns": [
+ "created_at",
+ "request_sequence",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "prompt_history_entries_thread_scope_created_idx": {
+ "name": "prompt_history_entries_thread_scope_created_idx",
+ "columns": [
+ "thread_id",
+ "scope",
+ "created_at",
+ "request_sequence",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "prompt_history_entries_project_id_projects_id_fk": {
+ "name": "prompt_history_entries_project_id_projects_id_fk",
+ "tableFrom": "prompt_history_entries",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "prompt_history_entries_thread_id_threads_id_fk": {
+ "name": "prompt_history_entries_thread_id_threads_id_fk",
+ "tableFrom": "prompt_history_entries",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "provider_model_catalogs": {
+ "name": "provider_model_catalogs",
+ "columns": {
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scope_key": {
+ "name": "scope_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "models_json": {
+ "name": "models_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "selected_only_models_json": {
+ "name": "selected_only_models_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "provider_model_catalogs_host_id_hosts_id_fk": {
+ "name": "provider_model_catalogs_host_id_hosts_id_fk",
+ "tableFrom": "provider_model_catalogs",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "provider_model_catalogs_host_id_provider_id_scope_key_pk": {
+ "columns": [
+ "host_id",
+ "provider_id",
+ "scope_key"
+ ],
+ "name": "provider_model_catalogs_host_id_provider_id_scope_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "queued_thread_messages": {
+ "name": "queued_thread_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "system_notice": {
+ "name": "system_notice",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sender_thread_id": {
+ "name": "sender_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin_plugin_id": {
+ "name": "origin_plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_by_initiator": {
+ "name": "requested_by_initiator",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "requested_by_thread_id": {
+ "name": "requested_by_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reasoning_level": {
+ "name": "reasoning_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission_mode": {
+ "name": "permission_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "service_tier": {
+ "name": "service_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_with_next": {
+ "name": "group_with_next",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "send_at": {
+ "name": "send_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "waiting_on": {
+ "name": "waiting_on",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "wait_holder": {
+ "name": "wait_holder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "failure_count": {
+ "name": "failure_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "payload_kind": {
+ "name": "payload_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'inline'"
+ },
+ "retry_of_turn_request_id": {
+ "name": "retry_of_turn_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retry_attempt": {
+ "name": "retry_attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retry_reason": {
+ "name": "retry_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "claim_token": {
+ "name": "claim_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "edit_held_until": {
+ "name": "edit_held_until",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_key": {
+ "name": "sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "queued_thread_messages_thread_created_idx": {
+ "name": "queued_thread_messages_thread_created_idx",
+ "columns": [
+ "thread_id",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "queued_thread_messages_thread_sort_idx": {
+ "name": "queued_thread_messages_thread_sort_idx",
+ "columns": [
+ "thread_id",
+ "sort_key",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "queued_thread_messages_due_idx": {
+ "name": "queued_thread_messages_due_idx",
+ "columns": [
+ "send_at",
+ "id"
+ ],
+ "isUnique": false,
+ "where": "\"queued_thread_messages\".\"send_at\" IS NOT NULL AND \"queued_thread_messages\".\"claimed_at\" IS NULL AND \"queued_thread_messages\".\"claim_token\" IS NULL"
+ },
+ "queued_thread_messages_wait_holder_idx": {
+ "name": "queued_thread_messages_wait_holder_idx",
+ "columns": [
+ "wait_holder",
+ "id"
+ ],
+ "isUnique": false,
+ "where": "\"queued_thread_messages\".\"wait_holder\" IS NOT NULL"
+ }
+ },
+ "foreignKeys": {
+ "queued_thread_messages_thread_id_threads_id_fk": {
+ "name": "queued_thread_messages_thread_id_threads_id_fk",
+ "tableFrom": "queued_thread_messages",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "retained_event_outputs": {
+ "name": "retained_event_outputs",
+ "columns": {
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "output_path": {
+ "name": "output_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "retained_event_outputs_expiry_idx": {
+ "name": "retained_event_outputs_expiry_idx",
+ "columns": [
+ "expires_at",
+ "event_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "retained_event_outputs_event_id_events_id_fk": {
+ "name": "retained_event_outputs_event_id_events_id_fk",
+ "tableFrom": "retained_event_outputs",
+ "tableTo": "events",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "system_experiments": {
+ "name": "system_experiments",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "terminal_sessions": {
+ "name": "terminal_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "daemon_session_id": {
+ "name": "daemon_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "initial_cwd": {
+ "name": "initial_cwd",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cols": {
+ "name": "cols",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rows": {
+ "name": "rows",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exit_code": {
+ "name": "exit_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "close_reason": {
+ "name": "close_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_user_input_at": {
+ "name": "last_user_input_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "terminal_sessions_thread_status_updated_idx": {
+ "name": "terminal_sessions_thread_status_updated_idx",
+ "columns": [
+ "thread_id",
+ "status",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_environment_status_idx": {
+ "name": "terminal_sessions_environment_status_idx",
+ "columns": [
+ "environment_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_host_status_idx": {
+ "name": "terminal_sessions_host_status_idx",
+ "columns": [
+ "host_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_daemon_session_idx": {
+ "name": "terminal_sessions_daemon_session_idx",
+ "columns": [
+ "daemon_session_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "terminal_sessions_thread_id_threads_id_fk": {
+ "name": "terminal_sessions_thread_id_threads_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "terminal_sessions_environment_id_environments_id_fk": {
+ "name": "terminal_sessions_environment_id_environments_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "terminal_sessions_host_id_hosts_id_fk": {
+ "name": "terminal_sessions_host_id_hosts_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": {
+ "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "host_daemon_sessions",
+ "columnsFrom": [
+ "daemon_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_conversation_outlines": {
+ "name": "thread_conversation_outlines",
+ "columns": {
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "projection_key": {
+ "name": "projection_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "items_json": {
+ "name": "items_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "thread_conversation_outlines_thread_id_threads_id_fk": {
+ "name": "thread_conversation_outlines_thread_id_threads_id_fk",
+ "tableFrom": "thread_conversation_outlines",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_dynamic_context_file_states": {
+ "name": "thread_dynamic_context_file_states",
+ "columns": {
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_key": {
+ "name": "file_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_status": {
+ "name": "content_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "shown_at": {
+ "name": "shown_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_dynamic_context_file_states_thread_file_idx": {
+ "name": "thread_dynamic_context_file_states_thread_file_idx",
+ "columns": [
+ "thread_id",
+ "file_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "thread_dynamic_context_file_states_thread_id_threads_id_fk": {
+ "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk",
+ "tableFrom": "thread_dynamic_context_file_states",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_plugin_metadata": {
+ "name": "thread_plugin_metadata",
+ "columns": {
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata_json": {
+ "name": "metadata_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "thread_plugin_metadata_thread_id_threads_id_fk": {
+ "name": "thread_plugin_metadata_thread_id_threads_id_fk",
+ "tableFrom": "thread_plugin_metadata",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "thread_plugin_metadata_thread_id_plugin_id_pk": {
+ "columns": [
+ "thread_id",
+ "plugin_id"
+ ],
+ "name": "thread_plugin_metadata_thread_id_plugin_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_pruning_cursors": {
+ "name": "thread_pruning_cursors",
+ "columns": {
+ "policy": {
+ "name": "policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_thread_id": {
+ "name": "last_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "current_thread_id": {
+ "name": "current_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "step": {
+ "name": "step",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "sequence": {
+ "name": "sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "upper_sequence": {
+ "name": "upper_sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "work_revision": {
+ "name": "work_revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cycle": {
+ "name": "cycle",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "latest_root_sequence": {
+ "name": "latest_root_sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "latest_context_sequence": {
+ "name": "latest_context_sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "probe_event_id": {
+ "name": "probe_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "probe_phase": {
+ "name": "probe_phase",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "probe_sequence": {
+ "name": "probe_sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "probe_witness_id": {
+ "name": "probe_witness_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_pruning_cursors_thread_idx": {
+ "name": "thread_pruning_cursors_thread_idx",
+ "columns": [
+ "thread_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "thread_pruning_cursors_thread_id_threads_id_fk": {
+ "name": "thread_pruning_cursors_thread_id_threads_id_fk",
+ "tableFrom": "thread_pruning_cursors",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "thread_pruning_cursors_policy_scope_pk": {
+ "columns": [
+ "policy",
+ "scope"
+ ],
+ "name": "thread_pruning_cursors_policy_scope_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "thread_pruning_cursors_scope_check": {
+ "name": "thread_pruning_cursors_scope_check",
+ "value": "\"thread_pruning_cursors\".\"scope\" = coalesce(\"thread_pruning_cursors\".\"thread_id\", '')"
+ }
+ }
+ },
+ "thread_pruning_work": {
+ "name": "thread_pruning_work",
+ "columns": {
+ "policy": {
+ "name": "policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ }
+ },
+ "indexes": {
+ "thread_pruning_work_thread_idx": {
+ "name": "thread_pruning_work_thread_idx",
+ "columns": [
+ "thread_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "thread_pruning_work_thread_id_threads_id_fk": {
+ "name": "thread_pruning_work_thread_id_threads_id_fk",
+ "tableFrom": "thread_pruning_work",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "thread_pruning_work_policy_thread_id_pk": {
+ "columns": [
+ "policy",
+ "thread_id"
+ ],
+ "name": "thread_pruning_work_policy_thread_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_search_segments": {
+ "name": "thread_search_segments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_seq": {
+ "name": "source_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_search_segments_source_idx": {
+ "name": "thread_search_segments_source_idx",
+ "columns": [
+ "thread_id",
+ "source_kind",
+ "source_key"
+ ],
+ "isUnique": true
+ },
+ "thread_search_segments_thread_source_seq_idx": {
+ "name": "thread_search_segments_thread_source_seq_idx",
+ "columns": [
+ "thread_id",
+ "source_seq"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "thread_search_segments_thread_id_threads_id_fk": {
+ "name": "thread_search_segments_thread_id_threads_id_fk",
+ "tableFrom": "thread_search_segments",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_sections": {
+ "name": "thread_sections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_sections_name_idx": {
+ "name": "thread_sections_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_tabs": {
+ "name": "thread_tabs",
+ "columns": {
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tabs_json": {
+ "name": "tabs_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "thread_tabs_thread_id_threads_id_fk": {
+ "name": "thread_tabs_thread_id_threads_id_fk",
+ "tableFrom": "thread_tabs",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "threads": {
+ "name": "threads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model_override": {
+ "name": "model_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_level_override": {
+ "name": "reasoning_level_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title_fallback": {
+ "name": "title_fallback",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'starting'"
+ },
+ "startup_context": {
+ "name": "startup_context",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parent_thread_id": {
+ "name": "parent_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lifecycle_owner_thread_id": {
+ "name": "lifecycle_owner_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_thread_id": {
+ "name": "source_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin_kind": {
+ "name": "origin_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin_plugin_id": {
+ "name": "origin_plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'visible'"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pin_sort_key": {
+ "name": "pin_sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storage_deleted_at": {
+ "name": "storage_deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "latest_attention_at": {
+ "name": "latest_attention_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "threads_project_id_idx": {
+ "name": "threads_project_id_idx",
+ "columns": [
+ "project_id",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "threads_project_updated_idx": {
+ "name": "threads_project_updated_idx",
+ "columns": [
+ "project_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "threads_project_archived_deleted_idx": {
+ "name": "threads_project_archived_deleted_idx",
+ "columns": [
+ "project_id",
+ "archived_at",
+ "deleted_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "threads_pin_sort_idx": {
+ "name": "threads_pin_sort_idx",
+ "columns": [
+ "archived_at",
+ "deleted_at",
+ "pin_sort_key",
+ "id"
+ ],
+ "isUnique": false,
+ "where": "\"threads\".\"pinned_at\" IS NOT NULL"
+ },
+ "threads_environment_idx": {
+ "name": "threads_environment_idx",
+ "columns": [
+ "environment_id"
+ ],
+ "isUnique": false
+ },
+ "threads_lifecycle_owner_idx": {
+ "name": "threads_lifecycle_owner_idx",
+ "columns": [
+ "lifecycle_owner_thread_id"
+ ],
+ "isUnique": false
+ },
+ "threads_parent_idx": {
+ "name": "threads_parent_idx",
+ "columns": [
+ "parent_thread_id"
+ ],
+ "isUnique": false
+ },
+ "threads_source_origin_idx": {
+ "name": "threads_source_origin_idx",
+ "columns": [
+ "source_thread_id",
+ "origin_kind"
+ ],
+ "isUnique": false
+ },
+ "threads_origin_plugin_archived_idx": {
+ "name": "threads_origin_plugin_archived_idx",
+ "columns": [
+ "origin_plugin_id",
+ "archived_at"
+ ],
+ "isUnique": false
+ },
+ "threads_section_archived_deleted_idx": {
+ "name": "threads_section_archived_deleted_idx",
+ "columns": [
+ "section_id",
+ "archived_at",
+ "deleted_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "threads_archived_status_idx": {
+ "name": "threads_archived_status_idx",
+ "columns": [
+ "archived_at",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "threads_deleted_cleanup_idx": {
+ "name": "threads_deleted_cleanup_idx",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false,
+ "where": "\"threads\".\"deleted_at\" IS NOT NULL"
+ },
+ "threads_environment_archived_deleted_idx": {
+ "name": "threads_environment_archived_deleted_idx",
+ "columns": [
+ "environment_id",
+ "archived_at",
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "threads_active_maintenance_idx": {
+ "name": "threads_active_maintenance_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false,
+ "where": "\"threads\".\"deleted_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "threads_project_id_projects_id_fk": {
+ "name": "threads_project_id_projects_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "threads_environment_id_environments_id_fk": {
+ "name": "threads_environment_id_environments_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "threads_section_id_thread_sections_id_fk": {
+ "name": "threads_section_id_thread_sections_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "thread_sections",
+ "columnsFrom": [
+ "section_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "threads_parent_thread_id_threads_id_fk": {
+ "name": "threads_parent_thread_id_threads_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "parent_thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "threads_lifecycle_owner_thread_id_threads_id_fk": {
+ "name": "threads_lifecycle_owner_thread_id_threads_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "lifecycle_owner_thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "threads_source_thread_id_threads_id_fk": {
+ "name": "threads_source_thread_id_threads_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "source_thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ui_preference_defaults": {
+ "name": "ui_preference_defaults",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value_json": {
+ "name": "value_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ui_preferences": {
+ "name": "ui_preferences",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value_json": {
+ "name": "value_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 0d36bcca665..2764be83c59 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -995,6 +995,13 @@
"when": 1791414564985,
"tag": "0141_moaning_vector",
"breakpoints": true
+ },
+ {
+ "idx": 142,
+ "version": "6",
+ "when": 1791840325007,
+ "tag": "0142_burly_baron_strucker",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/data/plugin-marketplaces.ts b/packages/db/src/data/plugin-marketplaces.ts
index e3ab640286b..b9749687c65 100644
--- a/packages/db/src/data/plugin-marketplaces.ts
+++ b/packages/db/src/data/plugin-marketplaces.ts
@@ -9,6 +9,7 @@ export interface PluginMarketplaceRow {
sourceKind: PluginMarketplaceSourceKind;
manifestUrl: string;
sourceGitRef: string | null;
+ sourceGitSubdirectory: string | null;
sourceGitCommit: string | null;
manifestJson: string;
statsJson: string | null;
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 183bc2a5075..708690c5ca5 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -368,6 +368,7 @@ export const pluginMarketplaces = sqliteTable("plugin_marketplaces", {
.default("https"),
manifestUrl: text("manifest_url").notNull(),
sourceGitRef: text("source_git_ref"),
+ sourceGitSubdirectory: text("source_git_subdirectory"),
sourceGitCommit: text("source_git_commit"),
manifestJson: text("manifest_json").notNull(),
statsJson: text("stats_json"),
diff --git a/packages/db/test/data/server-move.test.ts b/packages/db/test/data/server-move.test.ts
index 34c546623bb..3f7adda231b 100644
--- a/packages/db/test/data/server-move.test.ts
+++ b/packages/db/test/data/server-move.test.ts
@@ -128,6 +128,7 @@ function seedPluginPaths(db: DbConnection): void {
sourceKind,
manifestUrl,
sourceGitRef: null,
+ sourceGitSubdirectory: null,
sourceGitCommit: null,
manifestJson: "{}",
statsJson: null,
diff --git a/packages/db/test/helpers/rewind.ts b/packages/db/test/helpers/rewind.ts
index 9eae6749c1a..d24944d16c5 100644
--- a/packages/db/test/helpers/rewind.ts
+++ b/packages/db/test/helpers/rewind.ts
@@ -42,3 +42,16 @@ export function dropQueuedMessageEditHeldUntilColumn(db: DbConnection): void {
);
}
}
+
+export function dropMarketplaceSourceGitSubdirectoryColumn(
+ db: DbConnection,
+): void {
+ const columns = db.$client
+ .prepare<[], { name: string }>("PRAGMA table_info(plugin_marketplaces)")
+ .all();
+ if (columns.some((column) => column.name === "source_git_subdirectory")) {
+ db.$client.exec(
+ "ALTER TABLE plugin_marketplaces DROP COLUMN source_git_subdirectory",
+ );
+ }
+}
diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts
index ac349cbf7da..0c26748dd26 100644
--- a/packages/db/test/migrate.test.ts
+++ b/packages/db/test/migrate.test.ts
@@ -7,6 +7,7 @@ import {
dropPluginEnabledFollowsDefaultColumn,
dropIdleLifecycleIndexes,
rewindThreadPruningWork,
+ dropMarketplaceSourceGitSubdirectoryColumn,
dropQueuedMessageEditHeldUntilColumn,
} from "./helpers/rewind.js";
import { defaultAppSettings } from "@bb/domain";
@@ -720,6 +721,7 @@ function rewindEnvironmentProvisioningMigration(db: DbConnection): void {
rewindThreadPruningWork(db);
dropPluginEnabledFollowsDefaultColumn(db);
dropQueuedMessageEditHeldUntilColumn(db);
+ dropMarketplaceSourceGitSubdirectoryColumn(db);
db.$client.exec("DROP TRIGGER IF EXISTS threads_lifecycle_owner_insert");
db.$client.exec("DROP TRIGGER IF EXISTS threads_lifecycle_owner_immutable");
db.$client.exec("DROP INDEX IF EXISTS threads_lifecycle_owner_idx");
@@ -842,6 +844,7 @@ function rewindEnvironmentRowFactsMigration(db: DbConnection): void {
function rewindMachineProvidersMigration(db: DbConnection): void {
rewindThreadPruningWork(db);
dropQueuedMessageEditHeldUntilColumn(db);
+ dropMarketplaceSourceGitSubdirectoryColumn(db);
db.$client.exec("DROP TABLE IF EXISTS ui_preference_defaults");
const queuedDispatchOrigin = db.$client
.prepare<[], TableInfoRow>("PRAGMA table_info(queued_thread_messages)")
@@ -3305,6 +3308,7 @@ describe("migrate", () => {
dropIdleLifecycleIndexes(db);
rewindThreadPruningWork(db);
dropQueuedMessageEditHeldUntilColumn(db);
+ dropMarketplaceSourceGitSubdirectoryColumn(db);
db.$client.prepare("DROP INDEX projects_deleted_idx").run();
db.$client.prepare("ALTER TABLE projects DROP COLUMN deleted_at").run();
db.$client.prepare("ALTER TABLE events ADD producer_event_id text").run();
diff --git a/packages/db/test/pane-focus-bindings-migration.test.ts b/packages/db/test/pane-focus-bindings-migration.test.ts
index 2adf8cb6d30..6c37ab1729c 100644
--- a/packages/db/test/pane-focus-bindings-migration.test.ts
+++ b/packages/db/test/pane-focus-bindings-migration.test.ts
@@ -10,6 +10,7 @@ import {
dropPluginEnabledFollowsDefaultColumn,
dropIdleLifecycleIndexes,
rewindThreadPruningWork,
+ dropMarketplaceSourceGitSubdirectoryColumn,
dropQueuedMessageEditHeldUntilColumn,
} from "./helpers/rewind.js";
@@ -50,6 +51,7 @@ function upgradeDatabase(overrides: AppKeybindingOverrides | undefined) {
rewindThreadPruningWork(db);
dropPluginEnabledFollowsDefaultColumn(db);
dropQueuedMessageEditHeldUntilColumn(db);
+ dropMarketplaceSourceGitSubdirectoryColumn(db);
db.$client
.prepare("DELETE FROM __drizzle_migrations WHERE created_at >= ?")
.run(migrationTimestamp);
diff --git a/packages/db/test/sidebar-installation-defaults.test.ts b/packages/db/test/sidebar-installation-defaults.test.ts
index cf8a1e99e96..7cc9ef4c1a0 100644
--- a/packages/db/test/sidebar-installation-defaults.test.ts
+++ b/packages/db/test/sidebar-installation-defaults.test.ts
@@ -12,6 +12,7 @@ import {
dropPluginEnabledFollowsDefaultColumn,
dropIdleLifecycleIndexes,
rewindThreadPruningWork,
+ dropMarketplaceSourceGitSubdirectoryColumn,
dropQueuedMessageEditHeldUntilColumn,
} from "./helpers/rewind.js";
@@ -88,6 +89,7 @@ describe.each(["project", "thread", "preference"] as const)(
rewindThreadPruningWork(db);
dropPluginEnabledFollowsDefaultColumn(db);
dropQueuedMessageEditHeldUntilColumn(db);
+ dropMarketplaceSourceGitSubdirectoryColumn(db);
db.$client
.prepare("DELETE FROM __drizzle_migrations WHERE created_at >= ?")
.run(SIDEBAR_INSTALLATION_DEFAULTS_MIGRATION_TIMESTAMP);
diff --git a/packages/db/test/thread-drafts-migration.test.ts b/packages/db/test/thread-drafts-migration.test.ts
index 92a8ae5a8f9..7d4bc16755b 100644
--- a/packages/db/test/thread-drafts-migration.test.ts
+++ b/packages/db/test/thread-drafts-migration.test.ts
@@ -13,6 +13,7 @@ import {
dropPluginEnabledFollowsDefaultColumn,
dropIdleLifecycleIndexes,
rewindThreadPruningWork,
+ dropMarketplaceSourceGitSubdirectoryColumn,
dropQueuedMessageEditHeldUntilColumn,
} from "./helpers/rewind.js";
@@ -114,6 +115,7 @@ it.each([false, true])(
rewindThreadPruningWork(db);
dropPluginEnabledFollowsDefaultColumn(db);
dropQueuedMessageEditHeldUntilColumn(db);
+ dropMarketplaceSourceGitSubdirectoryColumn(db);
migrate(db);
migrate(db);
diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts
index 0a11ef76b31..5d68939f101 100644
--- a/packages/domain/src/plugin-sdk-version.ts
+++ b/packages/domain/src/plugin-sdk-version.ts
@@ -1,3 +1,3 @@
-export const PLUGIN_SDK_VERSION = "0.6.41";
+export const PLUGIN_SDK_VERSION = "0.6.43";
export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]);
diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json
index d96e6ddfa17..54c18f025f2 100644
--- a/packages/plugin-sdk/package.json
+++ b/packages/plugin-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@get-bb/plugin-sdk",
- "version": "0.6.41",
+ "version": "0.6.43",
"homepage": "https://github.com/get-bb/bb#readme",
"bugs": {
"url": "https://github.com/get-bb/bb/issues"
diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts
index ad0d17c610a..8d3fa14a907 100644
--- a/packages/plugin-sdk/src/app-contract.ts
+++ b/packages/plugin-sdk/src/app-contract.ts
@@ -501,6 +501,51 @@ export interface PluginMessageDirectiveProps {
openWorkspaceFile: PluginMessageDirectiveOpenWorkspaceFile | null;
}
+/**
+ * Where a fenced code block stands while its message is written.
+ *
+ * - `"streaming"`: the closing delimiter has not arrived and the message is
+ * still being written; `code` is a prefix of the final body.
+ * - `"closed"`: the closing delimiter is present; `code` is final.
+ * - `"unclosed"`: the message ended without a closing delimiter (for example
+ * an interrupted reply); `code` will not grow.
+ *
+ * @experimental Audit before relying on this as a stable contract.
+ */
+export type ExperimentalCodeFenceStatus = "streaming" | "closed" | "unclosed";
+
+/**
+ * Props passed to an `experimental_codeFenceRenderer` component — one fenced
+ * code block whose info-string language the plugin claimed, inside assistant
+ * (or nested agent) message Markdown. BB mounts the component once per fence
+ * and keeps that instance for as long as the message stays on screen, so
+ * component state survives the whole stream, including the moment the fence
+ * closes and the moment the reply finishes.
+ *
+ * @experimental Audit before relying on this as a stable contract.
+ */
+export interface ExperimentalCodeFenceRendererProps {
+ /** The fence body received so far, without the fence delimiters. */
+ code: string;
+ /** The matched lowercase info-string language (e.g. `"ui"`). */
+ language: string;
+ status: ExperimentalCodeFenceStatus;
+ /**
+ * Zero-based ordinal of this fence among the fences of the same language in
+ * the same message. It does not change while the message streams, so
+ * `${message.id}#${language}#${index}` keys state that must outlive the
+ * mounted component (a reload, or the message scrolling out of the rendered
+ * window).
+ */
+ index: number;
+ message: PluginMessageDirectiveMessage;
+ /**
+ * BB's own code block for this fence. Render it to show the source, for
+ * example while `status` is `"streaming"` or when `code` does not parse.
+ */
+ Original: ComponentType;
+}
+
// ---------------------------------------------------------------------------
// Slot registrations (the arguments to `app.slots.*`).
// ---------------------------------------------------------------------------
@@ -1798,6 +1843,31 @@ export interface PluginMessageDirectiveRegistration {
component: ComponentType;
}
+/**
+ * Render fenced code blocks of chosen languages inside assistant (and nested
+ * agent) message Markdown. ` ```ui ` matches a registration whose `languages`
+ * include `"ui"`. Fences elsewhere (user messages, file previews, plugin
+ * Markdown) keep BB's code block.
+ *
+ * BB's code block also renders when two plugins claim the same language (BB
+ * logs a warning naming them), when the plugin is disabled, and for a fence
+ * whose component throws. A throw affects that fence only, and BB tries the
+ * component again when the fence's `code` or `status` changes.
+ *
+ * @experimental Audit before relying on this as a stable contract.
+ */
+export interface ExperimentalCodeFenceRendererRegistration {
+ /** Unique within the plugin; letters, digits, `-`, `_`. */
+ id: string;
+ /**
+ * Lowercase info-string languages, e.g. `["ui", "app"]`. Each starts with a
+ * letter or digit and contains only `a-z`, `0-9`, `+`, `#`, `.`, `_`, `-`.
+ * A language may be claimed once per plugin.
+ */
+ languages: readonly string[];
+ component: ComponentType;
+}
+
/**
* A narrow, stable reference to one rendered chat message — NOT an internal
* timeline row. `sourceSeqEnd` is the last source event sequence the message
@@ -2222,6 +2292,14 @@ export interface PluginAppSlots {
*/
experimental_diffRenderer(registration: PluginDiffRendererRegistration): void;
messageDirective(registration: PluginMessageDirectiveRegistration): void;
+ /**
+ * Render fenced code blocks of chosen languages in assistant messages (see
+ * {@link ExperimentalCodeFenceRendererRegistration}). Experimental: see
+ * docs/api_to_audit.md.
+ */
+ experimental_codeFenceRenderer(
+ registration: ExperimentalCodeFenceRendererRegistration,
+ ): void;
messageAction(registration: PluginMessageActionRegistration): void;
/**
* @deprecated Use `app.commands.register` with the same registration.
diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts
index 2db8bae2370..4c14fd54cd0 100644
--- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts
+++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import type {
ComposerCustomization,
ExperimentalAppOverlayRegistration,
+ ExperimentalCodeFenceRendererRegistration,
ExperimentalSidebarFooter,
ExperimentalSidebarFooterActionContext,
ExperimentalSidebarFooterActionRegistration,
@@ -48,6 +49,8 @@ import {
requireUniqueId,
} from "./composer-customization-validation.js";
+const CODE_FENCE_LANGUAGE_PATTERN = /^[a-z0-9][a-z0-9+#._-]*$/;
+
export type ExperimentalSidebarFooterCommandKind = "open" | "close" | "toggle";
export interface ExperimentalSidebarFooterRuntimeSnapshot {
@@ -360,6 +363,7 @@ export interface CollectedPluginAppRegistrations {
sourceCodeRenderers: PluginSourceCodeRendererRegistration[];
diffRenderers: PluginDiffRendererRegistration[];
messageDirectives: PluginMessageDirectiveRegistration[];
+ codeFenceRenderers: ExperimentalCodeFenceRendererRegistration[];
messageActions: PluginMessageActionRegistration[];
commandPaletteActions: CollectedPluginCommandRegistration[];
providerIcons: CollectedPluginProviderIconRegistration[];
@@ -485,6 +489,7 @@ export function collectPluginAppRegistrations(
sourceCodeRenderers: [],
diffRenderers: [],
messageDirectives: [],
+ codeFenceRenderers: [],
messageActions: [],
commandPaletteActions: [],
providerIcons: [],
@@ -514,6 +519,8 @@ export function collectPluginAppRegistrations(
sourceCodeRenderer: new Set(),
diffRenderer: new Set(),
messageDirective: new Set(),
+ codeFenceRenderer: new Set(),
+ codeFenceLanguage: new Set(),
messageAction: new Set(),
command: new Set(),
providerIcon: new Set(),
@@ -889,6 +896,37 @@ export function collectPluginAppRegistrations(
component: requireComponent(kind, registration.component),
});
},
+ experimental_codeFenceRenderer(registration) {
+ const kind = "slots.experimental_codeFenceRenderer";
+ const id = requireSlotId(kind, registration?.id);
+ requireUniqueId(kind, seenIds.codeFenceRenderer, id);
+ const rawLanguages: unknown = registration.languages;
+ if (!Array.isArray(rawLanguages) || rawLanguages.length === 0) {
+ throw new Error(
+ `${kind}: "languages" must be a non-empty array of lowercase info-string languages`,
+ );
+ }
+ const languages = rawLanguages.map((language: unknown) => {
+ if (
+ typeof language !== "string" ||
+ !CODE_FENCE_LANGUAGE_PATTERN.test(language)
+ ) {
+ throw new Error(
+ `${kind}: languages must match ${CODE_FENCE_LANGUAGE_PATTERN}, got ${JSON.stringify(language)}`,
+ );
+ }
+ if (seenIds.codeFenceLanguage.has(language)) {
+ throw new Error(`${kind}: duplicate language "${language}"`);
+ }
+ seenIds.codeFenceLanguage.add(language);
+ return language;
+ });
+ collected.codeFenceRenderers.push({
+ id,
+ languages,
+ component: requireComponent(kind, registration.component),
+ });
+ },
messageAction(registration) {
const kind = "slots.messageAction";
const id = requireSlotId(kind, registration?.id);
diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
index c28ac8ca785..bd5c947ec3b 100644
--- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
+++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx
@@ -1297,6 +1297,87 @@ describe("loadPluginApp", () => {
).rejects.toThrow('slots.messageDirective: duplicate id "inline-vis"');
});
+ it("captures experimental_codeFenceRenderer registrations and validates them like the host", async () => {
+ const FenceRenderer = () => null;
+ const captured = await loadPluginApp(
+ definePluginApp((builder) => {
+ builder.slots.experimental_codeFenceRenderer({
+ id: "generative-ui",
+ languages: ["ui", "app"],
+ component: FenceRenderer,
+ });
+ builder.slots.experimental_codeFenceRenderer({
+ id: "charts",
+ languages: ["c++", "chart.v2"],
+ component: FenceRenderer,
+ });
+ }),
+ );
+ expect(captured.codeFenceRenderers).toEqual([
+ {
+ id: "generative-ui",
+ languages: ["ui", "app"],
+ component: FenceRenderer,
+ },
+ {
+ id: "charts",
+ languages: ["c++", "chart.v2"],
+ component: FenceRenderer,
+ },
+ ]);
+
+ const kind = "slots.experimental_codeFenceRenderer";
+ const rejected: [string, Record[]][] = [
+ [
+ `${kind}: "languages" must be a non-empty array`,
+ [{ id: "ui", languages: [], component: FenceRenderer }],
+ ],
+ [
+ `${kind}: "languages" must be a non-empty array`,
+ [{ id: "ui", languages: "ui", component: FenceRenderer }],
+ ],
+ [
+ `${kind}: languages must match`,
+ [{ id: "ui", languages: ["UI"], component: FenceRenderer }],
+ ],
+ [
+ `${kind}: languages must match`,
+ [{ id: "ui", languages: ["ui block"], component: FenceRenderer }],
+ ],
+ [
+ `${kind}: duplicate language "ui"`,
+ [
+ { id: "first", languages: ["ui"], component: FenceRenderer },
+ { id: "second", languages: ["app", "ui"], component: FenceRenderer },
+ ],
+ ],
+ [
+ `${kind}: duplicate id "ui"`,
+ [
+ { id: "ui", languages: ["ui"], component: FenceRenderer },
+ { id: "ui", languages: ["app"], component: FenceRenderer },
+ ],
+ ],
+ [
+ `${kind}: "component"`,
+ [{ id: "ui", languages: ["ui"], component: undefined }],
+ ],
+ ];
+ for (const [message, registrations] of rejected) {
+ await expect(
+ loadPluginApp(
+ definePluginApp((builder) => {
+ for (const registration of registrations) {
+ builder.slots.experimental_codeFenceRenderer(
+ registration as never,
+ );
+ }
+ }),
+ ),
+ ).rejects.toThrow(message);
+ }
+ });
+
it("captures messageAction registrations and validates them like the host", async () => {
expect(app.messageActions).toHaveLength(1);
expect(app.messageActions[0]).toMatchObject({
diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx
index 7328ab36833..8bfc171fd49 100644
--- a/packages/plugin-sdk/src/testing/app.tsx
+++ b/packages/plugin-sdk/src/testing/app.tsx
@@ -43,6 +43,7 @@ import {
type PluginHomepageSectionRegistration,
type PluginMessageActionRegistration,
type PluginMessageDirectiveRegistration,
+ type ExperimentalCodeFenceRendererRegistration,
type PluginDiffRendererRegistration,
type PluginNavPanelRegistration,
type PluginNewThreadPanelActionRegistration,
@@ -1512,6 +1513,7 @@ export interface CapturedPluginApp {
sourceCodeRenderers: PluginSourceCodeRendererRegistration[];
diffRenderers: PluginDiffRendererRegistration[];
messageDirectives: PluginMessageDirectiveRegistration[];
+ codeFenceRenderers: ExperimentalCodeFenceRendererRegistration[];
messageActions: PluginMessageActionRegistration[];
providerIcons: CollectedPluginProviderIconRegistration[];
icons: ExperimentalIconRegistration[];
diff --git a/packages/sdk/src/areas/plugins.ts b/packages/sdk/src/areas/plugins.ts
index ac104fec634..a1cb0b95425 100644
--- a/packages/sdk/src/areas/plugins.ts
+++ b/packages/sdk/src/areas/plugins.ts
@@ -144,6 +144,7 @@ export interface PluginCatalogInstallPlanArgs {
export interface PluginMarketplaceAddArgs {
source: string;
+ subdirectory?: string;
}
export interface PluginMarketplaceListArgs {
diff --git a/packages/server-contract/src/api/plugins.ts b/packages/server-contract/src/api/plugins.ts
index 9aa499c0b8d..42e04bd3f0c 100644
--- a/packages/server-contract/src/api/plugins.ts
+++ b/packages/server-contract/src/api/plugins.ts
@@ -628,6 +628,7 @@ export const pluginMarketplaceSchema = z.object({
official: z.boolean(),
sourceKind: pluginMarketplaceSourceKindSchema,
source: z.string(),
+ subdirectory: z.string().nullable().default(null),
resolvedCommit: z.string().nullable(),
entryCount: z.number(),
lastRefreshAt: z.number().nullable(),
@@ -643,6 +644,7 @@ export const pluginMarketplaceListResponseSchema = z.object({
export const pluginMarketplaceAddRequestSchema = z
.object({
source: z.string().min(1),
+ subdirectory: z.string().min(1).optional(),
})
.strict();
diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts
index f083588f01c..37af2838a92 100644
--- a/packages/server-contract/src/api/system.ts
+++ b/packages/server-contract/src/api/system.ts
@@ -1,3 +1,4 @@
+import { coreFeatureUsedPropertiesSchema } from "../feature-telemetry.js";
import { rejectMultipleWorkspaceSelectors } from "./shared.js";
import { z } from "zod";
import {
@@ -244,6 +245,49 @@ const tipTelemetryPropertiesSchema = z
.strict();
export const recordTelemetryEventRequestSchema = z.discriminatedUnion("name", [
+ z
+ .object({
+ name: z.literal("feature_used"),
+ properties: coreFeatureUsedPropertiesSchema,
+ })
+ .strict(),
+ z
+ .object({
+ name: z.literal("onboarding_agent_state"),
+ properties: z
+ .object({
+ entry: onboardingEntryTelemetrySchema,
+ agent_installed: z.boolean().nullable(),
+ agent_ready: z.boolean().nullable(),
+ })
+ .strict(),
+ })
+ .strict(),
+ z
+ .object({
+ name: z.literal("onboarding_sign_in"),
+ properties: z
+ .object({
+ entry: onboardingEntryTelemetrySchema,
+ outcome: z.enum([
+ "started",
+ "ready",
+ "exited",
+ "failed",
+ "cancelled",
+ ]),
+ })
+ .strict(),
+ })
+ .strict(),
+ z
+ .object({
+ name: z.literal("new_thread_submit"),
+ properties: z
+ .object({ outcome: z.enum(["started", "accepted", "failed"]) })
+ .strict(),
+ })
+ .strict(),
z
.object({
name: z.literal("onboarding_started"),
@@ -294,6 +338,24 @@ export const recordTelemetryEventRequestSchema = z.discriminatedUnion("name", [
properties: tipTelemetryPropertiesSchema,
})
.strict(),
+ z
+ .object({
+ name: z.literal("automation_created"),
+ properties: z
+ .object({
+ trigger: z.enum(["schedule", "once"]),
+ mode: z.enum(["agent", "script"]),
+ origin: z.enum(["human", "app", "agent"]),
+ })
+ .strict(),
+ })
+ .strict(),
+ z
+ .object({
+ name: z.literal("split_view_opened"),
+ properties: z.object({ panes: z.number().int().min(2).max(8) }).strict(),
+ })
+ .strict(),
]);
export type RecordTelemetryEventRequest = z.infer<
typeof recordTelemetryEventRequestSchema
diff --git a/packages/server-contract/src/feature-telemetry.ts b/packages/server-contract/src/feature-telemetry.ts
new file mode 100644
index 00000000000..9f158a910ec
--- /dev/null
+++ b/packages/server-contract/src/feature-telemetry.ts
@@ -0,0 +1,67 @@
+import { z } from "zod";
+
+export const coreFeatureSchema = z.enum([
+ "thread",
+ "child_thread",
+ "provider",
+ "remote_access",
+ "automation",
+ "browser",
+ "terminal",
+ "file",
+ "git",
+ "settings",
+ "project",
+ "environment",
+ "split_view",
+ "search",
+ "voice",
+ "plugin_management",
+]);
+export type CoreFeature = z.infer;
+
+export const telemetryTriggerSchema = z.enum([
+ "person",
+ "agent",
+ "schedule",
+ "system",
+ "unknown",
+]);
+export type TelemetryTrigger = z.infer;
+
+export const pluginSourceSchema = z.enum([
+ "builtin",
+ "public_catalog",
+ "private_marketplace",
+ "local",
+ "unknown",
+]);
+export type TelemetryPluginSource = z.infer;
+
+const featureInteractionSchema = z.enum(["opened", "invoked", "ran"]);
+
+export const coreFeatureUsedPropertiesSchema = z
+ .object({
+ feature: coreFeatureSchema,
+ feature_scope: z.literal("core"),
+ interaction: featureInteractionSchema,
+ trigger: telemetryTriggerSchema,
+ })
+ .strict();
+
+export const featureUsedPropertiesSchema = z.discriminatedUnion(
+ "feature_scope",
+ [
+ coreFeatureUsedPropertiesSchema,
+ z
+ .object({
+ feature: z.union([coreFeatureSchema, z.literal("plugin")]),
+ feature_scope: z.literal("plugin"),
+ interaction: featureInteractionSchema,
+ trigger: telemetryTriggerSchema,
+ plugin_source: pluginSourceSchema,
+ })
+ .strict(),
+ ],
+);
+export type FeatureUsedProperties = z.infer;
diff --git a/packages/server-contract/src/index.ts b/packages/server-contract/src/index.ts
index c3b8a39551f..7321e815aa0 100644
--- a/packages/server-contract/src/index.ts
+++ b/packages/server-contract/src/index.ts
@@ -22,3 +22,5 @@ export type {
} from "@bb/domain";
export * from "./api/machine-environment.js";
+
+export * from "./feature-telemetry.js";
diff --git a/packages/shared-ui/src/components/ui/icon-extended.tsx b/packages/shared-ui/src/components/ui/icon-extended.tsx
index fd5b96b78a4..f759775ba0d 100644
--- a/packages/shared-ui/src/components/ui/icon-extended.tsx
+++ b/packages/shared-ui/src/components/ui/icon-extended.tsx
@@ -107,6 +107,7 @@ import TextWrapIcon from "@hugeicons/core-free-icons/TextWrapIcon";
import TimeScheduleIcon from "@hugeicons/core-free-icons/TimeScheduleIcon";
import Unarchive03Icon from "@hugeicons/core-free-icons/Unarchive03Icon";
import UserIcon from "@hugeicons/core-free-icons/UserIcon";
+import UserMultiple02Icon from "@hugeicons/core-free-icons/UserMultiple02Icon";
import ViewIcon from "@hugeicons/core-free-icons/ViewIcon";
import ViewOffIcon from "@hugeicons/core-free-icons/ViewOffIcon";
import ZoomInAreaIcon from "@hugeicons/core-free-icons/ZoomInAreaIcon";
@@ -253,6 +254,7 @@ export const EXTENDED_ICON_MAP: ExtendedIconMap = {
ChevronUp: ArrowUp01Icon,
ChevronsDown: ArrowDownDoubleIcon,
ChevronsUp: ArrowUpDoubleIcon,
+ ChildThread: UserMultiple02Icon,
CircleArrowShrink: CircleArrowShrink01Icon,
Clean: CleanIcon,
Clock: Clock01Icon,
diff --git a/packages/shared-ui/src/components/ui/icon-registry.ts b/packages/shared-ui/src/components/ui/icon-registry.ts
index 525b8f8e1a3..03b1e3d951d 100644
--- a/packages/shared-ui/src/components/ui/icon-registry.ts
+++ b/packages/shared-ui/src/components/ui/icon-registry.ts
@@ -27,6 +27,7 @@ export const EXTENDED_ICON_NAMES = [
"ChevronUp",
"ChevronsDown",
"ChevronsUp",
+ "ChildThread",
"CircleArrowShrink",
"Clean",
"Clock",
diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md
index 99b3803d118..cc7cb308fd5 100644
--- a/packages/templates/src/templates/bb-guide-plugins.md
+++ b/packages/templates/src/templates/bb-guide-plugins.md
@@ -457,7 +457,10 @@ added/updated/unchanged counts.
git:[@[], or path:]. bb
validates the manifest, caches the catalog,
and fetches the entry icons. Adding a
- marketplace installs nothing
+ marketplace installs nothing. For a git:
+ source, --subdirectory reads
+ marketplace.json from that directory of the
+ repository instead of its root
bb marketplace list Name, source, entry count, and last refresh of
every marketplace (--json for raw rows)
bb marketplace refresh [name] Re-read one catalog, or every one of them.
@@ -564,8 +567,15 @@ path: on the bb server's machine:
bb marketplace add https://plugins.acme.dev/marketplace.json
bb marketplace add git:github.com/acme/bb-marketplace@main
+ bb marketplace add git:github.com/acme/agent-plugins@main --subdirectory bb
bb marketplace add path:/work/acme-marketplace
+A git repository can keep its catalog in a subdirectory. --subdirectory names
+a relative path inside the checkout, with the same rules as
+`bb plugin install --subdirectory`. bb reads marketplace.json and relative
+icons from that directory on every refresh. `source.git.subdir` in a catalog
+entry stays relative to the repository root.
+
The manifest `name` is the marketplace identity. bb refuses a duplicate name.
The `bb-official` and `bb-community` names are reserved. You cannot add or
remove them. A third-party marketplace can use manifest v1 or v2. A git or
@@ -747,14 +757,19 @@ experimental_newThreadPanelAction (the root New thread counterpart, with
`projectId: string | null` instead of `threadId`), pendingInteraction (temporarily replace a thread composer with a
plugin form), fileOpener (register as a per-extension file viewer/editor;
users pick defaults under Settings → File openers and can right-click a
-file link for a one-off choice), and messageDirective (replace a leaf
+file link for a one-off choice), messageDirective (replace a leaf
`::name{k="v"}` block inside assistant / nested-agent Markdown with a plugin
component; unknown, disabled, incomplete, code-fenced, or crashing
directives fall back to the original source; components receive a nullable
openWorkspaceFile(path) callback for opening a worktree-relative file in the
host workspace viewer and a nullable
openThreadPanel({ actionId, title?, params? }) callback for opening one of the
-same plugin's thread-panel actions). Hooks:
+same plugin's thread-panel actions), and experimental_codeFenceRenderer
+(render fenced code blocks of claimed languages in the same assistant /
+nested-agent Markdown with a plugin component receiving { code, language,
+status, index, message, Original }, mounted once per fence for the whole
+stream; BB's code block is the fallback for a disabled plugin, a language two
+plugins claim, and a fence whose component throws). Hooks:
useRpc, useRealtime, useRealtimeConnectionState (the shared realtime socket's
connecting/connected/reconnecting lifecycle; reconcile on later connected
transitions, not the initial connection), useSettings (secrets excluded),
@@ -981,7 +996,7 @@ reload/disable/shutdown).
Frontend entries register React slots (homepageSection, settingsSection,
navPanel, threadPanelAction, experimental_newThreadPanelAction, fileOpener,
-messageDirective) and composer
+messageDirective, experimental_codeFenceRenderer) and composer
customizations via `app.composer.customize({ actions, plusMenu, sendMenu,
banners, richText })`; action/banner components use `useComposer()`, while the
host renders plus-menu and send-menu rows and editor decorations. The deprecated pre-1.0 `slots.composerAccessory` footer API was
@@ -996,10 +1011,12 @@ For the complete authoring reference — exact signatures, working snippets
for every surface, the reload lifecycle, testing tips, and gotchas — use
the built-in `bb-plugin-authoring` skill (agents: it loads on demand;
humans: plugins/bb-guide/skills/bb-plugin-authoring/
-in a checkout). The builtin `inline-vis` plugin renders
-`::inline-vis{file="demo.html" height="480"}` through the sidebar's
-path-shaped, sandboxed worktree HTML iframe preview; `height` is optional.
-Its card header includes an open-in-sidebar action for the source HTML file.
+in a checkout). The builtin `inline-vis` plugin shows an HTML page as part
+of the reply, in a borderless sandboxed frame that follows the page's height
+and bb's theme. The page comes from `::inline-vis{file="demo.html"}` (a
+workspace or thread-storage file; an optional `height` caps the frame) or
+from a fenced `inline-vis` code block whose body is the page. A button that
+appears on hover opens the file in the sidebar, or reveals a block's source.
The `plugins/` directory contains every bundled plugin: the auto-installed
builtins and the store-only BB Official GitHub, Docs, Memory, and Tasks
plugins. The `examples/plugins/` reference plugins cover slack-bot (webhook
diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md
index aca827da9d1..d80a429857e 100644
--- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md
+++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md
@@ -44,8 +44,8 @@ hide the navigation rail's destinations (see Navigation rail below).
`sidebar.threadListProvider`).
- The same registry stores `infoPanel.collapsedSections`, the thread Info panel
sections collapsed from their headings (`commits`, `uncommittedChanges`,
- `forks`, `threadStorage`). Read or change it with `bb settings ui get` and
- `bb settings ui set`.
+ `childThreads`, `forks`, `threadStorage`). Read or change it with
+ `bb settings ui get` and `bb settings ui set`.
- The built-in sidebar's Filter selects Active and Archived, defaulting to Active,
including threads with saved messages. This selection is browser-local, not
a server-backed preference or SDK/CLI setting. Selected archived rows
diff --git a/plugins/bb-guide/skills/bb-cli/references/plugins.md b/plugins/bb-guide/skills/bb-cli/references/plugins.md
index dc731bfdffb..da06e981c11 100644
--- a/plugins/bb-guide/skills/bb-cli/references/plugins.md
+++ b/plugins/bb-guide/skills/bb-cli/references/plugins.md
@@ -37,7 +37,10 @@
- `bb marketplace add ` — add a marketplace from an https manifest
URL, `git:[@[]` (bb reads `marketplace.json` from the checkout),
or `path:]`. The CLI resolves a relative path from its current
- directory before it sends the request. BB validates the
+ directory before it sends the request. For a `git:` source,
+ `--subdirectory ` reads `marketplace.json` and relative
+ icons from that directory of the repository instead of its root; bb stores
+ the subdirectory and reuses it on every refresh. BB validates the
manifest, caches the catalog, and fetches its icons. **Adding a
marketplace installs nothing.** The manifest's own `name` is the
marketplace's identity, so a name collision is refused. The `bb-official`
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md
index f57fe68e256..b540e1b6c94 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md
@@ -200,7 +200,10 @@ Read the installed declarations for exact current signatures.
- `PluginMessageDirectiveMessage`
- `PluginMessageDirectiveOpenWorkspaceFile`
- `PluginMessageDirectiveProps`
+- `ExperimentalCodeFenceRendererProps`
- `PluginMessageDirectiveRegistration`
+- `ExperimentalCodeFenceRendererRegistration`
+- `ExperimentalCodeFenceStatus`
- `PluginNavPanelProps`
- `PluginNavPanelRegistration`
- `PluginNewThreadPanelActionContext`
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/distribution.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/distribution.md
index cd65008c9e0..84b698d0fc8 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/distribution.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/distribution.md
@@ -153,6 +153,7 @@ Host it three ways, and users add whichever fits:
```sh
bb marketplace add https://plugins.acme.dev/marketplace.json
bb marketplace add git:github.com/acme/bb-marketplace@main
+bb marketplace add git:github.com/acme/agent-plugins@main --subdirectory bb
bb marketplace add path:/work/acme-marketplace
bb marketplace list
bb marketplace refresh acme-plugins
@@ -165,7 +166,10 @@ plugins continue as direct installs.
An https marketplace is re-read with a conditional request; a git one is
cloned into a throwaway checkout each refresh, with `marketplace.json` and any
-relative icons read from the repository root. Prefer git tag ranges over
+relative icons read from the repository root, or from the directory named by
+`--subdirectory ` when the repository keeps its bb files apart
+from other content. `source.git.subdir` in an entry stays relative to the
+repository root. Prefer git tag ranges over
pinned refs so a release reaches users without a catalog change. Before
installing from a marketplace that is not `bb-community`, bb resolves and shows
the true source — including the exact release tag and commit a range lands
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md
index 2df4c98381b..45b8f68cf3e 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md
@@ -134,6 +134,7 @@ Read the installed SDK declarations for the exact current signatures.
- `PluginMessageDirectiveMessage`
- `PluginMessageDirectiveOpenWorkspaceFile`
- `PluginMessageDirectiveProps`
+- `ExperimentalCodeFenceRendererProps`
- `PluginHomepageSectionRegistration`
- `PluginSettingsSectionRegistration`
- `ExperimentalAppOverlayRegistration`
@@ -207,6 +208,8 @@ Read the installed SDK declarations for the exact current signatures.
- `PluginSourceCodeRendererRegistration`
- `PluginDiffRendererRegistration`
- `PluginMessageDirectiveRegistration`
+- `ExperimentalCodeFenceRendererRegistration`
+- `ExperimentalCodeFenceStatus`
- `ThreadChatMessageReference`
- `PluginTargetedPanelActionOpenOptions`
- `PluginMessageActionContext`
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md
index 57d990d03a1..d7f489ae80b 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md
@@ -282,6 +282,7 @@ compact viewports (the host's responsive behavior) — same API.
Crash isolation stays local to the slot. Additive slots can show a crash chip
or nothing. Replacement slots fall back to the native list or renderer.
`messageDirective` falls back to the original source text.
+`experimental_codeFenceRenderer` falls back to BB's code block (`Original`).
The `run` pattern (threadPanelAction): `run` is the place to resolve
server state before deciding what to open — e.g. call a backend rpc, then
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md
index 5670c256460..27bd1efdb4c 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md
@@ -124,6 +124,11 @@ export default definePluginApp((app) => {
onActivate: ({ openPluginDetails }) => openPluginDetails(),
});
app.slots.messageDirective({ id: "inline-vis", component: InlineVis });
+ app.slots.experimental_codeFenceRenderer({
+ id: "ui-blocks",
+ languages: ["ui"],
+ component: UiBlock,
+ });
app.slots.experimental_threadList({
id: "inbox",
title: "Inbox",
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md
index 9837af4467d..3c8192a6ce9 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md
@@ -65,8 +65,43 @@ openWorkspaceFile }` — register a leaf
attributes as attacker-controlled even though the model emitted them;
load workspace data through `bb.sdk.files` with root/host confinement
rather than trusting paths. Reference implementation:
- `plugins/inline-vis` (the sidebar's path-shaped, sandboxed worktree
- iframe preview, including relative assets and normal web loading).
+ `plugins/inline-vis` (a workspace or thread-storage HTML file shown in a
+ borderless, sandboxed frame that follows the page's height and bb's theme,
+ including relative assets and normal web loading).
+- `experimental_codeFenceRenderer` → `{ code, language, status, index,
+message, Original }` — render fenced code blocks of chosen languages in
+ assistant-message Markdown with a plugin component (e.g. a ` ```ui `
+ block rendered live as the reply streams). Registration:
+ `{ id, languages, component }`; `id` uses letters, digits, `-`, `_` and is
+ unique within the plugin; `languages` is non-empty and each entry matches
+ `/^[a-z0-9][a-z0-9+#._-]*$/`. A plugin may claim a language once across its
+ registrations (duplicates are rejected at load). Props: `code` is the fence
+ body so far, without the delimiters; `status` is `"streaming"` while the
+ closing fence has not arrived and the reply is still being written,
+ `"closed"` once the closing fence is present (`code` is final), and
+ `"unclosed"` when the reply ended without one (for example an interrupted
+ reply); `index` is the zero-based ordinal among fences of the same language
+ in the message (other languages don't shift it); `message` matches
+ `messageDirective`'s; `Original` renders BB's code block (highlighting,
+ copy button, mermaid diagrams). BB mounts the component once per fence and
+ keeps that instance while the message stays on screen, so component state
+ survives the whole stream, including the fence closing and the reply
+ finishing. State that must outlive the mounted component (a reload, the
+ message scrolling out of the rendered window) belongs under
+ `${message.id}#${language}#${index}`. `useBbNavigate().openThreadPanel`
+ works inside it as for `messageDirective`. **Host behavior / fallbacks:**
+ activates only where `messageDirective` does (assistant and nested agent
+ Markdown); user messages, file previews, and plugin Markdown keep BB's code
+ block. BB's code block also renders when two plugins claim the same
+ language (BB logs a console warning naming them), when the plugin is
+ missing or disabled, and for a fence whose component throws. A throw
+ affects that fence only, and BB tries the component again when the fence's
+ `code` or `status` changes, so a renderer that fails on a half-streamed
+ body recovers on the next chunk. Treat `code` as attacker-controlled even
+ though the model emitted it: run generated markup or scripts in a sandboxed
+ frame, never in the bb page. Reference implementation:
+ `plugins/inline-vis` (a ` ```inline-vis ` block holding an HTML page).
+ Experimental: see `docs/api_to_audit.md`.
- `messageAction` → an action on chat messages: an icon button in the
per-message action bar (user and assistant messages) and an entry in the
assistant-message text-selection menu. Slot actions are limited to the main
diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md
index 68282f0099b..15a0617eb06 100644
--- a/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md
+++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md
@@ -192,7 +192,8 @@ await contentScripts.lifecycle.dispose();
`loadPluginApp` validates registrations with the host's own rules (slot id
patterns, settingsSection optional title, navPanel path,
-fileOpener extensions, and content-script ids/mount functions) and returns
+fileOpener extensions, code-fence renderer languages (exposed as
+`app.codeFenceRenderers`), and content-script ids/mount functions) and returns
them typed with defaults filled. `mountPluginContentScripts` mirrors ordered
mount, abort-before-cleanup, reverse rollback, exact-once disposal, and
per-window instances. Working examples:
diff --git a/plugins/inline-vis/PLUGIN_OVERVIEW.md b/plugins/inline-vis/PLUGIN_OVERVIEW.md
index 177dc1f54b7..4cb213149cf 100644
--- a/plugins/inline-vis/PLUGIN_OVERVIEW.md
+++ b/plugins/inline-vis/PLUGIN_OVERVIEW.md
@@ -1,33 +1,39 @@
-See an agent's chart, demo, report, or Markdown document in the conversation without opening a side panel. The agent writes an HTML or Markdown file to the workspace or the thread's storage directory. The plugin shows that file inside the assistant message.
+See an agent's chart, mockup, demo, report, or Markdown document in the conversation without opening a side panel. The agent either writes a small HTML page directly in its reply, or writes an HTML or Markdown file to the workspace or the thread's storage directory. The plugin shows the page inside the assistant message.
## What you get
-- A live HTML preview or formatted Markdown document in the message.
-- A default viewport height of 224 pixels. The agent can set a height from 120 to 1200 pixels.
-- A header action that opens the source file in bb's sidebar viewer, for workspace and thread-storage previews alike.
-- Cmd-click, Ctrl-click, or middle-click that action on an HTML preview to open the rendered page in a browser tab instead.
-- A collapse control that remembers whether inline previews should stay collapsed on the current client.
-- A clear inline error when the file is missing, too large, or unsupported.
+- An HTML page shown as part of the reply: no card and no title bar, on the thread's own background, as tall as its content.
+- The page in your theme. It gets bb's colours, radius, shadows, and font as CSS variables, and follows a theme or light/dark change live without reloading.
+- For a page from a file, a button that appears on hover and opens the file in bb's sidebar viewer. Cmd-click, Ctrl-click, or middle-click it to open the rendered page in a browser tab instead.
+- For a page written in the reply, a button that appears on hover and shows or hides the HTML source below the page.
+- A formatted Markdown document in a card that shows its file name, with buttons to copy the Markdown and open the file in the sidebar. A long document shows a preview and expands in place with "Show more".
+- A clear inline error when a file is missing, too large, or unsupported.
## How it works
-The agent emits a message directive that names a source-relative `.html`, `.htm`, `.md`, or `.markdown` file. Omitting `source` defaults to the workspace, and explicit `source="workspace"` is equivalent:
+There are two ways for the agent to show a page.
-```text
-::inline-vis{file="charts/out.html" height="480"}
-::inline-vis{file="reports/summary.md"}
+A fenced code block with the language `inline-vis` holds the HTML page itself. Nothing is written to disk. While the reply is still streaming, the block shows a short status line. The page appears when the closing fence arrives. If the reply ends before the fence closes, bb shows its normal code block.
+
+```inline-vis
+…
```
-Read-only artifacts in the thread's storage directory can be rendered without resolving the workspace:
+A message directive names a source-relative `.html`, `.htm`, `.md`, or `.markdown` file. Omitting `source` defaults to the workspace, and explicit `source="workspace"` is equivalent:
```text
+::inline-vis{file="charts/out.html"}
+::inline-vis{file="charts/out.html" height="480"}
+::inline-vis{file="reports/summary.md"}
::inline-vis{source="thread-storage" file="reports/result.html"}
```
-The plugin confirms the file exists in the selected source before it renders. Files must be UTF-8 text with a maximum size of 5 MiB. Relative assets next to HTML files load as usual.
+For a directive, the plugin checks the path, confirms the file exists in the selected source, and reads its text. Files must be UTF-8 text with a maximum size of 5 MiB. Relative assets next to an HTML file load as usual. A page written in a fenced block has no sibling files.
+
+An HTML frame fits its page: the page reports its content height and the frame follows it, from 80 to 2000 pixels. A `height` on the directive caps the frame, and a taller page then scrolls inside it. A Markdown card uses `height` as the height of its scrolling area.
-HTML runs in a sandboxed iframe with an opaque origin. Scripts in the file cannot read the bb page, its cookies, or its storage. Markdown uses bb's renderer with sanitized HTML support.
+HTML runs in a sandboxed iframe with an opaque origin. Scripts in the page cannot read the bb page, its cookies, or its storage. A page cannot open windows. When the reader clicks a link to another site, the app opens it in a new tab. Markdown uses bb's renderer with sanitized HTML support.
## For agents
-The bundled `inline-vis` skill teaches the agent when to emit the directive and how to write the file. No account or external service is required.
+The bundled `inline-vis` skill teaches the agent when to use a fenced block and when to use the directive, how to lay a page out so it reads as part of the reply, and which theme variables to style with. No account or external service is required.
diff --git a/plugins/inline-vis/README.md b/plugins/inline-vis/README.md
index 6540401e7c4..3e2f377e44a 100644
--- a/plugins/inline-vis/README.md
+++ b/plugins/inline-vis/README.md
@@ -1,7 +1,33 @@
# bb-plugin-inline-vis
-Builtin plugin for the assistant **message directive** slot
-(`app.slots.messageDirective`). When the model emits:
+Builtin plugin that shows HTML pages and Markdown documents inside assistant
+messages. It fills two slots: the assistant **message directive** slot
+(`app.slots.messageDirective`) and the **code fence renderer** slot
+(`app.slots.experimental_codeFenceRenderer`).
+
+## Fenced block
+
+A fenced code block with the language `inline-vis` holds the HTML page itself:
+
+````text
+```inline-vis
+…
+```
+````
+
+bb mounts the plugin's component once for the fence and keeps it for the whole
+stream. The component renders by fence status:
+
+- `streaming`: a short "Writing visualization…" status line.
+- `closed`: the page in the frame described below. A button that appears on
+ hover shows or hides bb's normal code block for the fence below the frame.
+- `unclosed`, or a fence whose body is empty: bb's normal code block.
+
+The page needs no file and no RPC. Its base URL is the app's root, so it
+cannot reference workspace files by relative path. A page that contains three
+backticks needs a four-backtick fence.
+
+## Directive
```text
::inline-vis{file="demo.html"}
@@ -15,36 +41,78 @@ For a read-only artifact in the current thread's storage directory, use:
::inline-vis{source="thread-storage" file="reports/result.html"}
```
-Set an optional preview height in pixels with `height`:
+Set an optional height in pixels with `height`, a whole number from 80 through
+2000:
```text
::inline-vis{file="demo.html" height="480"}
```
-The default is 224px; accepted values are whole numbers from 120 through 1200.
+For an HTML file, `height` caps the frame, and a taller page scrolls inside
+it. Without it the frame fits the page. For a Markdown file, `height` is the
+height of the card's preview, 256px by default; a longer document expands in
+place.
-bb replaces that leaf with this plugin's React component, which:
+bb replaces the directive with this plugin's React component, which:
-1. Validates the untrusted `source` and `file` attributes.
+1. Validates the untrusted `source`, `file`, and `height` attributes.
2. Calls the plugin RPC `preparePreview` with the message `threadId`, source,
- and file path to validate the target and surface clean inline errors.
-3. Shows loading / error states on the first read. The last eight successful
- preview results are retained in memory by thread, message, source, and path; remounts
- revalidate without discarding them on temporary read failures. A confirmed missing
- file clears its cached result and shows an error. Every preview includes a header action that
- opens the source file in bb's sidebar viewer, from the workspace or the
- thread's storage directory. For HTML previews that action is a link to the
- rendered page: Cmd-click, Ctrl-click, or middle-click opens it in a browser
- tab (the default browser in desktop bb). The header also collapses or expands the
- preview and remembers that preference on the current client.
-4. Points HTML files at bb's existing path-shaped worktree or thread storage
- route inside a sandboxed iframe. Relative sibling assets work, scripts are
- enabled, and normal web loading is allowed. The iframe keeps an opaque
- origin (no `allow-same-origin`) so scripts cannot access the bb page, its
- cookies, or storage. Remote scripts, styles, images, fonts, media, fetches,
- and WebSockets work subject to ordinary browser CORS, mixed-content, and
- remote-server policies.
-5. Renders Markdown files with bb's Markdown renderer. HTML is sanitized by the shared renderer.
+ and file path. The RPC validates the target and returns the file's text.
+ For HTML it also returns the URL of the served file and the SHA-256 of the
+ text.
+3. Shows loading and error states on the first read. An HTML file reserves
+ blank space while it loads; a Markdown file shows its card with a skeleton.
+ The last eight successful results are retained in memory by thread,
+ message, source, and path. Remounts revalidate without discarding them on
+ temporary read failures. A confirmed missing file clears its cached result
+ and shows an error.
+4. Renders HTML files in the frame described below, with the served file's URL
+ as the base URL so relative sibling assets load. A button that appears on
+ hover opens the source file in bb's sidebar viewer, from the workspace or
+ the thread's storage directory. The button is a link to the rendered page:
+ Cmd-click, Ctrl-click, or middle-click opens it in a browser tab (the
+ default browser in desktop bb).
+5. Renders Markdown files with bb's Markdown renderer inside a card. HTML is
+ sanitized by the shared renderer. The card header shows the file name and
+ folder, copies the Markdown source, and opens the file in the sidebar
+ viewer. A document more than 64px taller than the preview height is
+ clamped to it, with a fade and a "Show more" control that expands the
+ document in place; nothing scrolls inside the card. An expanded document
+ stays expanded when its message is rendered again in the same session.
+ Markdown links and images resolve
+ relative to the document's directory in the selected source. For
+ `::inline-vis{source="thread-storage" file="reports/report.md"}`,
+ `[Notes](notes.md)` and `` refer to files under
+ `reports/` in that thread's storage. The same rule applies to workspace
+ reports.
+
+## The HTML frame
+
+Both forms render HTML the same way (`frame.ts`, `theme.ts`, and `HtmlFrame`
+in `app.tsx`):
+
+- The page is an iframe with no card, title bar, or border, on the thread's
+ own background.
+- The iframe is fed by `srcdoc`. `frameDocument` adds a charset, a viewport,
+ a ` ` (unless the page has its own), the theme stylesheet, and a small
+ bootstrap script at the start of the page's ``.
+- The sandbox is `allow-scripts allow-forms` without `allow-same-origin`, so
+ the page keeps an opaque origin. Its scripts cannot access the bb page, its
+ cookies, or its storage. Remote scripts, styles, images, fonts, media,
+ fetches, and WebSockets work subject to ordinary browser CORS,
+ mixed-content, and remote-server policies.
+- The theme is bb's active tokens as CSS custom properties on `:root`
+ (`FRAME_THEME_TOKENS` in `frame.ts`), plus a six-colour chart series and the
+ app's sans font. When the app's theme or light/dark mode changes, the host
+ posts the new values and the bootstrap applies them without a reload.
+- The bootstrap reports the page's content height, and the frame follows it
+ from 80 to 2000 pixels. The last reported height is remembered per page
+ (by content hash for a file, by message and fence index for a fenced block)
+ so a remounted frame starts at the right size.
+- A page cannot open windows. When the reader clicks an `http` or `https`
+ link, the bootstrap asks the host, and the host opens it in a new tab only
+ if the frame has focus and the click is a user activation. Anchor links
+ move within the page.
## Backend security
@@ -55,22 +123,18 @@ storage previews use `bb.sdk.threads.storageLocation` instead and do not resolve
the workspace. Both sources confine the relative `.html`, `.htm`, `.md`, or
`.markdown` path under the returned root and read it through `bb.sdk.files`
(host-routed). Absolute paths, traversal, unsupported extensions, missing files,
-non-UTF-8 content, and files over 5 MiB are rejected. HTML previews then use
-bb's existing confined worktree or thread-storage route to serve the document
-and relative assets; Markdown previews render the validated content returned by
-the RPC.
+non-UTF-8 content, and files over 5 MiB are rejected. HTML previews render the
+validated content returned by the RPC and use bb's existing confined worktree
+or thread-storage route for relative assets and for opening the page in a
+browser tab. Markdown previews render the validated content returned by the
+RPC.
-It ships with bb and is reconciled through the builtin plugin lifecycle. Ship
-a supported file in either source, then ask the agent to show it with the
-directive (see the bundled `inline-vis` skill).
+It ships with bb and is reconciled through the builtin plugin lifecycle. Ask
+the agent to show a page with either form (see the bundled `inline-vis`
+skill).
## Tests
```bash
-pnpm exec turbo run test typecheck --filter=bb-plugin-inline-vis
+pnpm exec turbo run test typecheck lint --filter=bb-plugin-inline-vis
```
-
-Markdown links and images resolve relative to the document's directory in the
-selected source. For `::inline-vis{source="thread-storage" file="reports/report.md"}`,
-`[Notes](notes.md)` and `` refer to files under `reports/`
-in that thread's storage. The same rule applies to workspace reports.
diff --git a/plugins/inline-vis/app.test.tsx b/plugins/inline-vis/app.test.tsx
index 980c58c0bd7..80a0b97c051 100644
--- a/plugins/inline-vis/app.test.tsx
+++ b/plugins/inline-vis/app.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { cleanup, fireEvent, waitFor } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { act, cleanup, fireEvent, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app";
let app: Awaited>;
@@ -11,7 +11,10 @@ beforeEach(async () => {
afterEach(() => {
cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
window.localStorage.clear();
+ document.documentElement.classList.remove("dark");
});
const message = {
@@ -21,6 +24,117 @@ const message = {
projectId: "proj_1",
};
+function directiveProps(attributes: Record) {
+ const source = `::inline-vis{${Object.entries(attributes)
+ .map(([name, value]) => `${name}="${value}"`)
+ .join(" ")}}`;
+ return { attributes, source, message, openWorkspaceFile: null };
+}
+
+function htmlPreview(
+ file: string,
+ overrides: { content?: string; sha256?: string } = {},
+) {
+ return {
+ kind: "html" as const,
+ file,
+ source: "workspace" as const,
+ target: { kind: "workspace" as const, environmentId: "env_1", path: file },
+ url: `/api/v1/environments/env_1/files/${file
+ .split("/")
+ .map(encodeURIComponent)
+ .join("/")}`,
+ content: overrides.content ?? "Demo ",
+ sha256: overrides.sha256 ?? `sha-${file}`,
+ };
+}
+
+async function findFrame(slot: {
+ container: HTMLElement;
+}): Promise {
+ return waitFor(() => {
+ const el = slot.container.querySelector("iframe");
+ if (!(el instanceof HTMLIFrameElement)) {
+ throw new Error("Expected the inline visualization iframe to render");
+ }
+ return el;
+ });
+}
+
+function frameBox(iframe: HTMLIFrameElement): HTMLElement {
+ const box = iframe.closest("[data-inline-vis-frame]");
+ if (!(box instanceof HTMLElement)) {
+ throw new Error("Expected the iframe to sit in an inline-vis frame");
+ }
+ return box;
+}
+
+function postMessageFrom(source: Window | null, data: unknown): void {
+ act(() => {
+ window.dispatchEvent(new MessageEvent("message", { data, source }));
+ });
+}
+
+function markdownPreview(file: string, content: string) {
+ return {
+ kind: "markdown" as const,
+ file,
+ source: "workspace" as const,
+ target: { kind: "workspace" as const, environmentId: "env_1", path: file },
+ rootPath: "/work/repo",
+ content,
+ };
+}
+
+function observeDocumentHeights() {
+ const observed = new Map void>();
+ let height = 0;
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ private readonly notify: () => void;
+ constructor(callback: ResizeObserverCallback) {
+ this.notify = () => callback([], this as unknown as ResizeObserver);
+ }
+ observe(element: Element) {
+ observed.set(element, this.notify);
+ }
+ disconnect() {
+ for (const [element, notify] of observed) {
+ if (notify === this.notify) observed.delete(element);
+ }
+ }
+ },
+ );
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
+ () => new DOMRect(0, 0, 700, height),
+ );
+ return {
+ report(next: number) {
+ height = next;
+ act(() => {
+ for (const notify of observed.values()) notify();
+ });
+ },
+ };
+}
+
+function documentBody(slot: { container: HTMLElement }): HTMLElement {
+ const body = slot.container.querySelector("[data-inline-vis-document]");
+ if (!(body instanceof HTMLElement)) {
+ throw new Error("Expected the Markdown document body to render");
+ }
+ return body;
+}
+
+function sizeChanged(height: number) {
+ return {
+ jsonrpc: "2.0",
+ method: "ui/notifications/size-changed",
+ params: { height },
+ };
+}
+
describe("inline-vis messageDirective registration", () => {
it("registers the inline-vis directive", () => {
expect(app.messageDirectives).toHaveLength(1);
@@ -141,15 +255,10 @@ describe("InlineVisDirective", () => {
]);
});
- it("uses the sidebar worktree route with an opaque-origin script sandbox", async () => {
+ it("renders an HTML page in a borderless opaque-origin frame built from its content", async () => {
const slot = renderSlot(
app.messageDirectives[0]!,
- {
- attributes: { file: "charts/demo file.html" },
- source: '::inline-vis{file="charts/demo file.html"}',
- message,
- openWorkspaceFile: null,
- },
+ directiveProps({ file: "charts/demo file.html" }),
{
rpc: {
preparePreview: (input) => {
@@ -157,17 +266,9 @@ describe("InlineVisDirective", () => {
threadId: "thr_1",
file: "charts/demo file.html",
});
- return {
- kind: "html",
- file: "charts/demo file.html",
- source: "workspace",
- target: {
- kind: "workspace",
- environmentId: "env_1",
- path: "charts/demo file.html",
- },
- url: "/api/v1/environments/env_1/files/charts/demo%20file.html",
- };
+ return htmlPreview("charts/demo file.html", {
+ content: "Quarterly chart ",
+ });
},
},
},
@@ -176,25 +277,36 @@ describe("InlineVisDirective", () => {
await slot.findByRole("status", {
name: "Loading visualization charts/demo file.html",
});
+ const iframe = await findFrame(slot);
+
+ expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
+ expect(iframe.getAttribute("src")).toBeNull();
+ expect(iframe.title).toBe("inline-vis: charts/demo file.html");
+ const srcDoc = iframe.getAttribute("srcdoc") ?? "";
+ expect(srcDoc).toContain("Quarterly chart ");
+ expect(srcDoc).toContain('hi